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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user