Files
ObsidianDragon/src/services/network_refresh_service.cpp
T
DanSandClaude Opus 4.8 3ee90993ed fix(resilience,perf): gate send on a known tip, surface dropped/stale txs, advance cached confs, memoize explorer
Backlog mediums (display/gating/caching only — no send/sweep/encrypt call or args changed):

Resilience:
- Gate Send/Review on a KNOWN peer-derived tip (SyncInfo::sendTipReady): a fresh reconnect or peerless node
  has tip_known=false, so its local height isn't a trustworthy network tip — sending is disabled with a
  clear tooltip until it is. Lite is exempt (its own sync gate). The z_sendmany call is untouched.
- History now shows "Dropped" for a conflicted tx (confirmations < 0) and "Expired?" for one unconfirmed
  > 1h, instead of "Pending" forever (new TransactionInfo::isConflicted/isStalePending; the tab inlines the
  check on its DisplayTx fields).
- A cached z_viewtransaction row's confirmations now advance by the blocks mined since it was sampled
  (observed_height), instead of freezing at the cached value; never goes backwards; pending (0) isn't
  extrapolated.

Performance:
- Explorer block list uses loadRangeCached() (memoized on the cache revision) instead of loadRange(), so it
  only hits SQLite after the cache changes, not every frame.

Tests: testResilienceMediums (tip gate, confirmations-advance, dropped/stale predicates) + loadRangeCached
memoization in testExplorerBlockCache (isolated on its own cache). Held: Overview/Balance per-frame list
cache, peers clippers, off-thread tx-history cache load (larger refactors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 19:12:32 -05:00

2410 lines
115 KiB
C++

#include "network_refresh_service.h"
#include "history_scan_policy.h"
#include "rpc/rpc_busy_gate.h"
#include "../util/logger.h"
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
#include "../util/amount_format.h" // util::toSats: integer-satoshi accumulation
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <limits>
#include <map>
#include <set>
#include <unordered_map>
#include <unordered_set>
using json = nlohmann::json;
namespace dragonx {
namespace services {
namespace {
template <typename T>
std::optional<T> readOptional(const json& source, const char* key)
{
try {
if (source.contains(key)) return source[key].get<T>();
} catch (...) {}
return std::nullopt;
}
std::optional<double> readBalanceString(const json& source, const char* key)
{
try {
if (source.contains(key)) {
const std::string text = source[key].get<std::string>();
// Strict: plain decimal only (digits, one '.', optional leading '-'). std::stod would accept
// '1.5abc', whitespace, '+', hex, exponents and nan/inf, silently.
if (text.empty()) return std::nullopt;
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
if (!((c >= '0' && c <= '9') || c == '.' || (c == '-' && i == 0))) return std::nullopt;
}
char* end = nullptr;
const double value = std::strtod(text.c_str(), &end);
if (end != text.c_str() + text.size() || !std::isfinite(value)) return std::nullopt;
return value;
}
} catch (...) {}
return std::nullopt;
}
// Whole-wallet balance sweeps (z_gettotalbalance / z_listunspent) hold the daemon's cs_main and take tens
// of seconds on a very large note set. Give them a long reply timeout (like the sent-history sweep) so a
// slow pass completes and populates per-address balances instead of aborting at the default 30 s and
// leaving them at 0. The governor's cost-proportional floor keeps these from being issued too often.
constexpr long kBalanceSweepTimeoutSec = 120;
void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& unspent)
{
if (!unspent.is_array()) return;
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
// Accumulated in integer satoshis (not doubles) so many notes can't drift by an ulp per addition.
std::map<std::string, std::int64_t> total;
std::map<std::string, std::int64_t> spendable;
for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount");
if (!address || !amount || !std::isfinite(*amount)) continue;
const std::int64_t sats = util::toSats(*amount);
total[*address] += sats;
auto conf = readOptional<int>(output, "confirmations");
if (conf && *conf >= 1) spendable[*address] += sats;
}
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
// notes in this set is 0, and spendableBalance is always a subset sum of balance.
for (auto& info : addresses) {
auto t = total.find(info.address);
auto s = spendable.find(info.address);
info.balance = (t != total.end()) ? static_cast<double>(t->second) / 1e8 : 0.0;
info.spendableBalance = (s != spendable.end()) ? static_cast<double>(s->second) / 1e8 : 0.0;
}
}
bool hasTransactionType(const std::vector<TransactionInfo>& transactions,
const std::string& txid,
const std::string& type)
{
for (const auto& transaction : transactions) {
if (transaction.txid == txid && transaction.type == type) return true;
}
return false;
}
bool tryFetchRawTransaction(NetworkRefreshService::RefreshRpcGateway& rpc,
const std::string& txid,
json& rawTransaction)
{
try {
rawTransaction = rpc.call("gettransaction", json::array({txid}));
return !rawTransaction.is_null();
} catch (...) {
return false;
}
}
void applyRawTransactionMetadata(TransactionInfo& info,
const json& rawTransaction,
bool includeSendDetails)
{
if (rawTransaction.is_null()) return;
if (auto value = readOptional<std::int64_t>(rawTransaction, "time")) info.timestamp = *value;
else if (auto value = readOptional<std::int64_t>(rawTransaction, "timereceived")) info.timestamp = *value;
if (auto value = readOptional<int>(rawTransaction, "confirmations")) info.confirmations = *value;
if (!includeSendDetails) return;
if (auto value = readOptional<double>(rawTransaction, "amount")) info.amount = *value;
if (!rawTransaction.contains("details") || !rawTransaction["details"].is_array()) return;
for (const auto& detail : rawTransaction["details"]) {
auto category = readOptional<std::string>(detail, "category");
if (!category || *category != "send") continue;
if (auto value = readOptional<double>(detail, "amount")) info.amount = *value;
if (auto value = readOptional<std::string>(detail, "address")) info.address = *value;
break;
}
}
void applyRawTransactionMetadata(NetworkRefreshService::TransactionViewCacheEntry& entry,
const json& rawTransaction)
{
if (rawTransaction.is_null()) return;
if (auto value = readOptional<std::int64_t>(rawTransaction, "time")) entry.timestamp = *value;
else if (auto value = readOptional<std::int64_t>(rawTransaction, "timereceived")) entry.timestamp = *value;
if (auto value = readOptional<int>(rawTransaction, "confirmations")) entry.confirmations = *value;
}
void appendTrackedSendPlaceholder(std::vector<TransactionInfo>& transactions,
const std::string& txid,
const json* rawTransaction)
{
if (hasTransactionType(transactions, txid, "send")) return;
TransactionInfo info;
info.txid = txid;
info.type = "send";
if (rawTransaction) applyRawTransactionMetadata(info, *rawTransaction, true);
transactions.push_back(std::move(info));
}
// A row the shielded scans (or z_viewtransaction) produce rather than listtransactions.
bool isShieldedRelatedRow(const TransactionInfo& transaction)
{
return (!transaction.address.empty() && transaction.address[0] == 'z') ||
(!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
!transaction.memo.empty();
}
void appendMissingPreviousTransactions(std::vector<TransactionInfo>& transactions,
const std::vector<TransactionInfo>& previousTransactions,
bool includeAll,
const std::unordered_set<std::string>& pinnedTxids)
{
// Presence is decided against the rows built this cycle, before any is carried: deduping against rows
// just appended kept only the first leg of a multi-recipient send.
std::set<std::pair<std::string, std::string>> present;
for (const auto& transaction : transactions) present.emplace(transaction.txid, transaction.type);
for (const auto& transaction : previousTransactions) {
bool pinned = pinnedTxids.find(transaction.txid) != pinnedTxids.end();
if (!includeAll && !pinned && !isShieldedRelatedRow(transaction)) continue;
if (present.count({transaction.txid, transaction.type})) continue;
transactions.push_back(transaction);
}
}
// On a full rebuild, keep the stored rows of sends still waiting to be re-enriched. Presence is decided
// against the rows built this cycle, before any is carried, so every row of a multi-recipient send is kept
// (deduping against rows just appended would keep only the first).
void appendPinnedPreviousTransactions(std::vector<TransactionInfo>& transactions,
const std::vector<TransactionInfo>& previousTransactions,
const std::unordered_set<std::string>& pinnedTxids)
{
std::set<std::pair<std::string, std::string>> present;
for (const auto& transaction : transactions) present.emplace(transaction.txid, transaction.type);
for (const auto& transaction : previousTransactions) {
if (!pinnedTxids.count(transaction.txid)) continue;
if (present.count({transaction.txid, transaction.type})) continue;
transactions.push_back(transaction);
}
}
using HushChatMemoOutputMap = std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>;
std::size_t readMemoPosition(const json& source, std::size_t fallback)
{
for (const char* key : {"position", "outputIndex", "outindex"}) {
auto value = readOptional<int>(source, key);
if (value && *value >= 0) return static_cast<std::size_t>(*value);
}
return fallback;
}
void recordHushChatMemoOutput(HushChatMemoOutputMap* outputs,
const std::string& txid,
const std::string& memo,
std::size_t position)
{
if (!outputs || txid.empty() || memo.empty()) return;
(*outputs)[txid].push_back(chat::HushChatMemoOutput{position, memo});
}
void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
const std::string& txid,
const std::vector<chat::HushChatMemoOutput>& outputs)
{
auto extracted = chat::extractHushChatTransactionMetadata(chat::HushChatTransactionInput{txid, outputs});
for (auto& metadata : extracted.metadata) {
destination.push_back(std::move(metadata));
}
}
void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
const HushChatMemoOutputMap& outputsByTxid)
{
if (!chat::hushChatFeatureEnabledAtBuild()) return;
for (const auto& [txid, outputs] : outputsByTxid) {
appendExtractedHushChatMetadata(destination, txid, outputs);
}
}
} // namespace
NetworkRefreshService::ConnectionInfoResult NetworkRefreshService::parseConnectionInfoResult(const json& info)
{
ConnectionInfoResult result;
if (!info.is_object()) return result;
result.ok = true;
result.daemonVersion = readOptional<int>(info, "version");
result.protocolVersion = readOptional<int>(info, "protocolversion");
result.p2pPort = readOptional<int>(info, "p2pport");
result.longestChain = readOptional<int>(info, "longestchain");
result.notarized = readOptional<int>(info, "notarized");
result.blocks = readOptional<int>(info, "blocks");
return result;
}
NetworkRefreshService::WalletEncryptionResult NetworkRefreshService::parseWalletEncryptionResult(const json& walletInfo)
{
WalletEncryptionResult result;
if (!walletInfo.is_object()) return result;
result.ok = true;
result.encrypted = walletInfo.contains("unlocked_until");
if (result.encrypted) {
try {
result.unlockedUntil = walletInfo["unlocked_until"].get<std::int64_t>();
} catch (...) {
result.ok = false;
}
}
if (walletInfo.contains("txcount") && walletInfo["txcount"].is_number_integer())
result.txCount = walletInfo["txcount"].get<int>();
return result;
}
bool NetworkRefreshService::connectionInfoTipKnown(const ConnectionInfoResult& info)
{
return info.ok && info.longestChain && *info.longestChain > 0;
}
bool NetworkRefreshService::connectionInfoShowsBehind(const ConnectionInfoResult& info)
{
return connectionInfoTipKnown(info) && info.blocks && *info.blocks < *info.longestChain - 2;
}
NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPollResult(RefreshRpcGateway& rpc)
{
WarmupPollResult result;
try {
json info = rpc.call("getinfo", json::array());
result.ready = true;
result.info = parseConnectionInfoResult(info);
} catch (const std::exception& e) {
result.errorMessage = e.what();
result.errorKind = rpc::rpcFailureKindOf(e);
}
return result;
}
NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnectionInitResult(
RefreshRpcGateway& rpc,
const std::optional<ConnectionInfoResult>& prefetchedInfo)
{
ConnectionInitResult result;
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());
result.encryption = parseWalletEncryptionResult(walletInfo);
} catch (...) {}
return result;
}
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
const json& totalBalance, const json& spendableBalance, bool balanceOk, const json& blockInfo, bool blockOk)
{
CoreRefreshResult result;
result.balanceOk = balanceOk && totalBalance.is_object();
if (result.balanceOk) {
result.shieldedBalance = readBalanceString(totalBalance, "private");
result.transparentBalance = readBalanceString(totalBalance, "transparent");
result.totalBalance = readBalanceString(totalBalance, "total");
const auto total = totalBalance.find("total");
if (total != totalBalance.end() && total->is_string())
result.totalBalanceZat = parseMoneyZat(total->get<std::string>());
}
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
}
result.blockchainOk = blockOk && blockInfo.is_object();
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");
}
return result;
}
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc,
const CoreRefreshPlan& plan)
{
json totalBalance;
json spendableBalance;
json blockInfo;
bool balanceOk = false;
bool blockOk = false;
double balanceScanMs = 0.0;
rpc::RpcFailureKind blockErrKind = rpc::RpcFailureKind::None;
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
// getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else
// (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance
// scan keeps sync detection from being delayed behind (or, under contention, starved by) that
// scan — the failure mode where the wallet kept reading "synced" while actually falling behind,
// so kSyncProfile never engaged. See effectivelySyncing()'s sticky-behind latch.
try {
blockInfo = rpc.call("getblockchaininfo", json::array());
blockOk = true;
} catch (const std::exception& e) {
blockErrKind = rpc::rpcFailureKindOf(e);
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
}
// The mempool's size: with the tip hash and the wallet's mutation epoch, what tells the caller whether a
// wallet scan could see anything new. getmempoolinfo takes no cs_main. Failing leaves it unknown, which
// counts as changed.
bool mempoolFetched = false, mempoolOk = false;
std::int64_t mempoolTxs = 0, mempoolBytes = 0;
if (plan.fetchMempool && blockOk) {
mempoolFetched = true;
try {
json pool = rpc.call("getmempoolinfo", json::array());
if (pool.is_object()) {
mempoolOk = true;
if (pool.contains("size") && pool["size"].is_number_integer()) mempoolTxs = pool["size"].get<std::int64_t>();
if (pool.contains("bytes") && pool["bytes"].is_number_integer()) mempoolBytes = pool["bytes"].get<std::int64_t>();
}
} catch (const std::exception& e) {
DEBUG_LOGF("getmempoolinfo error: %s\n", e.what());
}
}
// If getblockchaininfo shows we're behind (same 2-block tolerance as applyCoreRefreshResult), skip
// the balance scan this cycle regardless of includeBalance: the balance is incomplete mid-sync
// anyway, and skipping it lets the sync-state update — and hence kSyncProfile — take effect at the
// end of THIS (now-cheap) task instead of being delayed ~20s behind the scan. This is what lets the
// sticky-behind latch engage promptly the first time the node falls behind.
bool behind = false;
if (blockOk && blockInfo.is_object()) {
const long long b = blockInfo.value("blocks", 0LL);
const long long lc = blockInfo.value("longestchain", 0LL);
if (lc > 0 && b < lc - 2) behind = true;
}
// Don't follow a chain-info call that found the node busy (a reply timeout or a full queue) with two
// balance scans: they need the same cs_main, and each would only take another of its few RPC slots.
bool balanceAttempted = false;
bool spendableQueried = false;
if (plan.includeBalance && (!behind || plan.evenIfBehind) && !rpc::rpcKindMeansBusy(blockErrKind)) {
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
// (balanceRefreshDue()), keeping balance scans from starving block connection.
const auto balanceStart = std::chrono::steady_clock::now();
try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
totalBalance = rpc.callLong("z_gettotalbalance", json::array({0}), kBalanceSweepTimeoutSec);
balanceOk = true;
} catch (const std::exception& e) {
balanceErrKind = rpc::rpcFailureKindOf(e);
DEBUG_LOGF("Balance error: %s\n", e.what());
}
// A Deferred call was held back by the busy gate and never reached the daemon.
balanceAttempted = balanceErrKind != rpc::RpcFailureKind::Deferred;
// SPENDABLE total: minconf=1 (confirmed), a second whole-wallet scan. Only after the display call
// succeeded (its result is dropped otherwise), and only when it can differ from the display total:
// something is unconfirmed or pending (needSpendable), or the exact total moved since the last
// poll, which is how a new incoming 0-conf payment first shows. Skipped, spendable equals display
// in applyCoreRefreshResult and nothing reads as unconfirmed.
if (balanceOk) {
std::optional<std::int64_t> totalZat;
const auto total = totalBalance.is_object() ? totalBalance.find("total") : totalBalance.end();
if (total != totalBalance.end() && total->is_string()) totalZat = parseMoneyZat(total->get<std::string>());
const bool totalMoved = !totalZat || !plan.prevTotalZat || *totalZat != *plan.prevTotalZat;
// Not on the session's first read while the node is behind: nothing can be sent until it caught
// up anyway, and it is a second whole-wallet scan into the catch-up.
if ((plan.needSpendable || totalMoved) && !(behind && plan.evenIfBehind)) {
spendableQueried = true;
try { // If it fails, spendable degrades to display in apply.
spendableBalance = rpc.callLong("z_gettotalbalance", json::array({1}), kBalanceSweepTimeoutSec);
} catch (...) {}
}
}
balanceScanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - balanceStart).count();
}
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
result.balanceScanMs = balanceScanMs;
result.blockchainErrKind = blockErrKind;
result.balanceErrKind = balanceErrKind;
result.balanceAttempted = balanceAttempted;
result.spendableQueried = spendableQueried;
result.mempoolFetched = mempoolFetched;
result.mempoolOk = mempoolOk;
result.mempoolTxs = mempoolTxs;
result.mempoolBytes = mempoolBytes;
return result;
}
std::optional<std::int64_t> NetworkRefreshService::parseMoneyZat(const std::string& text)
{
constexpr std::int64_t kCoin = 100000000;
// Leaves room for up to 99999999 zatoshis of fraction on top of whole * 1e8.
constexpr std::int64_t kMaxWhole = std::numeric_limits<std::int64_t>::max() / kCoin - 1;
std::size_t i = 0;
const bool negative = !text.empty() && text[0] == '-';
if (negative) ++i;
std::int64_t whole = 0;
std::size_t wholeDigits = 0;
for (; i < text.size() && text[i] >= '0' && text[i] <= '9'; ++i, ++wholeDigits) {
const int digit = text[i] - '0';
if (whole > (kMaxWhole - digit) / 10) return std::nullopt; // the amount would overflow
whole = whole * 10 + digit;
}
if (wholeDigits == 0) return std::nullopt;
std::int64_t fraction = 0;
if (i < text.size() && text[i] == '.') {
++i;
std::size_t fractionDigits = 0;
for (; i < text.size() && text[i] >= '0' && text[i] <= '9'; ++i, ++fractionDigits) {
if (fractionDigits == 8) return std::nullopt; // finer than a zatoshi: not a daemon amount
fraction = fraction * 10 + (text[i] - '0');
}
if (fractionDigits == 0) return std::nullopt;
for (; fractionDigits < 8; ++fractionDigits) fraction *= 10;
}
if (i != text.size()) return std::nullopt;
const std::int64_t zat = whole * kCoin + fraction;
return negative ? -zat : zat;
}
std::optional<std::int64_t> NetworkRefreshService::nextTotalBaseline(const CoreRefreshResult& result,
std::optional<std::int64_t> previous)
{
if (!result.balanceOk) return previous; // no fresh total
if (result.spendableQueried && !result.spendableTotalBalance) return std::nullopt; // re-measure next poll
return result.totalBalanceZat;
}
bool NetworkRefreshService::spendableBalanceNeeded(const WalletState& state, bool pendingSendWork, bool forced)
{
if (forced || pendingSendWork) return true;
if (state.last_balance_update == 0) return true;
if (state.unconfirmedBalance > 1e-9) return true;
// The address scan saw unconfirmed funds (z_listunspent/listunspent at minconf=0 with confirmations).
auto anyUnconfirmed = [](const std::vector<AddressInfo>& addresses) {
return std::any_of(addresses.begin(), addresses.end(), [](const AddressInfo& address) {
return address.balance - address.spendableBalance > 1e-9;
});
};
return anyUnconfirmed(state.z_addresses) || anyUnconfirmed(state.t_addresses);
}
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
const json& miningInfo, bool miningOk, const json& localHashrate, bool hashrateOk, double daemonMemoryMb)
{
MiningRefreshResult result;
result.daemonMemoryMb = daemonMemoryMb;
if (hashrateOk) {
try {
result.localHashrate = localHashrate.get<double>();
} catch (...) {}
}
result.miningOk = miningOk && miningInfo.is_object();
if (result.miningOk) {
result.generate = readOptional<bool>(miningInfo, "generate");
result.genproclimit = readOptional<int>(miningInfo, "genproclimit");
result.blocks = readOptional<int>(miningInfo, "blocks");
result.difficulty = readOptional<double>(miningInfo, "difficulty");
result.networkHashrate = readOptional<double>(miningInfo, "networkhashps");
result.chain = readOptional<std::string>(miningInfo, "chain");
}
return result;
}
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::collectMiningRefreshResult(
RefreshRpcGateway& rpc,
double daemonMemoryMb,
bool includeSlowRefresh,
bool includeLocalHashrate)
{
json miningInfo;
json localHashrate;
bool miningOk = false;
bool hashrateOk = false;
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) {
try {
miningInfo = rpc.call("getmininginfo", json::array());
miningOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("getMiningInfo error: %s\n", e.what());
}
}
return parseMiningRefreshResult(miningInfo, miningOk, localHashrate, hashrateOk, daemonMemoryMb);
}
NetworkRefreshService::PeerRefreshResult NetworkRefreshService::parsePeerRefreshResult(
const json& peers, const json& bannedPeers)
{
PeerRefreshResult result;
if (peers.is_array()) {
for (const auto& peer : peers) {
PeerInfo info;
if (auto value = readOptional<int>(peer, "id")) info.id = *value;
if (auto value = readOptional<std::string>(peer, "addr")) info.addr = *value;
if (auto value = readOptional<std::string>(peer, "subver")) info.subver = *value;
if (auto value = readOptional<std::string>(peer, "services")) info.services = *value;
if (auto value = readOptional<int>(peer, "version")) info.version = *value;
if (auto value = readOptional<std::int64_t>(peer, "conntime")) info.conntime = *value;
if (auto value = readOptional<int>(peer, "banscore")) info.banscore = *value;
if (auto value = readOptional<double>(peer, "pingtime")) info.pingtime = *value;
if (auto value = readOptional<std::int64_t>(peer, "bytessent")) info.bytessent = *value;
if (auto value = readOptional<std::int64_t>(peer, "bytesrecv")) info.bytesrecv = *value;
if (auto value = readOptional<int>(peer, "startingheight")) info.startingheight = *value;
if (auto value = readOptional<int>(peer, "synced_headers")) info.synced_headers = *value;
if (auto value = readOptional<int>(peer, "synced_blocks")) info.synced_blocks = *value;
if (auto value = readOptional<bool>(peer, "inbound")) info.inbound = *value;
if (auto value = readOptional<std::string>(peer, "tls_cipher")) info.tls_cipher = *value;
if (auto value = readOptional<bool>(peer, "tls_verified")) info.tls_verified = *value;
result.peers.push_back(std::move(info));
}
}
if (bannedPeers.is_array()) {
for (const auto& ban : bannedPeers) {
BannedPeer info;
if (auto value = readOptional<std::string>(ban, "address")) info.address = *value;
if (auto value = readOptional<std::int64_t>(ban, "banned_until")) info.banned_until = *value;
result.bannedPeers.push_back(std::move(info));
}
}
return result;
}
NetworkRefreshService::PeerRefreshResult NetworkRefreshService::collectPeerRefreshResult(RefreshRpcGateway& rpc)
{
json peers = json::array();
json bannedPeers = json::array();
bool peersOk = false;
bool bannedOk = false;
try {
peers = rpc.call("getpeerinfo", json::array());
peersOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("getPeerInfo error: %s\n", e.what());
}
try {
bannedPeers = rpc.call("listbanned", json::array());
bannedOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("listBanned error: %s\n", e.what());
}
return parsePeerRefreshResult(peersOk ? peers : json::array(),
bannedOk ? bannedPeers : json::array());
}
std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::parseCoinGeckoPriceResponse(
const std::string& response, std::time_t fetchedAt)
{
try {
auto parsed = json::parse(response);
if (!parsed.contains("dragonx-2")) return std::nullopt;
const auto& data = parsed["dragonx-2"];
// CoinGecko emits JSON null (not an omitted key) for fields it can't currently compute —
// commonly usd_24h_change on illiquid/newly-listed tokens — while still returning a valid
// spot price in the same object. .value(key, default) throws type_error on a PRESENT null,
// which the outer catch turns into "no price update at all", so read null-tolerantly and
// keep the valid usd/btc rather than discarding the whole refresh.
auto num = [&data](const char* key, double def) {
auto it = data.find(key);
return (it != data.end() && it->is_number()) ? it->get<double>() : def;
};
PriceRefreshResult result;
result.market.price_usd = num("usd", 0.0);
result.market.price_btc = num("btc", 0.0);
result.market.change_24h = num("usd_24h_change", 0.0);
result.market.volume_24h = num("usd_24h_vol", 0.0);
result.market.market_cap = num("usd_market_cap", 0.0);
char buf[64];
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
// reentrant variant into a local tm (matches the rest of the codebase).
std::tm tmv{};
#ifdef _WIN32
localtime_s(&tmv, &fetchedAt);
#else
localtime_r(&fetchedAt, &tmv);
#endif
if (std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tmv) > 0) {
result.market.last_updated = buf;
}
return result;
} catch (...) {
return std::nullopt;
}
}
std::vector<std::pair<std::time_t, double>> NetworkRefreshService::parseCoinGeckoMarketChart(
const std::string& response)
{
std::vector<std::pair<std::time_t, double>> series;
try {
auto parsed = json::parse(response);
if (!parsed.contains("prices") || !parsed["prices"].is_array()) return series;
const auto& prices = parsed["prices"];
series.reserve(prices.size());
for (const auto& row : prices) {
// Each row is [milliseconds, price]; skip anything malformed.
if (!row.is_array() || row.size() < 2) continue;
if (!row[0].is_number() || !row[1].is_number()) continue;
const double ms = row[0].get<double>();
const double price = row[1].get<double>();
series.emplace_back(static_cast<std::time_t>(ms / 1000.0), price);
}
} catch (...) {
series.clear();
}
return series;
}
NetworkRefreshService::PriceHttpResult NetworkRefreshService::parsePriceHttpResponse(
const PriceHttpResponse& response,
std::time_t fetchedAt)
{
PriceHttpResult result;
if (!response.transportOk || response.httpStatus != 200) {
const std::string reason = response.transportOk ? "OK" : response.transportError;
result.errorMessage = "Price fetch failed: " + reason +
" (HTTP " + std::to_string(response.httpStatus) + ")";
return result;
}
auto parsed = parseCoinGeckoPriceResponse(response.body, fetchedAt);
if (!parsed) {
result.errorMessage = "Price fetch returned an unrecognized response";
return result;
}
result.price = std::move(*parsed);
return result;
}
AddressInfo NetworkRefreshService::buildShieldedAddressInfo(const std::string& address,
const json& validation,
bool validationSucceeded)
{
AddressInfo info;
info.address = address;
info.type = "shielded";
if (!validationSucceeded) {
info.has_spending_key = true;
return info;
}
info.has_spending_key = false;
if (auto isMine = readOptional<bool>(validation, "ismine")) {
info.has_spending_key = *isMine;
}
return info;
}
AddressInfo NetworkRefreshService::buildTransparentAddressInfo(const std::string& address)
{
AddressInfo info;
info.address = address;
info.type = "transparent";
return info;
}
std::vector<AddressInfo> NetworkRefreshService::parseTransparentAddressList(const json& addressList)
{
std::vector<AddressInfo> addresses;
if (!addressList.is_array()) return addresses;
for (const auto& addressJson : addressList) {
try {
addresses.push_back(buildTransparentAddressInfo(addressJson.get<std::string>()));
} catch (...) {}
}
return addresses;
}
void NetworkRefreshService::applyShieldedBalancesFromUnspent(std::vector<AddressInfo>& addresses,
const json& unspent)
{
applyBalancesFromUnspent(addresses, unspent);
}
void NetworkRefreshService::applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
const json& unspent)
{
applyBalancesFromUnspent(addresses, unspent);
}
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,
bool listOnly)
{
AddressRefreshResult result;
result.listOnly = listOnly;
// Time the whole scan — z_listunspent (and per-address z_getbalance fallback) hold the daemon's
// cs_main for the duration, seconds on a large shielded wallet. The measured cost feeds the
// caller's adaptive throttle so the address poll can't starve block connection.
const auto scanStart = std::chrono::steady_clock::now();
try {
json zList = rpc.call("z_listaddresses", json::array());
if (zList.is_array()) {
for (const auto& addressJson : zList) {
std::string address;
try {
address = addressJson.get<std::string>();
} catch (...) {
continue;
}
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));
}
}
}
} catch (const std::exception& e) {
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
}
try {
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
result.transparentAddresses = parseTransparentAddressList(tList);
} catch (const std::exception& e) {
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
}
// The balances need the lists they belong to: none in a list-only pass, and none when an enumeration
// failed (the caller discards the whole pass and keeps the previous lists).
const bool scanBalances = !listOnly && result.addressListOk;
if (scanBalances) try {
json unspent = rpc.callLong("z_listunspent", json::array({0, 9999999, false}), kBalanceSweepTimeoutSec); // minconf=0 → include 0-conf change
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
// Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead
// of issuing its own z_listunspent. rawconfirmations is the TRUE depth; `confirmations` is
// dPoW-clamped to 1 and understates it.
if (unspent.is_array()) {
result.unspentNotes.reserve(unspent.size());
for (const auto& nz : unspent) {
if (!nz.is_object()) continue;
UnspentNoteLite lite;
lite.amount = nz.value("amount", 0.0);
lite.locked = nz.value("locked", false);
lite.confirmations = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
? nz["rawconfirmations"].get<int>()
: nz.value("confirmations", 0);
if (nz.contains("address") && nz["address"].is_string()) lite.address = nz["address"].get<std::string>();
if (nz.contains("txid") && nz["txid"].is_string()) lite.txid = nz["txid"].get<std::string>();
if (nz.contains("change") && nz["change"].is_boolean()) lite.change = nz["change"].get<bool>();
result.unspentNotes.push_back(lite);
}
}
} catch (const std::exception& e) {
const auto kind = rpc::rpcFailureKindOf(e);
result.replyTimedOut |= kind == rpc::RpcFailureKind::ReplyTimeout;
if (rpc::rpcKindMeansBusy(kind)) {
// The node is busy: every z_getbalance would be held back and read 0. Nothing more this pass.
DEBUG_LOGF("z_listunspent: node busy (%s)\n", e.what());
result.balancesOk = false;
result.nodeBusy = true;
} else {
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
for (auto& info : result.shieldedAddresses) {
try { // display total (minconf=0, includes pending change)
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
if (!total.is_null()) info.balance = total.get<double>();
} catch (...) { result.balancesOk = false; }
try { // spendable (minconf=1); degrade to the display value on old daemons
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
} catch (...) { info.spendableBalance = info.balance; }
}
}
}
if (scanBalances && !result.nodeBusy) try {
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("listunspent error: %s\n", e.what());
result.balancesOk = false;
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
}
// A balance that wasn't read reads 0: never overwrite known balances with that. The pass degrades to
// list-only (apply carries the previous balances), and its notes aren't a full view either.
if (scanBalances && !result.balancesOk) {
result.listOnly = true;
result.unspentNotes.clear();
}
result.scanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - scanStart).count();
return result;
}
NetworkRefreshService::TransactionRefreshSnapshot NetworkRefreshService::buildTransactionRefreshSnapshot(
const WalletState& state,
const TransactionViewCache& viewTxCache,
const std::unordered_set<std::string>& sendTxids)
{
TransactionRefreshSnapshot snapshot;
for (const auto& address : state.z_addresses) {
if (!address.address.empty()) snapshot.shieldedAddresses.push_back(address.address);
}
for (const auto& cachedView : viewTxCache) {
snapshot.fullyEnrichedTxids.insert(cachedView.first);
}
for (const auto& transaction : state.transactions) {
if (transaction.confirmations > 6 && transaction.timestamp != 0 && !transaction.txid.empty()) {
snapshot.fullyEnrichedTxids.insert(transaction.txid);
}
}
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 std::set<std::string>& miningAddresses)
{
if (!result.is_array()) return;
for (const auto& transactionJson : result) {
TransactionInfo info;
if (auto value = readOptional<std::string>(transactionJson, "txid")) info.txid = *value;
// C266: listtransactions("*") also returns "move" accounting rows (no txid/address/vout) for
// every account. They are not wallet transactions - skip so they never become blank History rows.
if (info.txid.empty()) continue;
if (auto value = readOptional<std::string>(transactionJson, "category")) info.type = *value;
if (auto value = readOptional<double>(transactionJson, "amount")) info.amount = *value;
if (auto value = readOptional<std::int64_t>(transactionJson, "time")) {
info.timestamp = *value;
} else if (auto received = readOptional<std::int64_t>(transactionJson, "timereceived")) {
info.timestamp = *received;
}
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));
}
}
void NetworkRefreshService::appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
std::set<std::string>& knownTxids,
const std::string& address,
const json& received,
const std::set<std::string>& miningAddresses,
HushChatMemoOutputMap* chatMemoOutputs)
{
if (received.is_null() || !received.is_array()) return;
std::size_t fallbackPosition = 0;
for (const auto& note : received) {
const std::size_t notePosition = readMemoPosition(note, fallbackPosition++);
auto txid = readOptional<std::string>(note, "txid");
if (!txid || txid->empty()) continue;
auto change = readOptional<bool>(note, "change");
if (change && *change) continue;
auto memoValue = readOptional<std::string>(note, "memoStr");
if (memoValue) recordHushChatMemoOutput(chatMemoOutputs, *txid, *memoValue, notePosition);
bool dominated = false;
for (const auto& existing : transactions) {
if (existing.txid == *txid && existing.type == "receive") {
dominated = true;
break;
}
}
if (dominated) continue;
TransactionInfo info;
info.txid = *txid;
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;
if (auto value = readOptional<std::int64_t>(note, "time")) info.timestamp = *value;
if (memoValue) info.memo = *memoValue;
knownTxids.insert(*txid);
transactions.push_back(std::move(info));
}
}
NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseViewTransactionCacheEntry(
const json& viewTransaction)
{
TransactionViewCacheEntry entry;
if (!viewTransaction.is_object()) return entry;
if (viewTransaction.contains("spends") && viewTransaction["spends"].is_array()) {
for (const auto& spend : viewTransaction["spends"]) {
if (auto address = readOptional<std::string>(spend, "address")) {
entry.from_address = *address;
break;
}
}
}
if (viewTransaction.contains("outputs") && viewTransaction["outputs"].is_array()) {
std::size_t fallbackPosition = 0;
for (const auto& output : viewTransaction["outputs"]) {
const std::size_t outputPosition = readMemoPosition(output, fallbackPosition++);
bool outgoing = false;
if (auto value = readOptional<bool>(output, "outgoing")) outgoing = *value;
if (!outgoing) continue;
TransactionViewCacheEntry::Output out;
if (auto value = readOptional<std::string>(output, "address")) out.address = *value;
if (auto value = readOptional<double>(output, "value")) out.value = *value;
if (auto value = readOptional<std::string>(output, "memoStr")) out.memo = *value;
out.position = outputPosition;
entry.outgoing_outputs.push_back(std::move(out));
}
}
return entry;
}
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
const std::string& txid,
const TransactionViewCacheEntry& entry,
int currentHeight)
{
// A mined entry's confirmations grow by one per block since it was sampled; never go backwards.
int entryConfirmations = entry.confirmations;
if (entry.confirmations >= 1 && entry.observed_height >= 0 && currentHeight > entry.observed_height)
entryConfirmations += currentHeight - entry.observed_height;
for (const auto& out : entry.outgoing_outputs) {
bool alreadyTracked = false;
for (const auto& existing : transactions) {
// Key on address as well as amount: two outgoing outputs of EQUAL value to DIFFERENT
// recipients in one tx are distinct rows — an amount-only match would drop the second.
if (existing.txid == txid && existing.type == "send" &&
std::abs(existing.amount + out.value) < 0.00000001 &&
existing.address == out.address) {
alreadyTracked = true;
break;
}
}
if (alreadyTracked) continue;
TransactionInfo info;
info.txid = txid;
info.type = "send";
info.address = out.address;
info.amount = -out.value;
info.memo = out.memo;
info.from_address = entry.from_address;
bool foundExistingMetadata = false;
for (const auto& existing : transactions) {
if (existing.txid == txid) {
info.confirmations = existing.confirmations;
info.timestamp = existing.timestamp;
foundExistingMetadata = true;
break;
}
}
if (!foundExistingMetadata) {
info.confirmations = entryConfirmations;
info.timestamp = entry.timestamp;
}
transactions.push_back(std::move(info));
}
}
void NetworkRefreshService::appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
const std::string& txid,
const nlohmann::json& sentEntry)
{
if (!sentEntry.is_object() || !sentEntry.contains("sends") || !sentEntry["sends"].is_object())
return;
const auto& sends = sentEntry["sends"];
auto it = sends.find("transparentSends");
if (it == sends.end() || !it->is_array()) return;
// Per-tx metadata for the synthesized rows.
int confirmations = 0;
std::int64_t timestamp = 0;
if (auto v = readOptional<int>(sentEntry, "confirmations")) confirmations = *v;
if (auto v = readOptional<std::int64_t>(sentEntry, "blocktime")) timestamp = *v;
if (timestamp == 0) {
if (auto v = readOptional<std::int64_t>(sentEntry, "time")) timestamp = *v;
}
for (const auto& out : *it) {
auto amount = readOptional<double>(out, "amount");
if (!amount) continue;
const double debit = -std::abs(*amount); // the daemon reports -nValue; a send is a debit
std::string address;
if (auto a = readOptional<std::string>(out, "address")) address = *a;
// Dedup against rows already present — notably listtransactions-derived t->t sends, which
// this whole-wallet payload ALSO reports. Same txid+address+amount == the same output.
bool exists = false;
for (const auto& e : transactions) {
if (e.txid == txid && e.type == "send" && e.address == address &&
std::abs(e.amount - debit) < 0.00000001) {
exists = true;
break;
}
}
if (exists) continue;
TransactionInfo info;
info.txid = txid;
info.type = "send";
info.address = address;
info.amount = debit;
info.confirmations = confirmations;
info.timestamp = timestamp;
transactions.push_back(std::move(info));
}
}
void NetworkRefreshService::sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions)
{
std::sort(transactions.begin(), transactions.end(),
[](const TransactionInfo& left, const TransactionInfo& right) {
return left.timestamp > right.timestamp;
});
}
NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTransactionRefreshResult(
RefreshRpcGateway& rpc,
const TransactionRefreshSnapshot& snapshot,
int currentBlockHeight,
int maxViewTransactionsPerCycle)
{
TransactionRefreshResult result;
result.blockHeight = currentBlockHeight;
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
result.shieldedScanHeights = snapshot.shieldedScanHeights;
// Time the whole cycle: listtransactions, the per-address z_listreceivedbyaddress scans and
// z_listsentbyaddress are each O(mapWallet) and hold the daemon's cs_main. The measured cost feeds the
// caller's throttle (txRefreshDue()) so routine scans can't starve block connection on a large wallet.
const auto scanStart = std::chrono::steady_clock::now();
auto msSince = [](std::chrono::steady_clock::time_point from) {
return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - from).count();
};
std::set<std::string> knownTxids;
HushChatMemoOutputMap hushChatReceivedOutputs;
HushChatMemoOutputMap* hushChatReceivedOutputsPtr = chat::hushChatFeatureEnabledAtBuild()
? &hushChatReceivedOutputs
: nullptr;
bool transactionRpcError = false;
// ── Transparent rows (listtransactions) ─────────────────────────────────────────────────────────
if (snapshot.reuseTransparentRows && !snapshot.previousTransactions.empty()) {
// Same tip and nothing changed since the rows on screen were fetched: keep their transparent rows
// instead of paging the whole history again. Shielded rows come from the scans below, and rows of
// tracked sends and pending ops are rebuilt by enrichment.
for (const auto& transaction : snapshot.previousTransactions) {
if (isShieldedRelatedRow(transaction)) continue;
if (snapshot.sendTxids.count(transaction.txid) || snapshot.pendingOpids.count(transaction.txid)) continue;
result.transactions.push_back(transaction);
if (!transaction.txid.empty()) knownTxids.insert(transaction.txid);
}
result.transparentRowsReused = true;
} else {
try {
// The first page holds last time's count plus headroom, so most wallets get every row in one
// call (re-paging 1000 rows at a time formats ~rows^2/2000 rows); skip-paging stays the fallback.
const int pageSize = transparentPageSize(snapshot.transparentRowHint);
std::unordered_set<std::string> seenPagedRows;
for (int skip = 0;; skip += pageSize) {
json page = rpc.call("listtransactions", json::array({"*", pageSize, skip}));
const std::size_t got = page.is_array() ? page.size() : 0;
// C267: skip-paging walks a list that can shift between RPCs (a new tx pushes the
// previous page's tail into this page's head). De-dup on the daemon's stable per-output
// identity (txid, send/non-send class, vout). Key on the send/non-send CLASS, not the
// exact category, so a coinbase maturing (immature->generate) between the two fetches is
// still recognised as the same row; keep vout so two equal-value outputs of one tx stay
// distinct. Rows without txid or vout ("move") are never keyed and pass through
// (appendTransparentTransactions drops them anyway).
json uniq = json::array();
if (page.is_array()) {
for (const auto& row : page) {
auto txid = readOptional<std::string>(row, "txid");
auto vout = readOptional<int>(row, "vout");
if (txid && !txid->empty() && vout) {
const bool isSend =
readOptional<std::string>(row, "category").value_or("") == "send";
std::string key = *txid + '|' + (isSend ? 's' : 'r') + '|' + std::to_string(*vout);
if (!seenPagedRows.insert(std::move(key)).second) continue; // true duplicate
}
uniq.push_back(row);
}
}
appendTransparentTransactions(result.transactions, knownTxids, uniq, snapshot.miningAddresses);
result.transparentRowCount += got; // RAW page size: drives the stop test + next row hint
if (got < static_cast<std::size_t>(pageSize)) break;
}
result.transparentChainOk = true;
} catch (const std::exception& e) {
transactionRpcError = true;
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
DEBUG_LOGF("listtransactions error: %s\n", e.what());
}
}
// ── Shielded receives (z_listreceivedbyaddress, one per z-address) ──────────────────────────────
// A pass re-scans each address once: one scanned below the pass floor (or never) needs a scan, so a
// pass over many addresses completes however the tip moves meanwhile. Priority addresses go first.
const int scanFloor = snapshot.shieldedScanFloorHeight;
auto needsPassScan = [&](const std::string& address) {
const auto it = result.shieldedScanHeights.find(address);
if (it == result.shieldedScanHeights.end()) return true;
return scanFloor >= 0 && it->second < scanFloor;
};
auto needsPriorityScan = [&](const std::string& address) {
const auto priority = snapshot.priorityScanFloors.find(address);
if (priority == snapshot.priorityScanFloors.end()) return false;
const auto it = result.shieldedScanHeights.find(address);
return it == result.shieldedScanHeights.end() || it->second < priority->second;
};
auto budgetLeft = [&]() {
return snapshot.maxShieldedReceiveScans == 0 ||
result.shieldedAddressesScanned < snapshot.maxShieldedReceiveScans;
};
std::unordered_set<std::string> scannedNow;
auto scanAddress = [&](const std::string& address) {
++result.shieldedAddressesScanned;
scannedNow.insert(address);
const auto callStart = std::chrono::steady_clock::now();
try {
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
appendShieldedReceivedTransactions(result.transactions,
knownTxids,
address,
received,
snapshot.miningAddresses,
hushChatReceivedOutputsPtr);
++result.shieldedScansSucceeded;
result.receiveScanOkMs += msSince(callStart);
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
} catch (const std::exception& e) {
transactionRpcError = true;
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
DEBUG_LOGF("z_listreceivedbyaddress error: %s\n", e.what());
}
};
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
std::size_t nextSearchIndex = shieldedStart;
if (!snapshot.skipShieldedScans) {
for (const auto& address : snapshot.shieldedAddresses) {
if (!budgetLeft()) break;
if (needsPriorityScan(address)) scanAddress(address);
}
for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
const auto& address = snapshot.shieldedAddresses[index];
if (!needsPassScan(address) || scannedNow.count(address)) {
nextSearchIndex = index + 1;
continue;
}
if (!budgetLeft()) {
nextSearchIndex = index;
break;
}
nextSearchIndex = index + 1;
scanAddress(address);
}
}
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 (needsPassScan(snapshot.shieldedAddresses[index])) return index;
}
return std::nullopt;
};
auto pendingShieldedIndex = findPendingShieldedIndex();
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
// ── Outgoing shielded sends (z_listsentbyaddress) ───────────────────────────────────────────────
// A pure z->z send moves no value in or out of the transparent pool (valueBalance == 0), so the
// daemon's `listtransactions` never reports it, and — being a send rather than a receive — it never
// shows up in `z_listreceivedbyaddress` either. Its txid only reaches `knownTxids`, and so the
// z_viewtransaction enrichment below, through this call.
//
// `z_listsentbyaddress` scans the whole wallet for sends — the address argument is NOT used to
// filter (the daemon iterates every wallet tx against "*"), so a single call yields all send
// txids. Two legs need handling:
// * SHIELDED (z->z) outgoing legs are surfaced by z_viewtransaction, so we fold the txid into
// `knownTxids` and let the z_viewtransaction / appendViewTransactionOutputs path build them.
// * TRANSPARENT outgoing legs (z->t deshields) are NOT surfaced by z_viewtransaction (it
// enumerates only Sapling outputs), and for a z-originated send listtransactions also omits
// the external t-vout (GetAmounts' isFromMyTaddr is false when inputs are shielded). So we
// build those rows directly from the payload's `sends.transparentSends[]` here. They are
// deduped (by txid+address+amount) against listtransactions-derived t->t sends above, so a
// purely-transparent (t->t) send — which the payload ALSO reports — is not double-counted.
//
// It trial-decrypts every send it returns under cs_main, the heaviest call here on a wallet with many
// sends. So it runs once a pass is complete and, past a successful sweep (the caller's watermark), only
// over the txs at most a little deeper than the blocks since then (filter type 2). The whole-wallet
// sweep, needed when there's no watermark, gets a long timeout so it can finish on a large wallet.
const bool sweepIsFull = sentSweepIsFull(currentBlockHeight, snapshot.sentScanFromHeight);
if (!snapshot.shieldedAddresses.empty() && !snapshot.skipSentSweep &&
(result.shieldedScanComplete || (snapshot.allowMidPassSentSweep && !sweepIsFull))) {
int effectiveFromHeight = snapshot.sentScanFromHeight;
bool effectiveFull = sweepIsFull;
// Anchor verify: a watermark restored from disk is trusted only if the block at A still matches the
// stored H_A (no reorg deeper than the margin, same chain/datadir). A mismatch, or an inability to
// check, drops the watermark and forces a full sweep. Checked only for a windowed sweep.
if (snapshot.sentVerifyPending && !sweepIsFull && snapshot.sentVerifyAnchorHeight > 0) {
bool anchorOk = false;
try {
json h = rpc.call("getblockhash", json::array({snapshot.sentVerifyAnchorHeight}));
if (h.is_string()) anchorOk = (h.get<std::string>() == snapshot.sentVerifyAnchorHash);
} catch (...) { anchorOk = false; }
if (!anchorOk) { effectiveFromHeight = -1; effectiveFull = true; }
}
// A verify that turned a windowed mid-pass sweep into a full one must wait for a completed pass,
// rather than run a whole-wallet sweep in the middle of one.
if (!(effectiveFull && !result.shieldedScanComplete)) {
result.sentSweepRan = true;
result.sentSweepFull = effectiveFull;
const std::string& z = snapshot.shieldedAddresses.front();
const auto sweepStart = std::chrono::steady_clock::now();
std::unordered_set<std::string> sweepSeen; // dedupe across overlapping bands
auto takeRows = [&](const json& sent) {
if (!sent.is_array()) return;
for (const auto& sentTx : sent) {
auto txid = readOptional<std::string>(sentTx, "txid");
if (!txid || txid->empty()) continue;
if (!sweepSeen.insert(*txid).second) continue; // from the previous band's overlap
appendSentTransparentOutputs(result.transactions, *txid, sentTx);
knownTxids.insert(*txid); // shielded (z->z) legs enriched via z_viewtransaction
}
};
try {
if (effectiveFull) {
// Banded full sweep: cover [0, tip] in adaptive depth bands (minconf=a + filter-2
// maxDepth=b => depth [a, b]) so no single call holds the daemon's cs_main for the whole
// sweep. Newest band first (its minconf=0 includes the mempool); bands grow in sparse
// depth and shrink in dense depth to stay near the target duration. W is set only after
// every band succeeds, so a mid-sweep failure re-sweeps instead of advancing the mark.
int bandLow = 0;
int bandSize = kSentSweepBandInitialBlocks;
for (;;) {
// maxDepth = currentBlockHeight covers down to genesis (a genesis tx has depth = tip).
const int bandHigh = std::min(bandLow + bandSize, currentBlockHeight);
const json bandParams = json::array({z, bandLow, 2, bandHigh});
const auto bandStart = std::chrono::steady_clock::now();
takeRows(rpc.callLong("z_listsentbyaddress", bandParams, kFullSentSweepTimeoutSec));
if (bandHigh >= currentBlockHeight) break; // reached the deepest possible depth
bandLow = bandHigh - kSentSweepBandOverlap;
bandSize = sentSweepNextBandSize(bandSize, msSince(bandStart));
}
result.sentSweepOk = true;
result.sentSweepHeight = currentBlockHeight;
} else {
// Windowed sweep: a single call. Bigger margin on the first one after a load (absorbs
// notifier lag); the long timeout only for a watermark saved far back.
const int margin = sentSweepMargin(snapshot.sentVerifyPending);
const json params = sentSweepParams(z, currentBlockHeight, effectiveFromHeight, margin);
const bool longCall = sentSweepNeedsLongCall(currentBlockHeight, effectiveFromHeight);
json sent = longCall
? rpc.callLong("z_listsentbyaddress", params, kFullSentSweepTimeoutSec)
: rpc.call("z_listsentbyaddress", params);
if (sent.is_array()) {
takeRows(sent);
result.sentSweepOk = true;
result.sentSweepHeight = currentBlockHeight;
}
}
if (result.sentSweepOk) {
// Anchor for the NEXT load's verify: A = W - steady margin, H_A = getblockhash(A). An
// empty H_A (fetch failed) just means the next load can't verify and does a full sweep.
const int newAnchor = sentSweepAnchor(currentBlockHeight, false);
result.sentSweepAnchorHeight = newAnchor;
try {
json ha = rpc.call("getblockhash", json::array({newAnchor}));
if (ha.is_string()) result.sentSweepAnchorHash = ha.get<std::string>();
} catch (...) {}
}
} catch (const std::exception& e) {
transactionRpcError = true;
result.sentSweepErrKind = rpc::rpcFailureKindOf(e);
result.replyTimedOut |= result.sentSweepErrKind == rpc::RpcFailureKind::ReplyTimeout;
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
}
result.sentSweepMs = msSince(sweepStart);
}
}
for (const auto& txid : snapshot.sendTxids) {
knownTxids.insert(txid);
}
for (const auto& [txid, cachedView] : snapshot.viewTxCache) {
if (!cachedView.outgoing_outputs.empty()) knownTxids.insert(txid);
}
// Shielded send rows from an earlier session (the history cache) whose view isn't cached: they count as
// enriched by their age, so nothing would rebuild them, and a windowed sweep never rediscovers an old
// send. Re-enrich them, and keep their stored rows ("pinned") until the daemon has answered.
std::unordered_set<std::string> rowBacklog;
// A settled stored row's block time and confirmations (>= 10, the History's "confirmed"): reused for
// its re-enrichment instead of a gettransaction per row. An unsettled one still asks, or its stale
// count would stick for the session (a cached view keeps the metadata it was built with).
std::unordered_map<std::string, std::pair<std::int64_t, int>> settledStoredMeta;
for (const auto& transaction : snapshot.previousTransactions) {
if (transaction.type != "send" || transaction.txid.empty() || !isShieldedRelatedRow(transaction)) continue;
if (snapshot.viewTxCache.count(transaction.txid) || snapshot.sendTxids.count(transaction.txid) ||
snapshot.pendingOpids.count(transaction.txid)) continue;
rowBacklog.insert(transaction.txid);
if (transaction.timestamp != 0 && transaction.confirmations >= 10)
settledStoredMeta.emplace(transaction.txid, std::make_pair(transaction.timestamp, transaction.confirmations));
}
std::set<std::string> backlog(snapshot.enrichBacklogTxids.begin(), snapshot.enrichBacklogTxids.end());
backlog.insert(rowBacklog.begin(), rowBacklog.end());
std::unordered_set<std::string> pinnedRows = rowBacklog;
// Tracked sends go first so they get the per-cycle z_viewtransaction budget ahead of routine
// enrichment, then this cycle's discoveries, then the backlog. A spent budget only skips further
// RPCs: cached rows later in the order are still appended (stopping the loop at the cap dropped them
// from History), and what was skipped is handed back as unenrichedTxids.
std::vector<const std::string*> enrichOrder;
enrichOrder.reserve(knownTxids.size() + backlog.size());
for (const auto& txid : knownTxids) {
if (snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
}
for (const auto& txid : knownTxids) {
if (!snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
}
for (const auto& txid : backlog) {
if (!knownTxids.count(txid) && !snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
}
int viewTxCount = 0;
for (const std::string* txidPtr : enrichOrder) {
const std::string& txid = *txidPtr;
bool trackedSend = snapshot.sendTxids.count(txid) > 0;
auto cached = snapshot.viewTxCache.find(txid);
if (cached != snapshot.viewTxCache.end()) {
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
appendViewTransactionOutputs(result.transactions, txid, cached->second, currentBlockHeight);
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
// recorded by the local echo. (The recent-refresh variant already omits this.)
continue;
}
}
if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid) && !rowBacklog.count(txid)) continue;
if (viewTxCount >= maxViewTransactionsPerCycle) {
if (!trackedSend) result.unenrichedTxids.insert(txid);
continue;
}
++viewTxCount;
try {
json viewTransaction = rpc.call("z_viewtransaction", json::array({txid}));
if (viewTransaction.is_null() || !viewTransaction.is_object()) {
if (trackedSend) {
json rawTransaction;
bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
appendTrackedSendPlaceholder(result.transactions,
txid,
hasRawTransaction ? &rawTransaction : nullptr);
} else {
result.unenrichedTxids.insert(txid);
}
continue;
}
auto entry = parseViewTransactionCacheEntry(viewTransaction);
entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip
const bool storedRow = rowBacklog.count(txid) > 0;
if (storedRow && entry.timestamp == 0) {
const auto settled = settledStoredMeta.find(txid);
if (settled != settledStoredMeta.end()) {
entry.timestamp = settled->second.first;
entry.confirmations = settled->second.second;
}
}
const std::size_t rowsBeforeView = result.transactions.size();
appendViewTransactionOutputs(result.transactions, txid, entry);
// (Chat metadata is harvested only from RECEIVED notes, not our own outgoing memos — see
// the cached-view branch above.)
json rawTransaction;
bool hasRawTransaction = false;
bool needsRawTransaction = false;
for (const auto& info : result.transactions) {
if (info.txid == txid && info.timestamp == 0) {
needsRawTransaction = true;
break;
}
}
if (needsRawTransaction) {
hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
if (hasRawTransaction) {
applyRawTransactionMetadata(entry, rawTransaction);
for (auto& info : result.transactions) {
if (info.txid == txid && info.timestamp == 0) {
applyRawTransactionMetadata(info, rawTransaction, false);
}
}
}
}
if (storedRow) {
// A stored send rebuilt without a date (gettransaction failed): keep its stored rows (still
// pinned) and retry next cycle, rather than cache an undated view for the session.
bool undated = false;
for (std::size_t i = rowsBeforeView; i < result.transactions.size(); ++i) {
if (result.transactions[i].txid == txid && result.transactions[i].timestamp == 0) undated = true;
}
if (undated) {
result.transactions.resize(rowsBeforeView);
result.unenrichedTxids.insert(txid);
continue;
}
pinnedRows.erase(txid);
}
if (trackedSend && !hasTransactionType(result.transactions, txid, "send")) {
if (!hasRawTransaction) hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
appendTrackedSendPlaceholder(result.transactions,
txid,
hasRawTransaction ? &rawTransaction : nullptr);
}
if (!trackedSend || !entry.outgoing_outputs.empty()) {
result.newViewTxEntries[txid] = std::move(entry);
} else {
// A tracked op that only paid the wallet itself never shows an outgoing output, so the
// check above kept it tracked (and forcing history refreshes past the scan throttle on
// every tab) for the rest of the session. While it's unconfirmed the empty result stays
// retryable; once it is mined, or conflicted, the view result is final: cache it and
// stop tracking. Confirmations come from this txid's rows (listtransactions, or the
// gettransaction-backed placeholder) and any raw metadata applied to the entry.
bool mined = entry.confirmations >= 1;
bool conflicted = entry.confirmations < 0;
for (const auto& info : result.transactions) {
if (info.txid != txid) continue;
if (info.confirmations >= 1) mined = true;
else if (info.confirmations < 0) conflicted = true;
}
if (mined || conflicted) {
result.newViewTxEntries[txid] = std::move(entry);
result.resolvedSendTxids.insert(txid);
}
}
} catch (const std::exception& e) {
if (trackedSend) {
json rawTransaction;
bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
appendTrackedSendPlaceholder(result.transactions,
txid,
hasRawTransaction ? &rawTransaction : nullptr);
} else if (const auto* rpcError = dynamic_cast<const rpc::RpcError*>(&e);
rpcError && rpcError->code == -5) {
// "Invalid or non-wallet transaction id": definitive. Cache an empty view so it isn't
// asked again, and let a stored row for it go.
result.newViewTxEntries[txid] = TransactionViewCacheEntry{};
pinnedRows.erase(txid);
} else {
result.unenrichedTxids.insert(txid); // anything else may be transient: retry
}
}
}
appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);
if (!snapshot.previousTransactions.empty()) {
std::unordered_set<std::string> pinned = snapshot.pendingOpids;
pinned.insert(pinnedRows.begin(), pinnedRows.end());
if (transactionRpcError) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, pinned);
} else if (snapshot.shieldedAddresses.empty() || !result.shieldedScanComplete ||
result.shieldedAddressesScanned < result.shieldedAddressCount) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, pinned);
} else if (!pinnedRows.empty()) {
// A full rebuild, except the stored rows of sends still waiting to be re-enriched.
appendPinnedPreviousTransactions(result.transactions, snapshot.previousTransactions, pinnedRows);
}
}
sortTransactionsNewestFirst(result.transactions);
result.scanMs = msSince(scanStart);
result.rpcError = transactionRpcError;
return result;
}
NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRecentTransactionRefreshResult(
RefreshRpcGateway& rpc,
const TransactionRefreshSnapshot& snapshot,
int currentBlockHeight,
int pageSize)
{
TransactionRefreshResult result;
const auto scanStart = std::chrono::steady_clock::now();
result.blockHeight = currentBlockHeight;
result.transactions = snapshot.previousTransactions;
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
result.shieldedScanHeights = snapshot.shieldedScanHeights;
HushChatMemoOutputMap hushChatReceivedOutputs;
HushChatMemoOutputMap* hushChatReceivedOutputsPtr = chat::hushChatFeatureEnabledAtBuild()
? &hushChatReceivedOutputs
: nullptr;
// Index result.transactions by (txid, type) once so the two replace-loops below
// can find-and-replace in O(1) instead of a nested linear scan over the full
// (potentially thousands-large) tx list on every 'recent' refresh cycle. The map
// holds the index of the FIRST occurrence of each key (preserving the linear
// scan's break-on-first-match), and is kept in sync on every append so a later
// entry in the same cycle still finds an earlier appended one — exactly as the
// re-scanned vector did before.
auto txKey = [](const TransactionInfo& tx) {
return tx.txid + '\x1f' + tx.type;
};
std::unordered_map<std::string, std::size_t> byKey;
byKey.reserve(result.transactions.size());
for (std::size_t i = 0; i < result.transactions.size(); ++i) {
byKey.emplace(txKey(result.transactions[i]), i); // keep first-occurrence index
}
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);
// A coinbase changes category in place (immature -> generate at maturity, or -> orphan on a reorg): drop
// the previous row of the OTHER category before merging, or both stay and the earnings double count
// (C119). Note "mined" (a transparent receive remapped to a mining address) is NOT a coinbase category
// and never transitions, so it is intentionally excluded.
{
const auto coinbaseType = [](const std::string& t) {
return t == "generate" || t == "immature" || t == "orphan";
};
std::unordered_map<std::string, std::string> freshCoinbase; // txid -> its fresh category
for (const auto& recent : recentTransactions)
if (coinbaseType(recent.type)) freshCoinbase[recent.txid] = recent.type;
if (!freshCoinbase.empty()) {
const auto before = result.transactions.size();
result.transactions.erase(
std::remove_if(result.transactions.begin(), result.transactions.end(),
[&](const TransactionInfo& t) {
if (!coinbaseType(t.type)) return false;
const auto it = freshCoinbase.find(t.txid);
return it != freshCoinbase.end() && it->second != t.type;
}),
result.transactions.end());
if (result.transactions.size() != before) {
byKey.clear();
for (std::size_t i = 0; i < result.transactions.size(); ++i)
byKey.emplace(txKey(result.transactions[i]), i);
}
}
}
for (auto& recent : recentTransactions) {
auto it = byKey.find(txKey(recent));
if (it != byKey.end()) {
result.transactions[it->second] = recent;
} else {
byKey.emplace(txKey(recent), result.transactions.size());
result.transactions.push_back(std::move(recent));
}
}
} catch (const std::exception& e) {
result.rpcError = true;
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
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);
}
// Without the probe (the scan throttle is closed) the poll is listtransactions only.
if (snapshot.skipShieldedScans) shieldedLimit = 0;
for (std::size_t index = shieldedStart; index < shieldedStart + shieldedLimit; ++index) {
const auto& address = snapshot.shieldedAddresses[index];
const auto callStart = std::chrono::steady_clock::now();
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,
hushChatReceivedOutputsPtr);
for (auto& scanned : scannedTransactions) {
auto it = byKey.find(txKey(scanned));
if (it != byKey.end()) {
result.transactions[it->second] = scanned;
} else {
byKey.emplace(txKey(scanned), result.transactions.size());
result.transactions.push_back(std::move(scanned));
}
}
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
++result.shieldedAddressesScanned;
++result.shieldedScansSucceeded;
result.receiveScanOkMs += std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - callStart).count();
} catch (const std::exception& e) {
++result.shieldedAddressesScanned;
result.rpcError = true;
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
DEBUG_LOGF("recent z_listreceivedbyaddress error: %s\n", e.what());
}
}
if (snapshot.skipShieldedScans) {
result.nextShieldedScanStartIndex = shieldedStart;
} else {
result.nextShieldedScanStartIndex =
(shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
}
result.shieldedScanComplete = true;
appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);
sortTransactionsNewestFirst(result.transactions);
result.scanMs = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - scanStart).count();
return result;
}
NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOperationStatusPoll(
const json& result,
const std::vector<std::string>& requestedOpids)
{
OperationStatusPollResult parsed;
if (!result.is_array()) return parsed;
// The poll is filtered to our opids (buildOperationFilterParams), but falls back to the unfiltered
// form, which lists ALL operations, if a daemon rejects the filter. Only act on the ones we're
// tracking — otherwise an unrelated failed/old operation would fire a spurious error toast or
// pollute send state.
const std::set<std::string> requested(requestedOpids.begin(), requestedOpids.end());
std::set<std::string> reported;
for (const auto& op : result) {
if (!op.is_object()) continue;
// Type-checked reads: .value(key, default) throws if the key is PRESENT with a non-string
// type, which would abort the whole poll (and wedge it for the session — see the call site).
if (!op.contains("id") || !op["id"].is_string()) continue;
std::string opid = op["id"].get<std::string>();
if (opid.empty()) continue;
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
reported.insert(opid);
std::string status = (op.contains("status") && op["status"].is_string())
? op["status"].get<std::string>() : 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);
parsed.failureByOpid[opid] = msg;
}
}
for (const auto& opid : requestedOpids) {
if (!opid.empty() && reported.find(opid) == reported.end()) {
parsed.staleOpids.push_back(opid);
}
}
return parsed;
}
std::optional<json> NetworkRefreshService::buildOperationFilterParams(const std::vector<std::string>& opids)
{
json ids = json::array();
for (const auto& opid : opids) {
if (!opid.empty()) ids.push_back(opid);
}
if (ids.empty()) return std::nullopt;
json params = json::array();
params.push_back(std::move(ids));
return params;
}
NetworkRefreshService::OperationBatchPlan NetworkRefreshService::planOperationBatch(
const OperationStatusPollResult& parsed, const std::unordered_map<std::string, OperationOrigin>& origins)
{
auto originOf = [&](const std::string& id) {
const auto it = origins.find(id);
return it == origins.end() ? OperationOrigin::User : it->second;
};
OperationBatchPlan plan;
auto terminal = [&](const std::string& id) {
if (originOf(id) == OperationOrigin::Chat) plan.chatTerminal = true;
else plan.walletTerminal = true;
};
for (const auto& id : parsed.doneOpids) { // success, failed or cancelled
terminal(id);
if (parsed.failureByOpid.count(id)) continue; // failed: nothing committed
const auto origin = originOf(id);
if (origin == OperationOrigin::User) plan.userSucceeded = true; // a success without a txid too
else if (origin == OperationOrigin::Auto) plan.autoSucceeded = true;
}
for (const auto& id : parsed.staleOpids) terminal(id);
return plan;
}
bool NetworkRefreshService::anyUserOperationPending(const std::vector<std::string>& pendingOpids,
const std::unordered_map<std::string, OperationOrigin>& origins)
{
for (const auto& id : pendingOpids) {
const auto it = origins.find(id);
if (it == origins.end() || it->second == OperationOrigin::User) return true;
}
return false;
}
void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
{
if (!result.ok) return;
if (result.daemonVersion) state.daemon_version = *result.daemonVersion;
if (result.protocolVersion) state.protocol_version = *result.protocolVersion;
if (result.p2pPort) state.p2p_port = *result.p2pPort;
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
if (result.notarized) state.notarized = *result.notarized;
if (result.blocks) state.sync.blocks = *result.blocks;
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
}
void NetworkRefreshService::applyWalletEncryptionResult(WalletState& state, const WalletEncryptionResult& result)
{
if (!result.ok) return;
if (result.encrypted) {
state.encrypted = true;
state.unlocked_until = result.unlockedUntil;
state.locked = (result.unlockedUntil == 0);
} else {
state.encrypted = false;
state.locked = false;
state.unlocked_until = 0;
}
state.encryption_state_known = true;
if (result.txCount) state.wallet_tx_count = *result.txCount;
}
void NetworkRefreshService::applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result)
{
applyConnectionInfoResult(state, result.info);
applyWalletEncryptionResult(state, result.encryption);
}
void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
const CoreRefreshResult& result,
std::time_t updatedAt)
{
if (result.balanceOk) {
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
if (result.totalBalance) state.total_balance = *result.totalBalance;
// Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
// display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
state.spendablePrivateBalance = result.spendableShieldedBalance.value_or(state.privateBalance);
state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
state.spendableTotalBalance = result.spendableTotalBalance.value_or(state.totalBalance);
state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
state.last_balance_update = updatedAt;
}
if (!result.blockchainOk) return;
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;
// A peerless daemon reports longestchain == 0 (no peer heights). Track that as "tip unknown" from
// THIS refresh's raw value (not the retained state, which keeps the last positive value) so a
// peerless node isn't reported synced against its own stale local height. (audit: peerless-synced)
state.sync.tip_known = (result.longestChain.has_value() && *result.longestChain > 0);
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
if (state.longestchain > 0)
state.sync.syncing = (state.sync.blocks < state.longestchain - 2);
else
state.sync.syncing = (state.sync.blocks < state.sync.headers - 2);
if (result.notarized) state.notarized = *result.notarized;
}
void NetworkRefreshService::applyMiningRefreshResult(WalletState& state,
const MiningRefreshResult& result,
std::time_t updatedAt)
{
if (result.localHashrate) {
state.mining.localHashrate = *result.localHashrate;
state.mining.hashrate_history.push_back(state.mining.localHashrate);
state.mining.hashrate_history_t.push_back(updatedAt);
if (state.mining.hashrate_history.size() > MiningInfo::MAX_HISTORY) {
state.mining.hashrate_history.erase(state.mining.hashrate_history.begin());
state.mining.hashrate_history_t.erase(state.mining.hashrate_history_t.begin());
}
}
if (result.miningOk) {
if (result.generate) state.mining.generate = *result.generate;
if (result.genproclimit) state.mining.genproclimit = *result.genproclimit;
if (result.blocks) state.mining.blocks = *result.blocks;
if (result.difficulty) state.mining.difficulty = *result.difficulty;
if (result.networkHashrate) state.mining.networkHashrate = *result.networkHashrate;
if (result.chain) state.mining.chain = *result.chain;
state.last_mining_update = updatedAt;
}
state.mining.daemon_memory_mb = result.daemonMemoryMb;
}
void NetworkRefreshService::applyPeerRefreshResult(WalletState& state,
PeerRefreshResult&& result,
std::time_t updatedAt)
{
state.peers = std::move(result.peers);
state.bannedPeers = std::move(result.bannedPeers);
state.last_peer_update = updatedAt;
}
void NetworkRefreshService::markPriceRefreshStarted(WalletState& state)
{
state.market.price_loading = true;
state.market.price_error.clear();
}
void NetworkRefreshService::applyPriceRefreshResult(WalletState& state,
const PriceRefreshResult& result,
std::chrono::steady_clock::time_point fetchedAt)
{
state.market.price_loading = false;
state.market.price_error.clear();
state.market.price_usd = result.market.price_usd;
state.market.price_btc = result.market.price_btc;
state.market.change_24h = result.market.change_24h;
state.market.volume_24h = result.market.volume_24h;
state.market.market_cap = result.market.market_cap;
state.market.last_updated = result.market.last_updated;
state.market.last_fetch_time = fetchedAt;
state.market.price_history.push_back(result.market.price_usd);
if (state.market.price_history.size() > MarketInfo::MAX_HISTORY) {
state.market.price_history.erase(state.market.price_history.begin());
}
}
void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
const std::string& errorMessage)
{
state.market.price_loading = false;
state.market.price_error = errorMessage.empty()
? std::string(util::tr("market_price_fetch_failed"))
: errorMessage;
}
void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
AddressRefreshResult&& result)
{
if (result.listOnly) {
// No balance scan ran: keep each address's last known balances (a new address shows 0 until the
// next full pass).
auto carry = [](std::vector<AddressInfo>& fresh, const std::vector<AddressInfo>& previous) {
std::unordered_map<std::string, const AddressInfo*> known;
for (const auto& info : previous) known[info.address] = &info;
for (auto& info : fresh) {
const auto it = known.find(info.address);
if (it == known.end()) continue;
info.balance = it->second->balance;
info.spendableBalance = it->second->spendableBalance;
}
};
carry(result.shieldedAddresses, state.z_addresses);
carry(result.transparentAddresses, state.t_addresses);
}
state.z_addresses = std::move(result.shieldedAddresses);
state.t_addresses = std::move(result.transparentAddresses);
// Keep the combined `addresses` view in sync with the authoritative z/t lists.
// Consumers that read state.addresses (Market-tab portfolio, auto-shield target
// selection, pool-mining transparent fallback) depend on this being rebuilt — the
// full-node refresh path is the only place that bulk-updates the address lists.
state.rebuildAddressList();
}
void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
TransactionCacheUpdate cacheUpdate,
TransactionRefreshResult&& result,
std::time_t updatedAt)
{
state.setTransactionsIfChanged(std::move(result.transactions)); // bumps tx_revision only on a real change
state.last_tx_update = updatedAt;
cacheUpdate.lastTxBlockHeight = result.blockHeight;
for (auto& [txid, entry] : result.newViewTxEntries) {
bool hasOutgoingOutputs = !entry.outgoing_outputs.empty();
cacheUpdate.viewTxCache[txid] = std::move(entry);
if (hasOutgoingOutputs || result.resolvedSendTxids.count(txid)) cacheUpdate.sendTxids.erase(txid);
}
cacheUpdate.confirmedTxCache.clear();
cacheUpdate.confirmedTxIds.clear();
for (const auto& tx : state.transactions) {
if (tx.confirmations >= 10 && tx.timestamp != 0) {
cacheUpdate.confirmedTxIds.insert(tx.txid);
cacheUpdate.confirmedTxCache.push_back(tx);
}
}
cacheUpdate.confirmedCacheBlock = result.blockHeight;
}
bool NetworkRefreshService::shouldRefreshTransactions(int lastTxBlockHeight,
int currentBlockHeight,
bool transactionsDirty) const
{
return scheduler_.shouldRefreshTransactions(lastTxBlockHeight,
currentBlockHeight,
transactionsDirty);
}
bool NetworkRefreshService::beginJob(Job job)
{
return beginDispatch(job).accepted;
}
bool NetworkRefreshService::beginJob(Job job, std::size_t queuedWork, std::size_t maxQueuedWork)
{
return beginDispatch(job, queuedWork, maxQueuedWork).accepted;
}
void NetworkRefreshService::finishJob(Job job)
{
jobFlag(job).store(false, std::memory_order_release);
}
bool NetworkRefreshService::jobInProgress(Job job) const
{
return jobFlag(job).load(std::memory_order_acquire);
}
void NetworkRefreshService::resetJobs()
{
coreInProgress_.store(false, std::memory_order_release);
addressesInProgress_.store(false, std::memory_order_release);
transactionsInProgress_.store(false, std::memory_order_release);
miningInProgress_.store(false, std::memory_order_release);
peersInProgress_.store(false, std::memory_order_release);
priceInProgress_.store(false, std::memory_order_release);
encryptionInProgress_.store(false, std::memory_order_release);
connectionInitInProgress_.store(false, std::memory_order_release);
// Bump every job's generation so any ticket minted before this reset (a refresh still
// in flight on the worker when a disconnect cleared state_) is treated as stale by
// completeDispatch — preventing last-connection data from landing on the new session.
for (auto& generation : generations_) {
generation.fetch_add(1, std::memory_order_acq_rel);
}
}
NetworkRefreshService::DaemonRescanScan NetworkRefreshService::parseDaemonRescanOutput(const std::string& output)
{
DaemonRescanScan scan;
bool& foundRescan = scan.foundRescan;
bool& finished = scan.finished;
float& rescanPct = scan.rescanPct;
std::string& lastStatus = scan.lastStatus;
// Sapling witness rebuild progress. Two daemon signals:
// "Setting Initial Sapling Witness for tx <hash>, <i> of <N>" (the common one —
// one per wallet tx; <hash> is the stable per-tx key, <N> the total tx count)
// "Building Witnesses for block <h> <frac> complete, <n> remaining" (rarer)
bool& foundWitness = scan.foundWitness;
float& witnessPct = scan.witnessPct; // -1 = no fraction parsed this batch
int& witnessRemaining = scan.witnessRemaining;
int& witnessReadTotal = scan.witnessReadTotal; // new daemon: the <total> in "<done> / <total>"
bool& witnessRebuilt = scan.witnessRebuilt; // new daemon's completion line seen this batch
std::vector<std::string>& witnessTxids = scan.witnessTxids; // distinct-tx keys seen this batch
int& witnessTotalTxs = scan.witnessTotalTxs; // max N seen this batch
size_t pos = 0;
while (pos < output.size()) {
size_t eol = output.find('\n', pos);
if (eol == std::string::npos) eol = output.size();
std::string line = output.substr(pos, eol - pos);
pos = eol + 1;
if (line.find("Rescanning from height") != std::string::npos ||
line.find("Rescanning last") != std::string::npos) {
foundRescan = true;
lastStatus = line;
}
auto stillIdx = line.find("Still rescanning");
if (stillIdx != std::string::npos) {
foundRescan = true;
auto progIdx = line.find("Progress=");
if (progIdx != std::string::npos) {
size_t numStart = progIdx + 9;
size_t numEnd = numStart;
while (numEnd < line.size() && (std::isdigit(line[numEnd]) || line[numEnd] == '.')) {
numEnd++;
}
if (numEnd > numStart) {
try { rescanPct = std::stof(line.substr(numStart, numEnd - numStart)) * 100.0f; } catch (...) {}
}
}
lastStatus = line;
}
auto rescIdx = line.find("Rescanning...");
if (rescIdx != std::string::npos) {
foundRescan = true;
auto pctIdx = line.find('%');
if (pctIdx != std::string::npos && pctIdx > 0) {
size_t numEnd = pctIdx;
size_t numStart = numEnd;
while (numStart > 0 && (std::isdigit(line[numStart - 1]) || line[numStart - 1] == '.')) {
numStart--;
}
if (numStart < numEnd) {
try { rescanPct = std::stof(line.substr(numStart, numEnd - numStart)); } catch (...) {}
}
}
lastStatus = line;
}
if (line.find("Done rescanning") != std::string::npos ||
line.find("Rescan complete") != std::string::npos) {
finished = true;
}
// This daemon prints no "Done rescanning" line; instead it logs the rescan
// benchmark timing exactly when the scan finishes, e.g.:
// "... rescan 16760577ms"
// Match the lowercase " rescan " bench category ending in "<digits>ms" (the
// progress lines are "Still rescanning"/"Rescanning...", which never end in ms).
if (line.find(" rescan ") != std::string::npos) {
std::string t = line;
while (!t.empty() && (t.back() == '\r' || t.back() == '\n' || t.back() == ' '))
t.pop_back();
if (t.size() >= 3 && t.compare(t.size() - 2, 2, "ms") == 0 &&
std::isdigit(static_cast<unsigned char>(t[t.size() - 3]))) {
finished = true;
}
}
// Sapling note witness rebuild: "Building Witnesses for block <h> <frac>
// complete, <n> remaining". The float before "complete" is a 0..1 fraction.
auto witIdx = line.find("Building Witnesses for block");
if (witIdx != std::string::npos) {
foundWitness = true;
auto compIdx = line.find(" complete", witIdx);
if (compIdx != std::string::npos) {
size_t numEnd = compIdx, numStart = numEnd;
while (numStart > 0 && (std::isdigit((unsigned char)line[numStart - 1]) ||
line[numStart - 1] == '.')) numStart--;
if (numStart < numEnd) {
try { witnessPct = std::stof(line.substr(numStart, numEnd - numStart)) * 100.0f; } catch (...) {}
}
}
auto remIdx = line.find(" remaining", witIdx);
if (remIdx != std::string::npos) {
size_t numEnd = remIdx, numStart = numEnd;
while (numStart > 0 && std::isdigit((unsigned char)line[numStart - 1])) numStart--;
if (numStart < numEnd) {
try { witnessRemaining = std::stoi(line.substr(numStart, numEnd - numStart)); } catch (...) {}
}
}
lastStatus = line;
}
// Newer multi-threaded daemon: "Reading blocks for witness rebuild: <done> / <total>".
// This replaced the per-block "Building Witnesses" line and is an exact done/total
// count, so capture BOTH (total drives the denominator directly — no peak anchoring).
auto rdIdx = line.find("Reading blocks for witness rebuild:");
if (rdIdx != std::string::npos) {
foundWitness = true;
auto slash = line.find('/', rdIdx);
if (slash != std::string::npos) {
size_t dEnd = slash;
while (dEnd > rdIdx && !std::isdigit((unsigned char)line[dEnd - 1])) dEnd--;
size_t dStart = dEnd;
while (dStart > rdIdx && std::isdigit((unsigned char)line[dStart - 1])) dStart--;
size_t tStart = slash + 1;
while (tStart < line.size() && !std::isdigit((unsigned char)line[tStart])) tStart++;
size_t tEnd = tStart;
while (tEnd < line.size() && std::isdigit((unsigned char)line[tEnd])) tEnd++;
if (dStart < dEnd && tStart < tEnd) {
try {
long done = std::stol(line.substr(dStart, dEnd - dStart));
long total = std::stol(line.substr(tStart, tEnd - tStart));
if (total > 0 && done >= 0 && done <= total) {
witnessRemaining = static_cast<int>(total - done);
witnessReadTotal = static_cast<int>(total);
}
} catch (...) {}
}
}
lastStatus = line;
}
// New daemon's witness-rebuild completion line:
// "... rebuilt <n> note witness cache(s) to height <h> in <ms>ms using <t> thread(s)".
// The parallel rebuild logs no progress, so on completion snap the bar to 100%.
if (line.find("note witness cache") != std::string::npos &&
line.find("thread(s)") != std::string::npos) {
foundWitness = true;
witnessRebuilt = true;
lastStatus = line;
}
// Primary signal: "Setting Initial Sapling Witness for tx <hash>, <i> of <N>".
// <i> is the tx's slot in an unordered map (bounces — useless as progress), so
// we key on <hash> (one per tx) and count distinct txs against <N>. Extract the
// hash between "for tx " and the following comma, and N after " of ".
{
auto setIdx = line.find("Setting Initial Sapling Witness for tx ");
if (setIdx != std::string::npos) {
foundWitness = true;
size_t hStart = setIdx + std::string("Setting Initial Sapling Witness for tx ").size();
size_t comma = line.find(',', hStart);
if (comma != std::string::npos && comma > hStart) {
witnessTxids.push_back(line.substr(hStart, comma - hStart));
auto ofIdx = line.find(" of ", comma);
if (ofIdx != std::string::npos) {
size_t nStart = ofIdx + 4, nEnd = nStart;
while (nEnd < line.size() && std::isdigit((unsigned char)line[nEnd])) nEnd++;
if (nEnd > nStart) {
try {
int n = std::stoi(line.substr(nStart, nEnd - nStart));
if (n > witnessTotalTxs) witnessTotalTxs = n;
} catch (...) {}
}
}
}
lastStatus = line;
}
}
}
return scan;
}
NetworkRefreshService::DispatchTicket NetworkRefreshService::beginDispatch(
Job job, std::size_t queuedWork, std::size_t maxQueuedWork)
{
std::size_t index = jobIndex(job);
lastQueueDepth_[index].store(queuedWork, std::memory_order_release);
if (maxQueuedWork > 0 && queuedWork >= maxQueuedWork) {
skippedQueuePressure_[index].fetch_add(1, std::memory_order_relaxed);
return {job, generations_[index].load(std::memory_order_acquire), false};
}
bool expected = false;
if (!jobFlag(job).compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
skippedInFlight_[index].fetch_add(1, std::memory_order_relaxed);
return {job, generations_[index].load(std::memory_order_acquire), false};
}
std::uint64_t generation = generations_[index].fetch_add(1, std::memory_order_acq_rel) + 1;
started_[index].fetch_add(1, std::memory_order_relaxed);
return {job, generation, true};
}
bool NetworkRefreshService::completeDispatch(const DispatchTicket& ticket)
{
if (!ticket.accepted) return false;
std::size_t index = jobIndex(ticket.job);
if (generations_[index].load(std::memory_order_acquire) != ticket.generation) {
staleCallbacks_[index].fetch_add(1, std::memory_order_relaxed);
return false;
}
jobFlag(ticket.job).store(false, std::memory_order_release);
finished_[index].fetch_add(1, std::memory_order_relaxed);
return true;
}
void NetworkRefreshService::cancelDispatch(const DispatchTicket& ticket)
{
if (!ticket.accepted) return;
std::size_t index = jobIndex(ticket.job);
if (generations_[index].load(std::memory_order_acquire) == ticket.generation) {
jobFlag(ticket.job).store(false, std::memory_order_release);
} else {
staleCallbacks_[index].fetch_add(1, std::memory_order_relaxed);
}
}
NetworkRefreshService::JobStats NetworkRefreshService::stats(Job job) const
{
std::size_t index = jobIndex(job);
return {
started_[index].load(std::memory_order_acquire),
finished_[index].load(std::memory_order_acquire),
skippedInFlight_[index].load(std::memory_order_acquire),
skippedQueuePressure_[index].load(std::memory_order_acquire),
staleCallbacks_[index].load(std::memory_order_acquire),
lastQueueDepth_[index].load(std::memory_order_acquire)
};
}
std::atomic<bool>& NetworkRefreshService::jobFlag(Job job)
{
switch (job) {
case Job::Core: return coreInProgress_;
case Job::Addresses: return addressesInProgress_;
case Job::Transactions: return transactionsInProgress_;
case Job::Mining: return miningInProgress_;
case Job::Peers: return peersInProgress_;
case Job::Price: return priceInProgress_;
case Job::Encryption: return encryptionInProgress_;
case Job::ConnectionInit: return connectionInitInProgress_;
case Job::Count: return coreInProgress_;
}
return coreInProgress_;
}
const std::atomic<bool>& NetworkRefreshService::jobFlag(Job job) const
{
switch (job) {
case Job::Core: return coreInProgress_;
case Job::Addresses: return addressesInProgress_;
case Job::Transactions: return transactionsInProgress_;
case Job::Mining: return miningInProgress_;
case Job::Peers: return peersInProgress_;
case Job::Price: return priceInProgress_;
case Job::Encryption: return encryptionInProgress_;
case Job::ConnectionInit: return connectionInitInProgress_;
case Job::Count: return coreInProgress_;
}
return coreInProgress_;
}
std::size_t NetworkRefreshService::jobIndex(Job job)
{
std::size_t index = static_cast<std::size_t>(job);
constexpr std::size_t count = static_cast<std::size_t>(Job::Count);
return index < count ? index : 0;
}
} // namespace services
} // namespace dragonx