#include "network_refresh_service.h" #include "rpc/rpc_busy_gate.h" #include "../util/logger.h" #include #include #include #include #include #include #include #include using json = nlohmann::json; namespace dragonx { namespace services { namespace { template std::optional readOptional(const json& source, const char* key) { try { if (source.contains(key)) return source[key].get(); } catch (...) {} return std::nullopt; } std::optional readBalanceString(const json& source, const char* key) { try { if (source.contains(key)) return std::stod(source[key].get()); } catch (...) {} return std::nullopt; } void applyBalancesFromUnspent(std::vector& 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 total; std::map spendable; for (const auto& output : unspent) { auto address = readOptional(output, "address"); auto amount = readOptional(output, "amount"); if (!address || !amount) continue; total[*address] += *amount; auto conf = readOptional(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& 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(rawTransaction, "time")) info.timestamp = *value; else if (auto value = readOptional(rawTransaction, "timereceived")) info.timestamp = *value; if (auto value = readOptional(rawTransaction, "confirmations")) info.confirmations = *value; if (!includeSendDetails) return; if (auto value = readOptional(rawTransaction, "amount")) info.amount = *value; if (!rawTransaction.contains("details") || !rawTransaction["details"].is_array()) return; for (const auto& detail : rawTransaction["details"]) { auto category = readOptional(detail, "category"); if (!category || *category != "send") continue; if (auto value = readOptional(detail, "amount")) info.amount = *value; if (auto value = readOptional(detail, "address")) info.address = *value; break; } } void applyRawTransactionMetadata(NetworkRefreshService::TransactionViewCacheEntry& entry, const json& rawTransaction) { if (rawTransaction.is_null()) return; if (auto value = readOptional(rawTransaction, "time")) entry.timestamp = *value; else if (auto value = readOptional(rawTransaction, "timereceived")) entry.timestamp = *value; if (auto value = readOptional(rawTransaction, "confirmations")) entry.confirmations = *value; } void appendTrackedSendPlaceholder(std::vector& 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& transactions, const std::vector& previousTransactions, bool includeAll, const std::unordered_set& pinnedTxids) { for (const auto& transaction : previousTransactions) { bool pinned = pinnedTxids.find(transaction.txid) != pinnedTxids.end(); if (!includeAll && !pinned) { bool shieldedRelated = (!transaction.address.empty() && transaction.address[0] == 'z') || (!transaction.from_address.empty() && transaction.from_address[0] == 'z') || !transaction.memo.empty(); if (!shieldedRelated) continue; } if (hasTransactionType(transactions, transaction.txid, transaction.type)) continue; transactions.push_back(transaction); } } using HushChatMemoOutputMap = std::unordered_map>; std::size_t readMemoPosition(const json& source, std::size_t fallback) { for (const char* key : {"position", "outputIndex", "outindex"}) { auto value = readOptional(source, key); if (value && *value >= 0) return static_cast(*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& destination, const std::string& txid, const std::vector& outputs) { auto extracted = chat::extractHushChatTransactionMetadata(chat::HushChatTransactionInput{txid, outputs}); for (auto& metadata : extracted.metadata) { destination.push_back(std::move(metadata)); } } void appendExtractedHushChatMetadata(std::vector& 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(info, "version"); result.protocolVersion = readOptional(info, "protocolversion"); result.p2pPort = readOptional(info, "p2pport"); result.longestChain = readOptional(info, "longestchain"); result.notarized = readOptional(info, "notarized"); result.blocks = readOptional(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(); } 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& 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(blockInfo, "blocks"); result.headers = readOptional(blockInfo, "headers"); result.bestBlockHash = readOptional(blockInfo, "bestblockhash"); result.verificationProgress = readOptional(blockInfo, "verificationprogress"); result.longestChain = readOptional(blockInfo, "longestchain"); result.notarized = readOptional(blockInfo, "notarized"); } 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( 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(); } catch (...) {} } result.miningOk = miningOk && miningInfo.is_object(); if (result.miningOk) { result.generate = readOptional(miningInfo, "generate"); result.genproclimit = readOptional(miningInfo, "genproclimit"); result.blocks = readOptional(miningInfo, "blocks"); result.difficulty = readOptional(miningInfo, "difficulty"); result.networkHashrate = readOptional(miningInfo, "networkhashps"); result.chain = readOptional(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(peer, "id")) info.id = *value; if (auto value = readOptional(peer, "addr")) info.addr = *value; if (auto value = readOptional(peer, "subver")) info.subver = *value; if (auto value = readOptional(peer, "services")) info.services = *value; if (auto value = readOptional(peer, "version")) info.version = *value; if (auto value = readOptional(peer, "conntime")) info.conntime = *value; if (auto value = readOptional(peer, "banscore")) info.banscore = *value; if (auto value = readOptional(peer, "pingtime")) info.pingtime = *value; if (auto value = readOptional(peer, "bytessent")) info.bytessent = *value; if (auto value = readOptional(peer, "bytesrecv")) info.bytesrecv = *value; if (auto value = readOptional(peer, "startingheight")) info.startingheight = *value; if (auto value = readOptional(peer, "synced_headers")) info.synced_headers = *value; if (auto value = readOptional(peer, "synced_blocks")) info.synced_blocks = *value; if (auto value = readOptional(peer, "inbound")) info.inbound = *value; if (auto value = readOptional(peer, "tls_cipher")) info.tls_cipher = *value; if (auto value = readOptional(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(ban, "address")) info.address = *value; if (auto value = readOptional(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::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() : 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> NetworkRefreshService::parseCoinGeckoMarketChart( const std::string& response) { std::vector> 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(); const double price = row[1].get(); series.emplace_back(static_cast(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(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 NetworkRefreshService::parseTransparentAddressList(const json& addressList) { std::vector addresses; if (!addressList.is_array()) return addresses; for (const auto& addressJson : addressList) { try { addresses.push_back(buildTransparentAddressInfo(addressJson.get())); } catch (...) {} } return addresses; } void NetworkRefreshService::applyShieldedBalancesFromUnspent(std::vector& addresses, const json& unspent) { applyBalancesFromUnspent(addresses, unspent); } void NetworkRefreshService::applyTransparentBalancesFromUnspent(std::vector& 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(); } 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() : 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(); } 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() : 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( std::chrono::steady_clock::now() - scanStart).count(); return result; } NetworkRefreshService::TransactionRefreshSnapshot NetworkRefreshService::buildTransactionRefreshSnapshot( const WalletState& state, const TransactionViewCache& viewTxCache, const std::unordered_set& 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& transactions, std::set& knownTxids, const json& result, const std::set& miningAddresses) { if (!result.is_array()) return; for (const auto& transactionJson : result) { TransactionInfo info; if (auto value = readOptional(transactionJson, "txid")) info.txid = *value; if (auto value = readOptional(transactionJson, "category")) info.type = *value; if (auto value = readOptional(transactionJson, "amount")) info.amount = *value; if (auto value = readOptional(transactionJson, "time")) { info.timestamp = *value; } else if (auto received = readOptional(transactionJson, "timereceived")) { info.timestamp = *received; } if (auto value = readOptional(transactionJson, "confirmations")) info.confirmations = *value; if (auto value = readOptional(transactionJson, "address")) info.address = *value; if (info.type == "receive" && !info.address.empty() && miningAddresses.count(info.address)) { info.type = "mined"; } if (!info.txid.empty()) knownTxids.insert(info.txid); transactions.push_back(std::move(info)); } } void NetworkRefreshService::appendShieldedReceivedTransactions(std::vector& transactions, std::set& knownTxids, const std::string& address, const json& received, const std::set& 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(note, "txid"); if (!txid || txid->empty()) continue; auto change = readOptional(note, "change"); if (change && *change) continue; auto memoValue = readOptional(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(note, "amount")) info.amount = *value; if (auto value = readOptional(note, "confirmations")) info.confirmations = *value; if (auto value = readOptional(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(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(output, "outgoing")) outgoing = *value; if (!outgoing) continue; TransactionViewCacheEntry::Output out; if (auto value = readOptional(output, "address")) out.address = *value; if (auto value = readOptional(output, "value")) out.value = *value; if (auto value = readOptional(output, "memoStr")) out.memo = *value; out.position = outputPosition; entry.outgoing_outputs.push_back(std::move(out)); } } return entry; } void NetworkRefreshService::appendViewTransactionOutputs(std::vector& 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& 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(sentEntry, "confirmations")) confirmations = *v; if (auto v = readOptional(sentEntry, "blocktime")) timestamp = *v; if (timestamp == 0) { if (auto v = readOptional(sentEntry, "time")) timestamp = *v; } for (const auto& out : *it) { auto amount = readOptional(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(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& 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 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(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 { 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(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( 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 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 recentTxids; std::vector 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 scannedTransactions; std::set 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& 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 requested(requestedOpids.begin(), requestedOpids.end()); std::set 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(); 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(); if (status == "success") { parsed.doneOpids.push_back(opid); parsed.anySuccess = true; if (op.contains("result") && op["result"].is_object() && op["result"].contains("txid")) { try { std::string txid = op["result"]["txid"].get(); if (!txid.empty()) { parsed.successTxids.push_back(txid); parsed.successTxidsByOpid[opid] = std::move(txid); } } catch (...) {} } } else if (status == "failed" || status == "cancelled" || status == "canceled") { parsed.doneOpids.push_back(opid); std::string msg = status == "failed" ? "Transaction failed" : "Transaction cancelled"; if (op.contains("error") && op["error"].is_object() && op["error"].contains("message")) { try { msg = op["error"]["message"].get(); } catch (...) {} } parsed.failureMessages.push_back(msg); 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 , of " (the common one — // one per wallet tx; is the stable per-tx key, the total tx count) // "Building Witnesses for block complete, 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 in " / " bool& witnessRebuilt = scan.witnessRebuilt; // new daemon's completion line seen this batch std::vector& 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 "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(t[t.size() - 3]))) { finished = true; } } // Sapling note witness rebuild: "Building Witnesses for block // complete, 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: / ". // 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(total - done); witnessReadTotal = static_cast(total); } } catch (...) {} } } lastStatus = line; } // New daemon's witness-rebuild completion line: // "... rebuilt note witness cache(s) to height in ms using 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 , of ". // is the tx's slot in an unordered map (bounces — useless as progress), so // we key on (one per tx) and count distinct txs against . 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& 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& 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(job); constexpr std::size_t count = static_cast(Job::Count); return index < count ? index : 0; } } // namespace services } // namespace dragonx