feat(wallet): persist history and surface pending sends
Add an encrypted SQLite transaction history cache with cached tip metadata and per-address shielded scan progress so startup and full refreshes avoid re-scanning every z-address while still invalidating on wallet/address/rescan changes. Improve wallet history loading by paging transparent transactions, preserving cached shielded and sent rows, keeping recent/unconfirmed activity visible, and classifying mining-address receives. Show z_sendmany opid sends immediately in History and Overview, pin pending rows through refreshes, and apply optimistic address/balance debits until opids resolve. Add timestamped RPC console tracing by source/method without logging params or results, reduce redundant refresh/RPC calls, and cache Explorer recent block summaries in SQLite. Expand focused tests for transaction cache encryption, scan-progress persistence/invalidation, history preservation, operation-status parsing, pending send visibility, and Explorer/RPC refresh behavior.
This commit is contained in:
@@ -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}
|
||||
)
|
||||
|
||||
|
||||
178
src/app.cpp
178
src/app.cpp
@@ -80,6 +80,7 @@
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <unordered_set>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <thread>
|
||||
@@ -103,6 +104,7 @@ bool App::sendStopCommandSafely(rpc::RPCClient& client, const char* context)
|
||||
{
|
||||
const char* label = context ? context : "App";
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Daemon lifecycle / Stop command");
|
||||
client.call("stop");
|
||||
DEBUG_LOGF("[%s] Stop command sent\n", label);
|
||||
return true;
|
||||
@@ -405,10 +407,12 @@ void App::update()
|
||||
// Poll getrescaninfo for rescan progress (if rescan flag is set)
|
||||
// Use fast_rpc_ when available to avoid blocking on rpc_'s
|
||||
// curl_mutex (which may be held by a long-running import).
|
||||
if (state_.sync.rescanning && fast_worker_) {
|
||||
if (state_.sync.rescanning && fast_worker_ && !rescan_status_poll_in_progress_) {
|
||||
auto* rescanRpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
rescan_status_poll_in_progress_ = true;
|
||||
fast_worker_->post([this, rescanRpc]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Startup / Rescan monitor");
|
||||
auto info = rescanRpc->call("getrescaninfo");
|
||||
bool rescanning = info.value("rescanning", false);
|
||||
float progress = 0.0f;
|
||||
@@ -417,6 +421,7 @@ void App::update()
|
||||
try { progress = std::stof(progStr) * 100.0f; } catch (...) {}
|
||||
}
|
||||
return [this, rescanning, progress]() {
|
||||
rescan_status_poll_in_progress_ = false;
|
||||
if (rescanning) {
|
||||
state_.sync.rescanning = true;
|
||||
if (progress > 0.0f) {
|
||||
@@ -432,7 +437,7 @@ void App::update()
|
||||
};
|
||||
} catch (...) {
|
||||
// RPC not available yet or failed
|
||||
return [](){};
|
||||
return [this](){ rescan_status_poll_in_progress_ = false; };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -573,70 +578,59 @@ void App::update()
|
||||
|
||||
// Poll pending z_sendmany operations for completion
|
||||
if (network_refresh_.isDue(RefreshTimer::Opid) && !pending_opids_.empty()
|
||||
&& state_.connected && fast_worker_) {
|
||||
&& state_.connected && fast_worker_ && !opid_poll_in_progress_) {
|
||||
network_refresh_.reset(RefreshTimer::Opid);
|
||||
auto opids = pending_opids_; // copy for worker thread
|
||||
opid_poll_in_progress_ = true;
|
||||
fast_worker_->post([this, opids]() -> rpc::RPCWorker::MainCb {
|
||||
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
if (!rpc) return [](){};
|
||||
if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
|
||||
json ids = json::array();
|
||||
for (const auto& id : opids) ids.push_back(id);
|
||||
json result;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Send tab / Operation status");
|
||||
result = rpc->call("z_getoperationstatus", {ids});
|
||||
} catch (...) {
|
||||
return [](){};
|
||||
return [this](){ opid_poll_in_progress_ = false; };
|
||||
}
|
||||
// Collect completed/failed opids
|
||||
std::vector<std::string> done;
|
||||
bool anySuccess = false;
|
||||
for (const auto& op : result) {
|
||||
std::string status = op.value("status", "");
|
||||
std::string opid = op.value("id", "");
|
||||
if (status == "success") {
|
||||
done.push_back(opid);
|
||||
anySuccess = true;
|
||||
} else if (status == "failed") {
|
||||
done.push_back(opid);
|
||||
std::string msg = "Transaction failed";
|
||||
if (op.contains("error") && op["error"].contains("message"))
|
||||
msg = op["error"]["message"].get<std::string>();
|
||||
// Capture for main thread
|
||||
return [this, done, msg]() {
|
||||
ui::Notifications::instance().error(msg);
|
||||
for (const auto& id : done) {
|
||||
pending_opids_.erase(
|
||||
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
|
||||
pending_opids_.end());
|
||||
}
|
||||
};
|
||||
auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
|
||||
return [this, parsed = std::move(parsed)]() mutable {
|
||||
opid_poll_in_progress_ = false;
|
||||
for (const auto& msg : parsed.failureMessages) {
|
||||
ui::Notifications::instance().error(msg);
|
||||
}
|
||||
}
|
||||
// Extract txids from successful operations so shielded
|
||||
// sends are discoverable by z_viewtransaction.
|
||||
std::vector<std::string> successTxids;
|
||||
for (const auto& op : result) {
|
||||
if (op.value("status", "") == "success"
|
||||
&& op.contains("result") && op["result"].contains("txid")) {
|
||||
successTxids.push_back(op["result"]["txid"].get<std::string>());
|
||||
}
|
||||
}
|
||||
return [this, done, anySuccess,
|
||||
successTxids = std::move(successTxids)]() {
|
||||
for (const auto& id : done) {
|
||||
std::vector<std::string> terminalOpids = std::move(parsed.doneOpids);
|
||||
terminalOpids.insert(terminalOpids.end(),
|
||||
parsed.staleOpids.begin(), parsed.staleOpids.end());
|
||||
for (const auto& id : terminalOpids) {
|
||||
pending_opids_.erase(
|
||||
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
|
||||
pending_opids_.end());
|
||||
}
|
||||
if (anySuccess) {
|
||||
for (const auto& txid : successTxids) {
|
||||
if (parsed.anySuccess) {
|
||||
std::unordered_set<std::string> successfulOpids;
|
||||
for (const auto& [opid, txid] : parsed.successTxidsByOpid) {
|
||||
successfulOpids.insert(opid);
|
||||
markPendingSendTransactionSucceeded(opid, txid);
|
||||
send_txids_.insert(txid);
|
||||
}
|
||||
std::vector<std::string> successOpids;
|
||||
std::vector<std::string> failedOrStaleOpids;
|
||||
for (const auto& opid : terminalOpids) {
|
||||
if (successfulOpids.find(opid) != successfulOpids.end()) successOpids.push_back(opid);
|
||||
else failedOrStaleOpids.push_back(opid);
|
||||
}
|
||||
removePendingSendTransactions(successOpids, false);
|
||||
removePendingSendTransactions(failedOrStaleOpids, true);
|
||||
// Transaction confirmed by daemon — force immediate data refresh
|
||||
transactions_dirty_ = true;
|
||||
addresses_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
network_refresh_.markWalletMutationRefresh();
|
||||
} else {
|
||||
removePendingSendTransactions(terminalOpids, true);
|
||||
maybeFinishTransactionSendProgress();
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -645,16 +639,23 @@ void App::update()
|
||||
// Per-category refresh with tab-aware intervals
|
||||
// Skip when wallet is locked — same reason as above.
|
||||
if (state_.connected && !state_.isLocked()) {
|
||||
const bool walletDataPage = currentPageNeedsWalletDataRefresh();
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Core)) {
|
||||
refreshCoreData();
|
||||
}
|
||||
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28
|
||||
if (!state_.warming_up) {
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
|
||||
refreshTransactionData();
|
||||
if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
|
||||
refreshTransactionData();
|
||||
} else if (walletDataPage && shouldRefreshRecentTransactions()) {
|
||||
refreshRecentTransactionData();
|
||||
}
|
||||
}
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
|
||||
refreshAddressData();
|
||||
if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) {
|
||||
refreshAddressData();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Peers)) {
|
||||
@@ -1062,8 +1063,12 @@ void App::render()
|
||||
bool prevCollapsed = sidebar_collapsed_;
|
||||
{
|
||||
PERF_SCOPE("Render.Sidebar");
|
||||
ui::RenderSidebar(current_page_, sidebarW, sidebarH, sbStatus, sidebar_collapsed_,
|
||||
ui::NavPage requestedPage = current_page_;
|
||||
ui::RenderSidebar(requestedPage, sidebarW, sidebarH, sbStatus, sidebar_collapsed_,
|
||||
state_.isLocked());
|
||||
if (requestedPage != current_page_) {
|
||||
setCurrentPage(requestedPage);
|
||||
}
|
||||
}
|
||||
if (sbStatus.exitClicked) {
|
||||
requestQuit();
|
||||
@@ -1601,6 +1606,22 @@ void App::renderStatusBar()
|
||||
displayHashrate);
|
||||
}
|
||||
|
||||
// Transaction submission/operation progress
|
||||
if (hasTransactionSendProgress()) {
|
||||
ImGui::SameLine(0, sbSectionGap);
|
||||
ImGui::TextDisabled("|");
|
||||
ImGui::SameLine(0, sbSeparatorGap);
|
||||
ImGui::PushFont(ui::material::Type().iconSmall());
|
||||
float pulse = 0.6f + 0.4f * sinf((float)ImGui::GetTime() * 3.0f);
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_SEND);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, sbIconTextGap);
|
||||
int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
|
||||
const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
|
||||
std::string status = transactionSendProgressText();
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", status.c_str(), dotStr);
|
||||
}
|
||||
|
||||
// Decrypt-import background task indicator
|
||||
if (wallet_security_workflow_.importActive()) {
|
||||
ImGui::SameLine(0, sbSectionGap);
|
||||
@@ -2026,6 +2047,7 @@ void App::refreshNow()
|
||||
transactions_dirty_ = true; // Force transaction list update
|
||||
addresses_dirty_ = true; // Force address/balance update
|
||||
last_tx_block_height_ = -1; // Reset tx cache
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
}
|
||||
|
||||
void App::handlePaymentURI(const std::string& uri)
|
||||
@@ -2302,6 +2324,9 @@ void App::rescanBlockchain()
|
||||
state_.sync.rescanning = true;
|
||||
state_.sync.rescan_progress = 0.0f;
|
||||
state_.sync.rescan_status = decision.status;
|
||||
transactions_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
|
||||
// Set rescan flag BEFORE stopping so it's ready when we restart
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
@@ -3064,6 +3089,67 @@ bool App::hasPendingRPCResults() const {
|
||||
return (worker_ && worker_->hasPendingResults())
|
||||
|| (fast_worker_ && fast_worker_->hasPendingResults());
|
||||
}
|
||||
|
||||
std::string App::transactionSendProgressText() const
|
||||
{
|
||||
using Job = services::NetworkRefreshService::Job;
|
||||
if (send_submissions_in_flight_ > 0) return TR("tx_progress_submitting");
|
||||
|
||||
if (!pending_opids_.empty()) {
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof(buf), TR("tx_progress_waiting_ops"), (int)pending_opids_.size());
|
||||
return buf;
|
||||
}
|
||||
|
||||
if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) {
|
||||
return TR("tx_progress_balances");
|
||||
}
|
||||
|
||||
if (transactions_dirty_ || network_refresh_.jobInProgress(Job::Transactions)) {
|
||||
return TR("tx_progress_history");
|
||||
}
|
||||
|
||||
return TR("tx_progress_finalizing");
|
||||
}
|
||||
|
||||
std::string App::transactionRefreshProgressText() const
|
||||
{
|
||||
using Job = services::NetworkRefreshService::Job;
|
||||
bool running = network_refresh_.jobInProgress(Job::Transactions);
|
||||
bool canRefresh = state_.connected && !state_.warming_up && !state_.isLocked();
|
||||
if (!running && !(canRefresh && transactions_dirty_)) return {};
|
||||
|
||||
if (!running && transactions_dirty_) return TR("tx_loading_queued");
|
||||
|
||||
char buf[128];
|
||||
if (!send_txids_.empty()) {
|
||||
snprintf(buf, sizeof(buf), TR("tx_loading_enriching_sends"), (int)send_txids_.size());
|
||||
return buf;
|
||||
}
|
||||
|
||||
if (!state_.transactions.empty()) {
|
||||
snprintf(buf, sizeof(buf), TR("tx_loading_refreshing_cached"), (int)state_.transactions.size());
|
||||
return buf;
|
||||
}
|
||||
|
||||
if (!state_.z_addresses.empty()) {
|
||||
snprintf(buf, sizeof(buf), TR("tx_loading_scanning_shielded"), (int)state_.z_addresses.size());
|
||||
return buf;
|
||||
}
|
||||
|
||||
return TR("tx_loading_fetching_transparent");
|
||||
}
|
||||
|
||||
void App::maybeFinishTransactionSendProgress()
|
||||
{
|
||||
using Job = services::NetworkRefreshService::Job;
|
||||
if (!send_progress_active_) return;
|
||||
if (send_submissions_in_flight_ > 0 || !pending_opids_.empty()) return;
|
||||
if (addresses_dirty_ || transactions_dirty_) return;
|
||||
if (network_refresh_.jobInProgress(Job::Addresses) ||
|
||||
network_refresh_.jobInProgress(Job::Transactions)) return;
|
||||
send_progress_active_ = false;
|
||||
}
|
||||
void App::restartDaemon()
|
||||
{
|
||||
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
||||
|
||||
55
src/app.h
55
src/app.h
@@ -12,6 +12,7 @@
|
||||
#include <chrono>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
@@ -217,6 +218,9 @@ public:
|
||||
void setAddressSortOrder(const std::string& addr, int order);
|
||||
int getNextSortOrder() const;
|
||||
void swapAddressOrder(const std::string& a, const std::string& b);
|
||||
bool isMiningAddress(const std::string& addr) const;
|
||||
void setMiningAddress(const std::string& addr, bool mining);
|
||||
void invalidateAddressValidationCache();
|
||||
|
||||
// Key export/import
|
||||
void exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback);
|
||||
@@ -353,12 +357,38 @@ public:
|
||||
|
||||
/// @brief Check if RPC worker has queued results waiting to be processed
|
||||
bool hasPendingRPCResults() const;
|
||||
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
|
||||
std::string transactionSendProgressText() const;
|
||||
std::string transactionRefreshProgressText() const;
|
||||
bool isTransactionRefreshInProgress() const {
|
||||
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
|
||||
}
|
||||
|
||||
private:
|
||||
friend class AppDaemonLifecycleRuntime;
|
||||
friend class AppDaemonLifecycleTaskContext;
|
||||
|
||||
bool sendStopCommandSafely(rpc::RPCClient& client, const char* context);
|
||||
void maybeFinishTransactionSendProgress();
|
||||
void upsertPendingSendTransaction(const std::string& opid,
|
||||
const std::string& from,
|
||||
const std::string& to,
|
||||
double amount,
|
||||
const std::string& memo);
|
||||
void markPendingSendTransactionSucceeded(const std::string& opid,
|
||||
const std::string& txid);
|
||||
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
||||
bool restoreBalances);
|
||||
void applyPendingSendBalanceDeltas(bool includeAggregateBalances);
|
||||
std::string transactionHistoryCacheWalletIdentity() const;
|
||||
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
|
||||
void unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase);
|
||||
void loadTransactionHistoryCacheIfAvailable();
|
||||
void storeTransactionHistoryCacheIfAvailable();
|
||||
void wipePendingTransactionHistoryCachePassphrase();
|
||||
void resetTransactionHistoryCacheSession();
|
||||
void pruneShieldedHistoryScanProgress();
|
||||
void invalidateShieldedHistoryScanProgress(bool persistCache);
|
||||
|
||||
// Subsystems
|
||||
std::unique_ptr<rpc::RPCClient> rpc_;
|
||||
@@ -477,6 +507,9 @@ private:
|
||||
// P4: Incremental transaction cache
|
||||
int last_tx_block_height_ = -1; // block height at last full tx fetch
|
||||
static constexpr int MAX_VIEWTX_PER_CYCLE = 25; // cap z_viewtransaction calls per refresh
|
||||
std::size_t shielded_history_scan_cursor_ = 0;
|
||||
bool shielded_history_scan_pending_ = false;
|
||||
std::unordered_map<std::string, int> shielded_history_scan_heights_;
|
||||
|
||||
// P4b: z_viewtransaction result cache — avoids re-calling the RPC for
|
||||
// txids we've already enriched. Keyed by txid.
|
||||
@@ -492,11 +525,24 @@ private:
|
||||
|
||||
// Dirty flags for demand-driven refresh
|
||||
bool addresses_dirty_ = true; // true → refreshAddresses() will run
|
||||
bool address_validation_cache_dirty_ = true;
|
||||
bool transactions_dirty_ = false; // true → force tx refresh regardless of block height
|
||||
bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect
|
||||
bool rescan_status_poll_in_progress_ = false;
|
||||
bool opid_poll_in_progress_ = false;
|
||||
|
||||
// Pending z_sendmany operation tracking
|
||||
bool send_progress_active_ = false;
|
||||
int send_submissions_in_flight_ = 0;
|
||||
std::vector<std::string> pending_opids_; // opids to poll for completion
|
||||
struct PendingSendInfo {
|
||||
std::string from;
|
||||
std::string to;
|
||||
std::string memo;
|
||||
double amount = 0.0;
|
||||
std::int64_t timestamp = 0;
|
||||
};
|
||||
std::unordered_map<std::string, PendingSendInfo> pending_send_info_;
|
||||
// Txids from completed z_sendmany operations.
|
||||
// Ensures shielded sends are discoverable by z_viewtransaction
|
||||
// even when they don't appear in listtransactions or
|
||||
@@ -520,6 +566,9 @@ private:
|
||||
|
||||
// PIN vault
|
||||
std::unique_ptr<util::SecureVault> vault_;
|
||||
data::TransactionHistoryCache transaction_history_cache_;
|
||||
std::string pending_transaction_history_cache_passphrase_;
|
||||
bool transaction_history_cache_loaded_ = false;
|
||||
|
||||
// Lock screen state
|
||||
bool lock_screen_was_visible_ = false; // tracks lock→unlock transitions for auto-focus
|
||||
@@ -600,13 +649,17 @@ private:
|
||||
void refreshCoreData(); // Balance + blockchain info (can use fast_worker_)
|
||||
void refreshAddressData(); // Address lists + balances
|
||||
void refreshTransactionData(); // Transaction list + z_viewtransaction enrichment
|
||||
void refreshEncryptionState(); // Wallet encryption/lock state
|
||||
void refreshRecentTransactionData(); // Lightweight recent/unconfirmed tx poll
|
||||
bool refreshEncryptionState(); // Wallet encryption/lock state
|
||||
void refreshBalance(); // Legacy: balance-only refresh (used by specific callers)
|
||||
void refreshAddresses(); // Legacy: standalone address refresh
|
||||
void refreshPrice();
|
||||
void refreshWalletEncryptionState();
|
||||
void applyRefreshPolicy(ui::NavPage page);
|
||||
bool currentPageNeedsWalletDataRefresh() const;
|
||||
bool shouldRunWalletTransactionRefresh() const;
|
||||
bool shouldRefreshTransactions() const;
|
||||
bool shouldRefreshRecentTransactions() const;
|
||||
void checkAutoLock();
|
||||
void checkIdleMining();
|
||||
};
|
||||
|
||||
@@ -43,9 +43,13 @@
|
||||
#include "util/platform.h"
|
||||
#include "util/perf_log.h"
|
||||
#include "util/i18n.h"
|
||||
#include "util/secure_vault.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <curl/curl.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
#include <fstream>
|
||||
#include <utility>
|
||||
|
||||
@@ -56,19 +60,61 @@ using NetworkRefreshService = services::NetworkRefreshService;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string unencryptedTransactionHistoryCacheKey(const std::string& walletIdentity)
|
||||
{
|
||||
return std::string("obsidian-dragon-unencrypted-tx-cache-v1:") +
|
||||
data::TransactionHistoryCache::walletIdentityHash(walletIdentity);
|
||||
}
|
||||
|
||||
class AppRefreshRpcGateway final : public NetworkRefreshService::RefreshRpcGateway {
|
||||
public:
|
||||
explicit AppRefreshRpcGateway(rpc::RPCClient& rpc) : rpc_(rpc) {}
|
||||
AppRefreshRpcGateway(rpc::RPCClient& rpc, std::string source)
|
||||
: rpc_(rpc), source_(std::move(source)) {}
|
||||
|
||||
json call(const std::string& method, const json& params) override
|
||||
{
|
||||
rpc::RPCClient::TraceScope trace(source_);
|
||||
return rpc_.call(method, params);
|
||||
}
|
||||
|
||||
private:
|
||||
rpc::RPCClient& rpc_;
|
||||
std::string source_;
|
||||
};
|
||||
|
||||
const char* tracePageName(ui::NavPage page)
|
||||
{
|
||||
switch (page) {
|
||||
case ui::NavPage::Overview: return "Overview tab";
|
||||
case ui::NavPage::Send: return "Send tab";
|
||||
case ui::NavPage::Receive: return "Receive tab";
|
||||
case ui::NavPage::History: return "History tab";
|
||||
case ui::NavPage::Mining: return "Mining tab";
|
||||
case ui::NavPage::Market: return "Market tab";
|
||||
case ui::NavPage::Console: return "Console tab";
|
||||
case ui::NavPage::Peers: return "Network tab";
|
||||
case ui::NavPage::Explorer: return "Explorer tab";
|
||||
case ui::NavPage::Settings: return "Settings";
|
||||
case ui::NavPage::Count_: break;
|
||||
}
|
||||
return "App";
|
||||
}
|
||||
|
||||
std::string traceSource(ui::NavPage page, const char* process)
|
||||
{
|
||||
std::string source = tracePageName(page);
|
||||
if (process && process[0] != '\0') {
|
||||
source += " / ";
|
||||
source += process;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
std::size_t shieldedReceiveScanBudget(ui::NavPage page)
|
||||
{
|
||||
return page == ui::NavPage::History ? 8u : 4u;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ============================================================================
|
||||
@@ -362,11 +408,19 @@ void App::onConnected()
|
||||
// screen appears immediately instead of after 6+ queued RPC calls.
|
||||
bool initialPrefetchQueued = false;
|
||||
if (worker_ && rpc_) {
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_);
|
||||
auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc);
|
||||
auto prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this, prefetchedInfo]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Connection init");
|
||||
auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc, prefetchedInfo);
|
||||
return [this, result]() {
|
||||
NetworkRefreshService::applyConnectionInitResult(state_, result);
|
||||
if (state_.isLocked()) {
|
||||
resetTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
};
|
||||
}, 3);
|
||||
initialPrefetchQueued = enqueued.enqueued;
|
||||
@@ -425,6 +479,15 @@ void App::onDisconnected(const std::string& reason)
|
||||
confirmed_tx_ids_.clear();
|
||||
confirmed_cache_block_ = -1;
|
||||
last_tx_block_height_ = -1;
|
||||
pending_opids_.clear();
|
||||
pending_send_info_.clear();
|
||||
send_progress_active_ = false;
|
||||
send_submissions_in_flight_ = 0;
|
||||
network_refresh_.resetJobs();
|
||||
rescan_status_poll_in_progress_ = false;
|
||||
opid_poll_in_progress_ = false;
|
||||
address_validation_cache_dirty_ = true;
|
||||
resetTransactionHistoryCacheSession();
|
||||
|
||||
// Tear down the fast-lane connection
|
||||
if (fast_worker_) {
|
||||
@@ -464,6 +527,22 @@ void App::applyRefreshPolicy(ui::NavPage page)
|
||||
network_refresh_.setIntervals(getIntervalsForPage(page));
|
||||
}
|
||||
|
||||
bool App::currentPageNeedsWalletDataRefresh() const
|
||||
{
|
||||
using NP = ui::NavPage;
|
||||
return current_page_ == NP::Overview ||
|
||||
current_page_ == NP::Send ||
|
||||
current_page_ == NP::Receive ||
|
||||
current_page_ == NP::History;
|
||||
}
|
||||
|
||||
bool App::shouldRunWalletTransactionRefresh() const
|
||||
{
|
||||
if (currentPageNeedsWalletDataRefresh()) return true;
|
||||
if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
|
||||
return transactions_dirty_ && !shielded_history_scan_pending_;
|
||||
}
|
||||
|
||||
void App::setCurrentPage(ui::NavPage page)
|
||||
{
|
||||
if (page == current_page_) return;
|
||||
@@ -508,10 +587,319 @@ bool App::shouldRefreshTransactions() const
|
||||
const int currentBlocks = state_.sync.blocks;
|
||||
return network_refresh_.shouldRefreshTransactions(last_tx_block_height_,
|
||||
currentBlocks,
|
||||
state_.transactions.empty(),
|
||||
transactions_dirty_);
|
||||
}
|
||||
|
||||
bool App::shouldRefreshRecentTransactions() const
|
||||
{
|
||||
using RefreshTimer = services::NetworkRefreshService::Timer;
|
||||
return network_refresh_.isDue(RefreshTimer::TxAge)
|
||||
&& last_tx_block_height_ >= 0
|
||||
&& state_.sync.blocks == last_tx_block_height_
|
||||
&& !state_.transactions.empty()
|
||||
&& !transactions_dirty_
|
||||
&& !addresses_dirty_;
|
||||
}
|
||||
|
||||
void App::upsertPendingSendTransaction(const std::string& opid,
|
||||
const std::string& from,
|
||||
const std::string& to,
|
||||
double amount,
|
||||
const std::string& memo)
|
||||
{
|
||||
if (opid.empty()) return;
|
||||
|
||||
bool newPending = pending_send_info_.find(opid) == pending_send_info_.end();
|
||||
auto& pendingInfo = pending_send_info_[opid];
|
||||
if (pendingInfo.timestamp == 0) pendingInfo.timestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
pendingInfo.from = from;
|
||||
pendingInfo.to = to;
|
||||
pendingInfo.amount = std::abs(amount);
|
||||
pendingInfo.memo = memo;
|
||||
|
||||
TransactionInfo pending;
|
||||
pending.txid = opid;
|
||||
pending.type = "send";
|
||||
pending.amount = -pendingInfo.amount;
|
||||
pending.timestamp = pendingInfo.timestamp;
|
||||
pending.confirmations = 0;
|
||||
pending.address = pendingInfo.to;
|
||||
pending.from_address = pendingInfo.from;
|
||||
pending.memo = pendingInfo.memo;
|
||||
|
||||
auto existing = std::find_if(state_.transactions.begin(), state_.transactions.end(),
|
||||
[&](const TransactionInfo& transaction) { return transaction.txid == opid; });
|
||||
if (existing != state_.transactions.end()) {
|
||||
*existing = std::move(pending);
|
||||
} else {
|
||||
state_.transactions.insert(state_.transactions.begin(), std::move(pending));
|
||||
}
|
||||
if (newPending) {
|
||||
auto applyDelta = [&](std::vector<AddressInfo>& addresses) {
|
||||
for (auto& address : addresses) {
|
||||
if (address.address == pendingInfo.from) {
|
||||
address.balance = std::max(0.0, address.balance - pendingInfo.amount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses);
|
||||
if (!pendingInfo.from.empty() && pendingInfo.from[0] == 'z') {
|
||||
state_.privateBalance = std::max(0.0, state_.privateBalance - pendingInfo.amount);
|
||||
} else {
|
||||
state_.transparentBalance = std::max(0.0, state_.transparentBalance - pendingInfo.amount);
|
||||
}
|
||||
state_.totalBalance = std::max(0.0, state_.totalBalance - pendingInfo.amount);
|
||||
}
|
||||
state_.last_tx_update = std::time(nullptr);
|
||||
}
|
||||
|
||||
void App::markPendingSendTransactionSucceeded(const std::string& opid,
|
||||
const std::string& txid)
|
||||
{
|
||||
if (opid.empty() || txid.empty()) return;
|
||||
|
||||
auto pending = std::find_if(state_.transactions.begin(), state_.transactions.end(),
|
||||
[&](const TransactionInfo& transaction) { return transaction.txid == opid; });
|
||||
if (pending == state_.transactions.end()) return;
|
||||
|
||||
bool duplicateRealTx = std::any_of(state_.transactions.begin(), state_.transactions.end(),
|
||||
[&](const TransactionInfo& transaction) { return transaction.txid == txid; });
|
||||
if (duplicateRealTx) {
|
||||
state_.transactions.erase(pending);
|
||||
} else {
|
||||
pending->txid = txid;
|
||||
pending->confirmations = 0;
|
||||
if (pending->timestamp == 0) pending->timestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
}
|
||||
state_.last_tx_update = std::time(nullptr);
|
||||
}
|
||||
|
||||
void App::removePendingSendTransactions(const std::vector<std::string>& opids,
|
||||
bool restoreBalances)
|
||||
{
|
||||
if (opids.empty()) return;
|
||||
std::unordered_set<std::string> opidSet(opids.begin(), opids.end());
|
||||
if (restoreBalances) {
|
||||
for (const auto& opid : opidSet) {
|
||||
auto pending = pending_send_info_.find(opid);
|
||||
if (pending == pending_send_info_.end()) continue;
|
||||
auto restoreBalance = [&](std::vector<AddressInfo>& addresses) {
|
||||
for (auto& address : addresses) {
|
||||
if (address.address == pending->second.from) {
|
||||
address.balance += pending->second.amount;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!restoreBalance(state_.z_addresses)) restoreBalance(state_.t_addresses);
|
||||
if (!pending->second.from.empty() && pending->second.from[0] == 'z') {
|
||||
state_.privateBalance += pending->second.amount;
|
||||
} else {
|
||||
state_.transparentBalance += pending->second.amount;
|
||||
}
|
||||
state_.totalBalance += pending->second.amount;
|
||||
}
|
||||
}
|
||||
state_.transactions.erase(
|
||||
std::remove_if(state_.transactions.begin(), state_.transactions.end(),
|
||||
[&](const TransactionInfo& transaction) {
|
||||
return opidSet.find(transaction.txid) != opidSet.end();
|
||||
}),
|
||||
state_.transactions.end());
|
||||
for (const auto& opid : opidSet) pending_send_info_.erase(opid);
|
||||
state_.last_tx_update = std::time(nullptr);
|
||||
}
|
||||
|
||||
void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances)
|
||||
{
|
||||
for (const auto& [opid, pending] : pending_send_info_) {
|
||||
(void)opid;
|
||||
auto applyDelta = [&](std::vector<AddressInfo>& addresses) {
|
||||
for (auto& address : addresses) {
|
||||
if (address.address == pending.from) {
|
||||
address.balance = std::max(0.0, address.balance - pending.amount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses);
|
||||
if (includeAggregateBalances) {
|
||||
if (!pending.from.empty() && pending.from[0] == 'z') {
|
||||
state_.privateBalance = std::max(0.0, state_.privateBalance - pending.amount);
|
||||
} else {
|
||||
state_.transparentBalance = std::max(0.0, state_.transparentBalance - pending.amount);
|
||||
}
|
||||
state_.totalBalance = std::max(0.0, state_.totalBalance - pending.amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string App::transactionHistoryCacheWalletIdentity() const
|
||||
{
|
||||
std::vector<std::string> shieldedAddresses;
|
||||
std::vector<std::string> transparentAddresses;
|
||||
shieldedAddresses.reserve(state_.z_addresses.size());
|
||||
transparentAddresses.reserve(state_.t_addresses.size());
|
||||
for (const auto& address : state_.z_addresses) {
|
||||
if (!address.address.empty()) shieldedAddresses.push_back(address.address);
|
||||
}
|
||||
for (const auto& address : state_.t_addresses) {
|
||||
if (!address.address.empty()) transparentAddresses.push_back(address.address);
|
||||
}
|
||||
return data::TransactionHistoryCache::walletIdentityFromAddresses(
|
||||
shieldedAddresses, transparentAddresses);
|
||||
}
|
||||
|
||||
void App::wipePendingTransactionHistoryCachePassphrase()
|
||||
{
|
||||
if (!pending_transaction_history_cache_passphrase_.empty()) {
|
||||
util::SecureVault::secureZero(pending_transaction_history_cache_passphrase_.data(),
|
||||
pending_transaction_history_cache_passphrase_.size());
|
||||
pending_transaction_history_cache_passphrase_.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void App::resetTransactionHistoryCacheSession()
|
||||
{
|
||||
transaction_history_cache_.lockKey();
|
||||
wipePendingTransactionHistoryCachePassphrase();
|
||||
transaction_history_cache_loaded_ = false;
|
||||
invalidateShieldedHistoryScanProgress(false);
|
||||
}
|
||||
|
||||
void App::pruneShieldedHistoryScanProgress()
|
||||
{
|
||||
std::unordered_set<std::string> currentShieldedAddresses;
|
||||
currentShieldedAddresses.reserve(state_.z_addresses.size());
|
||||
for (const auto& address : state_.z_addresses) {
|
||||
if (!address.address.empty()) currentShieldedAddresses.insert(address.address);
|
||||
}
|
||||
|
||||
for (auto it = shielded_history_scan_heights_.begin(); it != shielded_history_scan_heights_.end();) {
|
||||
if (currentShieldedAddresses.find(it->first) == currentShieldedAddresses.end()) {
|
||||
it = shielded_history_scan_heights_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void App::invalidateShieldedHistoryScanProgress(bool persistCache)
|
||||
{
|
||||
shielded_history_scan_cursor_ = 0;
|
||||
shielded_history_scan_pending_ = false;
|
||||
shielded_history_scan_heights_.clear();
|
||||
if (persistCache) storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
|
||||
bool App::ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity)
|
||||
{
|
||||
if (walletIdentity.empty()) return false;
|
||||
if (transaction_history_cache_.isUnlockedFor(walletIdentity)) return true;
|
||||
|
||||
if (!pending_transaction_history_cache_passphrase_.empty()) {
|
||||
std::string passphrase = pending_transaction_history_cache_passphrase_;
|
||||
bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase);
|
||||
if (unlocked) wipePendingTransactionHistoryCachePassphrase();
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
if (unlocked) return true;
|
||||
}
|
||||
|
||||
if (state_.encryption_state_known && !state_.encrypted) {
|
||||
std::string cacheKey = unencryptedTransactionHistoryCacheKey(walletIdentity);
|
||||
bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, cacheKey);
|
||||
util::SecureVault::secureZero(cacheKey.data(), cacheKey.size());
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void App::unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase)
|
||||
{
|
||||
if (passphrase.empty()) return;
|
||||
|
||||
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
|
||||
if (walletIdentity.empty()) {
|
||||
wipePendingTransactionHistoryCachePassphrase();
|
||||
pending_transaction_history_cache_passphrase_ = passphrase;
|
||||
return;
|
||||
}
|
||||
|
||||
if (transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase)) {
|
||||
wipePendingTransactionHistoryCachePassphrase();
|
||||
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
|
||||
else storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
void App::loadTransactionHistoryCacheIfAvailable()
|
||||
{
|
||||
if (transaction_history_cache_loaded_ || !state_.transactions.empty()) return;
|
||||
|
||||
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
|
||||
if (walletIdentity.empty()) return;
|
||||
|
||||
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
|
||||
|
||||
auto loaded = transaction_history_cache_.load(walletIdentity,
|
||||
state_.sync.blocks,
|
||||
state_.sync.best_blockhash);
|
||||
if (!loaded.loaded) return;
|
||||
|
||||
state_.transactions = std::move(loaded.transactions);
|
||||
shielded_history_scan_heights_ = std::move(loaded.shieldedScanHeights);
|
||||
pruneShieldedHistoryScanProgress();
|
||||
state_.last_tx_update = loaded.updatedAt;
|
||||
last_tx_block_height_ = loaded.tipHeight;
|
||||
confirmed_tx_cache_.clear();
|
||||
confirmed_tx_ids_.clear();
|
||||
for (const auto& transaction : state_.transactions) {
|
||||
if (transaction.confirmations >= 10 && transaction.timestamp != 0) {
|
||||
confirmed_tx_ids_.insert(transaction.txid);
|
||||
confirmed_tx_cache_.push_back(transaction);
|
||||
}
|
||||
}
|
||||
confirmed_cache_block_ = loaded.tipHeight;
|
||||
transaction_history_cache_loaded_ = true;
|
||||
transactions_dirty_ = true;
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||||
}
|
||||
|
||||
void App::storeTransactionHistoryCacheIfAvailable()
|
||||
{
|
||||
if (state_.transactions.empty()) return;
|
||||
|
||||
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
|
||||
if (walletIdentity.empty()) return;
|
||||
|
||||
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
|
||||
pruneShieldedHistoryScanProgress();
|
||||
|
||||
std::unordered_set<std::string> pendingOpids(pending_opids_.begin(), pending_opids_.end());
|
||||
std::vector<TransactionInfo> cacheTransactions;
|
||||
cacheTransactions.reserve(state_.transactions.size());
|
||||
for (const auto& transaction : state_.transactions) {
|
||||
if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue;
|
||||
cacheTransactions.push_back(transaction);
|
||||
}
|
||||
if (cacheTransactions.empty()) return;
|
||||
|
||||
std::time_t updatedAt = state_.last_tx_update != 0
|
||||
? static_cast<std::time_t>(state_.last_tx_update)
|
||||
: std::time(nullptr);
|
||||
transaction_history_cache_.replace(walletIdentity,
|
||||
state_.sync.blocks,
|
||||
state_.sync.best_blockhash,
|
||||
cacheTransactions,
|
||||
updatedAt,
|
||||
shielded_history_scan_heights_);
|
||||
}
|
||||
|
||||
void App::refreshData()
|
||||
{
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
@@ -527,18 +915,22 @@ void App::refreshData()
|
||||
// as each completes, rather than waiting for the slowest phase.
|
||||
refreshCoreData();
|
||||
|
||||
if (addresses_dirty_)
|
||||
bool addressRefreshNeeded = addresses_dirty_;
|
||||
bool walletDataPage = currentPageNeedsWalletDataRefresh();
|
||||
if (addressRefreshNeeded)
|
||||
refreshAddressData();
|
||||
|
||||
if (shouldRefreshTransactions())
|
||||
if (!addressRefreshNeeded && shouldRunWalletTransactionRefresh() && shouldRefreshTransactions())
|
||||
refreshTransactionData();
|
||||
else if (!addressRefreshNeeded && walletDataPage && shouldRefreshRecentTransactions())
|
||||
refreshRecentTransactionData();
|
||||
|
||||
if (current_page_ == ui::NavPage::Peers)
|
||||
refreshPeerInfo();
|
||||
|
||||
if (!encryption_state_prefetched_) {
|
||||
encryption_state_prefetched_ = false;
|
||||
refreshEncryptionState();
|
||||
if (!state_.encryption_state_known &&
|
||||
!network_refresh_.jobInProgress(services::NetworkRefreshService::Job::ConnectionInit)) {
|
||||
encryption_state_prefetched_ = refreshEncryptionState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,7 +948,7 @@ void App::refreshCoreData()
|
||||
if (state_.warming_up) {
|
||||
if (!worker_) return;
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_);
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Warmup poll");
|
||||
auto result = NetworkRefreshService::collectWarmupPollResult(refreshRpc);
|
||||
return [this, result = std::move(result)]() {
|
||||
if (result.ready) {
|
||||
@@ -595,13 +987,15 @@ void App::refreshCoreData()
|
||||
auto* rpc = useFast && fast_rpc_ && fast_rpc_->isConnected()
|
||||
? fast_rpc_.get() : rpc_.get();
|
||||
if (!w || !rpc) return;
|
||||
ui::NavPage tracePage = current_page_;
|
||||
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc);
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
|
||||
auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc);
|
||||
return [this, result]() {
|
||||
try {
|
||||
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
|
||||
applyPendingSendBalanceDeltas(true);
|
||||
// Auto-shield transparent funds if enabled
|
||||
if (result.balanceOk && settings_ && settings_->getAutoShield() &&
|
||||
state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
|
||||
@@ -647,14 +1041,36 @@ void App::refreshCoreData()
|
||||
void App::refreshAddressData()
|
||||
{
|
||||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_);
|
||||
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc);
|
||||
const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
||||
const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity();
|
||||
auto addressSnapshot = address_validation_cache_dirty_
|
||||
? NetworkRefreshService::AddressRefreshSnapshot{}
|
||||
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
|
||||
ui::NavPage tracePage = current_page_;
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, addressSnapshot = std::move(addressSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh"));
|
||||
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
|
||||
|
||||
return [this, result = std::move(result)]() mutable {
|
||||
return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
|
||||
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
|
||||
applyPendingSendBalanceDeltas(false);
|
||||
address_validation_cache_dirty_ = false;
|
||||
address_list_dirty_ = true;
|
||||
addresses_dirty_ = false;
|
||||
const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
||||
const bool addressSetChanged = currentAddressCount != previousAddressCount ||
|
||||
transactionHistoryCacheWalletIdentity() != previousWalletIdentity;
|
||||
if (state_.transactions.empty() || addressSetChanged) {
|
||||
if (addressSetChanged) {
|
||||
invalidateShieldedHistoryScanProgress(false);
|
||||
}
|
||||
transactions_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||||
}
|
||||
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
|
||||
else storeTransactionHistoryCacheIfAvailable();
|
||||
maybeFinishTransactionSendProgress();
|
||||
};
|
||||
}, 3);
|
||||
if (!enqueued.enqueued) return;
|
||||
@@ -667,18 +1083,37 @@ void App::refreshAddressData()
|
||||
void App::refreshTransactionData()
|
||||
{
|
||||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||||
if (addresses_dirty_) {
|
||||
refreshAddressData();
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||||
return;
|
||||
}
|
||||
|
||||
const int currentBlocks = state_.sync.blocks;
|
||||
if (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ ||
|
||||
!shielded_history_scan_pending_) {
|
||||
shielded_history_scan_cursor_ = 0;
|
||||
shielded_history_scan_pending_ = false;
|
||||
}
|
||||
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
|
||||
state_, viewtx_cache_, send_txids_);
|
||||
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
|
||||
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
|
||||
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
||||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||||
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
|
||||
ui::NavPage tracePage = current_page_;
|
||||
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
||||
transactionSnapshot = std::move(transactionSnapshot)]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_);
|
||||
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Transaction refresh"));
|
||||
auto result = NetworkRefreshService::collectTransactionRefreshResult(
|
||||
refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE);
|
||||
|
||||
return [this, result = std::move(result)]() mutable {
|
||||
bool shieldedScanComplete = result.shieldedScanComplete;
|
||||
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
|
||||
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
|
||||
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
|
||||
viewtx_cache_,
|
||||
send_txids_,
|
||||
@@ -689,11 +1124,61 @@ void App::refreshTransactionData()
|
||||
};
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
||||
shielded_history_scan_pending_ = !shieldedScanComplete;
|
||||
transactions_dirty_ = !shieldedScanComplete;
|
||||
maybeFinishTransactionSendProgress();
|
||||
};
|
||||
}, 3);
|
||||
if (!enqueued.enqueued) return;
|
||||
|
||||
network_refresh_.resetTxAge();
|
||||
}
|
||||
|
||||
void App::refreshRecentTransactionData()
|
||||
{
|
||||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||||
if (!shouldRefreshRecentTransactions()) return;
|
||||
|
||||
const int currentBlocks = state_.sync.blocks;
|
||||
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
|
||||
state_, viewtx_cache_, send_txids_);
|
||||
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
|
||||
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
|
||||
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
||||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||||
transactionSnapshot.maxShieldedReceiveScans = 1;
|
||||
ui::NavPage tracePage = current_page_;
|
||||
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
||||
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll"));
|
||||
auto result = NetworkRefreshService::collectRecentTransactionRefreshResult(
|
||||
refreshRpc, transactionSnapshot, currentBlocks);
|
||||
|
||||
return [this, result = std::move(result)]() mutable {
|
||||
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
|
||||
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
|
||||
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
|
||||
viewtx_cache_,
|
||||
send_txids_,
|
||||
confirmed_tx_cache_,
|
||||
confirmed_tx_ids_,
|
||||
confirmed_cache_block_,
|
||||
last_tx_block_height_
|
||||
};
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
maybeFinishTransactionSendProgress();
|
||||
};
|
||||
}, 3);
|
||||
if (!enqueued.enqueued) return;
|
||||
|
||||
transactions_dirty_ = false;
|
||||
network_refresh_.resetTxAge();
|
||||
}
|
||||
|
||||
@@ -701,14 +1186,15 @@ void App::refreshTransactionData()
|
||||
// Encryption State: wallet info (one-shot on connect, lightweight)
|
||||
// ============================================================================
|
||||
|
||||
void App::refreshEncryptionState()
|
||||
bool App::refreshEncryptionState()
|
||||
{
|
||||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||||
if (!worker_ || !rpc_ || !state_.connected) return false;
|
||||
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Encryption, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
||||
json walletInfo;
|
||||
bool ok = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Startup / Wallet encryption state");
|
||||
walletInfo = rpc_->call("getwalletinfo");
|
||||
ok = true;
|
||||
} catch (...) {}
|
||||
@@ -718,9 +1204,16 @@ void App::refreshEncryptionState()
|
||||
auto result = NetworkRefreshService::parseWalletEncryptionResult(walletInfo);
|
||||
return [this, result]() {
|
||||
NetworkRefreshService::applyWalletEncryptionResult(state_, result);
|
||||
if (state_.isLocked()) {
|
||||
resetTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
};
|
||||
}, 3);
|
||||
if (!enqueued.enqueued) return;
|
||||
return enqueued.enqueued;
|
||||
}
|
||||
|
||||
void App::refreshBalance()
|
||||
@@ -750,17 +1243,21 @@ void App::refreshMiningInfo()
|
||||
}
|
||||
|
||||
// Slow-tick counter: run full getmininginfo every ~5 seconds
|
||||
// to reduce RPC overhead. getlocalsolps (returns H/s for RandomX) runs every tick (1s).
|
||||
// to reduce RPC overhead. getlocalsolps is only needed while solo mining
|
||||
// or while the Mining tab is actively showing live local hashrate.
|
||||
// NOTE: getinfo is NOT called here — longestchain/notarized are updated by
|
||||
// refreshBalance (via getblockchaininfo), and daemon_version/protocol_version/
|
||||
// p2p_port are static for the lifetime of a connection (set in onConnected).
|
||||
bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0);
|
||||
bool includeLocalHashrate = state_.mining.generate || current_page_ == ui::NavPage::Mining;
|
||||
if (!includeLocalHashrate && !doSlowRefresh) return;
|
||||
ui::NavPage tracePage = current_page_;
|
||||
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
|
||||
[this, rpc, daemonMemMb, doSlowRefresh]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc);
|
||||
[this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Mining refresh"));
|
||||
auto result = NetworkRefreshService::collectMiningRefreshResult(
|
||||
refreshRpc, daemonMemMb, doSlowRefresh);
|
||||
refreshRpc, daemonMemMb, doSlowRefresh, includeLocalHashrate);
|
||||
return [this, result]() {
|
||||
try {
|
||||
NetworkRefreshService::applyMiningRefreshResult(state_, result, std::time(nullptr));
|
||||
@@ -780,9 +1277,10 @@ void App::refreshPeerInfo()
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
if (!w) return;
|
||||
ui::NavPage tracePage = current_page_;
|
||||
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*r);
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r, tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*r, traceSource(tracePage, "Peer refresh"));
|
||||
auto result = NetworkRefreshService::collectPeerRefreshResult(refreshRpc);
|
||||
return [this, result = std::move(result)]() mutable {
|
||||
NetworkRefreshService::applyPeerRefreshResult(state_, std::move(result), std::time(nullptr));
|
||||
@@ -881,6 +1379,7 @@ void App::startMining(int threads)
|
||||
bool ok = false;
|
||||
std::string errMsg;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Mining tab / Start mining");
|
||||
rpc_->call("setgenerate", {true, threads});
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
@@ -908,6 +1407,7 @@ void App::stopMining()
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Mining tab / Stop mining");
|
||||
rpc_->call("setgenerate", {false, 0});
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
@@ -1065,6 +1565,7 @@ void App::applyDefaultBanlist()
|
||||
int applied = 0;
|
||||
for (const auto& ip : ips) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Startup / Default banlist");
|
||||
// 0 = permanent ban (until node restart or manual unban)
|
||||
// Using a very long duration (10 years) for effectively permanent bans
|
||||
rpc_->call("setban", {ip, "add", 315360000});
|
||||
@@ -1092,6 +1593,7 @@ void App::createNewZAddress(std::function<void(const std::string&)> callback)
|
||||
worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
|
||||
std::string addr;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Receive tab / New shielded address");
|
||||
json result = rpc_->call("z_getnewaddress");
|
||||
addr = result.get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
@@ -1122,6 +1624,7 @@ void App::createNewTAddress(std::function<void(const std::string&)> callback)
|
||||
worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
|
||||
std::string addr;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Receive tab / New transparent address");
|
||||
json result = rpc_->call("getnewaddress");
|
||||
addr = result.get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
@@ -1247,6 +1750,30 @@ void App::swapAddressOrder(const std::string& a, const std::string& b)
|
||||
}
|
||||
}
|
||||
|
||||
bool App::isMiningAddress(const std::string& addr) const
|
||||
{
|
||||
return settings_ && settings_->isMiningAddress(addr);
|
||||
}
|
||||
|
||||
void App::setMiningAddress(const std::string& addr, bool mining)
|
||||
{
|
||||
if (settings_) {
|
||||
settings_->setMiningAddress(addr, mining);
|
||||
settings_->save();
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
transactions_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||||
}
|
||||
}
|
||||
|
||||
void App::invalidateAddressValidationCache()
|
||||
{
|
||||
address_validation_cache_dirty_ = true;
|
||||
addresses_dirty_ = true;
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Key Export/Import Operations
|
||||
// ============================================================================
|
||||
@@ -1261,6 +1788,7 @@ void App::exportPrivateKey(const std::string& address, std::function<void(const
|
||||
auto keyKind = services::WalletSecurityController::classifyAddress(address);
|
||||
if (keyKind == services::WalletSecurityController::KeyKind::Shielded) {
|
||||
// Z-address: use z_exportkey
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export private key");
|
||||
rpc_->z_exportKey(address, [callback](const json& result) {
|
||||
if (callback) callback(result.get<std::string>());
|
||||
}, [callback](const std::string& error) {
|
||||
@@ -1270,6 +1798,7 @@ void App::exportPrivateKey(const std::string& address, std::function<void(const
|
||||
});
|
||||
} else {
|
||||
// T-address: use dumpprivkey
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export private key");
|
||||
rpc_->dumpPrivKey(address, [callback](const json& result) {
|
||||
if (callback) callback(result.get<std::string>());
|
||||
}, [callback](const std::string& error) {
|
||||
@@ -1339,7 +1868,9 @@ void App::importPrivateKey(const std::string& key, std::function<void(bool, cons
|
||||
auto keyKind = services::WalletSecurityController::classifyPrivateKey(key);
|
||||
|
||||
if (keyKind == services::WalletSecurityController::KeyKind::Shielded) {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Import private key");
|
||||
rpc_->z_importKey(key, true, [this, callback](const json& result) {
|
||||
invalidateAddressValidationCache();
|
||||
refreshAddresses();
|
||||
if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
|
||||
services::WalletSecurityController::KeyKind::Shielded));
|
||||
@@ -1347,7 +1878,9 @@ void App::importPrivateKey(const std::string& key, std::function<void(bool, cons
|
||||
if (callback) callback(false, error);
|
||||
});
|
||||
} else {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Import private key");
|
||||
rpc_->importPrivKey(key, true, [this, callback](const json& result) {
|
||||
invalidateAddressValidationCache();
|
||||
refreshAddresses();
|
||||
if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
|
||||
services::WalletSecurityController::KeyKind::Transparent));
|
||||
@@ -1425,34 +1958,45 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
||||
recipients.push_back(recipient);
|
||||
|
||||
// Run z_sendmany on worker thread to avoid blocking UI
|
||||
if (worker_) {
|
||||
worker_->post([this, from, recipients, callback]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string result_str;
|
||||
try {
|
||||
auto result = rpc_->call("z_sendmany", {from, recipients});
|
||||
result_str = result.get<std::string>();
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
result_str = e.what();
|
||||
}
|
||||
return [this, callback, ok, result_str]() {
|
||||
if (ok) {
|
||||
// A send changes address balances — refresh on next cycle
|
||||
addresses_dirty_ = true;
|
||||
// Force transaction list refresh so the sent tx appears immediately
|
||||
transactions_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
network_refresh_.markWalletMutationRefresh();
|
||||
// Track the opid so we can poll for completion
|
||||
if (!result_str.empty()) {
|
||||
pending_opids_.push_back(result_str);
|
||||
}
|
||||
}
|
||||
if (callback) callback(ok, result_str);
|
||||
};
|
||||
});
|
||||
if (!worker_) {
|
||||
send_progress_active_ = false;
|
||||
if (callback) callback(false, "RPC worker unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
send_progress_active_ = true;
|
||||
++send_submissions_in_flight_;
|
||||
worker_->post([this, from, to, amount, memo, recipients, callback]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string result_str;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Send tab / Submit transaction");
|
||||
auto result = rpc_->call("z_sendmany", {from, recipients});
|
||||
result_str = result.get<std::string>();
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
result_str = e.what();
|
||||
}
|
||||
return [this, callback, ok, result_str, from, to, amount, memo]() {
|
||||
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||||
if (ok) {
|
||||
// A send changes address balances — refresh on next cycle
|
||||
addresses_dirty_ = true;
|
||||
// Force transaction list refresh so the sent tx appears immediately
|
||||
transactions_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
network_refresh_.markWalletMutationRefresh();
|
||||
// Track the opid so we can poll for completion
|
||||
if (!result_str.empty()) {
|
||||
pending_opids_.push_back(result_str);
|
||||
upsertPendingSendTransaction(result_str, from, to, amount, memo);
|
||||
}
|
||||
} else {
|
||||
send_progress_active_ = false;
|
||||
}
|
||||
if (callback) callback(ok, result_str);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -45,7 +45,8 @@ namespace {
|
||||
|
||||
class WalletSecurityRpcAdapter : public services::WalletSecurityController::RpcGateway {
|
||||
public:
|
||||
explicit WalletSecurityRpcAdapter(rpc::RPCClient* rpc) : rpc_(rpc) {}
|
||||
explicit WalletSecurityRpcAdapter(rpc::RPCClient* rpc, std::string source = "Security settings")
|
||||
: rpc_(rpc), source_(std::move(source)) {}
|
||||
|
||||
bool encryptWallet(const std::string& passphrase, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("encryptwallet", {passphrase}); }, error);
|
||||
@@ -71,6 +72,7 @@ private:
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace(source_);
|
||||
fn();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
@@ -80,6 +82,7 @@ private:
|
||||
}
|
||||
|
||||
rpc::RPCClient* rpc_ = nullptr;
|
||||
std::string source_;
|
||||
};
|
||||
|
||||
class WalletSecurityVaultAdapter : public services::WalletSecurityController::VaultGateway {
|
||||
@@ -98,8 +101,9 @@ class WalletSecurityDecryptRpcAdapter : public services::WalletSecurityWorkflowE
|
||||
public:
|
||||
using StopFn = std::function<bool(rpc::RPCClient&, const char*)>;
|
||||
|
||||
WalletSecurityDecryptRpcAdapter(rpc::RPCClient* rpc, StopFn stopFn)
|
||||
: rpc_(rpc), stopFn_(std::move(stopFn)) {}
|
||||
WalletSecurityDecryptRpcAdapter(rpc::RPCClient* rpc, StopFn stopFn,
|
||||
std::string source = "Security / Decrypt wallet workflow")
|
||||
: rpc_(rpc), stopFn_(std::move(stopFn)), source_(std::move(source)) {}
|
||||
|
||||
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
|
||||
@@ -131,6 +135,7 @@ private:
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace(source_);
|
||||
fn();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
@@ -141,6 +146,7 @@ private:
|
||||
|
||||
rpc::RPCClient* rpc_ = nullptr;
|
||||
StopFn stopFn_;
|
||||
std::string source_;
|
||||
};
|
||||
|
||||
class WalletSecurityImportRpcAdapter : public services::WalletSecurityWorkflowExecutor::ImportGateway {
|
||||
@@ -162,6 +168,7 @@ public:
|
||||
}
|
||||
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / Import wallet workflow");
|
||||
rpcForImport->call("z_importwallet", {exportPath}, timeoutSeconds);
|
||||
if (importRpc) importRpc->disconnect();
|
||||
return true;
|
||||
@@ -396,12 +403,14 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
// Use fast-lane worker to bypass head-of-line blocking behind refreshData.
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this, r, passphrase, timeout]() -> rpc::RPCWorker::MainCb {
|
||||
w->post([this, r, passphrase = std::string(passphrase), timeout]() mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string err_msg;
|
||||
WalletSecurityRpcAdapter rpcAdapter(r);
|
||||
bool ok = rpcAdapter.unlockWallet(passphrase, timeout, err_msg);
|
||||
std::string cachePassphrase = passphrase;
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
|
||||
return [this, ok, err_msg, timeout]() {
|
||||
return [this, ok, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
|
||||
lock_unlock_in_progress_ = false;
|
||||
if (ok) {
|
||||
lock_error_msg_.clear();
|
||||
@@ -413,6 +422,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
state_.encrypted = true;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = std::time(nullptr) + timeout;
|
||||
unlockTransactionHistoryCacheWithPassphrase(passphrase);
|
||||
} else {
|
||||
lock_attempts_++;
|
||||
lock_error_msg_ = TR("incorrect_passphrase");
|
||||
@@ -426,6 +436,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
}
|
||||
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d): %s\n", lock_attempts_, err_msg.c_str());
|
||||
}
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -450,6 +461,7 @@ void App::lockWallet() {
|
||||
if (ok) {
|
||||
state_.locked = true;
|
||||
state_.unlocked_until = 0;
|
||||
resetTransactionHistoryCacheSession();
|
||||
DEBUG_LOGF("[App] Wallet locked\n");
|
||||
}
|
||||
};
|
||||
@@ -463,7 +475,10 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this, r, oldPass, newPass]() -> rpc::RPCWorker::MainCb {
|
||||
w->post([this,
|
||||
r,
|
||||
oldPass = std::string(oldPass),
|
||||
newPass = std::string(newPass)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string err_msg;
|
||||
try {
|
||||
@@ -472,8 +487,14 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
} catch (const std::exception& e) {
|
||||
err_msg = e.what();
|
||||
}
|
||||
std::string cacheNewPass = newPass;
|
||||
util::SecureVault::secureZero(oldPass.data(), oldPass.size());
|
||||
util::SecureVault::secureZero(newPass.data(), newPass.size());
|
||||
|
||||
return [this, ok, err_msg]() {
|
||||
return [this,
|
||||
ok,
|
||||
err_msg,
|
||||
newPass = std::move(cacheNewPass)]() mutable {
|
||||
encrypt_in_progress_ = false;
|
||||
if (ok) {
|
||||
encrypt_status_.clear();
|
||||
@@ -481,10 +502,13 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
memset(change_old_pass_buf_, 0, sizeof(change_old_pass_buf_));
|
||||
memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_));
|
||||
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
|
||||
unlockTransactionHistoryCacheWithPassphrase(newPass);
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
ui::Notifications::instance().info("Passphrase changed successfully");
|
||||
} else {
|
||||
encrypt_status_ = "Failed: " + err_msg;
|
||||
}
|
||||
util::SecureVault::secureZero(newPass.data(), newPass.size());
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -500,6 +524,7 @@ void App::refreshWalletEncryptionState() {
|
||||
json result;
|
||||
bool ok = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / Wallet encryption state");
|
||||
result = rpc_->call("getwalletinfo");
|
||||
ok = true;
|
||||
} catch (...) {}
|
||||
@@ -513,10 +538,24 @@ void App::refreshWalletEncryptionState() {
|
||||
int64_t until = result["unlocked_until"].get<int64_t>();
|
||||
state_.unlocked_until = until;
|
||||
state_.locked = (until == 0);
|
||||
state_.encryption_state_known = true;
|
||||
if (state_.locked) {
|
||||
resetTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
} else {
|
||||
state_.encrypted = false;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = 0;
|
||||
state_.encryption_state_known = true;
|
||||
if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
|
||||
// Wallet is no longer encrypted — if a PIN vault exists,
|
||||
// it's stale (passphrase it protects is gone). Reset PIN
|
||||
@@ -530,7 +569,6 @@ void App::refreshWalletEncryptionState() {
|
||||
settings_->save();
|
||||
}
|
||||
}
|
||||
state_.encryption_state_known = true;
|
||||
} catch (...) {}
|
||||
};
|
||||
});
|
||||
@@ -974,11 +1012,8 @@ void App::renderLockScreen() {
|
||||
rpcErr = e.what();
|
||||
}
|
||||
|
||||
// Securely wipe passphrase
|
||||
util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
|
||||
if (rpcOk) {
|
||||
return [this, timeout]() {
|
||||
return [this, timeout, passphrase = std::move(passphrase)]() mutable {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_error_msg_.clear();
|
||||
lock_attempts_ = 0;
|
||||
@@ -989,13 +1024,16 @@ void App::renderLockScreen() {
|
||||
state_.encrypted = true;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = std::time(nullptr) + timeout;
|
||||
unlockTransactionHistoryCacheWithPassphrase(passphrase);
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
} else {
|
||||
return [this, rpcErr]() {
|
||||
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_attempts_++;
|
||||
lock_error_msg_ = "Unlock failed: " + rpcErr;
|
||||
lock_error_timer_ = 3.0f;
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1496,6 +1534,7 @@ void App::renderDecryptWalletDialog() {
|
||||
wallet_security_workflow_.finishImport();
|
||||
|
||||
// Force address + peer refresh
|
||||
invalidateAddressValidationCache();
|
||||
addresses_dirty_ = true;
|
||||
transactions_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
@@ -1738,6 +1777,7 @@ void App::renderPinDialogs() {
|
||||
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
|
||||
// Verify passphrase via RPC (worker thread)
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletpassphrase", {passphrase, 5});
|
||||
} catch (const std::exception& e) {
|
||||
return [this]() {
|
||||
@@ -1751,6 +1791,7 @@ void App::renderPinDialogs() {
|
||||
|
||||
// Lock wallet back
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletlock");
|
||||
} catch (...) {}
|
||||
|
||||
|
||||
@@ -136,6 +136,8 @@ bool Settings::load(const std::string& path)
|
||||
m.icon = meta["icon"].get<std::string>();
|
||||
if (meta.contains("order") && meta["order"].is_number_integer())
|
||||
m.sortOrder = meta["order"].get<int>();
|
||||
if (meta.contains("mining") && meta["mining"].is_boolean())
|
||||
m.mining = meta["mining"].get<bool>();
|
||||
address_meta_[addr] = m;
|
||||
}
|
||||
}
|
||||
@@ -246,11 +248,12 @@ bool Settings::save(const std::string& path)
|
||||
{
|
||||
json meta_obj = json::object();
|
||||
for (const auto& [addr, m] : address_meta_) {
|
||||
if (m.label.empty() && m.icon.empty() && m.sortOrder < 0) continue;
|
||||
if (m.label.empty() && m.icon.empty() && m.sortOrder < 0 && !m.mining) continue;
|
||||
json entry = json::object();
|
||||
if (!m.label.empty()) entry["label"] = m.label;
|
||||
if (!m.icon.empty()) entry["icon"] = m.icon;
|
||||
if (m.sortOrder >= 0) entry["order"] = m.sortOrder;
|
||||
if (m.mining) entry["mining"] = true;
|
||||
meta_obj[addr] = entry;
|
||||
}
|
||||
j["address_meta"] = meta_obj;
|
||||
|
||||
@@ -147,6 +147,7 @@ public:
|
||||
std::string label;
|
||||
std::string icon; // material icon name, e.g. "savings"
|
||||
int sortOrder = -1; // -1 = auto (use default sort)
|
||||
bool mining = false;
|
||||
};
|
||||
const AddressMeta& getAddressMeta(const std::string& addr) const {
|
||||
static const AddressMeta empty{};
|
||||
@@ -162,6 +163,20 @@ public:
|
||||
void setAddressSortOrder(const std::string& addr, int order) {
|
||||
address_meta_[addr].sortOrder = order;
|
||||
}
|
||||
bool isMiningAddress(const std::string& addr) const {
|
||||
auto it = address_meta_.find(addr);
|
||||
return it != address_meta_.end() && it->second.mining;
|
||||
}
|
||||
void setMiningAddress(const std::string& addr, bool mining) {
|
||||
address_meta_[addr].mining = mining;
|
||||
}
|
||||
std::set<std::string> getMiningAddresses() const {
|
||||
std::set<std::string> addresses;
|
||||
for (const auto& [addr, meta] : address_meta_) {
|
||||
if (meta.mining) addresses.insert(addr);
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
int getNextSortOrder() const {
|
||||
int mx = -1;
|
||||
for (const auto& [k, v] : address_meta_)
|
||||
|
||||
510
src/data/transaction_history_cache.cpp
Normal file
510
src/data/transaction_history_cache.cpp
Normal file
@@ -0,0 +1,510 @@
|
||||
#include "transaction_history_cache.h"
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
#include <sodium.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kSchemaVersion = 1;
|
||||
constexpr std::size_t kKeyBytes = 32;
|
||||
|
||||
struct Statement {
|
||||
sqlite3_stmt* handle = nullptr;
|
||||
|
||||
Statement(sqlite3* db, const char* sql)
|
||||
{
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) {
|
||||
handle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
~Statement()
|
||||
{
|
||||
if (handle) sqlite3_finalize(handle);
|
||||
}
|
||||
|
||||
Statement(const Statement&) = delete;
|
||||
Statement& operator=(const Statement&) = delete;
|
||||
};
|
||||
|
||||
bool bindText(sqlite3_stmt* statement, int index, const std::string& value)
|
||||
{
|
||||
return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool bindBlob(sqlite3_stmt* statement, int index, const std::vector<unsigned char>& value)
|
||||
{
|
||||
return sqlite3_bind_blob(statement, index, value.data(), static_cast<int>(value.size()), SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> readBlob(sqlite3_stmt* statement, int index)
|
||||
{
|
||||
const void* data = sqlite3_column_blob(statement, index);
|
||||
int bytes = sqlite3_column_bytes(statement, index);
|
||||
if (!data || bytes <= 0) return {};
|
||||
const auto* begin = static_cast<const unsigned char*>(data);
|
||||
return std::vector<unsigned char>(begin, begin + bytes);
|
||||
}
|
||||
|
||||
std::string hexEncode(const unsigned char* bytes, std::size_t length)
|
||||
{
|
||||
static constexpr char kHex[] = "0123456789abcdef";
|
||||
std::string output;
|
||||
output.resize(length * 2);
|
||||
for (std::size_t index = 0; index < length; ++index) {
|
||||
output[index * 2] = kHex[(bytes[index] >> 4) & 0x0F];
|
||||
output[index * 2 + 1] = kHex[bytes[index] & 0x0F];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
json transactionToJson(const TransactionInfo& transaction)
|
||||
{
|
||||
return json{
|
||||
{"txid", transaction.txid},
|
||||
{"type", transaction.type},
|
||||
{"amount", transaction.amount},
|
||||
{"timestamp", transaction.timestamp},
|
||||
{"confirmations", transaction.confirmations},
|
||||
{"address", transaction.address},
|
||||
{"from_address", transaction.from_address},
|
||||
{"memo", transaction.memo}
|
||||
};
|
||||
}
|
||||
|
||||
TransactionInfo transactionFromJson(const json& source)
|
||||
{
|
||||
TransactionInfo transaction;
|
||||
transaction.txid = source.value("txid", std::string());
|
||||
transaction.type = source.value("type", std::string());
|
||||
transaction.amount = source.value("amount", 0.0);
|
||||
transaction.timestamp = source.value("timestamp", static_cast<std::int64_t>(0));
|
||||
transaction.confirmations = source.value("confirmations", 0);
|
||||
transaction.address = source.value("address", std::string());
|
||||
transaction.from_address = source.value("from_address", std::string());
|
||||
transaction.memo = source.value("memo", std::string());
|
||||
return transaction;
|
||||
}
|
||||
|
||||
std::string associatedDataForWallet(const std::string& walletHash)
|
||||
{
|
||||
return std::string("obsidian-dragon-tx-history-v1:") + walletHash;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TransactionHistoryCache::TransactionHistoryCache()
|
||||
: TransactionHistoryCache(defaultDatabasePath())
|
||||
{
|
||||
}
|
||||
|
||||
TransactionHistoryCache::TransactionHistoryCache(std::string databasePath)
|
||||
: database_path_(std::move(databasePath))
|
||||
{
|
||||
if (sodium_init() < 0) {
|
||||
DEBUG_LOGF("Failed to initialize libsodium for transaction history cache\n");
|
||||
}
|
||||
}
|
||||
|
||||
TransactionHistoryCache::~TransactionHistoryCache()
|
||||
{
|
||||
lockKey();
|
||||
close();
|
||||
}
|
||||
|
||||
std::string TransactionHistoryCache::defaultDatabasePath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "transaction_history.sqlite").string();
|
||||
}
|
||||
|
||||
std::string TransactionHistoryCache::walletIdentityFromAddresses(
|
||||
const std::vector<std::string>& shieldedAddresses,
|
||||
const std::vector<std::string>& transparentAddresses)
|
||||
{
|
||||
std::vector<std::string> addresses;
|
||||
addresses.reserve(shieldedAddresses.size() + transparentAddresses.size());
|
||||
for (const auto& address : shieldedAddresses) {
|
||||
if (!address.empty()) addresses.push_back("z:" + address);
|
||||
}
|
||||
for (const auto& address : transparentAddresses) {
|
||||
if (!address.empty()) addresses.push_back("t:" + address);
|
||||
}
|
||||
if (addresses.empty()) return {};
|
||||
|
||||
std::sort(addresses.begin(), addresses.end());
|
||||
std::string identity = "wallet-addresses-v1\n";
|
||||
for (const auto& address : addresses) {
|
||||
identity += address;
|
||||
identity += '\n';
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
std::string TransactionHistoryCache::walletIdentityHash(const std::string& walletIdentity)
|
||||
{
|
||||
unsigned char digest[crypto_generichash_BYTES];
|
||||
crypto_generichash(digest, sizeof(digest),
|
||||
reinterpret_cast<const unsigned char*>(walletIdentity.data()),
|
||||
walletIdentity.size(), nullptr, 0);
|
||||
return hexEncode(digest, sizeof(digest));
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
fs::path path(database_path_);
|
||||
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
|
||||
} catch (const std::exception& exception) {
|
||||
DEBUG_LOGF("Failed to create transaction history cache directory: %s\n", exception.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3* openedDb = nullptr;
|
||||
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Failed to open transaction history cache: %s\n",
|
||||
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity,
|
||||
const std::string& passphrase)
|
||||
{
|
||||
if (walletIdentity.empty() || passphrase.empty() || !ensureOpen()) return false;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
|
||||
if (salt.empty()) return false;
|
||||
|
||||
if (!deriveKey(passphrase, salt)) return false;
|
||||
unlocked_wallet_hash_ = std::move(walletHash);
|
||||
key_ready_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::lockKey()
|
||||
{
|
||||
if (key_ready_) sodium_memzero(key_.data(), key_.size());
|
||||
key_ready_ = false;
|
||||
unlocked_wallet_hash_.clear();
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const
|
||||
{
|
||||
return key_ready_ && !walletIdentity.empty() &&
|
||||
unlocked_wallet_hash_ == walletIdentityHash(walletIdentity);
|
||||
}
|
||||
|
||||
TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
|
||||
const std::string& walletIdentity,
|
||||
int currentTipHeight,
|
||||
const std::string& currentTipHash)
|
||||
{
|
||||
LoadResult result;
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
int tipHeight = 0;
|
||||
std::string tipHash;
|
||||
std::time_t updatedAt = 0;
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipherText;
|
||||
if (!readSnapshot(walletHash, tipHeight, tipHash, updatedAt, nonce, cipherText)) return result;
|
||||
|
||||
if ((currentTipHeight > 0 && tipHeight > currentTipHeight) ||
|
||||
(currentTipHeight > 0 && tipHeight == currentTipHeight &&
|
||||
!currentTipHash.empty() && !tipHash.empty() && tipHash != currentTipHash)) {
|
||||
clearWalletByHash(walletHash);
|
||||
result.invalidated = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string plainText;
|
||||
if (!decryptPayload(walletHash, nonce, cipherText, plainText)) return result;
|
||||
|
||||
try {
|
||||
json payload = json::parse(plainText);
|
||||
if (payload.value("schema_version", 0) != kSchemaVersion) return result;
|
||||
if (payload.value("wallet_hash", std::string()) != walletHash) return result;
|
||||
if (!payload.contains("transactions") || !payload["transactions"].is_array()) return result;
|
||||
|
||||
result.transactions.reserve(payload["transactions"].size());
|
||||
for (const auto& transactionJson : payload["transactions"]) {
|
||||
if (transactionJson.is_object()) {
|
||||
result.transactions.push_back(transactionFromJson(transactionJson));
|
||||
}
|
||||
}
|
||||
if (payload.contains("shielded_scan_heights") && payload["shielded_scan_heights"].is_object()) {
|
||||
for (auto it = payload["shielded_scan_heights"].begin();
|
||||
it != payload["shielded_scan_heights"].end(); ++it) {
|
||||
if (!it.key().empty() && it.value().is_number_integer()) {
|
||||
result.shieldedScanHeights[it.key()] = it.value().get<int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
result.tipHeight = tipHeight;
|
||||
result.tipHash = tipHash;
|
||||
result.updatedAt = updatedAt;
|
||||
result.loaded = true;
|
||||
} catch (...) {
|
||||
result.transactions.clear();
|
||||
}
|
||||
|
||||
sodium_memzero(plainText.data(), plainText.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::replace(const std::string& walletIdentity,
|
||||
int tipHeight,
|
||||
const std::string& tipHash,
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights)
|
||||
{
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return false;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
json payload;
|
||||
payload["schema_version"] = kSchemaVersion;
|
||||
payload["wallet_hash"] = walletHash;
|
||||
payload["tip_height"] = tipHeight;
|
||||
payload["tip_hash"] = tipHash;
|
||||
payload["updated_at"] = static_cast<std::int64_t>(updatedAt);
|
||||
payload["transactions"] = json::array();
|
||||
for (const auto& transaction : transactions) {
|
||||
payload["transactions"].push_back(transactionToJson(transaction));
|
||||
}
|
||||
payload["shielded_scan_heights"] = json::object();
|
||||
for (const auto& [address, height] : shieldedScanHeights) {
|
||||
if (!address.empty() && height >= 0) {
|
||||
payload["shielded_scan_heights"][address] = height;
|
||||
}
|
||||
}
|
||||
|
||||
std::string plainText = payload.dump();
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipherText;
|
||||
bool encrypted = encryptPayload(walletHash, plainText, nonce, cipherText);
|
||||
sodium_memzero(plainText.data(), plainText.size());
|
||||
if (!encrypted) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO transaction_history_snapshots "
|
||||
"(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
if (!statement.handle) return false;
|
||||
|
||||
if (!bindText(statement.handle, 1, walletHash)) return false;
|
||||
sqlite3_bind_int(statement.handle, 2, kSchemaVersion);
|
||||
sqlite3_bind_int(statement.handle, 3, std::max(0, tipHeight));
|
||||
if (!bindText(statement.handle, 4, tipHash)) return false;
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(updatedAt));
|
||||
if (!bindBlob(statement.handle, 6, nonce)) return false;
|
||||
if (!bindBlob(statement.handle, 7, cipherText)) return false;
|
||||
|
||||
return sqlite3_step(statement.handle) == SQLITE_DONE;
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::clearWallet(const std::string& walletIdentity)
|
||||
{
|
||||
if (walletIdentity.empty()) return;
|
||||
clearWalletByHash(walletIdentityHash(walletIdentity));
|
||||
}
|
||||
|
||||
int TransactionHistoryCache::snapshotCount()
|
||||
{
|
||||
if (!ensureOpen()) return 0;
|
||||
Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots");
|
||||
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
|
||||
return sqlite3_column_int(statement.handle, 0);
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::exec(const char* sql)
|
||||
{
|
||||
if (!db_) return false;
|
||||
char* error = nullptr;
|
||||
int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error);
|
||||
if (result != SQLITE_OK) {
|
||||
DEBUG_LOGF("Transaction history cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
|
||||
if (error) sqlite3_free(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS transaction_history_keys ("
|
||||
"wallet_hash TEXT PRIMARY KEY,"
|
||||
"salt BLOB NOT NULL)") &&
|
||||
exec("CREATE TABLE IF NOT EXISTS transaction_history_snapshots ("
|
||||
"wallet_hash TEXT PRIMARY KEY,"
|
||||
"schema_version INTEGER NOT NULL,"
|
||||
"tip_height INTEGER NOT NULL,"
|
||||
"tip_hash TEXT NOT NULL,"
|
||||
"updated_at INTEGER NOT NULL,"
|
||||
"nonce BLOB NOT NULL,"
|
||||
"ciphertext BLOB NOT NULL)");
|
||||
}
|
||||
|
||||
std::vector<unsigned char> TransactionHistoryCache::getOrCreateSalt(const std::string& walletHash)
|
||||
{
|
||||
if (!ensureOpen()) return {};
|
||||
|
||||
{
|
||||
Statement statement(db_, "SELECT salt FROM transaction_history_keys WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return {};
|
||||
if (!bindText(statement.handle, 1, walletHash)) return {};
|
||||
if (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
auto salt = readBlob(statement.handle, 0);
|
||||
if (salt.size() == crypto_pwhash_SALTBYTES) return salt;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<unsigned char> salt(crypto_pwhash_SALTBYTES);
|
||||
randombytes_buf(salt.data(), salt.size());
|
||||
|
||||
Statement insert(db_,
|
||||
"INSERT OR REPLACE INTO transaction_history_keys (wallet_hash, salt) VALUES (?, ?)");
|
||||
if (!insert.handle) return {};
|
||||
if (!bindText(insert.handle, 1, walletHash)) return {};
|
||||
if (!bindBlob(insert.handle, 2, salt)) return {};
|
||||
if (sqlite3_step(insert.handle) != SQLITE_DONE) return {};
|
||||
return salt;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt)
|
||||
{
|
||||
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
|
||||
unsigned char derived[kKeyBytes];
|
||||
int result = crypto_pwhash(derived, sizeof(derived),
|
||||
passphrase.c_str(), passphrase.size(),
|
||||
salt.data(),
|
||||
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_ALG_ARGON2ID13);
|
||||
if (result != 0) return false;
|
||||
std::copy(derived, derived + sizeof(derived), key_.begin());
|
||||
sodium_memzero(derived, sizeof(derived));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const
|
||||
{
|
||||
if (!key_ready_) return false;
|
||||
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||
randombytes_buf(nonce.data(), nonce.size());
|
||||
|
||||
std::string associatedData = associatedDataForWallet(walletHash);
|
||||
cipherText.resize(plainText.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
unsigned long long cipherLength = 0;
|
||||
int result = crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
cipherText.data(), &cipherLength,
|
||||
reinterpret_cast<const unsigned char*>(plainText.data()), plainText.size(),
|
||||
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
|
||||
nullptr, nonce.data(), key_.data());
|
||||
if (result != 0) return false;
|
||||
cipherText.resize(static_cast<std::size_t>(cipherLength));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
std::string& plainText) const
|
||||
{
|
||||
if (!key_ready_ || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
|
||||
cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string associatedData = associatedDataForWallet(walletHash);
|
||||
std::vector<unsigned char> plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
unsigned long long plainLength = 0;
|
||||
int result = crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
plain.data(), &plainLength, nullptr,
|
||||
cipherText.data(), cipherText.size(),
|
||||
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
|
||||
nonce.data(), key_.data());
|
||||
if (result != 0) return false;
|
||||
|
||||
plainText.assign(reinterpret_cast<const char*>(plain.data()), static_cast<std::size_t>(plainLength));
|
||||
sodium_memzero(plain.data(), plain.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::readSnapshot(const std::string& walletHash,
|
||||
int& tipHeight,
|
||||
std::string& tipHash,
|
||||
std::time_t& updatedAt,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText)
|
||||
{
|
||||
Statement statement(db_,
|
||||
"SELECT tip_height, tip_hash, updated_at, nonce, ciphertext "
|
||||
"FROM transaction_history_snapshots WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return false;
|
||||
if (!bindText(statement.handle, 1, walletHash)) return false;
|
||||
if (sqlite3_step(statement.handle) != SQLITE_ROW) return false;
|
||||
|
||||
tipHeight = sqlite3_column_int(statement.handle, 0);
|
||||
const unsigned char* tipHashText = sqlite3_column_text(statement.handle, 1);
|
||||
tipHash = tipHashText ? reinterpret_cast<const char*>(tipHashText) : std::string();
|
||||
updatedAt = static_cast<std::time_t>(sqlite3_column_int64(statement.handle, 2));
|
||||
nonce = readBlob(statement.handle, 3);
|
||||
cipherText = readBlob(statement.handle, 4);
|
||||
return !nonce.empty() && !cipherText.empty();
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return;
|
||||
if (!bindText(statement.handle, 1, walletHash)) return;
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::close()
|
||||
{
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
90
src/data/transaction_history_cache.h
Normal file
90
src/data/transaction_history_cache.h
Normal file
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include "wallet_state.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
class TransactionHistoryCache {
|
||||
public:
|
||||
struct LoadResult {
|
||||
bool loaded = false;
|
||||
bool invalidated = false;
|
||||
int tipHeight = 0;
|
||||
std::string tipHash;
|
||||
std::time_t updatedAt = 0;
|
||||
std::vector<TransactionInfo> transactions;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
};
|
||||
|
||||
TransactionHistoryCache();
|
||||
explicit TransactionHistoryCache(std::string databasePath);
|
||||
~TransactionHistoryCache();
|
||||
|
||||
TransactionHistoryCache(const TransactionHistoryCache&) = delete;
|
||||
TransactionHistoryCache& operator=(const TransactionHistoryCache&) = delete;
|
||||
|
||||
static std::string defaultDatabasePath();
|
||||
static std::string walletIdentityFromAddresses(const std::vector<std::string>& shieldedAddresses,
|
||||
const std::vector<std::string>& transparentAddresses);
|
||||
static std::string walletIdentityHash(const std::string& walletIdentity);
|
||||
|
||||
bool ensureOpen();
|
||||
bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase);
|
||||
void lockKey();
|
||||
bool hasKey() const { return key_ready_; }
|
||||
bool isUnlockedFor(const std::string& walletIdentity) const;
|
||||
|
||||
LoadResult load(const std::string& walletIdentity,
|
||||
int currentTipHeight,
|
||||
const std::string& currentTipHash);
|
||||
bool replace(const std::string& walletIdentity,
|
||||
int tipHeight,
|
||||
const std::string& tipHash,
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights = {});
|
||||
void clearWallet(const std::string& walletIdentity);
|
||||
int snapshotCount();
|
||||
|
||||
private:
|
||||
bool exec(const char* sql);
|
||||
bool createSchema();
|
||||
std::vector<unsigned char> getOrCreateSalt(const std::string& walletHash);
|
||||
bool deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt);
|
||||
bool encryptPayload(const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const;
|
||||
bool decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
std::string& plainText) const;
|
||||
bool readSnapshot(const std::string& walletHash,
|
||||
int& tipHeight,
|
||||
std::string& tipHash,
|
||||
std::time_t& updatedAt,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText);
|
||||
void clearWalletByHash(const std::string& walletHash);
|
||||
void close();
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::array<unsigned char, 32> key_{};
|
||||
bool key_ready_ = false;
|
||||
std::string unlocked_wallet_hash_;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -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();
|
||||
|
||||
@@ -10,13 +10,76 @@
|
||||
#include "../util/base64.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
#include "../util/logger.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex g_trace_mutex;
|
||||
RPCClient::TraceCallback g_trace_callback;
|
||||
std::atomic_bool g_trace_enabled{false};
|
||||
thread_local std::string g_trace_source;
|
||||
|
||||
void emitRpcTrace(const std::string& method)
|
||||
{
|
||||
if (!g_trace_enabled.load(std::memory_order_relaxed)) return;
|
||||
|
||||
RPCClient::TraceCallback callback;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_trace_mutex);
|
||||
callback = g_trace_callback;
|
||||
}
|
||||
if (!callback) return;
|
||||
|
||||
std::string source = g_trace_source.empty() ? std::string("App") : g_trace_source;
|
||||
callback(source, method);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RPCClient::TraceScope::TraceScope(std::string source)
|
||||
: previous_(RPCClient::currentTraceSource())
|
||||
{
|
||||
RPCClient::setTraceSource(std::move(source));
|
||||
}
|
||||
|
||||
RPCClient::TraceScope::~TraceScope()
|
||||
{
|
||||
RPCClient::setTraceSource(std::move(previous_));
|
||||
}
|
||||
|
||||
void RPCClient::setTraceCallback(TraceCallback callback)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_trace_mutex);
|
||||
g_trace_callback = std::move(callback);
|
||||
}
|
||||
|
||||
void RPCClient::setTraceEnabled(bool enabled)
|
||||
{
|
||||
g_trace_enabled.store(enabled, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool RPCClient::isTraceEnabled()
|
||||
{
|
||||
return g_trace_enabled.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::string RPCClient::currentTraceSource()
|
||||
{
|
||||
return g_trace_source;
|
||||
}
|
||||
|
||||
void RPCClient::setTraceSource(std::string source)
|
||||
{
|
||||
g_trace_source = std::move(source);
|
||||
}
|
||||
|
||||
// Callback for libcurl to write response data
|
||||
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
||||
size_t totalSize = size * nmemb;
|
||||
@@ -71,6 +134,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
host_ = host;
|
||||
port_ = port;
|
||||
last_connect_info_ = json();
|
||||
|
||||
// Create Basic auth header with proper base64 encoding
|
||||
std::string credentials = user + ":" + password;
|
||||
@@ -116,6 +180,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_error_.clear();
|
||||
last_connect_info_ = result;
|
||||
DEBUG_LOGF("Connected to dragonxd v%d\n", result["version"].get<int>());
|
||||
return true;
|
||||
}
|
||||
@@ -146,15 +211,23 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
connected_ = false;
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_info_ = json();
|
||||
return false;
|
||||
}
|
||||
|
||||
json RPCClient::getLastConnectInfo() const
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
return last_connect_info_;
|
||||
}
|
||||
|
||||
void RPCClient::disconnect()
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
connected_ = false;
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_info_ = json();
|
||||
if (impl_->curl) {
|
||||
curl_easy_cleanup(impl_->curl);
|
||||
impl_->curl = nullptr;
|
||||
@@ -182,6 +255,8 @@ json RPCClient::call(const std::string& method, const json& params)
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
std::string body = payload.dump();
|
||||
std::string response_data;
|
||||
@@ -235,6 +310,8 @@ json RPCClient::call(const std::string& method, const json& params, long timeout
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
// Temporarily override timeout
|
||||
long prevTimeout = 30L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec);
|
||||
@@ -294,6 +371,8 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
std::string body = payload.dump();
|
||||
std::string response_data;
|
||||
|
||||
@@ -25,6 +25,20 @@ using ErrorCallback = std::function<void(const std::string&)>;
|
||||
*/
|
||||
class RPCClient {
|
||||
public:
|
||||
using TraceCallback = std::function<void(const std::string& source, const std::string& method)>;
|
||||
|
||||
class TraceScope {
|
||||
public:
|
||||
explicit TraceScope(std::string source);
|
||||
~TraceScope();
|
||||
|
||||
TraceScope(const TraceScope&) = delete;
|
||||
TraceScope& operator=(const TraceScope&) = delete;
|
||||
|
||||
private:
|
||||
std::string previous_;
|
||||
};
|
||||
|
||||
RPCClient();
|
||||
~RPCClient();
|
||||
|
||||
@@ -74,6 +88,13 @@ public:
|
||||
* @brief Get the error message from the last failed connect() attempt.
|
||||
*/
|
||||
const std::string& getLastConnectError() const { return last_connect_error_; }
|
||||
json getLastConnectInfo() const;
|
||||
|
||||
static void setTraceCallback(TraceCallback callback);
|
||||
static void setTraceEnabled(bool enabled);
|
||||
static bool isTraceEnabled();
|
||||
static std::string currentTraceSource();
|
||||
static void setTraceSource(std::string source);
|
||||
|
||||
/**
|
||||
* @brief Make a raw RPC call
|
||||
@@ -202,6 +223,7 @@ private:
|
||||
bool warming_up_ = false;
|
||||
std::string warmup_status_;
|
||||
std::string last_connect_error_;
|
||||
json last_connect_info_;
|
||||
mutable std::recursive_mutex curl_mutex_; // serializes all curl handle access
|
||||
|
||||
// HTTP client (implementation hidden)
|
||||
|
||||
@@ -118,6 +118,24 @@ void appendTrackedSendPlaceholder(std::vector<TransactionInfo>& transactions,
|
||||
transactions.push_back(std::move(info));
|
||||
}
|
||||
|
||||
void appendMissingPreviousTransactions(std::vector<TransactionInfo>& transactions,
|
||||
const std::vector<TransactionInfo>& previousTransactions,
|
||||
bool includeAll,
|
||||
const std::unordered_set<std::string>& pinnedTxids)
|
||||
{
|
||||
for (const auto& transaction : previousTransactions) {
|
||||
bool pinned = pinnedTxids.find(transaction.txid) != pinnedTxids.end();
|
||||
if (!includeAll && !pinned) {
|
||||
bool shieldedRelated = (!transaction.address.empty() && transaction.address[0] == 'z') ||
|
||||
(!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
|
||||
!transaction.memo.empty();
|
||||
if (!shieldedRelated) continue;
|
||||
}
|
||||
if (hasTransactionType(transactions, transaction.txid, transaction.type)) continue;
|
||||
transactions.push_back(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NetworkRefreshService::ConnectionInfoResult NetworkRefreshService::parseConnectionInfoResult(const json& info)
|
||||
@@ -165,14 +183,20 @@ NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPoll
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnectionInitResult(RefreshRpcGateway& rpc)
|
||||
NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnectionInitResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const std::optional<ConnectionInfoResult>& prefetchedInfo)
|
||||
{
|
||||
ConnectionInitResult result;
|
||||
|
||||
try {
|
||||
json info = rpc.call("getinfo", json::array());
|
||||
result.info = parseConnectionInfoResult(info);
|
||||
} catch (...) {}
|
||||
if (prefetchedInfo && prefetchedInfo->ok) {
|
||||
result.info = *prefetchedInfo;
|
||||
} else {
|
||||
try {
|
||||
json info = rpc.call("getinfo", json::array());
|
||||
result.info = parseConnectionInfoResult(info);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
try {
|
||||
json walletInfo = rpc.call("getwalletinfo", json::array());
|
||||
@@ -197,6 +221,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
||||
if (result.blockchainOk) {
|
||||
result.blocks = readOptional<int>(blockInfo, "blocks");
|
||||
result.headers = readOptional<int>(blockInfo, "headers");
|
||||
result.bestBlockHash = readOptional<std::string>(blockInfo, "bestblockhash");
|
||||
result.verificationProgress = readOptional<double>(blockInfo, "verificationprogress");
|
||||
result.longestChain = readOptional<int>(blockInfo, "longestchain");
|
||||
result.notarized = readOptional<int>(blockInfo, "notarized");
|
||||
@@ -255,18 +280,21 @@ NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRef
|
||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::collectMiningRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
double daemonMemoryMb,
|
||||
bool includeSlowRefresh)
|
||||
bool includeSlowRefresh,
|
||||
bool includeLocalHashrate)
|
||||
{
|
||||
json miningInfo;
|
||||
json localHashrate;
|
||||
bool miningOk = false;
|
||||
bool hashrateOk = false;
|
||||
|
||||
try {
|
||||
localHashrate = rpc.call("getlocalsolps", json::array());
|
||||
hashrateOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getLocalHashrate error: %s\n", e.what());
|
||||
if (includeLocalHashrate) {
|
||||
try {
|
||||
localHashrate = rpc.call("getlocalsolps", json::array());
|
||||
hashrateOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getLocalHashrate error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
if (includeSlowRefresh) {
|
||||
@@ -448,7 +476,18 @@ void NetworkRefreshService::applyTransparentBalancesFromUnspent(std::vector<Addr
|
||||
applyBalancesFromUnspent(addresses, unspent);
|
||||
}
|
||||
|
||||
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(RefreshRpcGateway& rpc)
|
||||
NetworkRefreshService::AddressRefreshSnapshot NetworkRefreshService::buildAddressRefreshSnapshot(const WalletState& state)
|
||||
{
|
||||
AddressRefreshSnapshot snapshot;
|
||||
for (const auto& info : state.z_addresses) {
|
||||
if (!info.address.empty()) snapshot.shieldedSpendingKeys[info.address] = info.has_spending_key;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const AddressRefreshSnapshot& snapshot)
|
||||
{
|
||||
AddressRefreshResult result;
|
||||
|
||||
@@ -463,16 +502,25 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
continue;
|
||||
}
|
||||
|
||||
json validationResult;
|
||||
bool validationSucceeded = false;
|
||||
try {
|
||||
validationResult = rpc.call("z_validateaddress", json::array({address}));
|
||||
validationSucceeded = true;
|
||||
} catch (...) {
|
||||
// Older daemons can fail validation for wallet-owned addresses.
|
||||
auto cached = snapshot.shieldedSpendingKeys.find(address);
|
||||
if (cached != snapshot.shieldedSpendingKeys.end()) {
|
||||
AddressInfo info;
|
||||
info.address = address;
|
||||
info.type = "shielded";
|
||||
info.has_spending_key = cached->second;
|
||||
result.shieldedAddresses.push_back(std::move(info));
|
||||
} else {
|
||||
json validationResult;
|
||||
bool validationSucceeded = false;
|
||||
try {
|
||||
validationResult = rpc.call("z_validateaddress", json::array({address}));
|
||||
validationSucceeded = true;
|
||||
} catch (...) {
|
||||
// Older daemons can fail validation for wallet-owned addresses.
|
||||
}
|
||||
result.shieldedAddresses.push_back(
|
||||
buildShieldedAddressInfo(address, validationResult, validationSucceeded));
|
||||
}
|
||||
result.shieldedAddresses.push_back(
|
||||
buildShieldedAddressInfo(address, validationResult, validationSucceeded));
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
@@ -531,12 +579,14 @@ NetworkRefreshService::TransactionRefreshSnapshot NetworkRefreshService::buildTr
|
||||
|
||||
snapshot.viewTxCache = viewTxCache;
|
||||
snapshot.sendTxids = sendTxids;
|
||||
snapshot.previousTransactions = state.transactions;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::appendTransparentTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const json& result)
|
||||
const json& result,
|
||||
const std::set<std::string>& miningAddresses)
|
||||
{
|
||||
if (!result.is_array()) return;
|
||||
|
||||
@@ -552,6 +602,9 @@ void NetworkRefreshService::appendTransparentTransactions(std::vector<Transactio
|
||||
}
|
||||
if (auto value = readOptional<int>(transactionJson, "confirmations")) info.confirmations = *value;
|
||||
if (auto value = readOptional<std::string>(transactionJson, "address")) info.address = *value;
|
||||
if (info.type == "receive" && !info.address.empty() && miningAddresses.count(info.address)) {
|
||||
info.type = "mined";
|
||||
}
|
||||
if (!info.txid.empty()) knownTxids.insert(info.txid);
|
||||
transactions.push_back(std::move(info));
|
||||
}
|
||||
@@ -560,7 +613,8 @@ void NetworkRefreshService::appendTransparentTransactions(std::vector<Transactio
|
||||
void NetworkRefreshService::appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const std::string& address,
|
||||
const json& received)
|
||||
const json& received,
|
||||
const std::set<std::string>& miningAddresses)
|
||||
{
|
||||
if (received.is_null() || !received.is_array()) return;
|
||||
|
||||
@@ -582,7 +636,7 @@ void NetworkRefreshService::appendShieldedReceivedTransactions(std::vector<Trans
|
||||
|
||||
TransactionInfo info;
|
||||
info.txid = *txid;
|
||||
info.type = "receive";
|
||||
info.type = miningAddresses.count(address) ? "mined" : "receive";
|
||||
info.address = address;
|
||||
if (auto value = readOptional<double>(note, "amount")) info.amount = *value;
|
||||
if (auto value = readOptional<int>(note, "confirmations")) info.confirmations = *value;
|
||||
@@ -680,26 +734,73 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
{
|
||||
TransactionRefreshResult result;
|
||||
result.blockHeight = currentBlockHeight;
|
||||
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
|
||||
result.shieldedScanHeights = snapshot.shieldedScanHeights;
|
||||
|
||||
std::set<std::string> knownTxids;
|
||||
|
||||
bool transactionRpcError = false;
|
||||
try {
|
||||
json transactions = rpc.call("listtransactions", json::array({"", 9999}));
|
||||
appendTransparentTransactions(result.transactions, knownTxids, transactions);
|
||||
constexpr int pageSize = 1000;
|
||||
int skip = 0;
|
||||
while (true) {
|
||||
json transactions = rpc.call("listtransactions", json::array({"", pageSize, skip}));
|
||||
appendTransparentTransactions(result.transactions, knownTxids, transactions, snapshot.miningAddresses);
|
||||
if (!transactions.is_array() || transactions.size() < static_cast<std::size_t>(pageSize)) break;
|
||||
skip += pageSize;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
DEBUG_LOGF("listtransactions error: %s\n", e.what());
|
||||
}
|
||||
|
||||
for (const auto& address : snapshot.shieldedAddresses) {
|
||||
auto scannedAtTip = [&](const std::string& address) {
|
||||
if (currentBlockHeight < 0) return false;
|
||||
auto it = result.shieldedScanHeights.find(address);
|
||||
return it != result.shieldedScanHeights.end() && it->second >= currentBlockHeight;
|
||||
};
|
||||
|
||||
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
|
||||
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
|
||||
std::size_t nextSearchIndex = shieldedStart;
|
||||
|
||||
for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
|
||||
const auto& address = snapshot.shieldedAddresses[index];
|
||||
if (scannedAtTip(address)) {
|
||||
nextSearchIndex = index + 1;
|
||||
continue;
|
||||
}
|
||||
if (snapshot.maxShieldedReceiveScans > 0 &&
|
||||
result.shieldedAddressesScanned >= snapshot.maxShieldedReceiveScans) {
|
||||
nextSearchIndex = index;
|
||||
break;
|
||||
}
|
||||
++result.shieldedAddressesScanned;
|
||||
nextSearchIndex = index + 1;
|
||||
try {
|
||||
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
|
||||
appendShieldedReceivedTransactions(result.transactions, knownTxids, address, received);
|
||||
appendShieldedReceivedTransactions(result.transactions, knownTxids, address, received, snapshot.miningAddresses);
|
||||
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listreceivedbyaddress error for %s: %s\n",
|
||||
address.substr(0, 12).c_str(), e.what());
|
||||
transactionRpcError = true;
|
||||
DEBUG_LOGF("z_listreceivedbyaddress error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
auto findPendingShieldedIndex = [&]() -> std::optional<std::size_t> {
|
||||
if (snapshot.shieldedAddresses.empty()) return std::nullopt;
|
||||
std::size_t start = nextSearchIndex < snapshot.shieldedAddresses.size() ? nextSearchIndex : 0;
|
||||
for (std::size_t offset = 0; offset < snapshot.shieldedAddresses.size(); ++offset) {
|
||||
std::size_t index = (start + offset) % snapshot.shieldedAddresses.size();
|
||||
if (!scannedAtTip(snapshot.shieldedAddresses[index])) return index;
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
|
||||
auto pendingShieldedIndex = findPendingShieldedIndex();
|
||||
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
|
||||
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
|
||||
|
||||
for (const auto& txid : snapshot.sendTxids) {
|
||||
knownTxids.insert(txid);
|
||||
}
|
||||
@@ -784,10 +885,140 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot.previousTransactions.empty()) {
|
||||
if (transactionRpcError) {
|
||||
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, snapshot.pendingOpids);
|
||||
} else if (snapshot.shieldedAddresses.empty() || !result.shieldedScanComplete ||
|
||||
result.shieldedAddressesScanned < result.shieldedAddressCount) {
|
||||
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, snapshot.pendingOpids);
|
||||
}
|
||||
}
|
||||
|
||||
sortTransactionsNewestFirst(result.transactions);
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRecentTransactionRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
int currentBlockHeight,
|
||||
int pageSize)
|
||||
{
|
||||
TransactionRefreshResult result;
|
||||
result.blockHeight = currentBlockHeight;
|
||||
result.transactions = snapshot.previousTransactions;
|
||||
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
|
||||
result.shieldedScanHeights = snapshot.shieldedScanHeights;
|
||||
|
||||
try {
|
||||
std::set<std::string> recentTxids;
|
||||
std::vector<TransactionInfo> recentTransactions;
|
||||
json transactions = rpc.call("listtransactions", json::array({"", pageSize, 0}));
|
||||
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
|
||||
|
||||
for (auto& recent : recentTransactions) {
|
||||
bool replaced = false;
|
||||
for (auto& existing : result.transactions) {
|
||||
if (existing.txid == recent.txid && existing.type == recent.type) {
|
||||
existing = recent;
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!replaced) result.transactions.push_back(std::move(recent));
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
|
||||
}
|
||||
|
||||
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
|
||||
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
|
||||
std::size_t shieldedLimit = snapshot.shieldedAddresses.size() - shieldedStart;
|
||||
if (snapshot.maxShieldedReceiveScans > 0) {
|
||||
shieldedLimit = std::min(shieldedLimit, snapshot.maxShieldedReceiveScans);
|
||||
}
|
||||
|
||||
for (std::size_t index = shieldedStart; index < shieldedStart + shieldedLimit; ++index) {
|
||||
const auto& address = snapshot.shieldedAddresses[index];
|
||||
try {
|
||||
std::vector<TransactionInfo> scannedTransactions;
|
||||
std::set<std::string> scannedTxids;
|
||||
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
|
||||
appendShieldedReceivedTransactions(scannedTransactions, scannedTxids, address, received, snapshot.miningAddresses);
|
||||
for (auto& scanned : scannedTransactions) {
|
||||
bool replaced = false;
|
||||
for (auto& existing : result.transactions) {
|
||||
if (existing.txid == scanned.txid && existing.type == scanned.type) {
|
||||
existing = scanned;
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!replaced) result.transactions.push_back(std::move(scanned));
|
||||
}
|
||||
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
||||
++result.shieldedAddressesScanned;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("recent z_listreceivedbyaddress error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
result.nextShieldedScanStartIndex =
|
||||
(shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
|
||||
result.shieldedScanComplete = true;
|
||||
|
||||
sortTransactionsNewestFirst(result.transactions);
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOperationStatusPoll(
|
||||
const json& result,
|
||||
const std::vector<std::string>& requestedOpids)
|
||||
{
|
||||
OperationStatusPollResult parsed;
|
||||
if (!result.is_array()) return parsed;
|
||||
|
||||
std::set<std::string> reported;
|
||||
for (const auto& op : result) {
|
||||
if (!op.is_object()) continue;
|
||||
std::string opid = op.value("id", std::string());
|
||||
if (opid.empty()) continue;
|
||||
reported.insert(opid);
|
||||
|
||||
std::string status = op.value("status", std::string());
|
||||
if (status == "success") {
|
||||
parsed.doneOpids.push_back(opid);
|
||||
parsed.anySuccess = true;
|
||||
if (op.contains("result") && op["result"].is_object() && op["result"].contains("txid")) {
|
||||
try {
|
||||
std::string txid = op["result"]["txid"].get<std::string>();
|
||||
if (!txid.empty()) {
|
||||
parsed.successTxids.push_back(txid);
|
||||
parsed.successTxidsByOpid[opid] = std::move(txid);
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
} else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
||||
parsed.doneOpids.push_back(opid);
|
||||
std::string msg = status == "failed" ? "Transaction failed" : "Transaction cancelled";
|
||||
if (op.contains("error") && op["error"].is_object() && op["error"].contains("message")) {
|
||||
try {
|
||||
msg = op["error"]["message"].get<std::string>();
|
||||
} catch (...) {}
|
||||
}
|
||||
parsed.failureMessages.push_back(msg);
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& opid : requestedOpids) {
|
||||
if (!opid.empty() && reported.find(opid) == reported.end()) {
|
||||
parsed.staleOpids.push_back(opid);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
|
||||
{
|
||||
if (!result.ok) return;
|
||||
@@ -838,6 +1069,7 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
||||
|
||||
if (result.blocks) state.sync.blocks = *result.blocks;
|
||||
if (result.headers) state.sync.headers = *result.headers;
|
||||
if (result.bestBlockHash) state.sync.best_blockhash = *result.bestBlockHash;
|
||||
if (result.verificationProgress) state.sync.verification_progress = *result.verificationProgress;
|
||||
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
|
||||
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
|
||||
@@ -952,12 +1184,10 @@ void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
|
||||
|
||||
bool NetworkRefreshService::shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsEmpty,
|
||||
bool transactionsDirty) const
|
||||
{
|
||||
return scheduler_.shouldRefreshTransactions(lastTxBlockHeight,
|
||||
currentBlockHeight,
|
||||
transactionsEmpty,
|
||||
transactionsDirty);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ public:
|
||||
bool blockchainOk = false;
|
||||
std::optional<int> blocks;
|
||||
std::optional<int> headers;
|
||||
std::optional<std::string> bestBlockHash;
|
||||
std::optional<double> verificationProgress;
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
@@ -146,6 +147,10 @@ public:
|
||||
std::vector<AddressInfo> transparentAddresses;
|
||||
};
|
||||
|
||||
struct AddressRefreshSnapshot {
|
||||
std::unordered_map<std::string, bool> shieldedSpendingKeys;
|
||||
};
|
||||
|
||||
struct TransactionViewCacheEntry {
|
||||
std::string from_address;
|
||||
std::int64_t timestamp = 0;
|
||||
@@ -165,12 +170,32 @@ public:
|
||||
std::unordered_set<std::string> fullyEnrichedTxids;
|
||||
TransactionViewCache viewTxCache;
|
||||
std::unordered_set<std::string> sendTxids;
|
||||
std::unordered_set<std::string> pendingOpids;
|
||||
std::vector<TransactionInfo> previousTransactions;
|
||||
std::set<std::string> miningAddresses;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
std::size_t shieldedScanStartIndex = 0;
|
||||
std::size_t maxShieldedReceiveScans = 0;
|
||||
};
|
||||
|
||||
struct TransactionRefreshResult {
|
||||
std::vector<TransactionInfo> transactions;
|
||||
int blockHeight = -1;
|
||||
TransactionViewCache newViewTxEntries;
|
||||
std::size_t nextShieldedScanStartIndex = 0;
|
||||
std::size_t shieldedAddressesScanned = 0;
|
||||
std::size_t shieldedAddressCount = 0;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
bool shieldedScanComplete = true;
|
||||
};
|
||||
|
||||
struct OperationStatusPollResult {
|
||||
std::vector<std::string> doneOpids;
|
||||
std::vector<std::string> staleOpids;
|
||||
std::vector<std::string> successTxids;
|
||||
std::unordered_map<std::string, std::string> successTxidsByOpid;
|
||||
std::vector<std::string> failureMessages;
|
||||
bool anySuccess = false;
|
||||
};
|
||||
|
||||
struct TransactionCacheUpdate {
|
||||
@@ -187,7 +212,9 @@ public:
|
||||
static ConnectionInfoResult parseConnectionInfoResult(const nlohmann::json& info);
|
||||
static WalletEncryptionResult parseWalletEncryptionResult(const nlohmann::json& walletInfo);
|
||||
static WarmupPollResult collectWarmupPollResult(RefreshRpcGateway& rpc);
|
||||
static ConnectionInitResult collectConnectionInitResult(RefreshRpcGateway& rpc);
|
||||
static ConnectionInitResult collectConnectionInitResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
|
||||
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
|
||||
bool balanceOk,
|
||||
const nlohmann::json& blockInfo,
|
||||
@@ -200,7 +227,8 @@ public:
|
||||
double daemonMemoryMb);
|
||||
static MiningRefreshResult collectMiningRefreshResult(RefreshRpcGateway& rpc,
|
||||
double daemonMemoryMb,
|
||||
bool includeSlowRefresh);
|
||||
bool includeSlowRefresh,
|
||||
bool includeLocalHashrate = true);
|
||||
static PeerRefreshResult parsePeerRefreshResult(const nlohmann::json& peers,
|
||||
const nlohmann::json& bannedPeers);
|
||||
static PeerRefreshResult collectPeerRefreshResult(RefreshRpcGateway& rpc);
|
||||
@@ -217,17 +245,22 @@ public:
|
||||
const nlohmann::json& unspent);
|
||||
static void applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const nlohmann::json& unspent);
|
||||
static AddressRefreshResult collectAddressRefreshResult(RefreshRpcGateway& rpc);
|
||||
static AddressRefreshSnapshot buildAddressRefreshSnapshot(const WalletState& state);
|
||||
static AddressRefreshResult collectAddressRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const AddressRefreshSnapshot& snapshot = {});
|
||||
static TransactionRefreshSnapshot buildTransactionRefreshSnapshot(const WalletState& state,
|
||||
const TransactionViewCache& viewTxCache,
|
||||
const std::unordered_set<std::string>& sendTxids);
|
||||
static void appendTransparentTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const nlohmann::json& result);
|
||||
const nlohmann::json& result,
|
||||
const std::set<std::string>& miningAddresses = {});
|
||||
static void appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const std::string& address,
|
||||
const nlohmann::json& received);
|
||||
const nlohmann::json& received,
|
||||
const std::set<std::string>& miningAddresses = {});
|
||||
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
@@ -237,6 +270,13 @@ public:
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
int currentBlockHeight,
|
||||
int maxViewTransactionsPerCycle);
|
||||
static TransactionRefreshResult collectRecentTransactionRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
int currentBlockHeight,
|
||||
int pageSize = 100);
|
||||
static OperationStatusPollResult parseOperationStatusPoll(const nlohmann::json& result,
|
||||
const std::vector<std::string>& requestedOpids);
|
||||
|
||||
static void applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result);
|
||||
static void applyWalletEncryptionResult(WalletState& state, const WalletEncryptionResult& result);
|
||||
@@ -280,7 +320,6 @@ public:
|
||||
void resetTxAge() { scheduler_.resetTxAge(); }
|
||||
bool shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsEmpty,
|
||||
bool transactionsDirty) const;
|
||||
|
||||
bool beginJob(Job job);
|
||||
@@ -301,7 +340,12 @@ public:
|
||||
if (!ticket.accepted) return {ticket, false, queueDepth};
|
||||
|
||||
worker.post([this, ticket, work = std::forward<WorkFn>(work)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
auto mainCallback = work();
|
||||
rpc::RPCWorker::MainCb mainCallback;
|
||||
try {
|
||||
mainCallback = work();
|
||||
} catch (...) {
|
||||
mainCallback = nullptr;
|
||||
}
|
||||
return [this, ticket, mainCallback = std::move(mainCallback)]() mutable {
|
||||
if (!completeDispatch(ticket)) return;
|
||||
if (mainCallback) mainCallback();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -55,7 +55,6 @@ public:
|
||||
void resetTxAge();
|
||||
bool shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsEmpty,
|
||||
bool transactionsDirty) const;
|
||||
|
||||
private:
|
||||
|
||||
362
src/ui/explorer/explorer_block_cache.cpp
Normal file
362
src/ui/explorer/explorer_block_cache.cpp
Normal file
@@ -0,0 +1,362 @@
|
||||
#include "explorer_block_cache.h"
|
||||
|
||||
#include "../../util/logger.h"
|
||||
#include "../../util/platform.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <utility>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kCacheSchemaVersion = 1;
|
||||
|
||||
struct Statement {
|
||||
sqlite3_stmt* handle = nullptr;
|
||||
|
||||
Statement(sqlite3* db, const char* sql)
|
||||
{
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) {
|
||||
handle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
~Statement()
|
||||
{
|
||||
if (handle) sqlite3_finalize(handle);
|
||||
}
|
||||
|
||||
Statement(const Statement&) = delete;
|
||||
Statement& operator=(const Statement&) = delete;
|
||||
};
|
||||
|
||||
bool bindText(sqlite3_stmt* statement, int index, const std::string& value)
|
||||
{
|
||||
return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool blockSummaryFromJson(const json& source, ExplorerBlockSummary& block)
|
||||
{
|
||||
if (!source.is_object()) return false;
|
||||
try {
|
||||
block.height = source.value("height", 0);
|
||||
block.hash = source.value("hash", std::string());
|
||||
block.tx_count = source.value("tx_count", 0);
|
||||
block.size = source.value("size", 0);
|
||||
block.time = source.value("time", static_cast<std::int64_t>(0));
|
||||
block.difficulty = source.value("difficulty", 0.0);
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
return block.height > 0 && !block.hash.empty();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExplorerBlockCache::ExplorerBlockCache()
|
||||
: ExplorerBlockCache(defaultDatabasePath(), defaultLegacyJsonPath())
|
||||
{
|
||||
}
|
||||
|
||||
ExplorerBlockCache::ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath)
|
||||
: database_path_(std::move(databasePath)), legacy_json_path_(std::move(legacyJsonPath))
|
||||
{
|
||||
}
|
||||
|
||||
ExplorerBlockCache::~ExplorerBlockCache()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::defaultDatabasePath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks.sqlite").string();
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::defaultLegacyJsonPath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks_cache.json").string();
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
fs::path path(database_path_);
|
||||
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Failed to create explorer cache directory: %s\n", e.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3* openedDb = nullptr;
|
||||
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Failed to open explorer block cache: %s\n",
|
||||
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
migrateLegacyJsonIfNeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight, int maxHeight)
|
||||
{
|
||||
std::map<int, ExplorerBlockSummary> blocks;
|
||||
if (minHeight > maxHeight) std::swap(minHeight, maxHeight);
|
||||
if (minHeight < 1 || maxHeight < 1 || !ensureOpen()) return blocks;
|
||||
|
||||
Statement statement(db_,
|
||||
"SELECT height, hash, tx_count, size, time, difficulty "
|
||||
"FROM explorer_blocks WHERE height BETWEEN ? AND ? ORDER BY height DESC");
|
||||
if (!statement.handle) return blocks;
|
||||
|
||||
sqlite3_bind_int(statement.handle, 1, minHeight);
|
||||
sqlite3_bind_int(statement.handle, 2, maxHeight);
|
||||
|
||||
while (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
ExplorerBlockSummary block;
|
||||
block.height = sqlite3_column_int(statement.handle, 0);
|
||||
const unsigned char* hashText = sqlite3_column_text(statement.handle, 1);
|
||||
if (hashText) block.hash = reinterpret_cast<const char*>(hashText);
|
||||
block.tx_count = sqlite3_column_int(statement.handle, 2);
|
||||
block.size = sqlite3_column_int(statement.handle, 3);
|
||||
block.time = static_cast<std::int64_t>(sqlite3_column_int64(statement.handle, 4));
|
||||
block.difficulty = sqlite3_column_double(statement.handle, 5);
|
||||
if (block.height > 0 && !block.hash.empty()) {
|
||||
blocks[block.height] = std::move(block);
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||
{
|
||||
if (block.height < 1 || block.hash.empty() || !ensureOpen()) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO explorer_blocks "
|
||||
"(height, hash, tx_count, size, time, difficulty) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
if (!statement.handle) return false;
|
||||
|
||||
sqlite3_bind_int(statement.handle, 1, block.height);
|
||||
if (!bindText(statement.handle, 2, block.hash)) return false;
|
||||
sqlite3_bind_int(statement.handle, 3, block.tx_count);
|
||||
sqlite3_bind_int(statement.handle, 4, block.size);
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(block.time));
|
||||
sqlite3_bind_double(statement.handle, 6, block.difficulty);
|
||||
|
||||
return sqlite3_step(statement.handle) == SQLITE_DONE;
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::cachedBlockCount()
|
||||
{
|
||||
if (!ensureOpen()) return 0;
|
||||
Statement statement(db_, "SELECT COUNT(*) FROM explorer_blocks");
|
||||
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
|
||||
return sqlite3_column_int(statement.handle, 0);
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::clearBlocks()
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
exec("DELETE FROM explorer_blocks");
|
||||
}
|
||||
|
||||
ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
||||
int currentHeight, const std::string& currentBestHash)
|
||||
{
|
||||
SavedTipValidation validation;
|
||||
if (currentHeight <= 0 || !ensureOpen()) return validation;
|
||||
|
||||
int savedHeight = getMetaInt("tip_height", 0);
|
||||
std::string savedHash = getMetaValue("tip_hash");
|
||||
|
||||
if (savedHeight <= 0 || savedHash.empty()) {
|
||||
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentHeight < savedHeight) {
|
||||
clearBlocks();
|
||||
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
|
||||
else updateTip(0, std::string());
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentHeight == savedHeight) {
|
||||
if (currentBestHash.empty()) return validation;
|
||||
if (currentBestHash != savedHash) clearBlocks();
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentBestHash.empty()) return validation;
|
||||
|
||||
validation.needed = true;
|
||||
validation.height = savedHeight;
|
||||
validation.expectedHash = savedHash;
|
||||
return validation;
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::applySavedTipValidation(const SavedTipValidation& validation,
|
||||
const std::string& actualHash,
|
||||
int currentHeight,
|
||||
const std::string& currentBestHash)
|
||||
{
|
||||
if (!validation.needed || !ensureOpen()) return;
|
||||
if (actualHash.empty()) return;
|
||||
|
||||
if (actualHash != validation.expectedHash) {
|
||||
clearBlocks();
|
||||
}
|
||||
|
||||
if (currentHeight > 0 && !currentBestHash.empty()) {
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
}
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::updateTip(int height, const std::string& hash)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
setMetaValue("tip_height", std::to_string(std::max(0, height)));
|
||||
setMetaValue("tip_hash", hash);
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::exec(const char* sql)
|
||||
{
|
||||
if (!db_) return false;
|
||||
char* error = nullptr;
|
||||
int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error);
|
||||
if (result != SQLITE_OK) {
|
||||
DEBUG_LOGF("Explorer block cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
|
||||
if (error) sqlite3_free(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::getMetaValue(const std::string& key)
|
||||
{
|
||||
if (!ensureOpen()) return {};
|
||||
|
||||
Statement statement(db_, "SELECT value FROM explorer_cache_meta WHERE key = ?");
|
||||
if (!statement.handle) return {};
|
||||
if (!bindText(statement.handle, 1, key)) return {};
|
||||
if (sqlite3_step(statement.handle) != SQLITE_ROW) return {};
|
||||
|
||||
const unsigned char* valueText = sqlite3_column_text(statement.handle, 0);
|
||||
return valueText ? reinterpret_cast<const char*>(valueText) : std::string();
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::getMetaInt(const std::string& key, int fallback)
|
||||
{
|
||||
std::string value = getMetaValue(key);
|
||||
if (value.empty()) return fallback;
|
||||
try {
|
||||
return std::stoi(value);
|
||||
} catch (...) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::setMetaValue(const std::string& key, const std::string& value)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO explorer_cache_meta (key, value) VALUES (?, ?)");
|
||||
if (!statement.handle) return;
|
||||
if (!bindText(statement.handle, 1, key)) return;
|
||||
if (!bindText(statement.handle, 2, value)) return;
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS explorer_blocks ("
|
||||
"height INTEGER PRIMARY KEY,"
|
||||
"hash TEXT NOT NULL,"
|
||||
"tx_count INTEGER NOT NULL,"
|
||||
"size INTEGER NOT NULL,"
|
||||
"time INTEGER NOT NULL,"
|
||||
"difficulty REAL NOT NULL)") &&
|
||||
exec("CREATE TABLE IF NOT EXISTS explorer_cache_meta ("
|
||||
"key TEXT PRIMARY KEY,"
|
||||
"value TEXT NOT NULL)") &&
|
||||
exec("INSERT OR IGNORE INTO explorer_cache_meta (key, value) VALUES "
|
||||
"('schema_version', '1')");
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::migrateLegacyJsonIfNeeded()
|
||||
{
|
||||
if (!db_ || getMetaValue("json_migrated") == "1") return;
|
||||
|
||||
bool migrated = false;
|
||||
try {
|
||||
if (!legacy_json_path_.empty() && fs::exists(legacy_json_path_)) {
|
||||
std::ifstream file(legacy_json_path_);
|
||||
json cache;
|
||||
file >> cache;
|
||||
|
||||
if (cache.is_object() && cache.value("version", 0) == kCacheSchemaVersion) {
|
||||
exec("BEGIN IMMEDIATE TRANSACTION");
|
||||
if (cache.contains("blocks") && cache["blocks"].is_array()) {
|
||||
for (const auto& entry : cache["blocks"]) {
|
||||
ExplorerBlockSummary block;
|
||||
if (blockSummaryFromJson(entry, block)) {
|
||||
storeBlock(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
exec("COMMIT");
|
||||
|
||||
int tipHeight = cache.value("tip_height", 0);
|
||||
std::string tipHash = cache.value("tip_hash", std::string());
|
||||
if (tipHeight > 0 && !tipHash.empty()) {
|
||||
updateTip(tipHeight, tipHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
migrated = true;
|
||||
} catch (const std::exception& e) {
|
||||
exec("ROLLBACK");
|
||||
DEBUG_LOGF("Failed to migrate explorer JSON cache: %s\n", e.what());
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) setMetaValue("json_migrated", "1");
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::close()
|
||||
{
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
70
src/ui/explorer/explorer_block_cache.h
Normal file
70
src/ui/explorer/explorer_block_cache.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ExplorerBlockSummary {
|
||||
int height = 0;
|
||||
std::string hash;
|
||||
int tx_count = 0;
|
||||
int size = 0;
|
||||
std::int64_t time = 0;
|
||||
double difficulty = 0.0;
|
||||
};
|
||||
|
||||
class ExplorerBlockCache {
|
||||
public:
|
||||
struct SavedTipValidation {
|
||||
bool needed = false;
|
||||
int height = 0;
|
||||
std::string expectedHash;
|
||||
};
|
||||
|
||||
ExplorerBlockCache();
|
||||
ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath);
|
||||
~ExplorerBlockCache();
|
||||
|
||||
ExplorerBlockCache(const ExplorerBlockCache&) = delete;
|
||||
ExplorerBlockCache& operator=(const ExplorerBlockCache&) = delete;
|
||||
|
||||
static std::string defaultDatabasePath();
|
||||
static std::string defaultLegacyJsonPath();
|
||||
|
||||
bool ensureOpen();
|
||||
bool isOpen() const { return db_ != nullptr; }
|
||||
const std::string& databasePath() const { return database_path_; }
|
||||
|
||||
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
||||
bool storeBlock(const ExplorerBlockSummary& block);
|
||||
int cachedBlockCount();
|
||||
void clearBlocks();
|
||||
|
||||
SavedTipValidation prepareValidation(int currentHeight, const std::string& currentBestHash);
|
||||
void applySavedTipValidation(const SavedTipValidation& validation,
|
||||
const std::string& actualHash,
|
||||
int currentHeight,
|
||||
const std::string& currentBestHash);
|
||||
void updateTip(int height, const std::string& hash);
|
||||
|
||||
private:
|
||||
bool exec(const char* sql);
|
||||
std::string getMetaValue(const std::string& key);
|
||||
int getMetaInt(const std::string& key, int fallback);
|
||||
void setMetaValue(const std::string& key, const std::string& value);
|
||||
bool createSchema();
|
||||
void migrateLegacyJsonIfNeeded();
|
||||
void close();
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::string legacy_json_path_;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -42,7 +42,7 @@ std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListIn
|
||||
if (!AddressListMatchesFilter(input, filter)) continue;
|
||||
if (input.hidden && !showHidden) continue;
|
||||
if (hideZeroBalances && input.info->balance < 1e-9 && !input.hidden && !input.favorite) continue;
|
||||
rows.push_back({input.info, input.isZ, input.hidden, input.favorite,
|
||||
rows.push_back({input.info, input.isZ, input.hidden, input.favorite, input.mining,
|
||||
input.label, input.icon, input.sortOrder});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -652,6 +652,7 @@ static void RenderBalanceClassic(App* app)
|
||||
bool isZ;
|
||||
bool hidden;
|
||||
bool favorite;
|
||||
bool mining;
|
||||
};
|
||||
std::vector<AddrRow> rows;
|
||||
rows.reserve(state.z_addresses.size() + state.t_addresses.size());
|
||||
@@ -665,7 +666,7 @@ static void RenderBalanceClassic(App* app)
|
||||
bool isFav = app->isAddressFavorite(a.address);
|
||||
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav)
|
||||
continue;
|
||||
rows.push_back({&a, true, isHidden, isFav});
|
||||
rows.push_back({&a, true, isHidden, isFav, app->isMiningAddress(a.address)});
|
||||
}
|
||||
for (const auto& a : state.t_addresses) {
|
||||
std::string filter(addr_search);
|
||||
@@ -677,7 +678,7 @@ static void RenderBalanceClassic(App* app)
|
||||
bool isFav = app->isAddressFavorite(a.address);
|
||||
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav)
|
||||
continue;
|
||||
rows.push_back({&a, false, isHidden, isFav});
|
||||
rows.push_back({&a, false, isHidden, isFav, app->isMiningAddress(a.address)});
|
||||
}
|
||||
|
||||
// Sort: favorites first, then Z addresses, then by balance descending
|
||||
@@ -951,8 +952,9 @@ static void RenderBalanceClassic(App* app)
|
||||
const char* typeLabel = row.isZ ? "Shielded" : "Transparent";
|
||||
const char* hiddenTag = row.hidden ? " (hidden)" : "";
|
||||
const char* viewOnlyTag = (!addr.has_spending_key) ? " (view-only)" : "";
|
||||
const char* miningTag = row.mining ? TR("mining_tag") : "";
|
||||
char typeBuf[64];
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, viewOnlyTag);
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s%s", typeLabel, hiddenTag, viewOnlyTag, miningTag);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
|
||||
|
||||
// Label (if present, next to type)
|
||||
@@ -1033,6 +1035,9 @@ static void RenderBalanceClassic(App* app)
|
||||
app->setCurrentPage(NavPage::Send);
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem(row.mining ? TR("unmark_mining_address") : TR("mark_mining_address"))) {
|
||||
app->setMiningAddress(addr.address, !row.mining);
|
||||
}
|
||||
if (ImGui::MenuItem(TR("export_private_key"))) {
|
||||
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private);
|
||||
}
|
||||
@@ -1111,18 +1116,19 @@ static void RenderBalanceClassic(App* app)
|
||||
}
|
||||
ImGui::Spacing();
|
||||
|
||||
const auto& txs = state.transactions;
|
||||
int maxTx = kRecentTxCount;
|
||||
int count = (int)txs.size();
|
||||
if (count > maxTx) count = maxTx;
|
||||
ImFont* capFont = Type().caption();
|
||||
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
|
||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||
ImGui::BeginChild("##BalanceClassicRecentRows", ImVec2(0, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
|
||||
const auto& txs = state.transactions;
|
||||
int count = (int)txs.size();
|
||||
if (count == 0) {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
|
||||
"No transactions yet");
|
||||
} else {
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImFont* capFont = Type().caption();
|
||||
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
|
||||
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size, S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
@@ -1176,6 +1182,7 @@ static void RenderBalanceClassic(App* app)
|
||||
ImGui::Dummy(ImVec2(0, rowH));
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1297,7 +1304,8 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
std::string addrLabel = app->getAddressLabel(a.address);
|
||||
bool isHidden = app->isAddressHidden(a.address);
|
||||
bool isFav = app->isAddressFavorite(a.address);
|
||||
rowInputs.push_back({&a, isZ, isHidden, isFav,
|
||||
bool isMining = app->isMiningAddress(a.address);
|
||||
rowInputs.push_back({&a, isZ, isHidden, isFav, isMining,
|
||||
addrLabel, app->getAddressIcon(a.address),
|
||||
app->getAddressSortOrder(a.address)});
|
||||
}
|
||||
@@ -1646,8 +1654,9 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
{
|
||||
const char* typeLabel = row.isZ ? TR("shielded") : TR("transparent");
|
||||
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
|
||||
const char* miningTag = row.mining ? TR("mining_tag") : "";
|
||||
char typeBuf[64];
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s", typeLabel, hiddenTag);
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
|
||||
|
||||
// User label next to type
|
||||
@@ -1761,6 +1770,9 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
if (ImGui::MenuItem(TR("set_label"))) {
|
||||
AddressLabelDialog::show(app, addr.address, row.isZ);
|
||||
}
|
||||
if (ImGui::MenuItem(row.mining ? TR("unmark_mining_address") : TR("mark_mining_address"))) {
|
||||
app->setMiningAddress(addr.address, !row.mining);
|
||||
}
|
||||
if (ImGui::MenuItem(TR("export_private_key")))
|
||||
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private);
|
||||
if (row.isZ) {
|
||||
@@ -1869,10 +1881,13 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
|
||||
ImGui::Spacing();
|
||||
|
||||
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
|
||||
int maxTx = std::clamp((int)(recentH / scaledRowH), 2, 5);
|
||||
float availableListH = ImGui::GetContentRegionAvail().y;
|
||||
float listH = std::max({scaledRowH, recentH, availableListH});
|
||||
ImGui::BeginChild("##BalanceSharedRecentRows", ImVec2(0, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
|
||||
const auto& txs = state.transactions;
|
||||
int count = (int)txs.size();
|
||||
if (count > maxTx) count = maxTx;
|
||||
|
||||
if (count == 0) {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), "No transactions yet");
|
||||
@@ -1923,6 +1938,7 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
|
||||
ImGui::Dummy(ImVec2(0, rowH));
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
// Render sync progress bar (used by multiple layouts)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <ctime>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace dragonx {
|
||||
@@ -38,10 +40,36 @@ ImU32 ConsoleTab::COLOR_RESULT = IM_COL32(200, 200, 200, 255);
|
||||
ImU32 ConsoleTab::COLOR_ERROR = IM_COL32(246, 71, 64, 255);
|
||||
ImU32 ConsoleTab::COLOR_DAEMON = IM_COL32(160, 160, 160, 180);
|
||||
ImU32 ConsoleTab::COLOR_INFO = IM_COL32(191, 209, 229, 255);
|
||||
ImU32 ConsoleTab::COLOR_RPC = IM_COL32(120, 180, 255, 210);
|
||||
bool ConsoleTab::s_scanline_enabled = true;
|
||||
float ConsoleTab::s_console_zoom = 1.0f;
|
||||
bool ConsoleTab::s_daemon_messages_enabled = true;
|
||||
bool ConsoleTab::s_errors_only_enabled = false;
|
||||
bool ConsoleTab::s_rpc_trace_enabled = false;
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex s_rpc_trace_console_mutex;
|
||||
ConsoleTab* s_rpc_trace_console = nullptr;
|
||||
|
||||
std::string rpcTraceTimestamp()
|
||||
{
|
||||
std::time_t now = std::time(nullptr);
|
||||
std::tm localTime{};
|
||||
static std::mutex timeMutex;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(timeMutex);
|
||||
if (const std::tm* current = std::localtime(&now)) {
|
||||
localTime = *current;
|
||||
}
|
||||
}
|
||||
|
||||
char buffer[16];
|
||||
std::strftime(buffer, sizeof(buffer), "%H:%M:%S", &localTime);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ConsoleTab::refreshColors()
|
||||
{
|
||||
@@ -55,18 +83,21 @@ void ConsoleTab::refreshColors()
|
||||
auto err = S.drawElement("console", "color-error");
|
||||
auto dmn = S.drawElement("console", "color-daemon");
|
||||
auto inf = S.drawElement("console", "color-info");
|
||||
auto rpc = S.drawElement("console", "color-rpc");
|
||||
|
||||
ImU32 defCmd = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
||||
ImU32 defRes = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
|
||||
ImU32 defErr = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
|
||||
ImU32 defDmn = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
|
||||
ImU32 defInf = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
||||
ImU32 defRpc = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
|
||||
|
||||
COLOR_COMMAND = !cmd.color.empty() ? S.resolveColor(cmd.color, defCmd) : defCmd;
|
||||
COLOR_RESULT = !res.color.empty() ? S.resolveColor(res.color, defRes) : defRes;
|
||||
COLOR_ERROR = !err.color.empty() ? S.resolveColor(err.color, defErr) : defErr;
|
||||
COLOR_DAEMON = !dmn.color.empty() ? S.resolveColor(dmn.color, defDmn) : defDmn;
|
||||
COLOR_INFO = !inf.color.empty() ? S.resolveColor(inf.color, defInf) : defInf;
|
||||
COLOR_RPC = !rpc.color.empty() ? S.resolveColor(rpc.color, defRpc) : defRpc;
|
||||
} else {
|
||||
// No schema — use hardcoded defaults per theme
|
||||
COLOR_COMMAND = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
||||
@@ -74,11 +105,26 @@ void ConsoleTab::refreshColors()
|
||||
COLOR_ERROR = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
|
||||
COLOR_DAEMON = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
|
||||
COLOR_INFO = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
||||
COLOR_RPC = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
|
||||
}
|
||||
}
|
||||
|
||||
ConsoleTab::ConsoleTab()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
|
||||
s_rpc_trace_console = this;
|
||||
}
|
||||
rpc::RPCClient::setTraceCallback([](const std::string& source, const std::string& method) {
|
||||
ConsoleTab* console = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
|
||||
console = s_rpc_trace_console;
|
||||
}
|
||||
if (console) console->addRpcTraceLine(source, method);
|
||||
});
|
||||
rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
|
||||
|
||||
// Load console colors from ui.toml schema (uses current theme)
|
||||
refreshColors();
|
||||
|
||||
@@ -88,6 +134,14 @@ ConsoleTab::ConsoleTab()
|
||||
addLine("", COLOR_RESULT);
|
||||
}
|
||||
|
||||
ConsoleTab::~ConsoleTab()
|
||||
{
|
||||
rpc::RPCClient::setTraceEnabled(false);
|
||||
rpc::RPCClient::setTraceCallback(nullptr);
|
||||
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
|
||||
if (s_rpc_trace_console == this) s_rpc_trace_console = nullptr;
|
||||
}
|
||||
|
||||
void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc::RPCWorker* worker, daemon::XmrigManager* xmrig)
|
||||
{
|
||||
using namespace material;
|
||||
@@ -100,7 +154,7 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
|
||||
// Save old colors to remap existing lines
|
||||
ImU32 oldCmd = COLOR_COMMAND, oldRes = COLOR_RESULT;
|
||||
ImU32 oldErr = COLOR_ERROR, oldDmn = COLOR_DAEMON;
|
||||
ImU32 oldInf = COLOR_INFO;
|
||||
ImU32 oldInf = COLOR_INFO, oldRpc = COLOR_RPC;
|
||||
refreshColors();
|
||||
// Remap stored line colors from old to new
|
||||
{
|
||||
@@ -111,6 +165,7 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
|
||||
else if (line.color == oldErr) line.color = COLOR_ERROR;
|
||||
else if (line.color == oldDmn) line.color = COLOR_DAEMON;
|
||||
else if (line.color == oldInf) line.color = COLOR_INFO;
|
||||
else if (line.color == oldRpc) line.color = COLOR_RPC;
|
||||
}
|
||||
}
|
||||
s_lastDark = nowDark;
|
||||
@@ -282,81 +337,46 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
|
||||
|
||||
// CRT scanline effect over output area — aligned to text lines
|
||||
if (s_scanline_enabled) {
|
||||
float panelH = outPanelMax.y - outPanelMin.y;
|
||||
|
||||
// --- Text-aligned horizontal scanlines ---
|
||||
// Stride matches the actual text line height so each band sits between lines.
|
||||
float textLineH = output_line_height_;
|
||||
if (textLineH <= 1.0f) textLineH = Type().caption()->LegacySize * s_console_zoom + 2.0f; // fallback
|
||||
float bandH = schema::UI().drawElement("tabs.console", "scanline-gap").sizeOr(2.0f);
|
||||
int lineAlpha = (int)schema::UI().drawElement("tabs.console", "scanline-line-alpha").sizeOr(18.0f);
|
||||
if (textLineH <= 1.0f) textLineH = Type().caption()->LegacySize * s_console_zoom + 2.0f;
|
||||
|
||||
// Glow fringe parameters (soft gradient above/below each band)
|
||||
float glowSpread = schema::UI().drawElement("tabs.console", "scanline-glow-spread").sizeOr(0.0f);
|
||||
float glowIntensity = schema::UI().drawElement("tabs.console", "scanline-glow-intensity").sizeOr(0.6f);
|
||||
int glowRGB = (int)schema::UI().drawElement("tabs.console", "scanline-glow-color").sizeOr(255.0f);
|
||||
bool drawGlow = glowSpread > 0.0f && glowIntensity > 0.0f && lineAlpha > 0;
|
||||
int glowAlpha = drawGlow ? std::min(255, (int)(lineAlpha * glowIntensity)) : 0;
|
||||
ImU32 glowPeak = IM_COL32(glowRGB, glowRGB, glowRGB, glowAlpha);
|
||||
ImU32 glowClear = IM_COL32(glowRGB, glowRGB, glowRGB, 0);
|
||||
|
||||
if (textLineH >= 1.0f && lineAlpha > 0) {
|
||||
ImU32 lineCol = IM_COL32(255, 255, 255, lineAlpha);
|
||||
float stride = textLineH; // one text line per scanline period
|
||||
// Align with text: account for inner padding and scroll position
|
||||
float padY = Layout::spacingSm();
|
||||
float scrollFrac = std::fmod(consoleScrollY, stride);
|
||||
float startY = outPanelMin.y + padY - scrollFrac;
|
||||
// Ensure first band starts above the visible area
|
||||
while (startY > outPanelMin.y) startY -= stride;
|
||||
for (float y = startY; y < outPanelMax.y; y += stride) {
|
||||
// Place the dark band at the bottom edge of each text line period
|
||||
float bandTop = y + stride - bandH;
|
||||
float bandBot = y + stride;
|
||||
float yTop = std::max(bandTop, outPanelMin.y);
|
||||
float yBot = std::min(bandBot, outPanelMax.y);
|
||||
int lightAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-line-alpha").sizeOr(10.0f), 0, 255);
|
||||
int darkAlpha = std::clamp(lightAlpha + 10, 0, 255);
|
||||
if (textLineH >= 1.0f && (lightAlpha > 0 || darkAlpha > 0)) {
|
||||
ImU32 lightCol = IM_COL32(255, 255, 255, lightAlpha);
|
||||
ImU32 darkCol = IM_COL32(0, 0, 0, darkAlpha);
|
||||
for (const auto& row : scanline_rows_) {
|
||||
float yTop = std::max(row.yTop, outPanelMin.y);
|
||||
float yBot = std::min(row.yBot, outPanelMax.y);
|
||||
if (yTop < yBot) {
|
||||
// Glow fringes (gradient tapers away from band)
|
||||
if (drawGlow) {
|
||||
// Above fringe: transparent at top, glowPeak at bottom
|
||||
float gTop = std::max(yTop - glowSpread, outPanelMin.y);
|
||||
if (gTop < yTop) {
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, gTop), ImVec2(outPanelMax.x, yTop),
|
||||
glowClear, glowClear, glowPeak, glowPeak);
|
||||
}
|
||||
// Below fringe: glowPeak at top, transparent at bottom
|
||||
float gBot = std::min(yBot + glowSpread, outPanelMax.y);
|
||||
if (yBot < gBot) {
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, yBot), ImVec2(outPanelMax.x, gBot),
|
||||
glowPeak, glowPeak, glowClear, glowClear);
|
||||
}
|
||||
}
|
||||
// Opaque scanline band (drawn on top of glow)
|
||||
dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot), lineCol);
|
||||
dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot),
|
||||
(row.rowIndex % 2 == 0) ? lightCol : darkCol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Animated sweep band (brighter moving highlight) ---
|
||||
float panelH = outPanelMax.y - outPanelMin.y;
|
||||
float scanSpeed = schema::UI().drawElement("tabs.console", "scanline-speed").sizeOr(40.0f);
|
||||
float scanH = schema::UI().drawElement("tabs.console", "scanline-height").sizeOr(30.0f);
|
||||
int scanAlpha = (int)schema::UI().drawElement("tabs.console", "scanline-alpha").sizeOr(12.0f);
|
||||
float t = (float)std::fmod(ImGui::GetTime() * scanSpeed, (double)(panelH + scanH));
|
||||
float scanY = outPanelMin.y + t - scanH;
|
||||
float yTop = std::max(scanY, outPanelMin.y);
|
||||
float yBot = std::min(scanY + scanH, outPanelMax.y);
|
||||
if (yTop < yBot) {
|
||||
float mid = (yTop + yBot) * 0.5f;
|
||||
ImU32 clear = IM_COL32(255, 255, 255, 0);
|
||||
ImU32 peak = IM_COL32(255, 255, 255, scanAlpha);
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, mid),
|
||||
clear, clear, peak, peak);
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, mid), ImVec2(outPanelMax.x, yBot),
|
||||
peak, peak, clear, clear);
|
||||
float rawScanH = schema::UI().drawElement("tabs.console", "scanline-height").sizeOr(textLineH * 2.0f);
|
||||
int scanAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-alpha").sizeOr(8.0f), 0, 255);
|
||||
if (panelH > 1.0f && textLineH >= 1.0f && scanSpeed > 0.0f && scanAlpha > 0) {
|
||||
float scanLines = std::max(1.0f, std::round(rawScanH / textLineH));
|
||||
float scanH = scanLines * textLineH;
|
||||
float t = (float)std::fmod(ImGui::GetTime() * scanSpeed, (double)(panelH + scanH));
|
||||
float scanY = outPanelMin.y + t - scanH;
|
||||
float yTop = std::max(scanY, outPanelMin.y);
|
||||
float yBot = std::min(scanY + scanH, outPanelMax.y);
|
||||
if (yTop < yBot) {
|
||||
float mid = (yTop + yBot) * 0.5f;
|
||||
ImU32 clear = IM_COL32(255, 255, 255, 0);
|
||||
ImU32 peak = IM_COL32(255, 255, 255, scanAlpha);
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, mid),
|
||||
clear, clear, peak, peak);
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, mid), ImVec2(outPanelMax.x, yBot),
|
||||
peak, peak, clear, clear);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +512,25 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("%s", TR("console_show_errors_only"));
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::Spacing();
|
||||
ImGui::SameLine();
|
||||
|
||||
// App RPC trace toggle — captures method/source only, never results or params
|
||||
{
|
||||
static bool s_prev_rpc_trace_enabled = false;
|
||||
if (ImGui::Checkbox(TR("console_rpc_trace"), &s_rpc_trace_enabled)) {
|
||||
rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
|
||||
}
|
||||
if (s_prev_rpc_trace_enabled != s_rpc_trace_enabled && auto_scroll_) {
|
||||
scroll_to_bottom_ = true;
|
||||
}
|
||||
s_prev_rpc_trace_enabled = s_rpc_trace_enabled;
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("%s", TR("console_show_rpc_trace"));
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::Spacing();
|
||||
@@ -607,12 +646,15 @@ void ConsoleTab::renderOutput()
|
||||
output_line_height_ = line_height; // store for scanline alignment
|
||||
output_origin_ = ImGui::GetCursorScreenPos();
|
||||
output_scroll_y_ = ImGui::GetScrollY();
|
||||
scanline_rows_.clear();
|
||||
|
||||
// Build filtered line index list BEFORE mouse handling (so screenToTextPos works)
|
||||
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
|
||||
s_errors_only_enabled, COLOR_DAEMON, COLOR_ERROR};
|
||||
s_errors_only_enabled, s_rpc_trace_enabled,
|
||||
COLOR_DAEMON, COLOR_ERROR, COLOR_RPC};
|
||||
bool has_text_filter = !outputFilter.text.empty();
|
||||
bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled || outputFilter.errorsOnly;
|
||||
bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled ||
|
||||
!outputFilter.rpcTraceEnabled || outputFilter.errorsOnly;
|
||||
visible_indices_.clear();
|
||||
if (has_filter) {
|
||||
for (int i = 0; i < static_cast<int>(lines_.size()); i++) {
|
||||
@@ -826,6 +868,11 @@ void ConsoleTab::renderOutput()
|
||||
float rowY = lineOrigin.y + seg.yOffset;
|
||||
const char* segStart = line.text.c_str() + seg.byteStart;
|
||||
const char* segEnd = line.text.c_str() + seg.byteEnd;
|
||||
|
||||
if (s_scanline_enabled && line_height > 0.0f) {
|
||||
int rowIndex = static_cast<int>(std::floor((cumulative_y_offsets_[vi] + seg.yOffset) / line_height + 0.5f));
|
||||
scanline_rows_.push_back({rowY, rowY + seg.height, rowIndex});
|
||||
}
|
||||
|
||||
// Selection highlight for this sub-row
|
||||
if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) {
|
||||
@@ -1469,6 +1516,7 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
std::string result_str;
|
||||
bool is_error = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Console tab / User command");
|
||||
result_str = rpc->callRaw(method, params);
|
||||
} catch (const std::exception& e) {
|
||||
result_str = e.what();
|
||||
@@ -1499,6 +1547,7 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
} else {
|
||||
// Fallback: synchronous execution if no worker available
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Console tab / User command");
|
||||
std::string result_str = rpc->callRaw(method, params);
|
||||
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, false)) {
|
||||
addLine(resultLine.text, COLOR_RESULT);
|
||||
@@ -1545,6 +1594,11 @@ void ConsoleTab::addLine(const std::string& line, ImU32 color)
|
||||
scroll_to_bottom_ = auto_scroll_;
|
||||
}
|
||||
|
||||
void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& method)
|
||||
{
|
||||
addLine("[rpc] [" + rpcTraceTimestamp() + "] [" + source + "] " + method, COLOR_RPC);
|
||||
}
|
||||
|
||||
void ConsoleTab::addCommandResult(const std::string& cmd, const std::string& result, bool is_error)
|
||||
{
|
||||
addLine("> " + cmd, COLOR_COMMAND);
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace ui {
|
||||
class ConsoleTab {
|
||||
public:
|
||||
ConsoleTab();
|
||||
~ConsoleTab() = default;
|
||||
~ConsoleTab();
|
||||
|
||||
/**
|
||||
* @brief Render the console tab
|
||||
@@ -46,6 +46,7 @@ public:
|
||||
* @brief Add a line to the console output
|
||||
*/
|
||||
void addLine(const std::string& line, ImU32 color = IM_COL32(200, 200, 200, 255));
|
||||
void addRpcTraceLine(const std::string& source, const std::string& method);
|
||||
|
||||
/**
|
||||
* @brief Clear console output
|
||||
@@ -69,6 +70,9 @@ public:
|
||||
// Show only error messages (filter toggle)
|
||||
static bool s_errors_only_enabled;
|
||||
|
||||
// Show app RPC calls made through RPCClient (method/source only)
|
||||
static bool s_rpc_trace_enabled;
|
||||
|
||||
/// Refresh console text colors for current theme (call after theme switch)
|
||||
static void refreshColors();
|
||||
|
||||
@@ -78,6 +82,7 @@ public:
|
||||
static ImU32 COLOR_ERROR;
|
||||
static ImU32 COLOR_DAEMON;
|
||||
static ImU32 COLOR_INFO;
|
||||
static ImU32 COLOR_RPC;
|
||||
|
||||
private:
|
||||
struct ConsoleLine {
|
||||
@@ -129,6 +134,13 @@ private:
|
||||
float output_scroll_y_ = 0.0f; // Track scroll position for selection
|
||||
ImVec2 output_origin_ = {0, 0}; // Top-left of output area
|
||||
float output_line_height_ = 0.0f; // Text line height (for scanline alignment)
|
||||
|
||||
struct ScanlineRow {
|
||||
float yTop = 0.0f;
|
||||
float yBot = 0.0f;
|
||||
int rowIndex = 0;
|
||||
};
|
||||
std::vector<ScanlineRow> scanline_rows_;
|
||||
|
||||
std::mutex lines_mutex_;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
#include "../../data/wallet_state.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../explorer/explorer_block_cache.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -25,6 +26,8 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <atomic>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -39,18 +42,13 @@ static char s_search_buf[128] = {};
|
||||
static bool s_search_loading = false;
|
||||
static std::string s_search_error;
|
||||
|
||||
// Recent blocks cache
|
||||
struct BlockSummary {
|
||||
int height = 0;
|
||||
std::string hash;
|
||||
int tx_count = 0;
|
||||
int size = 0;
|
||||
int64_t time = 0;
|
||||
double difficulty = 0.0;
|
||||
};
|
||||
static std::vector<BlockSummary> s_recent_blocks;
|
||||
static int s_last_known_height = 0;
|
||||
using BlockSummary = ExplorerBlockSummary;
|
||||
static ExplorerBlockCache s_recent_block_cache;
|
||||
static std::set<int> s_pending_block_heights;
|
||||
static int s_recent_page = 0;
|
||||
static int s_recent_max_page = 0;
|
||||
static std::atomic<int> s_pending_block_fetches{0};
|
||||
static bool s_recent_disk_cache_validation_pending = false;
|
||||
|
||||
// Block detail modal
|
||||
static bool s_show_detail_modal = false;
|
||||
@@ -116,6 +114,34 @@ static std::string truncateHashToFit(const std::string& hash, ImFont* font, floa
|
||||
return truncateHash(hash, front, back);
|
||||
}
|
||||
|
||||
static void validateRecentBlockCache(App* app, const WalletState& state) {
|
||||
if (s_recent_disk_cache_validation_pending) return;
|
||||
|
||||
auto validation = s_recent_block_cache.prepareValidation(state.sync.blocks, state.sync.best_blockhash);
|
||||
if (!validation.needed) return;
|
||||
|
||||
auto* worker = app ? app->worker() : nullptr;
|
||||
auto* rpc = app ? app->rpc() : nullptr;
|
||||
if (!worker || !rpc || !rpc->isConnected()) return;
|
||||
|
||||
int currentHeight = state.sync.blocks;
|
||||
std::string currentHash = state.sync.best_blockhash;
|
||||
s_recent_disk_cache_validation_pending = true;
|
||||
worker->post([rpc, validation, currentHeight, currentHash = std::move(currentHash)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string actualHash;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Cache validation");
|
||||
actualHash = rpc->call("getblockhash", {validation.height}).get<std::string>();
|
||||
} catch (...) {}
|
||||
|
||||
return [validation, currentHeight, currentHash = std::move(currentHash),
|
||||
actualHash = std::move(actualHash)]() mutable {
|
||||
s_recent_disk_cache_validation_pending = false;
|
||||
s_recent_block_cache.applySavedTipValidation(validation, actualHash, currentHeight, currentHash);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static std::string formatSize(int bytes) {
|
||||
char b[32];
|
||||
if (bytes >= 1048576)
|
||||
@@ -127,6 +153,21 @@ static std::string formatSize(int bytes) {
|
||||
return b;
|
||||
}
|
||||
|
||||
static std::string formatHashrate(double hashrate) {
|
||||
char b[32];
|
||||
if (hashrate >= 1e12)
|
||||
snprintf(b, sizeof(b), "%.2f TH/s", hashrate / 1e12);
|
||||
else if (hashrate >= 1e9)
|
||||
snprintf(b, sizeof(b), "%.2f GH/s", hashrate / 1e9);
|
||||
else if (hashrate >= 1e6)
|
||||
snprintf(b, sizeof(b), "%.2f MH/s", hashrate / 1e6);
|
||||
else if (hashrate >= 1e3)
|
||||
snprintf(b, sizeof(b), "%.2f KH/s", hashrate / 1e3);
|
||||
else
|
||||
snprintf(b, sizeof(b), "%.0f H/s", hashrate);
|
||||
return b;
|
||||
}
|
||||
|
||||
// Copy icon button — draws a small icon that copies text to clipboard on click
|
||||
static void copyButton(const char* id, const std::string& text, float x, float y) {
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
@@ -211,6 +252,7 @@ static void fetchBlockDetail(App* app, int height) {
|
||||
json result;
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Block detail");
|
||||
auto hashResult = rpc->call("getblockhash", {height});
|
||||
std::string hash = hashResult.get<std::string>();
|
||||
result = rpc->call("getblock", {hash});
|
||||
@@ -236,12 +278,14 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
|
||||
bool gotTx = false;
|
||||
// Try as block hash first
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||
blockResult = rpc->call("getblock", {hash});
|
||||
gotBlock = true;
|
||||
} catch (...) {}
|
||||
// If not a block hash, try as txid
|
||||
if (!gotBlock) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||
txResult = rpc->call("getrawtransaction", {hash, 1});
|
||||
gotTx = true;
|
||||
} catch (...) {}
|
||||
@@ -267,43 +311,39 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
|
||||
});
|
||||
}
|
||||
|
||||
static bool fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
|
||||
static bool fetchRecentBlock(App* app, int height) {
|
||||
auto* worker = app->worker();
|
||||
auto* rpc = app->rpc();
|
||||
if (!worker || !rpc || s_pending_block_fetches > 0) return false;
|
||||
if (height < 1 || !worker || !rpc) return false;
|
||||
if (s_pending_block_heights.find(height) != s_pending_block_heights.end()) return false;
|
||||
|
||||
if (s_recent_blocks.empty()) s_recent_blocks.resize(count);
|
||||
s_pending_block_fetches = 1; // single batched fetch
|
||||
s_pending_block_heights.insert(height);
|
||||
s_pending_block_fetches.fetch_add(1);
|
||||
|
||||
worker->post([rpc, currentHeight, count]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<BlockSummary> results(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
int h = currentHeight - i;
|
||||
if (h < 1) continue;
|
||||
try {
|
||||
auto hashResult = rpc->call("getblockhash", {h});
|
||||
auto hash = hashResult.get<std::string>();
|
||||
auto result = rpc->call("getblock", {hash});
|
||||
auto& bs = results[i];
|
||||
bs.height = result.value("height", 0);
|
||||
bs.hash = result.value("hash", "");
|
||||
bs.time = result.value("time", (int64_t)0);
|
||||
bs.size = result.value("size", 0);
|
||||
bs.difficulty = result.value("difficulty", 0.0);
|
||||
if (result.contains("tx") && result["tx"].is_array())
|
||||
bs.tx_count = static_cast<int>(result["tx"].size());
|
||||
} catch (...) {}
|
||||
}
|
||||
return [results = std::move(results)]() mutable {
|
||||
bool gotAny = false;
|
||||
for (const auto& block : results) {
|
||||
if (block.height > 0) {
|
||||
gotAny = true;
|
||||
break;
|
||||
}
|
||||
worker->post([rpc, height]() -> rpc::RPCWorker::MainCb {
|
||||
BlockSummary block;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Recent blocks");
|
||||
auto hashResult = rpc->call("getblockhash", {height});
|
||||
auto hash = hashResult.get<std::string>();
|
||||
auto result = rpc->call("getblock", {hash});
|
||||
block.height = result.value("height", 0);
|
||||
block.hash = result.value("hash", "");
|
||||
block.time = result.value("time", (int64_t)0);
|
||||
block.size = result.value("size", 0);
|
||||
block.difficulty = result.value("difficulty", 0.0);
|
||||
if (result.contains("tx") && result["tx"].is_array())
|
||||
block.tx_count = static_cast<int>(result["tx"].size());
|
||||
} catch (...) {}
|
||||
|
||||
return [height, block = std::move(block)]() mutable {
|
||||
if (block.height > 0) {
|
||||
s_recent_block_cache.storeBlock(block);
|
||||
}
|
||||
s_pending_block_heights.erase(height);
|
||||
if (s_pending_block_fetches.load() > 0) {
|
||||
s_pending_block_fetches.fetch_sub(1);
|
||||
}
|
||||
if (gotAny) s_recent_blocks = std::move(results);
|
||||
s_pending_block_fetches = 0;
|
||||
};
|
||||
});
|
||||
return true;
|
||||
@@ -318,6 +358,7 @@ static void fetchMempoolInfo(App* app) {
|
||||
int txCount = 0;
|
||||
int64_t bytes = 0;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Mempool summary");
|
||||
auto result = rpc->call("getmempoolinfo", json::array());
|
||||
txCount = result.value("size", 0);
|
||||
bytes = result.value("bytes", (int64_t)0);
|
||||
@@ -338,7 +379,10 @@ static void fetchTxDetail(App* app, const std::string& txid) {
|
||||
s_tx_detail = json();
|
||||
worker->post([rpc, txid]() -> rpc::RPCWorker::MainCb {
|
||||
json result;
|
||||
try { result = rpc->call("getrawtransaction", {txid, 1}); }
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Transaction detail");
|
||||
result = rpc->call("getrawtransaction", {txid, 1});
|
||||
}
|
||||
catch (...) {}
|
||||
return [result]() {
|
||||
s_tx_loading = false;
|
||||
@@ -377,6 +421,16 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
auto& S = schema::UI();
|
||||
float pad = Layout::cardInnerPadding();
|
||||
|
||||
ImFont* capFont = Type().caption();
|
||||
float navBtnSz = std::max(24.0f * Layout::dpiScale(), capFont->LegacySize + 8.0f * Layout::dpiScale());
|
||||
if (s_recent_page > s_recent_max_page) s_recent_page = s_recent_max_page;
|
||||
if (s_recent_page < 0) s_recent_page = 0;
|
||||
char pageBuf[32];
|
||||
snprintf(pageBuf, sizeof(pageBuf), "%d / %d", s_recent_page + 1, s_recent_max_page + 1);
|
||||
float pageW = capFont->CalcTextSizeA(capFont->LegacySize, 1000.0f, 0.0f, pageBuf).x;
|
||||
float navGap = Layout::spacingXs();
|
||||
float navW = navBtnSz * 2.0f + pageW + navGap * 2.0f;
|
||||
|
||||
float inputW = std::min(
|
||||
S.drawElement("tabs.explorer", "search-input-width").size,
|
||||
availWidth * 0.65f);
|
||||
@@ -384,11 +438,12 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
float barH = S.drawElement("tabs.explorer", "search-bar-height").size;
|
||||
|
||||
// Clamp so search bar never overflows
|
||||
float maxInputW = availWidth - btnW - pad * 3 - Type().iconMed()->LegacySize;
|
||||
float maxInputW = availWidth - btnW - navW - pad * 4 - Type().iconMed()->LegacySize;
|
||||
if (inputW > maxInputW) inputW = maxInputW;
|
||||
if (inputW < 80.0f) inputW = 80.0f;
|
||||
|
||||
ImGui::Spacing();
|
||||
float rowStartX = ImGui::GetCursorScreenPos().x;
|
||||
|
||||
// Icon
|
||||
ImGui::PushFont(Type().iconMed());
|
||||
@@ -429,6 +484,32 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
ImGui::TextDisabled("%s", TR("loading"));
|
||||
}
|
||||
|
||||
float navX = rowStartX + availWidth - navW;
|
||||
float navY = cursorY + std::max(0.0f, (barH - navBtnSz) * 0.5f);
|
||||
ImGui::SetCursorScreenPos(ImVec2(navX, navY));
|
||||
ImGui::BeginDisabled(s_recent_page <= 0);
|
||||
ImGui::PushID("RecentBlocksSearchPrev");
|
||||
if (TactileButton(ICON_MD_CHEVRON_LEFT, ImVec2(navBtnSz, navBtnSz), Type().iconSmall())) {
|
||||
--s_recent_page;
|
||||
}
|
||||
ImGui::PopID();
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine(0, navGap);
|
||||
ImVec2 pagePos = ImGui::GetCursorScreenPos();
|
||||
ImGui::GetWindowDrawList()->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(pagePos.x, pagePos.y + (navBtnSz - capFont->LegacySize) * 0.5f), OnSurfaceMedium(), pageBuf);
|
||||
ImGui::Dummy(ImVec2(pageW, navBtnSz));
|
||||
ImGui::SameLine(0, navGap);
|
||||
ImGui::BeginDisabled(s_recent_page >= s_recent_max_page);
|
||||
ImGui::PushID("RecentBlocksSearchNext");
|
||||
if (TactileButton(ICON_MD_CHEVRON_RIGHT, ImVec2(navBtnSz, navBtnSz), Type().iconSmall())) {
|
||||
++s_recent_page;
|
||||
}
|
||||
ImGui::PopID();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(rowStartX, cursorY + barH));
|
||||
|
||||
// Error
|
||||
if (!s_search_error.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.4f, 0.4f, 1.0f));
|
||||
@@ -443,6 +524,7 @@ static void renderChainStats(App* app, float availWidth) {
|
||||
const auto& state = app->getWalletState();
|
||||
float pad = Layout::cardInnerPadding();
|
||||
float gap = Layout::cardGap();
|
||||
float dp = Layout::dpiScale();
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
GlassPanelSpec glassSpec;
|
||||
@@ -451,109 +533,185 @@ static void renderChainStats(App* app, float availWidth) {
|
||||
ImFont* ovFont = Type().overline();
|
||||
ImFont* capFont = Type().caption();
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
ImFont* heroFont = Type().h2();
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
|
||||
float cardW = (availWidth - gap) * 0.5f;
|
||||
float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
|
||||
float headerH = ovFont->LegacySize + Layout::spacingSm();
|
||||
float cardH = pad * 0.5f + headerH + rowH * 3 + Layout::spacingSm() * 2 + pad * 0.5f;
|
||||
float heroLineH = capFont->LegacySize + Layout::spacingXs() + heroFont->LegacySize;
|
||||
float hashLineH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
|
||||
float cardH = std::max(148.0f * dp,
|
||||
pad * 1.5f + headerH + heroLineH + hashLineH + Layout::spacingLg() * 2.0f);
|
||||
|
||||
bool stacked = availWidth < 760.0f * dp;
|
||||
float chainW = stacked ? availWidth : (availWidth - gap) * 0.60f;
|
||||
float metricsW = stacked ? availWidth : availWidth - chainW - gap;
|
||||
|
||||
ImVec2 basePos = ImGui::GetCursorScreenPos();
|
||||
char buf[128];
|
||||
|
||||
// ── Chain Card (left) ──
|
||||
{
|
||||
ImVec2 cardMin = basePos;
|
||||
auto drawStatusPill = [&](const ImVec2& cardMin, float cardW) {
|
||||
bool connected = app->rpc() && app->rpc()->isConnected();
|
||||
ImU32 pillCol = connected ? Success() : Error();
|
||||
std::string statusText;
|
||||
|
||||
if (!connected) {
|
||||
statusText = TR("not_connected");
|
||||
} else if (state.sync.syncing && state.sync.headers > 0) {
|
||||
snprintf(buf, sizeof(buf), "%s %.1f%%", TR("syncing"), state.sync.verification_progress * 100.0);
|
||||
statusText = buf;
|
||||
pillCol = Warning();
|
||||
} else {
|
||||
statusText = TR("connected");
|
||||
}
|
||||
|
||||
float maxTextW = cardW * 0.34f;
|
||||
statusText = truncateHashToFit(statusText, capFont, maxTextW);
|
||||
ImVec2 textSz = capFont->CalcTextSizeA(capFont->LegacySize, 1000.0f, 0.0f, statusText.c_str());
|
||||
float pillPadX = Layout::spacingSm();
|
||||
float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f;
|
||||
float pillW = textSz.x + pillPadX * 2.0f;
|
||||
ImVec2 pillMin(cardMin.x + cardW - pad - pillW, cardMin.y + pad * 0.5f);
|
||||
ImVec2 pillMax(pillMin.x + pillW, pillMin.y + pillH);
|
||||
|
||||
dl->AddRectFilled(pillMin, pillMax, WithAlpha(pillCol, 32), pillH * 0.5f);
|
||||
dl->AddRect(pillMin, pillMax, WithAlpha(pillCol, 110), pillH * 0.5f, 0, 1.0f * dp);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(pillMin.x + pillPadX, pillMin.y + Layout::spacingXs()), pillCol, statusText.c_str());
|
||||
};
|
||||
|
||||
auto drawChainTip = [&](const ImVec2& cardMin, float cardW) {
|
||||
ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
dl->AddText(ovFont, ovFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats"));
|
||||
drawStatusPill(cardMin, cardW);
|
||||
|
||||
float colW = (cardW - pad * 2) / 2.0f;
|
||||
float ry = cardMin.y + pad * 0.5f + headerH;
|
||||
float labelY = cardMin.y + pad * 0.5f + headerH + Layout::spacingLg();
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height"));
|
||||
|
||||
// Row 1: Height | Difficulty
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_block_height"));
|
||||
snprintf(buf, sizeof(buf), "%d", state.sync.blocks);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
snprintf(buf, sizeof(buf), "%d", state.sync.blocks);
|
||||
float valueY = labelY + capFont->LegacySize + Layout::spacingXs();
|
||||
DrawTextShadow(dl, heroFont, heroFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, valueY), OnSurface(), buf);
|
||||
|
||||
cx = cardMin.x + pad + colW;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("difficulty"));
|
||||
snprintf(buf, sizeof(buf), "%.4f", state.mining.difficulty);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
if (state.sync.syncing && state.sync.headers > 0) {
|
||||
float progress = std::clamp(static_cast<float>(state.sync.verification_progress), 0.0f, 1.0f);
|
||||
float barY = valueY + heroFont->LegacySize + Layout::spacingMd();
|
||||
float barH = std::max(3.0f * dp, 3.0f);
|
||||
float barW = cardW - pad * 2.0f;
|
||||
dl->AddRectFilled(ImVec2(cardMin.x + pad, barY),
|
||||
ImVec2(cardMin.x + pad + barW, barY + barH), WithAlpha(OnSurface(), 18), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(cardMin.x + pad, barY),
|
||||
ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f);
|
||||
}
|
||||
ry += rowH + Layout::spacingSm();
|
||||
|
||||
// Row 2: Hashrate | Notarized
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_hashrate"));
|
||||
double hr = state.mining.networkHashrate;
|
||||
if (hr >= 1e9)
|
||||
snprintf(buf, sizeof(buf), "%.2f GH/s", hr / 1e9);
|
||||
else if (hr >= 1e6)
|
||||
snprintf(buf, sizeof(buf), "%.2f MH/s", hr / 1e6);
|
||||
else if (hr >= 1e3)
|
||||
snprintf(buf, sizeof(buf), "%.2f KH/s", hr / 1e3);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "%.0f H/s", hr);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
float hashLabelY = cardMax.y - pad - hashLineH;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block"));
|
||||
|
||||
cx = cardMin.x + pad + colW;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_notarized"));
|
||||
snprintf(buf, sizeof(buf), "%d", state.notarized);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
float hashY = hashLabelY + capFont->LegacySize + Layout::spacingXs();
|
||||
float copyW = iconFont->LegacySize + Layout::spacingSm() * 2.0f;
|
||||
float hashMaxW = cardW - pad * 2.0f - (state.sync.best_blockhash.empty() ? 0.0f : copyW + Layout::spacingSm());
|
||||
std::string hashDisp = state.sync.best_blockhash.empty()
|
||||
? std::string("--")
|
||||
: truncateHashToFit(state.sync.best_blockhash, sub1, hashMaxW);
|
||||
dl->AddText(sub1, sub1->LegacySize,
|
||||
ImVec2(cardMin.x + pad, hashY), OnSurface(), hashDisp.c_str());
|
||||
|
||||
if (!state.sync.best_blockhash.empty()) {
|
||||
ImVec2 savedCursor = ImGui::GetCursorScreenPos();
|
||||
copyButton("ChainTipBestHash", state.sync.best_blockhash,
|
||||
cardMax.x - pad - copyW, hashY);
|
||||
ImGui::SetCursorScreenPos(savedCursor);
|
||||
}
|
||||
ry += rowH + Layout::spacingSm();
|
||||
};
|
||||
|
||||
// Row 3: Best Block Hash — adaptive truncation to fit card
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_best_block"));
|
||||
float maxHashW = cardW - pad * 2;
|
||||
std::string hashDisp = truncateHashToFit(state.sync.best_blockhash, sub1, maxHashW);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), hashDisp.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mempool Card (right) ──
|
||||
{
|
||||
ImVec2 cardMin(basePos.x + cardW + gap, basePos.y);
|
||||
auto drawMetricGrid = [&](const ImVec2& cardMin, float cardW) {
|
||||
ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
dl->AddText(ovFont, ovFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_mempool"));
|
||||
struct MetricSpec {
|
||||
const char* icon;
|
||||
const char* label;
|
||||
std::string value;
|
||||
std::string detail;
|
||||
ImU32 accent;
|
||||
};
|
||||
|
||||
float ry = cardMin.y + pad * 0.5f + headerH;
|
||||
snprintf(buf, sizeof(buf), "%.4f", state.mining.difficulty);
|
||||
std::string difficulty = buf;
|
||||
snprintf(buf, sizeof(buf), "%d", state.notarized);
|
||||
std::string notarized = buf;
|
||||
snprintf(buf, sizeof(buf), "%d", s_mempool_tx_count);
|
||||
std::string mempoolTxs = s_mempool_loading ? std::string("...") : std::string(buf);
|
||||
|
||||
// Row 1: Transactions
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_mempool_txs"));
|
||||
snprintf(buf, sizeof(buf), "%d", s_mempool_tx_count);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
MetricSpec metrics[4] = {
|
||||
{ ICON_MD_SPEED, TR("difficulty"), difficulty, "", Warning() },
|
||||
{ ICON_MD_SHOW_CHART, TR("peers_hashrate"), formatHashrate(state.mining.networkHashrate), "", Success() },
|
||||
{ ICON_MD_CONFIRMATION_NUMBER, TR("peers_notarized"), notarized, "", Primary() },
|
||||
{ ICON_MD_STORAGE, TR("explorer_mempool"), mempoolTxs, s_mempool_loading ? std::string("") : formatSize(static_cast<int>(s_mempool_size)), Secondary() },
|
||||
};
|
||||
|
||||
float gridX = cardMin.x + pad;
|
||||
float gridY = cardMin.y + pad;
|
||||
float gridW = cardW - pad * 2.0f;
|
||||
float gridH = cardH - pad * 2.0f;
|
||||
float cellW = gridW * 0.5f;
|
||||
float cellH = gridH * 0.5f;
|
||||
ImU32 dividerCol = WithAlpha(OnSurface(), 22);
|
||||
|
||||
dl->AddLine(ImVec2(gridX + cellW, gridY), ImVec2(gridX + cellW, gridY + gridH), dividerCol, 1.0f * dp);
|
||||
dl->AddLine(ImVec2(gridX, gridY + cellH), ImVec2(gridX + gridW, gridY + cellH), dividerCol, 1.0f * dp);
|
||||
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
int col = i % 2;
|
||||
int row = i / 2;
|
||||
ImVec2 cellMin(gridX + col * cellW, gridY + row * cellH);
|
||||
ImVec2 cellMax(cellMin.x + cellW, cellMin.y + cellH);
|
||||
float inset = Layout::spacingSm();
|
||||
float textX = cellMin.x + inset;
|
||||
float textY = cellMin.y + inset;
|
||||
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(cellMax.x - inset - iconFont->LegacySize, textY),
|
||||
WithAlpha(metrics[i].accent, 180), metrics[i].icon);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(textX, textY), OnSurfaceMedium(), metrics[i].label);
|
||||
|
||||
float valueY = textY + capFont->LegacySize + Layout::spacingXs();
|
||||
float valueMaxW = cellW - inset * 2.0f;
|
||||
std::string value = truncateHashToFit(metrics[i].value, sub1, valueMaxW);
|
||||
dl->AddText(sub1, sub1->LegacySize,
|
||||
ImVec2(textX, valueY), OnSurface(), value.c_str());
|
||||
|
||||
if (!metrics[i].detail.empty()) {
|
||||
std::string detail = truncateHashToFit(metrics[i].detail, capFont, valueMaxW);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(textX, valueY + sub1->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceDisabled(), detail.c_str());
|
||||
}
|
||||
}
|
||||
ry += rowH + Layout::spacingSm();
|
||||
};
|
||||
|
||||
// Row 2: Size
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_mempool_size"));
|
||||
std::string sizeStr = formatSize(static_cast<int>(s_mempool_size));
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), sizeStr.c_str());
|
||||
}
|
||||
drawChainTip(basePos, chainW);
|
||||
|
||||
if (stacked) {
|
||||
drawMetricGrid(ImVec2(basePos.x, basePos.y + cardH + gap), metricsW);
|
||||
ImGui::Dummy(ImVec2(availWidth, cardH * 2.0f + gap + Layout::spacingMd()));
|
||||
} else {
|
||||
drawMetricGrid(ImVec2(basePos.x + chainW + gap, basePos.y), metricsW);
|
||||
ImGui::Dummy(ImVec2(availWidth, cardH + Layout::spacingMd()));
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(availWidth, cardH + Layout::spacingMd()));
|
||||
}
|
||||
|
||||
static void renderRecentBlocks(App* app, float availWidth) {
|
||||
auto& S = schema::UI();
|
||||
float pad = Layout::cardInnerPadding();
|
||||
float dp = Layout::dpiScale();
|
||||
const auto& state = app->getWalletState();
|
||||
validateRecentBlockCache(app, state);
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
GlassPanelSpec glassSpec;
|
||||
@@ -564,19 +722,13 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
ImFont* body2 = Type().body2();
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
|
||||
float rowH = S.drawElement("tabs.explorer", "row-height").size;
|
||||
float baseRowH = S.drawElement("tabs.explorer", "row-height").size;
|
||||
float rowRound = S.drawElement("tabs.explorer", "row-rounding").size;
|
||||
float headerH = ovFont->LegacySize + Layout::spacingSm() + pad * 0.5f;
|
||||
|
||||
// Filter out empty entries
|
||||
std::vector<const BlockSummary*> blocks;
|
||||
for (const auto& bs : s_recent_blocks) {
|
||||
if (bs.height > 0) blocks.push_back(&bs);
|
||||
}
|
||||
|
||||
// Stretch card to fill the remaining tab height; rows scroll inside.
|
||||
float maxRows = 10.0f;
|
||||
float contentH = capFont->LegacySize + Layout::spacingXs() + rowH * maxRows;
|
||||
float contentH = capFont->LegacySize + Layout::spacingXs() + baseRowH * maxRows;
|
||||
float minTableH = headerH + contentH + pad;
|
||||
float remainingH = ImGui::GetContentRegionAvail().y;
|
||||
float tableH = std::max(minTableH, remainingH - Layout::spacingSm());
|
||||
@@ -585,10 +737,84 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + tableH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
// Header
|
||||
dl->AddText(ovFont, ovFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_recent_blocks"));
|
||||
|
||||
float hdrY = cardMin.y + pad * 0.5f + ovFont->LegacySize + Layout::spacingSm();
|
||||
float rowAreaTop = hdrY + capFont->LegacySize + Layout::spacingXs();
|
||||
float rowAreaH = cardMax.y - rowAreaTop - pad * 0.5f;
|
||||
int rowsPerPage = std::max(1, (int)std::ceil(rowAreaH / std::max(1.0f, baseRowH)));
|
||||
float rowH = baseRowH;
|
||||
|
||||
int maxPage = (state.sync.blocks > 0) ? (state.sync.blocks - 1) / rowsPerPage : 0;
|
||||
s_recent_max_page = maxPage;
|
||||
if (s_recent_page > maxPage) s_recent_page = maxPage;
|
||||
if (s_recent_page < 0) s_recent_page = 0;
|
||||
|
||||
int startHeight = state.sync.blocks - s_recent_page * rowsPerPage;
|
||||
std::vector<int> pageHeights;
|
||||
pageHeights.reserve(rowsPerPage);
|
||||
for (int i = 0; i < rowsPerPage; ++i) {
|
||||
int height = startHeight - i;
|
||||
if (height < 1) break;
|
||||
pageHeights.push_back(height);
|
||||
}
|
||||
|
||||
std::map<int, BlockSummary> cachedBlocks;
|
||||
if (!pageHeights.empty()) {
|
||||
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front());
|
||||
}
|
||||
|
||||
bool canFetchBlocks = state.sync.blocks > 0 &&
|
||||
!s_show_detail_modal && !s_detail_loading && !s_tx_loading;
|
||||
if (canFetchBlocks) {
|
||||
bool currentPageComplete = true;
|
||||
bool scheduledFetch = false;
|
||||
auto* rpc = app->rpc();
|
||||
if (rpc && rpc->isConnected()) {
|
||||
for (int height : pageHeights) {
|
||||
if (cachedBlocks.find(height) == cachedBlocks.end()) {
|
||||
currentPageComplete = false;
|
||||
scheduledFetch = fetchRecentBlock(app, height) || scheduledFetch;
|
||||
}
|
||||
}
|
||||
if (currentPageComplete && s_recent_page < maxPage) {
|
||||
std::vector<int> nextPageHeights;
|
||||
nextPageHeights.reserve(rowsPerPage);
|
||||
int nextStartHeight = state.sync.blocks - (s_recent_page + 1) * rowsPerPage;
|
||||
for (int i = 0; i < rowsPerPage; ++i) {
|
||||
int height = nextStartHeight - i;
|
||||
if (height < 1) break;
|
||||
nextPageHeights.push_back(height);
|
||||
}
|
||||
std::map<int, BlockSummary> nextCachedBlocks;
|
||||
if (!nextPageHeights.empty()) {
|
||||
nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front());
|
||||
}
|
||||
for (int height : nextPageHeights) {
|
||||
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
||||
scheduledFetch = fetchRecentBlock(app, height) || scheduledFetch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scheduledFetch) {
|
||||
fetchMempoolInfo(app);
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentBlockRow {
|
||||
int height = 0;
|
||||
const BlockSummary* block = nullptr;
|
||||
};
|
||||
|
||||
std::vector<RecentBlockRow> blocks;
|
||||
blocks.reserve(pageHeights.size());
|
||||
for (int height : pageHeights) {
|
||||
auto it = cachedBlocks.find(height);
|
||||
blocks.push_back({height,
|
||||
it != cachedBlocks.end() ? &it->second : nullptr});
|
||||
}
|
||||
// Responsive column layout — give height more room, use remaining for data
|
||||
float innerW = availWidth - pad * 2;
|
||||
float colHeight = pad;
|
||||
@@ -598,7 +824,6 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
float colHash = colHeight + innerW * 0.56f;
|
||||
float colTime = colHeight + innerW * 0.82f;
|
||||
|
||||
float hdrY = cardMin.y + pad * 0.5f + ovFont->LegacySize + Layout::spacingSm();
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colHeight, hdrY), OnSurfaceMedium(), TR("explorer_block_height"));
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colTxs, hdrY), OnSurfaceMedium(), TR("explorer_block_txs"));
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colSize, hdrY), OnSurfaceMedium(), TR("explorer_block_size"));
|
||||
@@ -607,13 +832,11 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colTime, hdrY), OnSurfaceMedium(), TR("explorer_block_time"));
|
||||
|
||||
// Scrollable child region for rows
|
||||
float rowAreaTop = hdrY + capFont->LegacySize + Layout::spacingXs();
|
||||
float rowAreaH = cardMax.y - rowAreaTop - pad * 0.5f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, rowAreaTop));
|
||||
|
||||
int parentVtx = dl->VtxBuffer.Size;
|
||||
ImGui::BeginChild("##BlockRows", ImVec2(availWidth, rowAreaH), false,
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ApplySmoothScroll();
|
||||
|
||||
ImDrawList* childDL = ImGui::GetWindowDrawList();
|
||||
@@ -624,12 +847,14 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
char buf[128];
|
||||
float rowInset = 2 * dp;
|
||||
|
||||
if (blocks.empty() && s_pending_block_fetches > 0) {
|
||||
if (blocks.empty()) {
|
||||
ImGui::SetCursorPosY(Layout::spacingMd());
|
||||
ImGui::TextDisabled("%s", TR("loading"));
|
||||
} else {
|
||||
for (size_t i = 0; i < blocks.size(); i++) {
|
||||
const auto* bs = blocks[i];
|
||||
const auto& row = blocks[i];
|
||||
const auto* bs = row.block;
|
||||
int blockHeight = bs ? bs->height : row.height;
|
||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||
float rowW = ImGui::GetContentRegionAvail().x - rowInset * 2;
|
||||
|
||||
@@ -651,19 +876,19 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
}
|
||||
|
||||
// Hover highlight — clear clickable feedback
|
||||
if (hovered) {
|
||||
if (hovered && bs) {
|
||||
childDL->AddRectFilled(rowMin, rowMax, WithAlpha(Primary(), 20), rowRound);
|
||||
childDL->AddRect(rowMin, rowMax, WithAlpha(Primary(), 40), rowRound);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
}
|
||||
|
||||
// Selected highlight
|
||||
if (s_show_detail_modal && s_detail_height == bs->height) {
|
||||
if (bs && s_show_detail_modal && s_detail_height == bs->height) {
|
||||
childDL->AddRectFilled(rowMin, rowMax, WithAlpha(Primary(), 25), rowRound);
|
||||
}
|
||||
|
||||
// Click opens block detail modal
|
||||
if (clicked && app->rpc() && app->rpc()->isConnected()) {
|
||||
if (bs && clicked && app->rpc() && app->rpc()->isConnected()) {
|
||||
fetchBlockDetail(app, bs->height);
|
||||
}
|
||||
|
||||
@@ -671,30 +896,32 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
float textY2 = rowPos.y + (rowH - body2->LegacySize) * 0.5f;
|
||||
|
||||
// Height — emphasized with larger font and primary color
|
||||
snprintf(buf, sizeof(buf), "#%d", bs->height);
|
||||
snprintf(buf, sizeof(buf), "#%d", blockHeight);
|
||||
childDL->AddText(sub1, sub1->LegacySize, ImVec2(cardMin.x + colHeight, textY),
|
||||
hovered ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf);
|
||||
(hovered && bs) ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf);
|
||||
|
||||
// Tx count
|
||||
snprintf(buf, sizeof(buf), "%d tx", bs->tx_count);
|
||||
if (bs) snprintf(buf, sizeof(buf), "%d tx", bs->tx_count);
|
||||
else snprintf(buf, sizeof(buf), "...");
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTxs, textY2), OnSurface(), buf);
|
||||
|
||||
// Size
|
||||
std::string sizeStr = formatSize(bs->size);
|
||||
std::string sizeStr = bs ? formatSize(bs->size) : "...";
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colSize, textY2), OnSurface(), sizeStr.c_str());
|
||||
|
||||
// Difficulty
|
||||
snprintf(buf, sizeof(buf), "%.2f", bs->difficulty);
|
||||
if (bs) snprintf(buf, sizeof(buf), "%.2f", bs->difficulty);
|
||||
else snprintf(buf, sizeof(buf), "...");
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colDiff, textY2), OnSurface(), buf);
|
||||
|
||||
// Hash — adaptive to available column width
|
||||
float hashMaxW = (colTime - colHash) - Layout::spacingSm();
|
||||
std::string hashDisp = truncateHashToFit(bs->hash, body2, hashMaxW);
|
||||
std::string hashDisp = bs ? truncateHashToFit(bs->hash, body2, hashMaxW) : "...";
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colHash, textY2),
|
||||
OnSurfaceMedium(), hashDisp.c_str());
|
||||
|
||||
// Time
|
||||
const char* timeStr = relativeTime(bs->time);
|
||||
const char* timeStr = bs ? relativeTime(bs->time) : "...";
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTime, textY2), OnSurfaceMedium(), timeStr);
|
||||
}
|
||||
}
|
||||
@@ -1023,30 +1250,14 @@ static void renderBlockDetailModal(App* app) {
|
||||
|
||||
void RenderExplorerTab(App* app)
|
||||
{
|
||||
const auto& state = app->getWalletState();
|
||||
auto* rpc = app->rpc();
|
||||
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
ImGui::BeginChild("##ExplorerScroll", avail, false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
ApplySmoothScroll();
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
|
||||
// Auto-refresh recent blocks when chain height changes, but avoid
|
||||
// starting expensive block fetches while the user is viewing details.
|
||||
if (state.sync.blocks > 0 && state.sync.blocks != s_last_known_height &&
|
||||
!s_show_detail_modal && !s_detail_loading && !s_tx_loading) {
|
||||
if (rpc && rpc->isConnected()) {
|
||||
if (fetchRecentBlocks(app, state.sync.blocks)) {
|
||||
s_last_known_height = state.sync.blocks;
|
||||
fetchMempoolInfo(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderSearchBar(app, availWidth);
|
||||
renderChainStats(app, availWidth);
|
||||
renderSearchBar(app, availWidth);
|
||||
renderRecentBlocks(app, availWidth);
|
||||
|
||||
ImGui::EndChild();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,6 +127,7 @@ void KeyExportDialog::render(App* app)
|
||||
std::string key;
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export key");
|
||||
auto result = rpc->call(method, {addr});
|
||||
key = result.get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
@@ -152,6 +153,7 @@ void KeyExportDialog::render(App* app)
|
||||
std::string key;
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export viewing key");
|
||||
auto result = rpc->call("z_exportviewingkey", {addr});
|
||||
key = result.get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
// - Recent received at bottom
|
||||
|
||||
#include "receive_tab.h"
|
||||
#include "send_tab.h"
|
||||
#include "../../app.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../util/i18n.h"
|
||||
@@ -360,145 +359,99 @@ static void DrawRecvIcon(ImDrawList* dl, float cx, float cy, float s, ImU32 col)
|
||||
// ============================================================================
|
||||
// Recent received transactions — styled to match transactions list
|
||||
// ============================================================================
|
||||
static void RenderRecentReceived(ImDrawList* dl, const AddressInfo& /* addr */,
|
||||
static void RenderRecentReceived(const AddressInfo& /* addr */,
|
||||
const WalletState& state, float width,
|
||||
ImFont* capFont, App* app) {
|
||||
auto& S = schema::UI();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("recent_received"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
float hs = Layout::hScale(width);
|
||||
float glassRound = Layout::glassRounding();
|
||||
|
||||
ImFont* body2 = Type().body2();
|
||||
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
|
||||
float iconSz = std::max(schema::UI().drawElement("tabs.receive", "recent-icon-min-size").size, schema::UI().drawElement("tabs.receive", "recent-icon-size").size * hs);
|
||||
float vs = Layout::vScale(std::max(1.0f, ImGui::GetContentRegionAvail().y));
|
||||
float dp = Layout::dpiScale();
|
||||
float rowH = std::max(18.0f * dp, S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f) * vs);
|
||||
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
|
||||
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||
ImU32 recvCol = Success();
|
||||
ImU32 greenCol = WithAlpha(Success(), (int)schema::UI().drawElement("tabs.receive", "recent-green-alpha").size);
|
||||
float rowPadLeft = Layout::spacingLg();
|
||||
|
||||
// Collect matching transactions
|
||||
std::vector<const TransactionInfo*> recvs;
|
||||
for (const auto& tx : state.transactions) {
|
||||
if (tx.type != "receive") continue;
|
||||
if (tx.type != "receive" && tx.type != "mined") continue;
|
||||
recvs.push_back(&tx);
|
||||
if (recvs.size() >= (size_t)schema::UI().drawElement("tabs.receive", "max-recent-receives").size) break;
|
||||
}
|
||||
|
||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||
|
||||
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
ImDrawList* rowDL = ImGui::GetWindowDrawList();
|
||||
|
||||
char buf[64];
|
||||
if (recvs.empty()) {
|
||||
ImGui::SetCursorPosY(Layout::spacingMd());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_recent_receives"));
|
||||
ImGui::EndChild();
|
||||
return;
|
||||
}
|
||||
|
||||
// Outer glass panel wrapping all rows
|
||||
float itemSpacingY = ImGui::GetStyle().ItemSpacing.y;
|
||||
float listH = rowH * (float)recvs.size() + itemSpacingY * (float)(recvs.size() - 1);
|
||||
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 listPanelMax(listPanelMin.x + width, listPanelMin.y + listH);
|
||||
GlassPanelSpec glassSpec;
|
||||
glassSpec.rounding = glassRound;
|
||||
DrawGlassPanel(dl, listPanelMin, listPanelMax, glassSpec);
|
||||
|
||||
// Clip draw commands to panel bounds to prevent overflow
|
||||
dl->PushClipRect(listPanelMin, listPanelMax, true);
|
||||
|
||||
char buf[64];
|
||||
for (size_t ri = 0; ri < recvs.size(); ri++) {
|
||||
const auto& tx = *recvs[ri];
|
||||
|
||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||
ImVec2 rowEnd(rowPos.x + width, rowPos.y + rowH);
|
||||
float rowY = rowPos.y + rowH * 0.5f;
|
||||
|
||||
// Hover glow
|
||||
bool hovered = material::IsRectHovered(rowPos, rowEnd);
|
||||
if (hovered) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.receive", "row-hover-alpha").size), schema::UI().drawElement("tabs.receive", "row-hover-rounding").size);
|
||||
// Icon
|
||||
DrawRecvIcon(rowDL, rowPos.x + Layout::spacingMd(), rowY, iconSz * 0.5f, recvCol);
|
||||
|
||||
// Type label (first line)
|
||||
float txX = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
|
||||
const char* typeText = tx.type == "mined" ? TR("mined_type") : TR("received_label");
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(txX, rowPos.y + 2.0f * dp), OnSurfaceMedium(), typeText);
|
||||
|
||||
// Address (second line)
|
||||
float addrX = txX + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
std::string addrDisplay = TruncateAddress(tx.address,
|
||||
(size_t)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2.0f * dp), OnSurfaceDisabled(), addrDisplay.c_str());
|
||||
|
||||
// Amount (right-aligned, first line)
|
||||
snprintf(buf, sizeof(buf), "+%.4f %s", std::abs(tx.amount), DRAGONX_TICKER);
|
||||
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, buf);
|
||||
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
|
||||
float amtX = rightEdge - amtSz.x - std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size,
|
||||
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(amtX, rowPos.y + 2.0f * dp), recvCol, buf);
|
||||
|
||||
// Time ago
|
||||
std::string ago = recvTimeAgo(tx.timestamp);
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, ago.c_str());
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f),
|
||||
rowPos.y + 2.0f * dp),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
|
||||
// Clickable row — hover highlight + navigate to History
|
||||
float rowW = ImGui::GetContentRegionAvail().x;
|
||||
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
||||
if (material::IsRectHovered(rowPos, rowEnd)) {
|
||||
rowDL->AddRectFilled(rowPos, rowEnd,
|
||||
IM_COL32(255, 255, 255, 15),
|
||||
S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0)) {
|
||||
app->setCurrentPage(ui::NavPage::History);
|
||||
}
|
||||
}
|
||||
|
||||
float cx = rowPos.x + rowPadLeft;
|
||||
float cy = rowPos.y + Layout::spacingMd();
|
||||
|
||||
// Icon
|
||||
DrawRecvIcon(dl, cx + iconSz, cy + body2->LegacySize * 0.5f, iconSz, recvCol);
|
||||
|
||||
// Type label (first line)
|
||||
float labelX = cx + iconSz * 2.0f + Layout::spacingSm();
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), recvCol, TR("received_label"));
|
||||
|
||||
// Time (next to type)
|
||||
std::string ago = recvTimeAgo(tx.timestamp);
|
||||
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, TR("received_label")).x;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX + typeW + Layout::spacingLg(), cy),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
|
||||
// Address (second line)
|
||||
std::string addr_display = TruncateAddress(tx.address, (int)schema::UI().drawElement("tabs.receive", "recent-addr-trunc-len").size);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceMedium(), addr_display.c_str());
|
||||
|
||||
// Amount (right-aligned, first line)
|
||||
snprintf(buf, sizeof(buf), "+%.8f", tx.amount);
|
||||
ImVec2 amtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, buf);
|
||||
float amtX = rowPos.x + width - amtSz.x - Layout::spacingLg();
|
||||
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(amtX, cy), recvCol, buf,
|
||||
schema::UI().drawElement("tabs.receive", "text-shadow-offset-x").size, schema::UI().drawElement("tabs.receive", "text-shadow-offset-y").size, IM_COL32(0, 0, 0, (int)schema::UI().drawElement("tabs.receive", "text-shadow-alpha").size));
|
||||
|
||||
// USD equivalent (right-aligned, second line)
|
||||
double priceUsd = state.market.price_usd;
|
||||
if (priceUsd > 0.0) {
|
||||
double usdVal = tx.amount * priceUsd;
|
||||
if (usdVal >= 1.0)
|
||||
snprintf(buf, sizeof(buf), "$%.2f", usdVal);
|
||||
else if (usdVal >= 0.01)
|
||||
snprintf(buf, sizeof(buf), "$%.4f", usdVal);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "$%.6f", usdVal);
|
||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rowPos.x + width - usdSz.x - Layout::spacingLg(), cy + body2->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
// Status badge
|
||||
{
|
||||
const char* statusStr;
|
||||
ImU32 statusCol;
|
||||
if (tx.confirmations == 0) {
|
||||
statusStr = TR("pending"); statusCol = Warning();
|
||||
} else if (tx.confirmations < (int)schema::UI().drawElement("tabs.receive", "confirmed-threshold").size) {
|
||||
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
||||
statusStr = buf; statusCol = Warning();
|
||||
} else {
|
||||
statusStr = TR("confirmed"); statusCol = greenCol;
|
||||
}
|
||||
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
|
||||
float statusX = amtX - sSz.x - Layout::spacingXxl();
|
||||
float minStatusX = cx + width * schema::UI().drawElement("tabs.receive", "status-min-x-ratio").size;
|
||||
if (statusX < minStatusX) statusX = minStatusX;
|
||||
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>((int)schema::UI().drawElement("tabs.receive", "status-pill-bg-alpha").size) << 24);
|
||||
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + (int)schema::UI().drawElement("tabs.receive", "status-pill-y-offset").size);
|
||||
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
|
||||
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.receive", "status-pill-rounding").size);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, rowH));
|
||||
|
||||
// Subtle divider between rows
|
||||
if (ri < recvs.size() - 1) {
|
||||
ImVec2 divStart = ImGui::GetCursorScreenPos();
|
||||
dl->AddLine(ImVec2(divStart.x + rowPadLeft + iconSz * 2.0f, divStart.y),
|
||||
ImVec2(divStart.x + width - Layout::spacingLg(), divStart.y),
|
||||
IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.receive", "row-divider-alpha").size));
|
||||
}
|
||||
}
|
||||
|
||||
dl->PopClipRect();
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -843,23 +796,6 @@ void RenderReceiveTab(App* app)
|
||||
ImGui::Dummy(ImVec2(addrColW, capFont->LegacySize + Layout::spacingSm()));
|
||||
}
|
||||
|
||||
// Clear button
|
||||
{
|
||||
bool hasData = (s_request_amount > 0 || s_request_memo[0]);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
||||
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "clear-btn-hover-alpha").size)));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
ImGui::ColorConvertU32ToFloat4(hasData ? OnSurfaceMedium() : OnSurfaceDisabled()));
|
||||
ImGui::BeginDisabled(!hasData);
|
||||
if (TactileSmallButton(TrId("clear_request", "recv").c_str(), S.resolveFont("button"))) {
|
||||
s_request_amount = 0.0;
|
||||
s_request_usd_amount = 0.0;
|
||||
s_request_memo[0] = '\0';
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleColor(3);
|
||||
}
|
||||
}
|
||||
float leftBottom = ImGui::GetCursorScreenPos().y;
|
||||
|
||||
@@ -869,9 +805,14 @@ void RenderReceiveTab(App* app)
|
||||
float rx = qrColX;
|
||||
float ry = sectionTop.y;
|
||||
|
||||
float maxQrSize = std::min(qrAvailW - Layout::spacingMd() * 2, S.drawElement("tabs.receive", "qr-max-size").size);
|
||||
float qrSize = std::max(S.drawElement("tabs.receive", "qr-min-size").size, maxQrSize);
|
||||
float qrPadding = Layout::spacingMd();
|
||||
float qrWidthBound = std::max(0.0f, qrAvailW - qrPadding * 2.0f);
|
||||
float qrHeightBound = std::max(0.0f, leftBottom - sectionTop.y - qrPadding * 2.0f);
|
||||
float qrMaxSize = qrHeightBound > 0.0f
|
||||
? std::min(qrWidthBound, qrHeightBound)
|
||||
: qrWidthBound;
|
||||
float qrMinSize = std::min(S.drawElement("tabs.receive", "qr-min-size").size, qrWidthBound);
|
||||
float qrSize = std::max(qrMinSize, qrMaxSize);
|
||||
float totalQrSize = qrSize + qrPadding * 2;
|
||||
float qrOffsetX = std::max(0.0f, (qrAvailW - totalQrSize) * 0.5f);
|
||||
ImVec2 qrPanelMin(rx + qrOffsetX, ry);
|
||||
@@ -917,6 +858,18 @@ void RenderReceiveTab(App* app)
|
||||
}
|
||||
|
||||
// Divider before action buttons
|
||||
{
|
||||
float actionBtnH = std::max(S.drawElement("tabs.receive", "action-btn-min-height").size,
|
||||
S.drawElement("tabs.receive", "action-btn-height").size * vScale);
|
||||
float footerH = innerGap + actionBtnH + pad;
|
||||
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
|
||||
float targetCardH = Layout::mainCardTargetH(formW, vScale);
|
||||
float footerTopH = targetCardH - footerH;
|
||||
if (currentCardH < footerTopH) {
|
||||
ImGui::Dummy(ImVec2(0, footerTopH - currentCardH));
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
||||
{
|
||||
ImVec2 divPos = ImGui::GetCursorScreenPos();
|
||||
@@ -964,6 +917,25 @@ void RenderReceiveTab(App* app)
|
||||
ImGui::PopStyleColor(3);
|
||||
}
|
||||
|
||||
{
|
||||
bool hasData = (s_request_amount > 0 || s_request_memo[0]);
|
||||
if (!firstBtn) ImGui::SameLine(0, btnGap);
|
||||
firstBtn = false;
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
||||
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "clear-btn-hover-alpha").size)));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
ImGui::ColorConvertU32ToFloat4(hasData ? OnSurfaceMedium() : OnSurfaceDisabled()));
|
||||
ImGui::BeginDisabled(!hasData);
|
||||
if (TactileButton(TrId("clear_request", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
|
||||
s_request_amount = 0.0;
|
||||
s_request_usd_amount = 0.0;
|
||||
s_request_memo[0] = '\0';
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleColor(3);
|
||||
}
|
||||
|
||||
if (!firstBtn) ImGui::SameLine(0, btnGap);
|
||||
firstBtn = false;
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
|
||||
@@ -978,20 +950,6 @@ void RenderReceiveTab(App* app)
|
||||
ImGui::PopStyleVar(); // FrameBorderSize
|
||||
ImGui::PopStyleColor(4);
|
||||
|
||||
if (selected.balance > 0) {
|
||||
if (!firstBtn) ImGui::SameLine(0, btnGap);
|
||||
firstBtn = false;
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
||||
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "btn-hover-alpha").size)));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()));
|
||||
if (TactileButton(TrId("send", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
|
||||
SetSendFromAddress(selected.address);
|
||||
app->setCurrentPage(NavPage::Send);
|
||||
}
|
||||
ImGui::PopStyleColor(3);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Bottom padding
|
||||
@@ -1020,7 +978,7 @@ void RenderReceiveTab(App* app)
|
||||
// ================================================================
|
||||
// RECENT RECEIVED
|
||||
// ================================================================
|
||||
RenderRecentReceived(dl, selected, state, formW, capFont, app);
|
||||
RenderRecentReceived(selected, state, formW, capFont, app);
|
||||
|
||||
ImGui::EndGroup();
|
||||
float measuredH = ImGui::GetCursorPosY() - contentStartY;
|
||||
|
||||
@@ -971,8 +971,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
// ============================================================================
|
||||
// Recent Sends section — styled to match transactions list
|
||||
// ============================================================================
|
||||
static void RenderRecentSends(ImDrawList* dl, const WalletState& state,
|
||||
float width, ImFont* capFont, App* app) {
|
||||
static void RenderRecentSends(const WalletState& state, float width, ImFont* capFont, App* app) {
|
||||
auto& S = schema::UI();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_recent_sends"));
|
||||
@@ -980,139 +979,89 @@ static void RenderRecentSends(ImDrawList* dl, const WalletState& state,
|
||||
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
float hs = Layout::hScale(avail.x);
|
||||
float glassRound = Layout::glassRounding();
|
||||
|
||||
ImFont* body2 = Type().body2();
|
||||
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
|
||||
float iconSz = std::max(schema::UI().drawElement("tabs.send", "recent-icon-min-size").size, schema::UI().drawElement("tabs.send", "recent-icon-size").size * hs);
|
||||
float vs = Layout::vScale(std::max(1.0f, avail.y));
|
||||
float dp = Layout::dpiScale();
|
||||
float rowH = std::max(18.0f * dp, S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f) * vs);
|
||||
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
|
||||
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||
ImU32 sendCol = Error();
|
||||
ImU32 greenCol = WithAlpha(Success(), (int)S.drawElement("tabs.send", "recent-green-alpha").size);
|
||||
float rowPadLeft = Layout::spacingLg();
|
||||
|
||||
// Collect matching transactions
|
||||
std::vector<const TransactionInfo*> sends;
|
||||
for (const auto& tx : state.transactions) {
|
||||
if (tx.type != "send") continue;
|
||||
if (tx.type != "send" && tx.type != "shield") continue;
|
||||
sends.push_back(&tx);
|
||||
if (sends.size() >= (size_t)S.drawElement("tabs.send", "max-recent-sends").size) break;
|
||||
}
|
||||
|
||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||
|
||||
ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
ImDrawList* rowDL = ImGui::GetWindowDrawList();
|
||||
|
||||
char buf[64];
|
||||
if (sends.empty()) {
|
||||
ImGui::SetCursorPosY(Layout::spacingMd());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("send_no_recent"));
|
||||
ImGui::EndChild();
|
||||
return;
|
||||
}
|
||||
|
||||
// Outer glass panel wrapping all rows
|
||||
float itemSpacingY = ImGui::GetStyle().ItemSpacing.y;
|
||||
float listH = rowH * (float)sends.size() + itemSpacingY * (float)(sends.size() - 1);
|
||||
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
|
||||
float listW = width;
|
||||
ImVec2 listPanelMax(listPanelMin.x + listW, listPanelMin.y + listH);
|
||||
GlassPanelSpec glassSpec;
|
||||
glassSpec.rounding = glassRound;
|
||||
DrawGlassPanel(dl, listPanelMin, listPanelMax, glassSpec);
|
||||
|
||||
// Clip draw commands to panel bounds to prevent overflow
|
||||
dl->PushClipRect(listPanelMin, listPanelMax, true);
|
||||
|
||||
char buf[64];
|
||||
for (size_t si = 0; si < sends.size(); si++) {
|
||||
const auto& tx = *sends[si];
|
||||
|
||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||
float innerW = listW;
|
||||
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
|
||||
float rowY = rowPos.y + rowH * 0.5f;
|
||||
|
||||
// Hover glow
|
||||
bool hovered = material::IsRectHovered(rowPos, rowEnd);
|
||||
if (hovered) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "row-hover-alpha").size), schema::UI().drawElement("tabs.send", "row-hover-rounding").size);
|
||||
// Icon
|
||||
DrawTxIcon(rowDL, "send", rowPos.x + Layout::spacingMd(), rowY, iconSz, sendCol);
|
||||
|
||||
// Type label (first line)
|
||||
float txX = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(txX, rowPos.y + 2.0f * dp), OnSurfaceMedium(), TR("sent_type"));
|
||||
|
||||
// Address (second line)
|
||||
float addrX = txX + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
std::string addrDisplay = TruncateAddress(tx.address,
|
||||
(size_t)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2.0f * dp), OnSurfaceDisabled(), addrDisplay.c_str());
|
||||
|
||||
// Amount (right-aligned, first line)
|
||||
snprintf(buf, sizeof(buf), "-%.4f %s", std::abs(tx.amount), DRAGONX_TICKER);
|
||||
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, buf);
|
||||
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
|
||||
float amtX = rightEdge - amtSz.x - std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size,
|
||||
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(amtX, rowPos.y + 2.0f * dp), sendCol, buf);
|
||||
|
||||
// Time ago
|
||||
std::string ago = timeAgo(tx.timestamp);
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, ago.c_str());
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f),
|
||||
rowPos.y + 2.0f * dp),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
|
||||
// Clickable row — hover highlight + navigate to History
|
||||
float rowW = ImGui::GetContentRegionAvail().x;
|
||||
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
||||
if (material::IsRectHovered(rowPos, rowEnd)) {
|
||||
rowDL->AddRectFilled(rowPos, rowEnd,
|
||||
IM_COL32(255, 255, 255, 15),
|
||||
S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0)) {
|
||||
app->setCurrentPage(ui::NavPage::History);
|
||||
}
|
||||
}
|
||||
|
||||
float cx = rowPos.x + rowPadLeft;
|
||||
float cy = rowPos.y + Layout::spacingMd();
|
||||
|
||||
// Icon
|
||||
DrawTxIcon(dl, tx.type, cx + iconSz, cy + body2->LegacySize * 0.5f, iconSz, sendCol);
|
||||
|
||||
// Type label (first line)
|
||||
float labelX = cx + iconSz * 2.0f + Layout::spacingSm();
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), sendCol, TR("sent_upper"));
|
||||
|
||||
// Time (next to type)
|
||||
std::string ago = timeAgo(tx.timestamp);
|
||||
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, TR("sent_upper")).x;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX + typeW + Layout::spacingLg(), cy),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
|
||||
// Address (second line)
|
||||
std::string addr_display = TruncateAddress(tx.address, (int)S.drawElement("tabs.send", "recent-addr-trunc-len").size);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceMedium(), addr_display.c_str());
|
||||
|
||||
// Amount (right-aligned, first line)
|
||||
snprintf(buf, sizeof(buf), "-%.8f", std::abs(tx.amount));
|
||||
ImVec2 amtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, buf);
|
||||
float amtX = rowPos.x + innerW - amtSz.x - Layout::spacingLg();
|
||||
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(amtX, cy), sendCol, buf,
|
||||
S.drawElement("tabs.send", "text-shadow-offset-x").size, S.drawElement("tabs.send", "text-shadow-offset-y").size, IM_COL32(0, 0, 0, (int)S.drawElement("tabs.send", "text-shadow-alpha").size));
|
||||
|
||||
// USD equivalent (right-aligned, second line)
|
||||
double priceUsd = state.market.price_usd;
|
||||
if (priceUsd > 0.0) {
|
||||
double usdVal = std::abs(tx.amount) * priceUsd;
|
||||
if (usdVal >= 1.0)
|
||||
snprintf(buf, sizeof(buf), "$%.2f", usdVal);
|
||||
else if (usdVal >= 0.01)
|
||||
snprintf(buf, sizeof(buf), "$%.4f", usdVal);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "$%.6f", usdVal);
|
||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rowPos.x + innerW - usdSz.x - Layout::spacingLg(), cy + body2->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
// Status badge
|
||||
{
|
||||
const char* statusStr;
|
||||
ImU32 statusCol;
|
||||
if (tx.confirmations == 0) {
|
||||
statusStr = TR("pending"); statusCol = Warning();
|
||||
} else if (tx.confirmations < (int)S.drawElement("tabs.send", "confirmed-threshold").size) {
|
||||
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
||||
statusStr = buf; statusCol = Warning();
|
||||
} else {
|
||||
statusStr = TR("confirmed"); statusCol = greenCol;
|
||||
}
|
||||
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
|
||||
float statusX = amtX - sSz.x - Layout::spacingXxl();
|
||||
float minStatusX = cx + innerW * S.drawElement("tabs.send", "status-min-x-ratio").size;
|
||||
if (statusX < minStatusX) statusX = minStatusX;
|
||||
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>((int)S.drawElement("tabs.send", "status-pill-bg-alpha").size) << 24);
|
||||
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + (int)S.drawElement("tabs.send", "status-pill-y-offset").size);
|
||||
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
|
||||
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.send", "status-pill-rounding").size);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, rowH));
|
||||
|
||||
// Subtle divider between rows
|
||||
if (si < sends.size() - 1) {
|
||||
ImVec2 divStart = ImGui::GetCursorScreenPos();
|
||||
dl->AddLine(ImVec2(divStart.x + rowPadLeft + iconSz * 2.0f, divStart.y),
|
||||
ImVec2(divStart.x + innerW - Layout::spacingLg(), divStart.y),
|
||||
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "row-divider-alpha").size));
|
||||
}
|
||||
}
|
||||
|
||||
dl->PopClipRect();
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1456,6 +1405,18 @@ void RenderSendTab(App* app)
|
||||
}
|
||||
|
||||
// Divider before action buttons
|
||||
{
|
||||
float actionBtnH = std::max(S.drawElement("tabs.send", "action-btn-min-height").size,
|
||||
S.drawElement("tabs.send", "action-btn-height").size * vScale);
|
||||
float footerH = innerGap + actionBtnH + pad * vScale;
|
||||
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
|
||||
float targetCardH = Layout::mainCardTargetH(formW, vScale);
|
||||
float footerTopH = targetCardH - footerH;
|
||||
if (currentCardH < footerTopH) {
|
||||
ImGui::Dummy(ImVec2(0, footerTopH - currentCardH));
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
||||
{
|
||||
ImVec2 divPos = ImGui::GetCursorScreenPos();
|
||||
@@ -1507,7 +1468,7 @@ void RenderSendTab(App* app)
|
||||
}
|
||||
|
||||
// ---- RECENT SENDS ----
|
||||
RenderRecentSends(dl, state, formW, capFont, app);
|
||||
RenderRecentSends(state, formW, capFont, app);
|
||||
|
||||
ImGui::EndGroup();
|
||||
ImGui::EndDisabled(); // sendSyncing guard
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -104,7 +104,7 @@ static void DrawTxIcon(ImDrawList* dl, const std::string& type,
|
||||
} else if (type == "receive") {
|
||||
icon = ICON_MD_CALL_RECEIVED;
|
||||
} else if (type == "shield") {
|
||||
icon = ICON_MD_SHIELD;
|
||||
icon = ICON_MD_CALL_MADE;
|
||||
} else {
|
||||
icon = ICON_MD_CONSTRUCTION;
|
||||
}
|
||||
@@ -147,6 +147,8 @@ void RenderTransactionsTab(App* app)
|
||||
ImU32 greenCol = Success();
|
||||
ImU32 redCol = Error();
|
||||
ImU32 goldCol = Warning();
|
||||
std::string txLoadingText = app->transactionRefreshProgressText();
|
||||
bool txLoading = !txLoadingText.empty();
|
||||
|
||||
// Expanded row index for inline detail
|
||||
static int s_expanded_row = -1;
|
||||
@@ -319,6 +321,18 @@ void RenderTransactionsTab(App* app)
|
||||
ExportTransactionsDialog::show();
|
||||
}
|
||||
|
||||
if (txLoading) {
|
||||
ImGui::SameLine(0, filterGap);
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
float pulse = 0.55f + 0.45f * std::sin((float)ImGui::GetTime() * 3.0f);
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_HOURGLASS_EMPTY);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4;
|
||||
const char* dotStr[] = {"", ".", "..", "..."};
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", txLoadingText.c_str(), dotStr[dots]);
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm() + Layout::spacingXs()));
|
||||
|
||||
// ================================================================
|
||||
@@ -415,8 +429,8 @@ void RenderTransactionsTab(App* app)
|
||||
for (size_t i = 0; i < display_txns.size(); i++) {
|
||||
const auto& dtx = display_txns[i];
|
||||
if (type_filter != 0) {
|
||||
if (type_filter == 1 && dtx.display_type != "send") continue;
|
||||
if (type_filter == 2 && dtx.display_type != "receive" && dtx.display_type != "shield") continue;
|
||||
if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue;
|
||||
if (type_filter == 2 && dtx.display_type != "receive") continue;
|
||||
if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue;
|
||||
}
|
||||
if (!search_str.empty()) {
|
||||
@@ -567,7 +581,14 @@ void RenderTransactionsTab(App* app)
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("not_connected"));
|
||||
} else if (state.transactions.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions"));
|
||||
if (txLoading) {
|
||||
int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4;
|
||||
const char* dotStr[] = {"", ".", "..", "..."};
|
||||
snprintf(buf, sizeof(buf), "%s%s", txLoadingText.c_str(), dotStr[dots]);
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
||||
} else {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions"));
|
||||
}
|
||||
} else if (filtered_indices.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_matching"));
|
||||
@@ -596,10 +617,11 @@ void RenderTransactionsTab(App* app)
|
||||
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
|
||||
|
||||
// Determine type info
|
||||
bool shieldedDisplay = tx.display_type == "shield";
|
||||
ImU32 iconCol;
|
||||
const char* typeStr;
|
||||
if (tx.display_type == "shield") {
|
||||
iconCol = Primary(); typeStr = TR("shielded_type");
|
||||
if (shieldedDisplay) {
|
||||
iconCol = redCol; typeStr = TR("sent_type");
|
||||
} else if (tx.display_type == "receive") {
|
||||
iconCol = greenCol; typeStr = TR("recv_type");
|
||||
} else if (tx.display_type == "send") {
|
||||
@@ -627,7 +649,8 @@ void RenderTransactionsTab(App* app)
|
||||
float cy = rowPos.y + Layout::spacingMd();
|
||||
|
||||
// Icon
|
||||
DrawTxIcon(dl, tx.display_type, cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
|
||||
DrawTxIcon(dl, shieldedDisplay ? "send" : tx.display_type,
|
||||
cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
|
||||
|
||||
// Type label
|
||||
float labelX = cx + rowIconSz * 2.0f + Layout::spacingSm();
|
||||
@@ -687,16 +710,35 @@ void RenderTransactionsTab(App* app)
|
||||
}
|
||||
// Position status badge in the middle-right area
|
||||
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
|
||||
float statusX = amtX - sSz.x - Layout::spacingXxl();
|
||||
const char* shieldedStr = TR("shielded_type");
|
||||
ImVec2 shieldSz = shieldedDisplay
|
||||
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, shieldedStr)
|
||||
: ImVec2(0, 0);
|
||||
float shieldPillW = shieldSz.x + Layout::spacingSm() * 2.0f;
|
||||
float stackW = shieldedDisplay ? std::max(sSz.x, shieldPillW) : sSz.x;
|
||||
float statusX = amtX - stackW - Layout::spacingXxl();
|
||||
float minStatusX = cx + innerW * 0.25f; // don't overlap address
|
||||
if (statusX < minStatusX) statusX = minStatusX;
|
||||
float statusTextX = statusX + (stackW - sSz.x) * 0.5f;
|
||||
if (shieldedDisplay) {
|
||||
float shieldX = statusX + (stackW - shieldSz.x) * 0.5f;
|
||||
ImU32 shieldCol = Primary();
|
||||
ImU32 shieldBg = (shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
|
||||
ImVec2 shieldPillMin(shieldX - Layout::spacingSm(), cy - 1.0f);
|
||||
ImVec2 shieldPillMax(shieldX + shieldSz.x + Layout::spacingSm(),
|
||||
shieldPillMin.y + capFont->LegacySize + Layout::spacingXs());
|
||||
dl->AddRectFilled(shieldPillMin, shieldPillMax, shieldBg,
|
||||
schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(shieldX, cy), shieldCol, shieldedStr);
|
||||
}
|
||||
// Background pill
|
||||
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
|
||||
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + 1);
|
||||
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
|
||||
ImVec2 pillMin(statusTextX - Layout::spacingSm(), cy + body2->LegacySize + 1);
|
||||
ImVec2 pillMax(statusTextX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
|
||||
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
|
||||
ImVec2(statusTextX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
|
||||
}
|
||||
|
||||
// Click to expand/collapse + invisible button for interaction
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "daemon/lifecycle_adapters.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
@@ -8,6 +9,7 @@
|
||||
#include "services/wallet_security_controller.h"
|
||||
#include "services/wallet_security_workflow.h"
|
||||
#include "services/wallet_security_workflow_executor.h"
|
||||
#include "ui/explorer/explorer_block_cache.h"
|
||||
#include "ui/windows/balance_address_list.h"
|
||||
#include "ui/windows/balance_recent_tx.h"
|
||||
#include "ui/windows/console_input_model.h"
|
||||
@@ -485,13 +487,23 @@ void testRefreshScheduler()
|
||||
scheduler.markDue(Timer::Peers);
|
||||
EXPECT_TRUE(scheduler.consumeDue(Timer::Peers));
|
||||
|
||||
scheduler.applyPage(dragonx::ui::NavPage::Console);
|
||||
EXPECT_NEAR(scheduler.intervals().core, 10.0, 0.0001);
|
||||
EXPECT_NEAR(scheduler.intervals().transactions, 30.0, 0.0001);
|
||||
EXPECT_NEAR(scheduler.intervals().addresses, 30.0, 0.0001);
|
||||
EXPECT_NEAR(scheduler.intervals().peers, 0.0, 0.0001);
|
||||
|
||||
EXPECT_FALSE(scheduler.isDue(Timer::Price));
|
||||
scheduler.markDue(Timer::Price);
|
||||
EXPECT_TRUE(scheduler.consumeDue(Timer::Price));
|
||||
|
||||
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false, false));
|
||||
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
|
||||
EXPECT_TRUE(scheduler.shouldRefreshTransactions(-1, 100, false));
|
||||
EXPECT_TRUE(scheduler.shouldRefreshTransactions(99, 100, false));
|
||||
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, true));
|
||||
scheduler.tick(RefreshScheduler::kTxMaxAge);
|
||||
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, false, false));
|
||||
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
|
||||
EXPECT_TRUE(scheduler.isDue(Timer::TxAge));
|
||||
}
|
||||
|
||||
void testNetworkRefreshService()
|
||||
@@ -808,6 +820,15 @@ void testNetworkRefreshRpcCollectors()
|
||||
EXPECT_TRUE(prefetch.encryption.encrypted);
|
||||
EXPECT_EQ(prefetch.encryption.unlockedUntil, static_cast<int64_t>(99));
|
||||
|
||||
MockRefreshRpc connectionReuseRpc;
|
||||
connectionReuseRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
|
||||
auto reusedInfo = Refresh::parseConnectionInfoResult(json{{"version", 120002}, {"blocks", 241}});
|
||||
auto reusedConnection = Refresh::collectConnectionInitResult(connectionReuseRpc, reusedInfo);
|
||||
EXPECT_TRUE(connectionReuseRpc.methodNames() == std::vector<std::string>({"getwalletinfo"}));
|
||||
EXPECT_TRUE(reusedConnection.info.ok);
|
||||
EXPECT_EQ(*reusedConnection.info.daemonVersion, 120002);
|
||||
EXPECT_TRUE(reusedConnection.encryption.ok);
|
||||
|
||||
MockRefreshRpc coreRpc;
|
||||
coreRpc.addResponse("z_gettotalbalance", json{
|
||||
{"private", "3.00000000"},
|
||||
@@ -817,6 +838,7 @@ void testNetworkRefreshRpcCollectors()
|
||||
coreRpc.addResponse("getblockchaininfo", json{
|
||||
{"blocks", 150},
|
||||
{"headers", 155},
|
||||
{"bestblockhash", "core-best-150"},
|
||||
{"verificationprogress", 0.80},
|
||||
{"longestchain", 160},
|
||||
{"notarized", 145}
|
||||
@@ -831,6 +853,7 @@ void testNetworkRefreshRpcCollectors()
|
||||
EXPECT_TRUE(core.blockchainOk);
|
||||
EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001);
|
||||
EXPECT_EQ(*core.blocks, 150);
|
||||
EXPECT_EQ(*core.bestBlockHash, std::string("core-best-150"));
|
||||
EXPECT_EQ(*core.longestChain, 160);
|
||||
|
||||
MockRefreshRpc coreFallbackRpc;
|
||||
@@ -918,6 +941,14 @@ void testNetworkRefreshRpcCollectors()
|
||||
EXPECT_TRUE(miningPartial.miningOk);
|
||||
EXPECT_FALSE(*miningPartial.generate);
|
||||
|
||||
MockRefreshRpc miningSlowOnlyRpc;
|
||||
miningSlowOnlyRpc.addResponse("getmininginfo", json{{"generate", false}, {"networkhashps", 222.0}});
|
||||
auto miningSlowOnly = Refresh::collectMiningRefreshResult(miningSlowOnlyRpc, 7.0, true, false);
|
||||
EXPECT_TRUE(miningSlowOnlyRpc.methodNames() == std::vector<std::string>({"getmininginfo"}));
|
||||
EXPECT_FALSE(miningSlowOnly.localHashrate.has_value());
|
||||
EXPECT_TRUE(miningSlowOnly.miningOk);
|
||||
EXPECT_NEAR(*miningSlowOnly.networkHashrate, 222.0, 0.00000001);
|
||||
|
||||
MockRefreshRpc addressRpc;
|
||||
addressRpc.addResponse("z_listaddresses", json::array({"zs-one", "zs-two"}));
|
||||
addressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
|
||||
@@ -961,6 +992,23 @@ void testNetworkRefreshRpcCollectors()
|
||||
EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
|
||||
EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);
|
||||
|
||||
dragonx::WalletState cachedAddressState;
|
||||
cachedAddressState.z_addresses.push_back({"zs-cached", 0.0, "shielded", false});
|
||||
auto addressSnapshot = Refresh::buildAddressRefreshSnapshot(cachedAddressState);
|
||||
MockRefreshRpc cachedAddressRpc;
|
||||
cachedAddressRpc.addResponse("z_listaddresses", json::array({"zs-cached", "zs-new"}));
|
||||
cachedAddressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
|
||||
cachedAddressRpc.addResponse("z_listunspent", json::array());
|
||||
cachedAddressRpc.addResponse("getaddressesbyaccount", json::array());
|
||||
cachedAddressRpc.addResponse("listunspent", json::array());
|
||||
auto cachedAddresses = Refresh::collectAddressRefreshResult(cachedAddressRpc, addressSnapshot);
|
||||
EXPECT_TRUE(cachedAddressRpc.methodNames() == std::vector<std::string>({
|
||||
"z_listaddresses", "z_validateaddress", "z_listunspent",
|
||||
"getaddressesbyaccount", "listunspent"
|
||||
}));
|
||||
EXPECT_FALSE(cachedAddresses.shieldedAddresses[0].has_spending_key);
|
||||
EXPECT_TRUE(cachedAddresses.shieldedAddresses[1].has_spending_key);
|
||||
|
||||
Refresh::TransactionRefreshSnapshot snapshot;
|
||||
snapshot.shieldedAddresses = {"zs-one", "zs-two"};
|
||||
snapshot.sendTxids = {"cached-send", "pending-send"};
|
||||
@@ -1075,6 +1123,227 @@ void testNetworkRefreshRpcCollectors()
|
||||
EXPECT_EQ(placeholderResult.transactions[0].confirmations, 0);
|
||||
EXPECT_EQ(placeholderResult.transactions[0].address, std::string("zs-placeholder-dest"));
|
||||
EXPECT_EQ(placeholderResult.newViewTxEntries.count("placeholder-send"), static_cast<size_t>(0));
|
||||
|
||||
Refresh::TransactionRefreshSnapshot missingAddressesSnapshot;
|
||||
missingAddressesSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||||
"shielded-fallback", "receive", 1.25, 150, 2, "zs-one", "", "memo"
|
||||
});
|
||||
MockRefreshRpc missingAddressesRpc;
|
||||
missingAddressesRpc.addResponse("listtransactions", json::array());
|
||||
auto missingAddressesResult = Refresh::collectTransactionRefreshResult(
|
||||
missingAddressesRpc, missingAddressesSnapshot, 325, 4);
|
||||
EXPECT_TRUE(missingAddressesRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||||
EXPECT_EQ(missingAddressesResult.transactions.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(missingAddressesResult.transactions[0].txid, std::string("shielded-fallback"));
|
||||
EXPECT_EQ(missingAddressesResult.transactions[0].type, std::string("receive"));
|
||||
|
||||
Refresh::TransactionRefreshSnapshot pendingOpidSnapshot;
|
||||
pendingOpidSnapshot.pendingOpids = {"opid-visible-send"};
|
||||
pendingOpidSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||||
"opid-visible-send", "send", -4.25, 170, 0, "R-destination", "R-source", ""
|
||||
});
|
||||
MockRefreshRpc pendingOpidRpc;
|
||||
pendingOpidRpc.addResponse("listtransactions", json::array());
|
||||
auto pendingOpidResult = Refresh::collectTransactionRefreshResult(
|
||||
pendingOpidRpc, pendingOpidSnapshot, 326, 4);
|
||||
EXPECT_TRUE(pendingOpidRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||||
EXPECT_EQ(pendingOpidResult.transactions.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(pendingOpidResult.transactions[0].txid, std::string("opid-visible-send"));
|
||||
EXPECT_EQ(pendingOpidResult.transactions[0].type, std::string("send"));
|
||||
EXPECT_NEAR(pendingOpidResult.transactions[0].amount, -4.25, 0.00000001);
|
||||
|
||||
Refresh::TransactionRefreshSnapshot partialFailureSnapshot;
|
||||
partialFailureSnapshot.shieldedAddresses = {"zs-one"};
|
||||
partialFailureSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||||
"old-receive", "receive", 2.50, 140, 8, "zs-one", "", "old memo"
|
||||
});
|
||||
MockRefreshRpc partialFailureRpc;
|
||||
partialFailureRpc.addResponse("listtransactions", json::array({
|
||||
json{{"txid", "transparent-b"}, {"category", "receive"}, {"amount", 0.75},
|
||||
{"time", 160}, {"confirmations", 2}, {"address", "R-two"}}
|
||||
}));
|
||||
partialFailureRpc.addFailure("z_listreceivedbyaddress", "temporary receive failure");
|
||||
auto partialFailureResult = Refresh::collectTransactionRefreshResult(
|
||||
partialFailureRpc, partialFailureSnapshot, 326, 4);
|
||||
EXPECT_EQ(partialFailureResult.transactions.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(partialFailureResult.transactions[0].txid, std::string("transparent-b"));
|
||||
EXPECT_EQ(partialFailureResult.transactions[1].txid, std::string("old-receive"));
|
||||
|
||||
Refresh::TransactionRefreshSnapshot pagedSnapshot;
|
||||
MockRefreshRpc pagedRpc;
|
||||
json firstPage = json::array();
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
firstPage.push_back(json{{"txid", "paged-" + std::to_string(i)}, {"category", "receive"},
|
||||
{"amount", 0.01}, {"time", i}, {"confirmations", 10},
|
||||
{"address", "R-page"}});
|
||||
}
|
||||
pagedRpc.addResponse("listtransactions", firstPage);
|
||||
pagedRpc.addResponse("listtransactions", json::array({
|
||||
json{{"txid", "paged-1000"}, {"category", "receive"}, {"amount", 0.02},
|
||||
{"time", 2000}, {"confirmations", 11}, {"address", "R-page"}}
|
||||
}));
|
||||
auto pagedResult = Refresh::collectTransactionRefreshResult(pagedRpc, pagedSnapshot, 327, 0);
|
||||
EXPECT_TRUE(pagedRpc.methodNames() == std::vector<std::string>({"listtransactions", "listtransactions"}));
|
||||
EXPECT_EQ(pagedRpc.calls[0].params, json::array({"", 1000, 0}));
|
||||
EXPECT_EQ(pagedRpc.calls[1].params, json::array({"", 1000, 1000}));
|
||||
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
|
||||
EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));
|
||||
|
||||
Refresh::TransactionRefreshSnapshot recentSnapshot;
|
||||
dragonx::TransactionInfo previousShielded;
|
||||
previousShielded.txid = "shielded-old";
|
||||
previousShielded.type = "receive";
|
||||
previousShielded.address = "zs-one";
|
||||
previousShielded.amount = 3.0;
|
||||
previousShielded.timestamp = 10;
|
||||
dragonx::TransactionInfo previousTransparent;
|
||||
previousTransparent.txid = "recent-one";
|
||||
previousTransparent.type = "receive";
|
||||
previousTransparent.address = "R-one";
|
||||
previousTransparent.amount = 1.0;
|
||||
previousTransparent.timestamp = 20;
|
||||
recentSnapshot.previousTransactions = {previousShielded, previousTransparent};
|
||||
MockRefreshRpc recentRpc;
|
||||
recentRpc.addResponse("listtransactions", json::array({
|
||||
json{{"txid", "recent-one"}, {"category", "receive"}, {"address", "R-one"}, {"amount", 2.0}, {"time", 30}, {"confirmations", 0}},
|
||||
json{{"txid", "recent-two"}, {"category", "send"}, {"address", "R-two"}, {"amount", -0.5}, {"time", 40}, {"confirmations", 0}}
|
||||
}));
|
||||
auto recent = Refresh::collectRecentTransactionRefreshResult(recentRpc, recentSnapshot, 123);
|
||||
EXPECT_TRUE(recentRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||||
EXPECT_EQ(recentRpc.calls[0].params, json::array({"", 100, 0}));
|
||||
EXPECT_EQ(recent.blockHeight, 123);
|
||||
EXPECT_EQ(recent.transactions.size(), static_cast<size_t>(3));
|
||||
EXPECT_EQ(recent.transactions[0].txid, std::string("recent-two"));
|
||||
EXPECT_EQ(recent.transactions[1].txid, std::string("recent-one"));
|
||||
EXPECT_NEAR(recent.transactions[1].amount, 2.0, 0.00000001);
|
||||
EXPECT_EQ(recent.transactions[2].txid, std::string("shielded-old"));
|
||||
|
||||
Refresh::TransactionRefreshSnapshot recentShieldedProbeSnapshot;
|
||||
recentShieldedProbeSnapshot.shieldedAddresses = {"zs-probe-a", "zs-probe-b"};
|
||||
recentShieldedProbeSnapshot.shieldedScanStartIndex = 1;
|
||||
recentShieldedProbeSnapshot.maxShieldedReceiveScans = 1;
|
||||
recentShieldedProbeSnapshot.shieldedScanHeights = {{"zs-probe-a", 600}, {"zs-probe-b", 600}};
|
||||
MockRefreshRpc recentShieldedProbeRpc;
|
||||
recentShieldedProbeRpc.addResponse("listtransactions", json::array());
|
||||
recentShieldedProbeRpc.addResponse("z_listreceivedbyaddress", json::array({
|
||||
json{{"txid", "same-tip-shielded"}, {"amount", 1.75}, {"confirmations", 0}, {"time", 170}}
|
||||
}));
|
||||
auto recentShieldedProbe = Refresh::collectRecentTransactionRefreshResult(
|
||||
recentShieldedProbeRpc, recentShieldedProbeSnapshot, 600);
|
||||
EXPECT_TRUE(recentShieldedProbeRpc.methodNames() == std::vector<std::string>({
|
||||
"listtransactions", "z_listreceivedbyaddress"
|
||||
}));
|
||||
EXPECT_EQ(recentShieldedProbeRpc.calls[1].params, json::array({"zs-probe-b", 0}));
|
||||
EXPECT_EQ(recentShieldedProbe.nextShieldedScanStartIndex, static_cast<size_t>(0));
|
||||
EXPECT_EQ(recentShieldedProbe.shieldedAddressesScanned, static_cast<size_t>(1));
|
||||
EXPECT_EQ(recentShieldedProbe.shieldedScanHeights.at("zs-probe-b"), 600);
|
||||
EXPECT_EQ(recentShieldedProbe.transactions.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(recentShieldedProbe.transactions[0].txid, std::string("same-tip-shielded"));
|
||||
EXPECT_EQ(recentShieldedProbe.transactions[0].confirmations, 0);
|
||||
|
||||
Refresh::TransactionRefreshSnapshot partialShieldedSnapshot;
|
||||
partialShieldedSnapshot.shieldedAddresses = {"zs-zero", "zs-one", "zs-two"};
|
||||
partialShieldedSnapshot.maxShieldedReceiveScans = 2;
|
||||
partialShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||||
"old-zs-two", "receive", 1.0, 90, 10, "zs-two", "", "memo"
|
||||
});
|
||||
MockRefreshRpc partialShieldedRpc;
|
||||
partialShieldedRpc.addResponse("listtransactions", json::array());
|
||||
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array({
|
||||
json{{"txid", "new-zs-zero"}, {"amount", 0.5}, {"confirmations", 1}, {"time", 100}}
|
||||
}));
|
||||
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||
auto partialShielded = Refresh::collectTransactionRefreshResult(
|
||||
partialShieldedRpc, partialShieldedSnapshot, 400, 0);
|
||||
EXPECT_TRUE(partialShieldedRpc.methodNames() == std::vector<std::string>({
|
||||
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress"
|
||||
}));
|
||||
EXPECT_EQ(partialShieldedRpc.calls[1].params, json::array({"zs-zero", 0}));
|
||||
EXPECT_EQ(partialShieldedRpc.calls[2].params, json::array({"zs-one", 0}));
|
||||
EXPECT_FALSE(partialShielded.shieldedScanComplete);
|
||||
EXPECT_EQ(partialShielded.nextShieldedScanStartIndex, static_cast<size_t>(2));
|
||||
EXPECT_EQ(partialShielded.shieldedAddressesScanned, static_cast<size_t>(2));
|
||||
EXPECT_EQ(partialShielded.transactions.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(partialShielded.transactions[0].txid, std::string("new-zs-zero"));
|
||||
EXPECT_EQ(partialShielded.transactions[1].txid, std::string("old-zs-two"));
|
||||
|
||||
partialShieldedSnapshot.shieldedScanStartIndex = partialShielded.nextShieldedScanStartIndex;
|
||||
partialShieldedSnapshot.shieldedScanHeights = partialShielded.shieldedScanHeights;
|
||||
MockRefreshRpc finalShieldedRpc;
|
||||
finalShieldedRpc.addResponse("listtransactions", json::array());
|
||||
finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||
auto finalShielded = Refresh::collectTransactionRefreshResult(
|
||||
finalShieldedRpc, partialShieldedSnapshot, 400, 0);
|
||||
EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
|
||||
"listtransactions", "z_listreceivedbyaddress"
|
||||
}));
|
||||
EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
|
||||
EXPECT_TRUE(finalShielded.shieldedScanComplete);
|
||||
EXPECT_EQ(finalShielded.nextShieldedScanStartIndex, static_cast<size_t>(0));
|
||||
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-zero"), 400);
|
||||
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-one"), 400);
|
||||
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-two"), 400);
|
||||
|
||||
Refresh::TransactionRefreshSnapshot cachedShieldedSnapshot;
|
||||
cachedShieldedSnapshot.shieldedAddresses = {"zs-cached"};
|
||||
cachedShieldedSnapshot.shieldedScanHeights = {{"zs-cached", 500}};
|
||||
cachedShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||||
"cached-shielded", "receive", 1.5, 120, 20, "zs-cached", "", "cached memo"
|
||||
});
|
||||
MockRefreshRpc cachedShieldedRpc;
|
||||
cachedShieldedRpc.addResponse("listtransactions", json::array());
|
||||
auto cachedShielded = Refresh::collectTransactionRefreshResult(
|
||||
cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
|
||||
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||||
EXPECT_TRUE(cachedShielded.shieldedScanComplete);
|
||||
EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
|
||||
EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(cachedShielded.transactions[0].txid, std::string("cached-shielded"));
|
||||
|
||||
Refresh::TransactionRefreshSnapshot staleProgressSnapshot;
|
||||
staleProgressSnapshot.shieldedAddresses = {"zs-current", "zs-stale", "zs-missing"};
|
||||
staleProgressSnapshot.shieldedScanHeights = {{"zs-current", 500}, {"zs-stale", 499}};
|
||||
staleProgressSnapshot.maxShieldedReceiveScans = 1;
|
||||
staleProgressSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||||
"current-shielded", "receive", 0.75, 110, 12, "zs-current", "", ""
|
||||
});
|
||||
MockRefreshRpc staleProgressRpc;
|
||||
staleProgressRpc.addResponse("listtransactions", json::array());
|
||||
staleProgressRpc.addResponse("z_listreceivedbyaddress", json::array({
|
||||
json{{"txid", "stale-shielded"}, {"amount", 2.0}, {"confirmations", 1}, {"time", 130}}
|
||||
}));
|
||||
auto staleProgress = Refresh::collectTransactionRefreshResult(
|
||||
staleProgressRpc, staleProgressSnapshot, 500, 0);
|
||||
EXPECT_TRUE(staleProgressRpc.methodNames() == std::vector<std::string>({
|
||||
"listtransactions", "z_listreceivedbyaddress"
|
||||
}));
|
||||
EXPECT_EQ(staleProgressRpc.calls[1].params, json::array({"zs-stale", 0}));
|
||||
EXPECT_FALSE(staleProgress.shieldedScanComplete);
|
||||
EXPECT_EQ(staleProgress.nextShieldedScanStartIndex, static_cast<size_t>(2));
|
||||
EXPECT_EQ(staleProgress.shieldedAddressesScanned, static_cast<size_t>(1));
|
||||
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-current"), 500);
|
||||
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-stale"), 500);
|
||||
EXPECT_TRUE(staleProgress.shieldedScanHeights.find("zs-missing") == staleProgress.shieldedScanHeights.end());
|
||||
EXPECT_EQ(staleProgress.transactions.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(staleProgress.transactions[0].txid, std::string("stale-shielded"));
|
||||
EXPECT_EQ(staleProgress.transactions[1].txid, std::string("current-shielded"));
|
||||
|
||||
Refresh::TransactionRefreshSnapshot miningSnapshot;
|
||||
miningSnapshot.shieldedAddresses = {"zs-mine"};
|
||||
miningSnapshot.miningAddresses = {"R-mine", "zs-mine"};
|
||||
MockRefreshRpc miningTxRpc;
|
||||
miningTxRpc.addResponse("listtransactions", json::array({
|
||||
json{{"txid", "transparent-mined"}, {"category", "receive"}, {"amount", 3.0},
|
||||
{"time", 220}, {"confirmations", 101}, {"address", "R-mine"}}
|
||||
}));
|
||||
miningTxRpc.addResponse("z_listreceivedbyaddress", json::array({
|
||||
json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
|
||||
{"time", 210}, {"memoStr", "pool"}}
|
||||
}));
|
||||
auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
|
||||
EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
|
||||
EXPECT_EQ(miningTxResult.transactions[1].type, std::string("mined"));
|
||||
}
|
||||
|
||||
void testNetworkRefreshResultModels()
|
||||
@@ -1087,7 +1356,7 @@ void testNetworkRefreshResultModels()
|
||||
auto core = Refresh::parseCoreRefreshResult(
|
||||
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}},
|
||||
true,
|
||||
json{{"blocks", 100}, {"headers", 105}, {"verificationprogress", 0.75},
|
||||
json{{"blocks", 100}, {"headers", 105}, {"bestblockhash", "apply-best-100"}, {"verificationprogress", 0.75},
|
||||
{"longestchain", 110}, {"notarized", 90}},
|
||||
true);
|
||||
Refresh::applyCoreRefreshResult(state, core, 1234);
|
||||
@@ -1096,6 +1365,7 @@ void testNetworkRefreshResultModels()
|
||||
EXPECT_NEAR(state.total_balance, 1.75, 0.00000001);
|
||||
EXPECT_EQ(state.sync.blocks, 100);
|
||||
EXPECT_EQ(state.sync.headers, 105);
|
||||
EXPECT_EQ(state.sync.best_blockhash, std::string("apply-best-100"));
|
||||
EXPECT_TRUE(state.sync.syncing);
|
||||
EXPECT_EQ(state.longestchain, 110);
|
||||
EXPECT_EQ(state.notarized, 90);
|
||||
@@ -1117,7 +1387,6 @@ void testNetworkRefreshResultModels()
|
||||
EXPECT_TRUE(state.encrypted);
|
||||
EXPECT_TRUE(state.locked);
|
||||
EXPECT_TRUE(state.encryption_state_known);
|
||||
|
||||
auto unencrypted = Refresh::parseWalletEncryptionResult(json::object());
|
||||
Refresh::applyWalletEncryptionResult(state, unencrypted);
|
||||
EXPECT_FALSE(state.encrypted);
|
||||
@@ -1297,6 +1566,36 @@ void testNetworkRefreshResultModels()
|
||||
EXPECT_EQ(confirmedBlock, 222);
|
||||
}
|
||||
|
||||
void testOperationStatusPollParsing()
|
||||
{
|
||||
using Refresh = dragonx::services::NetworkRefreshService;
|
||||
using nlohmann::json;
|
||||
|
||||
auto parsed = Refresh::parseOperationStatusPoll(json::array({
|
||||
json{{"id", "op-success"}, {"status", "success"}, {"result", json{{"txid", "tx-success"}}}},
|
||||
json{{"id", "op-failed"}, {"status", "failed"}, {"error", json{{"message", "bad memo"}}}},
|
||||
json{{"id", "op-running"}, {"status", "executing"}}
|
||||
}), {"op-success", "op-failed", "op-running", "op-stale"});
|
||||
|
||||
EXPECT_TRUE(parsed.anySuccess);
|
||||
EXPECT_EQ(parsed.doneOpids.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(parsed.doneOpids[0], std::string("op-success"));
|
||||
EXPECT_EQ(parsed.doneOpids[1], std::string("op-failed"));
|
||||
EXPECT_EQ(parsed.successTxids.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(parsed.successTxids[0], std::string("tx-success"));
|
||||
EXPECT_EQ(parsed.successTxidsByOpid.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success"));
|
||||
EXPECT_EQ(parsed.failureMessages.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo"));
|
||||
EXPECT_EQ(parsed.staleOpids.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(parsed.staleOpids[0], std::string("op-stale"));
|
||||
|
||||
auto malformed = Refresh::parseOperationStatusPoll(json{{"status", "success"}}, {"op-keep"});
|
||||
EXPECT_FALSE(malformed.anySuccess);
|
||||
EXPECT_TRUE(malformed.doneOpids.empty());
|
||||
EXPECT_TRUE(malformed.staleOpids.empty());
|
||||
}
|
||||
|
||||
void testWalletSecurityController()
|
||||
{
|
||||
using dragonx::services::WalletSecurityController;
|
||||
@@ -1677,9 +1976,13 @@ void testRendererHelpers()
|
||||
EXPECT_FALSE(dragonx::ui::IsPoolMiningActive(true, false, true));
|
||||
EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(false, false, true));
|
||||
|
||||
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, 10, 20};
|
||||
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, 10, 20, 30};
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", 20, filter));
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("daemon line", 10, filter));
|
||||
filter.text.clear();
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
|
||||
filter.rpcTraceEnabled = true;
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
|
||||
filter.daemonMessagesEnabled = true;
|
||||
filter.errorsOnly = true;
|
||||
filter.text.clear();
|
||||
@@ -1838,9 +2141,9 @@ void testBalanceAddressListModel()
|
||||
addresses.push_back(tFav);
|
||||
|
||||
std::vector<dragonx::ui::AddressListInput> inputs = {
|
||||
{&addresses[0], true, false, false, "Main Vault", "", -1},
|
||||
{&addresses[1], false, false, false, "Empty", "", -1},
|
||||
{&addresses[2], false, false, true, "Favorite", "", -1}
|
||||
{&addresses[0], true, false, false, false, "Main Vault", "", -1},
|
||||
{&addresses[1], false, false, false, false, "Empty", "", -1},
|
||||
{&addresses[2], false, false, true, false, "Favorite", "", -1}
|
||||
};
|
||||
|
||||
auto rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
|
||||
@@ -1869,6 +2172,255 @@ void testBalanceAddressListModel()
|
||||
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.0, 2.0), std::string(""));
|
||||
}
|
||||
|
||||
void testExplorerBlockCache()
|
||||
{
|
||||
using dragonx::ui::ExplorerBlockCache;
|
||||
using dragonx::ui::ExplorerBlockSummary;
|
||||
using nlohmann::json;
|
||||
|
||||
fs::path dir = makeTempDir();
|
||||
fs::path databasePath = dir / "explorer_blocks.sqlite";
|
||||
fs::path legacyPath = dir / "explorer_blocks_cache.json";
|
||||
|
||||
json legacy = {
|
||||
{"version", 1},
|
||||
{"tip_height", 10},
|
||||
{"tip_hash", "hash-10"},
|
||||
{"blocks", json::array({
|
||||
json{{"height", 10}, {"hash", "hash-10"}, {"tx_count", 3}, {"size", 1000}, {"time", 5000}, {"difficulty", 1.25}},
|
||||
json{{"height", 9}, {"hash", "hash-9"}, {"tx_count", 2}, {"size", 900}, {"time", 4900}, {"difficulty", 1.20}}
|
||||
})}
|
||||
};
|
||||
{
|
||||
std::ofstream file(legacyPath);
|
||||
file << legacy.dump(2);
|
||||
}
|
||||
|
||||
ExplorerBlockCache cache(databasePath.string(), legacyPath.string());
|
||||
EXPECT_TRUE(cache.ensureOpen());
|
||||
EXPECT_EQ(cache.cachedBlockCount(), 2);
|
||||
|
||||
auto range = cache.loadRange(9, 10);
|
||||
EXPECT_EQ(range.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(range[10].hash, std::string("hash-10"));
|
||||
EXPECT_EQ(range[9].tx_count, 2);
|
||||
|
||||
auto sameTipValidation = cache.prepareValidation(10, "hash-10");
|
||||
EXPECT_FALSE(sameTipValidation.needed);
|
||||
|
||||
auto advancedValidation = cache.prepareValidation(12, "hash-12");
|
||||
EXPECT_TRUE(advancedValidation.needed);
|
||||
EXPECT_EQ(advancedValidation.height, 10);
|
||||
EXPECT_EQ(advancedValidation.expectedHash, std::string("hash-10"));
|
||||
cache.applySavedTipValidation(advancedValidation, "hash-10", 12, "hash-12");
|
||||
|
||||
ExplorerBlockSummary block12;
|
||||
block12.height = 12;
|
||||
block12.hash = "hash-12";
|
||||
block12.tx_count = 5;
|
||||
block12.size = 1200;
|
||||
block12.time = 5200;
|
||||
block12.difficulty = 1.40;
|
||||
EXPECT_TRUE(cache.storeBlock(block12));
|
||||
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
|
||||
|
||||
{
|
||||
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
|
||||
EXPECT_TRUE(reopened.ensureOpen());
|
||||
EXPECT_EQ(reopened.cachedBlockCount(), 3);
|
||||
auto noRepeatValidation = reopened.prepareValidation(12, "hash-12");
|
||||
EXPECT_FALSE(noRepeatValidation.needed);
|
||||
}
|
||||
|
||||
cache.prepareValidation(12, "different-tip");
|
||||
EXPECT_EQ(cache.cachedBlockCount(), 0);
|
||||
|
||||
{
|
||||
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
|
||||
EXPECT_TRUE(reopened.ensureOpen());
|
||||
EXPECT_EQ(reopened.cachedBlockCount(), 0);
|
||||
}
|
||||
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
|
||||
void testTransactionHistoryCache()
|
||||
{
|
||||
using dragonx::TransactionInfo;
|
||||
using dragonx::data::TransactionHistoryCache;
|
||||
|
||||
std::string identityA = TransactionHistoryCache::walletIdentityFromAddresses(
|
||||
{"zs-beta", "zs-alpha"}, {"R-one"});
|
||||
std::string identityB = TransactionHistoryCache::walletIdentityFromAddresses(
|
||||
{"zs-alpha", "zs-beta"}, {"R-one"});
|
||||
std::string identityWithSameText = TransactionHistoryCache::walletIdentityFromAddresses(
|
||||
{"same-address-text"}, {"same-address-text"});
|
||||
EXPECT_EQ(identityA, identityB);
|
||||
EXPECT_TRUE(identityA.find("protocol=") == std::string::npos);
|
||||
EXPECT_TRUE(identityA.find("p2p_port=") == std::string::npos);
|
||||
EXPECT_TRUE(identityA.find("z:zs-alpha") != std::string::npos);
|
||||
EXPECT_TRUE(identityA.find("t:R-one") != std::string::npos);
|
||||
EXPECT_TRUE(identityWithSameText.find("z:same-address-text") != std::string::npos);
|
||||
EXPECT_TRUE(identityWithSameText.find("t:same-address-text") != std::string::npos);
|
||||
|
||||
fs::path dir = makeTempDir();
|
||||
fs::path databasePath = dir / "transaction_history.sqlite";
|
||||
std::string walletIdentity = "mainnet|R-alpha|zs-beta";
|
||||
std::string passphrase = "correct horse battery staple";
|
||||
|
||||
std::vector<TransactionInfo> transactions;
|
||||
transactions.push_back(TransactionInfo{
|
||||
"tx-sensitive-send", "send", -1.25, 1700000100, 12,
|
||||
"zs-destination-sensitive", "R-source-sensitive", "private memo"
|
||||
});
|
||||
transactions.push_back(TransactionInfo{
|
||||
"tx-mined", "mined", 3.5, 1700000000, 104,
|
||||
"R-mining-address", "", ""
|
||||
});
|
||||
std::unordered_map<std::string, int> shieldedScanHeights{
|
||||
{"zs-beta", 120},
|
||||
{"zs-archive", 118}
|
||||
};
|
||||
|
||||
{
|
||||
TransactionHistoryCache cache(databasePath.string());
|
||||
EXPECT_TRUE(cache.ensureOpen());
|
||||
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
||||
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000200, shieldedScanHeights));
|
||||
EXPECT_EQ(cache.snapshotCount(), 1);
|
||||
}
|
||||
|
||||
{
|
||||
std::ifstream database(databasePath, std::ios::binary);
|
||||
std::string bytes((std::istreambuf_iterator<char>(database)), std::istreambuf_iterator<char>());
|
||||
EXPECT_TRUE(bytes.find("tx-sensitive-send") == std::string::npos);
|
||||
EXPECT_TRUE(bytes.find("zs-destination-sensitive") == std::string::npos);
|
||||
EXPECT_TRUE(bytes.find("zs-archive") == std::string::npos);
|
||||
EXPECT_TRUE(bytes.find("private memo") == std::string::npos);
|
||||
}
|
||||
|
||||
{
|
||||
TransactionHistoryCache wrongKey(databasePath.string());
|
||||
EXPECT_TRUE(wrongKey.unlockWithPassphrase(walletIdentity, "wrong passphrase"));
|
||||
auto wrongLoad = wrongKey.load(walletIdentity, 120, "tip-120");
|
||||
EXPECT_FALSE(wrongLoad.loaded);
|
||||
}
|
||||
|
||||
{
|
||||
TransactionHistoryCache reopened(databasePath.string());
|
||||
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
|
||||
auto loaded = reopened.load(walletIdentity, 120, "tip-120");
|
||||
EXPECT_TRUE(loaded.loaded);
|
||||
EXPECT_FALSE(loaded.invalidated);
|
||||
EXPECT_EQ(loaded.tipHeight, 120);
|
||||
EXPECT_EQ(loaded.tipHash, std::string("tip-120"));
|
||||
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000200));
|
||||
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(loaded.transactions[0].txid, std::string("tx-sensitive-send"));
|
||||
EXPECT_EQ(loaded.transactions[0].memo, std::string("private memo"));
|
||||
EXPECT_EQ(loaded.transactions[1].type, std::string("mined"));
|
||||
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-beta"), 120);
|
||||
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-archive"), 118);
|
||||
}
|
||||
|
||||
{
|
||||
TransactionHistoryCache cache(databasePath.string());
|
||||
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
||||
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000250));
|
||||
auto loaded = cache.load(walletIdentity, 120, "tip-120");
|
||||
EXPECT_TRUE(loaded.loaded);
|
||||
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(0));
|
||||
}
|
||||
|
||||
{
|
||||
TransactionHistoryCache staleTip(databasePath.string());
|
||||
EXPECT_TRUE(staleTip.unlockWithPassphrase(walletIdentity, passphrase));
|
||||
auto invalidated = staleTip.load(walletIdentity, 119, "tip-119");
|
||||
EXPECT_FALSE(invalidated.loaded);
|
||||
EXPECT_TRUE(invalidated.invalidated);
|
||||
EXPECT_EQ(staleTip.snapshotCount(), 0);
|
||||
}
|
||||
|
||||
{
|
||||
TransactionHistoryCache cache(databasePath.string());
|
||||
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
||||
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000300));
|
||||
auto invalidated = cache.load(walletIdentity, 120, "different-tip");
|
||||
EXPECT_FALSE(invalidated.loaded);
|
||||
EXPECT_TRUE(invalidated.invalidated);
|
||||
EXPECT_EQ(cache.snapshotCount(), 0);
|
||||
}
|
||||
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
|
||||
void testTransactionHistoryCacheRefreshApply()
|
||||
{
|
||||
using Refresh = dragonx::services::NetworkRefreshService;
|
||||
using dragonx::TransactionInfo;
|
||||
using dragonx::data::TransactionHistoryCache;
|
||||
|
||||
fs::path dir = makeTempDir();
|
||||
fs::path databasePath = dir / "transaction_history_refresh.sqlite";
|
||||
std::string walletIdentity = "mainnet|R-refresh-cache|zs-refresh-cache";
|
||||
std::string passphrase = "refresh cache passphrase";
|
||||
|
||||
dragonx::WalletState state;
|
||||
state.sync.blocks = 333;
|
||||
state.sync.best_blockhash = "tip-333";
|
||||
|
||||
Refresh::TransactionRefreshResult refreshResult;
|
||||
refreshResult.blockHeight = 333;
|
||||
TransactionInfo refreshedTx;
|
||||
refreshedTx.txid = "rpc-refreshed-send";
|
||||
refreshedTx.type = "send";
|
||||
refreshedTx.amount = -2.75;
|
||||
refreshedTx.timestamp = 1700000400;
|
||||
refreshedTx.confirmations = 14;
|
||||
refreshedTx.address = "zs-rpc-destination";
|
||||
refreshedTx.from_address = "R-refresh-cache";
|
||||
refreshedTx.memo = "rpc refreshed memo";
|
||||
refreshResult.transactions = {refreshedTx};
|
||||
|
||||
Refresh::TransactionViewCache viewCache;
|
||||
std::unordered_set<std::string> sendTxids;
|
||||
std::vector<TransactionInfo> confirmedCache;
|
||||
std::unordered_set<std::string> confirmedIds;
|
||||
int confirmedBlock = -1;
|
||||
int lastTxBlock = -1;
|
||||
Refresh::TransactionCacheUpdate cacheUpdate{
|
||||
viewCache,
|
||||
sendTxids,
|
||||
confirmedCache,
|
||||
confirmedIds,
|
||||
confirmedBlock,
|
||||
lastTxBlock
|
||||
};
|
||||
Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(refreshResult), 1700000500);
|
||||
|
||||
TransactionHistoryCache cache(databasePath.string());
|
||||
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
||||
EXPECT_TRUE(cache.replace(walletIdentity,
|
||||
state.sync.blocks,
|
||||
state.sync.best_blockhash,
|
||||
state.transactions,
|
||||
state.last_tx_update));
|
||||
|
||||
TransactionHistoryCache reopened(databasePath.string());
|
||||
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
|
||||
auto loaded = reopened.load(walletIdentity, 333, "tip-333");
|
||||
EXPECT_TRUE(loaded.loaded);
|
||||
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(loaded.transactions[0].txid, std::string("rpc-refreshed-send"));
|
||||
EXPECT_EQ(loaded.transactions[0].memo, std::string("rpc refreshed memo"));
|
||||
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000500));
|
||||
EXPECT_EQ(lastTxBlock, 333);
|
||||
EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast<size_t>(1));
|
||||
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
|
||||
void testGeneratedResourceBehavior()
|
||||
{
|
||||
const auto* themes = dragonx::resources::getEmbeddedThemes();
|
||||
@@ -1893,6 +2445,7 @@ int main()
|
||||
testNetworkRefreshSnapshotHelpers();
|
||||
testNetworkRefreshRpcCollectors();
|
||||
testNetworkRefreshResultModels();
|
||||
testOperationStatusPollParsing();
|
||||
testWalletSecurityController();
|
||||
testWalletSecurityWorkflow();
|
||||
testWalletSecurityWorkflowExecutor();
|
||||
@@ -1903,6 +2456,9 @@ int main()
|
||||
testConsoleInputModel();
|
||||
testMiningBenchmarkModel();
|
||||
testBalanceAddressListModel();
|
||||
testExplorerBlockCache();
|
||||
testTransactionHistoryCache();
|
||||
testTransactionHistoryCacheRefreshApply();
|
||||
testGeneratedResourceBehavior();
|
||||
|
||||
if (g_failures != 0) {
|
||||
|
||||
Reference in New Issue
Block a user