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