Files
ObsidianDragon/src/services/network_refresh_service.cpp
T
DanSandClaude Opus 5.5 0f25c67600 feat(rpc): stop flooding a busy node's RPC queue; fix the reconnect storm
dragonxd serves RPC with 8 workers and a 16-deep queue. While a witness
rebuild holds cs_main, every request needing it waits, and a request the
client gave up on keeps its slot. The GUI kept polling and re-probing
until all 24 slots were its own abandoned requests (even stop was then
refused), treated the busy node as lost (tearing down the wallet state),
and retried connect at frame rate on some pages.

Busy gate (src/rpc/rpc_busy_gate.h), enforced in RPCClient::performCall
for the shared rpc_ / fast_rpc_:
- the first reply timeout or queue-full refusal marks the node busy;
  from then on nothing needing cs_main is sent (calls fail at once with
  the new RpcFailureKind::Deferred). stop, help and getrescaninfo are
  lock-free in dragonxd and always go through
- one canary getinfo with no client timeout waits on its own client and
  thread; its answer clears the gate and triggers an immediate refresh
  (queue-full: retried after 2..30 s; no answer: back to normal handling)
- the timer dispatch skips Core/Tx/Addresses/Peers/chat/mining/opid and
  connect attempts while busy
- the 3-strike teardown counts only failures that put the connection in
  doubt, never busy ones; no balance scans follow a busy chain-info call

Reconnect storm: retries were armed with setTimer(Core, kCoreDefault - 1),
i.e. "4 s elapsed", which against Overview's 2 s interval meant every
frame. RefreshScheduler::deferFor() is an absolute hold, and
ConnectRetryBackoff picks the delay by failure kind (not listening
1/2/5 s, queue full 2..30 s, busy 30 s, else 4 s).

Measured against the fake daemon: abandoned requests during a rebuild
went from ~24 (every slot) to at most 2, with no teardown and recovery
when the rebuild ends; a saturated queue sees about one canary per
back-off step instead of ~190 requests/s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:49:55 -05:00

1859 lines
82 KiB
C++

#include "network_refresh_service.h"
#include "rpc/rpc_busy_gate.h"
#include "../util/logger.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <map>
#include <set>
#include <unordered_map>
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)) return std::stod(source[key].get<std::string>());
} catch (...) {}
return std::nullopt;
}
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.
std::map<std::string, double> total;
std::map<std::string, double> spendable;
for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount");
if (!address || !amount) continue;
total[*address] += *amount;
auto conf = readOptional<int>(output, "confirmations");
if (conf && *conf >= 1) spendable[*address] += *amount;
}
// 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()) ? t->second : 0.0;
info.spendableBalance = (s != spendable.end()) ? s->second : 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));
}
void appendMissingPreviousTransactions(std::vector<TransactionInfo>& transactions,
const std::vector<TransactionInfo>& previousTransactions,
bool includeAll,
const std::unordered_set<std::string>& pinnedTxids)
{
for (const auto& transaction : previousTransactions) {
bool pinned = pinnedTxids.find(transaction.txid) != pinnedTxids.end();
if (!includeAll && !pinned) {
bool shieldedRelated = (!transaction.address.empty() && transaction.address[0] == 'z') ||
(!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
!transaction.memo.empty();
if (!shieldedRelated) continue;
}
if (hasTransactionType(transactions, transaction.txid, transaction.type)) continue;
transactions.push_back(transaction);
}
}
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;
}
}
return result;
}
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();
}
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");
}
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, bool includeBalance)
{
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());
}
// 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.
if (includeBalance && !behind && !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.call("z_gettotalbalance", json::array({0}));
balanceOk = true;
} catch (const std::exception& e) {
balanceErrKind = rpc::rpcFailureKindOf(e);
DEBUG_LOGF("Balance error: %s\n", e.what());
}
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
} 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;
return result;
}
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)
{
AddressRefreshResult result;
// 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
}
try {
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // 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);
result.unspentNotes.push_back(lite);
}
}
} catch (const std::exception& e) {
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 (...) {}
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; }
}
}
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
}
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.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;
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)
{
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 = entry.confirmations;
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 scan — the per-address z_listreceivedbyaddress pass is O(mapWallet) and holds the
// daemon's cs_main. The measured cost feeds the caller's adaptive throttle (txRefreshDue()) so the
// routine full history rescan can't starve block connection on a large wallet.
const auto scanStart = std::chrono::steady_clock::now();
std::set<std::string> knownTxids;
HushChatMemoOutputMap hushChatReceivedOutputs;
HushChatMemoOutputMap* hushChatReceivedOutputsPtr = chat::hushChatFeatureEnabledAtBuild()
? &hushChatReceivedOutputs
: nullptr;
bool transactionRpcError = false;
try {
constexpr int pageSize = 1000;
int skip = 0;
while (true) {
json transactions = rpc.call("listtransactions", json::array({"", pageSize, skip}));
appendTransparentTransactions(result.transactions, knownTxids, transactions, snapshot.miningAddresses);
if (!transactions.is_array() || transactions.size() < static_cast<std::size_t>(pageSize)) break;
skip += pageSize;
}
} catch (const std::exception& e) {
transactionRpcError = true;
DEBUG_LOGF("listtransactions error: %s\n", e.what());
}
// An address counts as "scanned at tip" if its last scan is within shieldedScanTipTolerance
// blocks of the current tip. Tolerance 0 means strict (exact tip); a small tolerance lets a
// multi-cycle pass over many addresses complete even as the tip advances during it.
const int scanStaleThreshold = currentBlockHeight - std::max(0, snapshot.shieldedScanTipTolerance);
auto scannedAtTip = [&](const std::string& address) {
if (currentBlockHeight < 0) return false;
auto it = result.shieldedScanHeights.find(address);
return it != result.shieldedScanHeights.end() && it->second >= scanStaleThreshold;
};
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
std::size_t nextSearchIndex = shieldedStart;
for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
const auto& address = snapshot.shieldedAddresses[index];
if (scannedAtTip(address)) {
nextSearchIndex = index + 1;
continue;
}
if (snapshot.maxShieldedReceiveScans > 0 &&
result.shieldedAddressesScanned >= snapshot.maxShieldedReceiveScans) {
nextSearchIndex = index;
break;
}
++result.shieldedAddressesScanned;
nextSearchIndex = index + 1;
try {
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
appendShieldedReceivedTransactions(result.transactions,
knownTxids,
address,
received,
snapshot.miningAddresses,
hushChatReceivedOutputsPtr);
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
} catch (const std::exception& e) {
transactionRpcError = true;
DEBUG_LOGF("z_listreceivedbyaddress error: %s\n", e.what());
}
}
auto findPendingShieldedIndex = [&]() -> std::optional<std::size_t> {
if (snapshot.shieldedAddresses.empty()) return std::nullopt;
std::size_t start = nextSearchIndex < snapshot.shieldedAddresses.size() ? nextSearchIndex : 0;
for (std::size_t offset = 0; offset < snapshot.shieldedAddresses.size(); ++offset) {
std::size_t index = (start + offset) % snapshot.shieldedAddresses.size();
if (!scannedAtTip(snapshot.shieldedAddresses[index])) return index;
}
return std::nullopt;
};
auto pendingShieldedIndex = findPendingShieldedIndex();
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
// Discover outgoing shielded (z->z) sends. 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 therefore never reaches `knownTxids`, so the z_viewtransaction enrichment
// below is never run for it and the send is invisible in History (the reported bug).
//
// `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.
//
// Gated on shieldedScanComplete so it adds no cost during the multi-cycle initial received
// scan: it is one more O(mapWallet) pass holding cs_main (comparable to the listtransactions
// pass), and the caller's adaptive throttle (txRefreshDue) already accounts for it via scanMs.
if (result.shieldedScanComplete && !snapshot.shieldedAddresses.empty()) {
try {
json sent = rpc.call("z_listsentbyaddress",
json::array({snapshot.shieldedAddresses.front(), 0}));
if (sent.is_array()) {
for (const auto& sentTx : sent) {
auto txid = readOptional<std::string>(sentTx, "txid");
if (!txid || txid->empty()) continue;
appendSentTransparentOutputs(result.transactions, *txid, sentTx);
knownTxids.insert(*txid); // shielded (z->z) legs enriched via z_viewtransaction
}
}
} catch (const std::exception& e) {
transactionRpcError = true;
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
}
}
for (const auto& txid : snapshot.sendTxids) {
knownTxids.insert(txid);
}
for (const auto& [txid, cachedView] : snapshot.viewTxCache) {
if (!cachedView.outgoing_outputs.empty()) knownTxids.insert(txid);
}
int viewTxCount = 0;
for (const auto& txid : knownTxids) {
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);
// 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)) continue;
if (viewTxCount >= maxViewTransactionsPerCycle) break;
++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);
}
continue;
}
auto entry = parseViewTransactionCacheEntry(viewTransaction);
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 (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);
}
} catch (const std::exception& e) {
(void)e;
if (trackedSend) {
json rawTransaction;
bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
appendTrackedSendPlaceholder(result.transactions,
txid,
hasRawTransaction ? &rawTransaction : nullptr);
}
}
}
appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);
if (!snapshot.previousTransactions.empty()) {
if (transactionRpcError) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, snapshot.pendingOpids);
} else if (snapshot.shieldedAddresses.empty() || !result.shieldedScanComplete ||
result.shieldedAddressesScanned < result.shieldedAddressCount) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, snapshot.pendingOpids);
}
}
sortTransactionsNewestFirst(result.transactions);
result.scanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - scanStart).count();
return result;
}
NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRecentTransactionRefreshResult(
RefreshRpcGateway& rpc,
const TransactionRefreshSnapshot& snapshot,
int currentBlockHeight,
int pageSize)
{
TransactionRefreshResult result;
result.blockHeight = currentBlockHeight;
result.transactions = snapshot.previousTransactions;
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
result.shieldedScanHeights = snapshot.shieldedScanHeights;
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);
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) {
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
}
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
std::size_t shieldedLimit = snapshot.shieldedAddresses.size() - shieldedStart;
if (snapshot.maxShieldedReceiveScans > 0) {
shieldedLimit = std::min(shieldedLimit, snapshot.maxShieldedReceiveScans);
}
for (std::size_t index = shieldedStart; index < shieldedStart + shieldedLimit; ++index) {
const auto& address = snapshot.shieldedAddresses[index];
try {
std::vector<TransactionInfo> scannedTransactions;
std::set<std::string> scannedTxids;
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
appendShieldedReceivedTransactions(scannedTransactions,
scannedTxids,
address,
received,
snapshot.miningAddresses,
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;
} catch (const std::exception& e) {
DEBUG_LOGF("recent z_listreceivedbyaddress error: %s\n", e.what());
}
}
result.nextShieldedScanStartIndex =
(shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
result.shieldedScanComplete = true;
appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);
sortTransactionsNewestFirst(result.transactions);
return result;
}
NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOperationStatusPoll(
const json& result,
const std::vector<std::string>& requestedOpids)
{
OperationStatusPollResult parsed;
if (!result.is_array()) return parsed;
// We poll z_getoperationstatus with no filter (this daemon rejects the per-opid filtered form),
// so the result lists ALL operations. 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;
}
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;
}
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);
if (state.mining.hashrate_history.size() > MiningInfo::MAX_HISTORY) {
state.mining.hashrate_history.erase(state.mining.hashrate_history.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("Price fetch failed")
: errorMessage;
}
void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
AddressRefreshResult&& result)
{
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.transactions = std::move(result.transactions);
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) 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