Files
ObsidianDragon/src/services/network_refresh_service.cpp
dan_s 229373e937 feat(wallet): persist history and surface pending sends
Add an encrypted SQLite transaction history cache with cached tip metadata and
per-address shielded scan progress so startup and full refreshes avoid
re-scanning every z-address while still invalidating on wallet/address/rescan
changes.

Improve wallet history loading by paging transparent transactions, preserving
cached shielded and sent rows, keeping recent/unconfirmed activity visible, and
classifying mining-address receives. Show z_sendmany opid sends immediately in
History and Overview, pin pending rows through refreshes, and apply optimistic
address/balance debits until opids resolve.

Add timestamped RPC console tracing by source/method without logging params or
results, reduce redundant refresh/RPC calls, and cache Explorer recent block
summaries in SQLite.

Expand focused tests for transaction cache encryption, scan-progress
persistence/invalidation, history preservation, operation-status parsing,
pending send visibility, and Explorer/RPC refresh behavior.
2026-05-05 03:22:14 -05:00

1325 lines
52 KiB
C++

#include "network_refresh_service.h"
#include "../util/logger.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <map>
#include <set>
using json = nlohmann::json;
namespace dragonx {
namespace services {
namespace {
template <typename T>
std::optional<T> readOptional(const json& source, const char* key)
{
try {
if (source.contains(key)) return source[key].get<T>();
} catch (...) {}
return std::nullopt;
}
std::optional<double> readBalanceString(const json& source, const char* key)
{
try {
if (source.contains(key)) 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;
std::map<std::string, double> balances;
for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount");
if (address && amount) balances[*address] += *amount;
}
for (auto& info : addresses) {
auto balance = balances.find(info.address);
if (balance != balances.end()) info.balance = balance->second;
}
}
bool hasTransactionType(const std::vector<TransactionInfo>& transactions,
const std::string& txid,
const std::string& type)
{
for (const auto& transaction : transactions) {
if (transaction.txid == txid && transaction.type == type) return true;
}
return false;
}
bool tryFetchRawTransaction(NetworkRefreshService::RefreshRpcGateway& rpc,
const std::string& txid,
json& rawTransaction)
{
try {
rawTransaction = rpc.call("gettransaction", json::array({txid}));
return !rawTransaction.is_null();
} catch (...) {
return false;
}
}
void applyRawTransactionMetadata(TransactionInfo& info,
const json& rawTransaction,
bool includeSendDetails)
{
if (rawTransaction.is_null()) return;
if (auto value = readOptional<std::int64_t>(rawTransaction, "time")) info.timestamp = *value;
else if (auto value = readOptional<std::int64_t>(rawTransaction, "timereceived")) info.timestamp = *value;
if (auto value = readOptional<int>(rawTransaction, "confirmations")) info.confirmations = *value;
if (!includeSendDetails) return;
if (auto value = readOptional<double>(rawTransaction, "amount")) info.amount = *value;
if (!rawTransaction.contains("details") || !rawTransaction["details"].is_array()) return;
for (const auto& detail : rawTransaction["details"]) {
auto category = readOptional<std::string>(detail, "category");
if (!category || *category != "send") continue;
if (auto value = readOptional<double>(detail, "amount")) info.amount = *value;
if (auto value = readOptional<std::string>(detail, "address")) info.address = *value;
break;
}
}
void applyRawTransactionMetadata(NetworkRefreshService::TransactionViewCacheEntry& entry,
const json& rawTransaction)
{
if (rawTransaction.is_null()) return;
if (auto value = readOptional<std::int64_t>(rawTransaction, "time")) entry.timestamp = *value;
else if (auto value = readOptional<std::int64_t>(rawTransaction, "timereceived")) entry.timestamp = *value;
if (auto value = readOptional<int>(rawTransaction, "confirmations")) entry.confirmations = *value;
}
void appendTrackedSendPlaceholder(std::vector<TransactionInfo>& transactions,
const std::string& txid,
const json* rawTransaction)
{
if (hasTransactionType(transactions, txid, "send")) return;
TransactionInfo info;
info.txid = txid;
info.type = "send";
if (rawTransaction) applyRawTransactionMetadata(info, *rawTransaction, true);
transactions.push_back(std::move(info));
}
void appendMissingPreviousTransactions(std::vector<TransactionInfo>& transactions,
const std::vector<TransactionInfo>& previousTransactions,
bool includeAll,
const std::unordered_set<std::string>& pinnedTxids)
{
for (const auto& transaction : previousTransactions) {
bool pinned = pinnedTxids.find(transaction.txid) != pinnedTxids.end();
if (!includeAll && !pinned) {
bool shieldedRelated = (!transaction.address.empty() && transaction.address[0] == 'z') ||
(!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
!transaction.memo.empty();
if (!shieldedRelated) continue;
}
if (hasTransactionType(transactions, transaction.txid, transaction.type)) continue;
transactions.push_back(transaction);
}
}
} // namespace
NetworkRefreshService::ConnectionInfoResult NetworkRefreshService::parseConnectionInfoResult(const json& info)
{
ConnectionInfoResult result;
if (!info.is_object()) return result;
result.ok = true;
result.daemonVersion = readOptional<int>(info, "version");
result.protocolVersion = readOptional<int>(info, "protocolversion");
result.p2pPort = readOptional<int>(info, "p2pport");
result.longestChain = readOptional<int>(info, "longestchain");
result.notarized = readOptional<int>(info, "notarized");
result.blocks = readOptional<int>(info, "blocks");
return result;
}
NetworkRefreshService::WalletEncryptionResult NetworkRefreshService::parseWalletEncryptionResult(const json& walletInfo)
{
WalletEncryptionResult result;
if (!walletInfo.is_object()) return result;
result.ok = true;
result.encrypted = walletInfo.contains("unlocked_until");
if (result.encrypted) {
try {
result.unlockedUntil = walletInfo["unlocked_until"].get<std::int64_t>();
} catch (...) {
result.ok = false;
}
}
return result;
}
NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPollResult(RefreshRpcGateway& rpc)
{
WarmupPollResult result;
try {
json info = rpc.call("getinfo", json::array());
result.ready = true;
result.info = parseConnectionInfoResult(info);
} catch (const std::exception& e) {
result.errorMessage = e.what();
}
return result;
}
NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnectionInitResult(
RefreshRpcGateway& rpc,
const std::optional<ConnectionInfoResult>& prefetchedInfo)
{
ConnectionInitResult result;
if (prefetchedInfo && prefetchedInfo->ok) {
result.info = *prefetchedInfo;
} else {
try {
json info = rpc.call("getinfo", json::array());
result.info = parseConnectionInfoResult(info);
} catch (...) {}
}
try {
json walletInfo = rpc.call("getwalletinfo", json::array());
result.encryption = parseWalletEncryptionResult(walletInfo);
} catch (...) {}
return result;
}
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
const json& totalBalance, 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");
}
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)
{
json totalBalance;
json blockInfo;
bool balanceOk = false;
bool blockOk = false;
try {
totalBalance = rpc.call("z_gettotalbalance", json::array());
balanceOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("Balance error: %s\n", e.what());
}
try {
blockInfo = rpc.call("getblockchaininfo", json::array());
blockOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
}
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
}
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"];
PriceRefreshResult result;
result.market.price_usd = data.value("usd", 0.0);
result.market.price_btc = data.value("btc", 0.0);
result.market.change_24h = data.value("usd_24h_change", 0.0);
result.market.volume_24h = data.value("usd_24h_vol", 0.0);
result.market.market_cap = data.value("usd_market_cap", 0.0);
char buf[64];
std::tm* tm = std::localtime(&fetchedAt);
if (tm && std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm) > 0) {
result.market.last_updated = buf;
}
return result;
} catch (...) {
return std::nullopt;
}
}
NetworkRefreshService::PriceHttpResult NetworkRefreshService::parsePriceHttpResponse(
const PriceHttpResponse& response,
std::time_t fetchedAt)
{
PriceHttpResult result;
if (!response.transportOk || response.httpStatus != 200) {
const std::string reason = response.transportOk ? "OK" : response.transportError;
result.errorMessage = "Price fetch failed: " + reason +
" (HTTP " + std::to_string(response.httpStatus) + ")";
return result;
}
auto parsed = parseCoinGeckoPriceResponse(response.body, fetchedAt);
if (!parsed) {
result.errorMessage = "Price fetch returned an unrecognized response";
return result;
}
result.price = std::move(*parsed);
return result;
}
AddressInfo NetworkRefreshService::buildShieldedAddressInfo(const std::string& address,
const json& validation,
bool validationSucceeded)
{
AddressInfo info;
info.address = address;
info.type = "shielded";
if (!validationSucceeded) {
info.has_spending_key = true;
return info;
}
info.has_spending_key = false;
if (auto isMine = readOptional<bool>(validation, "ismine")) {
info.has_spending_key = *isMine;
}
return info;
}
AddressInfo NetworkRefreshService::buildTransparentAddressInfo(const std::string& address)
{
AddressInfo info;
info.address = address;
info.type = "transparent";
return info;
}
std::vector<AddressInfo> NetworkRefreshService::parseTransparentAddressList(const json& addressList)
{
std::vector<AddressInfo> addresses;
if (!addressList.is_array()) return addresses;
for (const auto& addressJson : addressList) {
try {
addresses.push_back(buildTransparentAddressInfo(addressJson.get<std::string>()));
} catch (...) {}
}
return addresses;
}
void NetworkRefreshService::applyShieldedBalancesFromUnspent(std::vector<AddressInfo>& addresses,
const json& unspent)
{
applyBalancesFromUnspent(addresses, unspent);
}
void NetworkRefreshService::applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
const json& unspent)
{
applyBalancesFromUnspent(addresses, unspent);
}
NetworkRefreshService::AddressRefreshSnapshot NetworkRefreshService::buildAddressRefreshSnapshot(const WalletState& state)
{
AddressRefreshSnapshot snapshot;
for (const auto& info : state.z_addresses) {
if (!info.address.empty()) snapshot.shieldedSpendingKeys[info.address] = info.has_spending_key;
}
return snapshot;
}
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(
RefreshRpcGateway& rpc,
const AddressRefreshSnapshot& snapshot)
{
AddressRefreshResult result;
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());
}
try {
json unspent = rpc.call("z_listunspent", json::array());
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
for (auto& info : result.shieldedAddresses) {
try {
json balance = rpc.call("z_getbalance", json::array({info.address}));
if (!balance.is_null()) info.balance = balance.get<double>();
} catch (...) {}
}
}
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());
}
try {
json unspent = rpc.call("listunspent", json::array());
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("listunspent error: %s\n", e.what());
}
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)
{
if (received.is_null() || !received.is_array()) return;
for (const auto& note : received) {
auto txid = readOptional<std::string>(note, "txid");
if (!txid || txid->empty()) continue;
auto change = readOptional<bool>(note, "change");
if (change && *change) continue;
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 (auto value = readOptional<std::string>(note, "memoStr")) info.memo = *value;
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()) {
for (const auto& output : viewTransaction["outputs"]) {
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;
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) {
if (existing.txid == txid && existing.type == "send" &&
std::abs(existing.amount + out.value) < 0.00000001) {
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::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;
std::set<std::string> knownTxids;
bool transactionRpcError = false;
try {
constexpr int pageSize = 1000;
int skip = 0;
while (true) {
json transactions = rpc.call("listtransactions", json::array({"", pageSize, skip}));
appendTransparentTransactions(result.transactions, knownTxids, transactions, snapshot.miningAddresses);
if (!transactions.is_array() || transactions.size() < static_cast<std::size_t>(pageSize)) break;
skip += pageSize;
}
} catch (const std::exception& e) {
transactionRpcError = true;
DEBUG_LOGF("listtransactions error: %s\n", e.what());
}
auto scannedAtTip = [&](const std::string& address) {
if (currentBlockHeight < 0) return false;
auto it = result.shieldedScanHeights.find(address);
return it != result.shieldedScanHeights.end() && it->second >= currentBlockHeight;
};
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
std::size_t nextSearchIndex = shieldedStart;
for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
const auto& address = snapshot.shieldedAddresses[index];
if (scannedAtTip(address)) {
nextSearchIndex = index + 1;
continue;
}
if (snapshot.maxShieldedReceiveScans > 0 &&
result.shieldedAddressesScanned >= snapshot.maxShieldedReceiveScans) {
nextSearchIndex = index;
break;
}
++result.shieldedAddressesScanned;
nextSearchIndex = index + 1;
try {
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
appendShieldedReceivedTransactions(result.transactions, knownTxids, address, received, snapshot.miningAddresses);
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
} catch (const std::exception& e) {
transactionRpcError = true;
DEBUG_LOGF("z_listreceivedbyaddress error: %s\n", e.what());
}
}
auto findPendingShieldedIndex = [&]() -> std::optional<std::size_t> {
if (snapshot.shieldedAddresses.empty()) return std::nullopt;
std::size_t start = nextSearchIndex < snapshot.shieldedAddresses.size() ? nextSearchIndex : 0;
for (std::size_t offset = 0; offset < snapshot.shieldedAddresses.size(); ++offset) {
std::size_t index = (start + offset) % snapshot.shieldedAddresses.size();
if (!scannedAtTip(snapshot.shieldedAddresses[index])) return index;
}
return std::nullopt;
};
auto pendingShieldedIndex = findPendingShieldedIndex();
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
for (const auto& txid : snapshot.sendTxids) {
knownTxids.insert(txid);
}
for (const auto& [txid, cachedView] : snapshot.viewTxCache) {
if (!cachedView.outgoing_outputs.empty()) knownTxids.insert(txid);
}
int viewTxCount = 0;
for (const auto& txid : knownTxids) {
bool trackedSend = snapshot.sendTxids.count(txid) > 0;
auto cached = snapshot.viewTxCache.find(txid);
if (cached != snapshot.viewTxCache.end()) {
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
appendViewTransactionOutputs(result.transactions, txid, cached->second);
continue;
}
}
if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid)) continue;
if (viewTxCount >= maxViewTransactionsPerCycle) break;
++viewTxCount;
try {
json viewTransaction = rpc.call("z_viewtransaction", json::array({txid}));
if (viewTransaction.is_null() || !viewTransaction.is_object()) {
if (trackedSend) {
json rawTransaction;
bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
appendTrackedSendPlaceholder(result.transactions,
txid,
hasRawTransaction ? &rawTransaction : nullptr);
}
continue;
}
auto entry = parseViewTransactionCacheEntry(viewTransaction);
appendViewTransactionOutputs(result.transactions, txid, entry);
json rawTransaction;
bool hasRawTransaction = false;
bool needsRawTransaction = false;
for (const auto& info : result.transactions) {
if (info.txid == txid && info.timestamp == 0) {
needsRawTransaction = true;
break;
}
}
if (needsRawTransaction) {
hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
if (hasRawTransaction) {
applyRawTransactionMetadata(entry, rawTransaction);
for (auto& info : result.transactions) {
if (info.txid == txid && info.timestamp == 0) {
applyRawTransactionMetadata(info, rawTransaction, false);
}
}
}
}
if (trackedSend && !hasTransactionType(result.transactions, txid, "send")) {
if (!hasRawTransaction) hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
appendTrackedSendPlaceholder(result.transactions,
txid,
hasRawTransaction ? &rawTransaction : nullptr);
}
if (!trackedSend || !entry.outgoing_outputs.empty()) {
result.newViewTxEntries[txid] = std::move(entry);
}
} catch (const std::exception& e) {
(void)e;
if (trackedSend) {
json rawTransaction;
bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
appendTrackedSendPlaceholder(result.transactions,
txid,
hasRawTransaction ? &rawTransaction : nullptr);
}
}
}
if (!snapshot.previousTransactions.empty()) {
if (transactionRpcError) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, snapshot.pendingOpids);
} else if (snapshot.shieldedAddresses.empty() || !result.shieldedScanComplete ||
result.shieldedAddressesScanned < result.shieldedAddressCount) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, snapshot.pendingOpids);
}
}
sortTransactionsNewestFirst(result.transactions);
return result;
}
NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRecentTransactionRefreshResult(
RefreshRpcGateway& rpc,
const TransactionRefreshSnapshot& snapshot,
int currentBlockHeight,
int pageSize)
{
TransactionRefreshResult result;
result.blockHeight = currentBlockHeight;
result.transactions = snapshot.previousTransactions;
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
result.shieldedScanHeights = snapshot.shieldedScanHeights;
try {
std::set<std::string> recentTxids;
std::vector<TransactionInfo> recentTransactions;
json transactions = rpc.call("listtransactions", json::array({"", pageSize, 0}));
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
for (auto& recent : recentTransactions) {
bool replaced = false;
for (auto& existing : result.transactions) {
if (existing.txid == recent.txid && existing.type == recent.type) {
existing = recent;
replaced = true;
break;
}
}
if (!replaced) result.transactions.push_back(std::move(recent));
}
} catch (const std::exception& e) {
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
}
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
std::size_t shieldedLimit = snapshot.shieldedAddresses.size() - shieldedStart;
if (snapshot.maxShieldedReceiveScans > 0) {
shieldedLimit = std::min(shieldedLimit, snapshot.maxShieldedReceiveScans);
}
for (std::size_t index = shieldedStart; index < shieldedStart + shieldedLimit; ++index) {
const auto& address = snapshot.shieldedAddresses[index];
try {
std::vector<TransactionInfo> scannedTransactions;
std::set<std::string> scannedTxids;
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
appendShieldedReceivedTransactions(scannedTransactions, scannedTxids, address, received, snapshot.miningAddresses);
for (auto& scanned : scannedTransactions) {
bool replaced = false;
for (auto& existing : result.transactions) {
if (existing.txid == scanned.txid && existing.type == scanned.type) {
existing = scanned;
replaced = true;
break;
}
}
if (!replaced) result.transactions.push_back(std::move(scanned));
}
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
++result.shieldedAddressesScanned;
} catch (const std::exception& e) {
DEBUG_LOGF("recent z_listreceivedbyaddress error: %s\n", e.what());
}
}
result.nextShieldedScanStartIndex =
(shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
result.shieldedScanComplete = true;
sortTransactionsNewestFirst(result.transactions);
return result;
}
NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOperationStatusPoll(
const json& result,
const std::vector<std::string>& requestedOpids)
{
OperationStatusPollResult parsed;
if (!result.is_array()) return parsed;
std::set<std::string> reported;
for (const auto& op : result) {
if (!op.is_object()) continue;
std::string opid = op.value("id", std::string());
if (opid.empty()) continue;
reported.insert(opid);
std::string status = op.value("status", 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);
}
}
for (const auto& opid : requestedOpids) {
if (!opid.empty() && reported.find(opid) == reported.end()) {
parsed.staleOpids.push_back(opid);
}
}
return parsed;
}
void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
{
if (!result.ok) return;
if (result.daemonVersion) state.daemon_version = *result.daemonVersion;
if (result.protocolVersion) state.protocol_version = *result.protocolVersion;
if (result.p2pPort) state.p2p_port = *result.p2pPort;
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
if (result.notarized) state.notarized = *result.notarized;
if (result.blocks) state.sync.blocks = *result.blocks;
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
}
void NetworkRefreshService::applyWalletEncryptionResult(WalletState& state, const WalletEncryptionResult& result)
{
if (!result.ok) return;
if (result.encrypted) {
state.encrypted = true;
state.unlocked_until = result.unlockedUntil;
state.locked = (result.unlockedUntil == 0);
} else {
state.encrypted = false;
state.locked = false;
state.unlocked_until = 0;
}
state.encryption_state_known = true;
}
void NetworkRefreshService::applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result)
{
applyConnectionInfoResult(state, result.info);
applyWalletEncryptionResult(state, result.encryption);
}
void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
const CoreRefreshResult& result,
std::time_t updatedAt)
{
if (result.balanceOk) {
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
if (result.totalBalance) state.total_balance = *result.totalBalance;
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;
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
if (state.longestchain > 0)
state.sync.syncing = (state.sync.blocks < state.longestchain - 2);
else
state.sync.syncing = (state.sync.blocks < state.sync.headers - 2);
if (result.notarized) state.notarized = *result.notarized;
}
void NetworkRefreshService::applyMiningRefreshResult(WalletState& state,
const MiningRefreshResult& result,
std::time_t updatedAt)
{
if (result.localHashrate) {
state.mining.localHashrate = *result.localHashrate;
state.mining.hashrate_history.push_back(state.mining.localHashrate);
if (state.mining.hashrate_history.size() > MiningInfo::MAX_HISTORY) {
state.mining.hashrate_history.erase(state.mining.hashrate_history.begin());
}
}
if (result.miningOk) {
if (result.generate) state.mining.generate = *result.generate;
if (result.genproclimit) state.mining.genproclimit = *result.genproclimit;
if (result.blocks) state.mining.blocks = *result.blocks;
if (result.difficulty) state.mining.difficulty = *result.difficulty;
if (result.networkHashrate) state.mining.networkHashrate = *result.networkHashrate;
if (result.chain) state.mining.chain = *result.chain;
state.last_mining_update = updatedAt;
}
state.mining.daemon_memory_mb = result.daemonMemoryMb;
}
void NetworkRefreshService::applyPeerRefreshResult(WalletState& state,
PeerRefreshResult&& result,
std::time_t updatedAt)
{
state.peers = std::move(result.peers);
state.bannedPeers = std::move(result.bannedPeers);
state.last_peer_update = updatedAt;
}
void NetworkRefreshService::markPriceRefreshStarted(WalletState& state)
{
state.market.price_loading = true;
state.market.price_error.clear();
}
void NetworkRefreshService::applyPriceRefreshResult(WalletState& state,
const PriceRefreshResult& result,
std::chrono::steady_clock::time_point fetchedAt)
{
state.market.price_loading = false;
state.market.price_error.clear();
state.market.price_usd = result.market.price_usd;
state.market.price_btc = result.market.price_btc;
state.market.change_24h = result.market.change_24h;
state.market.volume_24h = result.market.volume_24h;
state.market.market_cap = result.market.market_cap;
state.market.last_updated = result.market.last_updated;
state.market.last_fetch_time = fetchedAt;
state.market.price_history.push_back(result.market.price_usd);
if (state.market.price_history.size() > MarketInfo::MAX_HISTORY) {
state.market.price_history.erase(state.market.price_history.begin());
}
}
void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
const std::string& errorMessage)
{
state.market.price_loading = false;
state.market.price_error = errorMessage.empty()
? std::string("Price fetch failed")
: errorMessage;
}
void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
AddressRefreshResult&& result)
{
state.z_addresses = std::move(result.shieldedAddresses);
state.t_addresses = std::move(result.transparentAddresses);
}
void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
TransactionCacheUpdate cacheUpdate,
TransactionRefreshResult&& result,
std::time_t updatedAt)
{
state.transactions = std::move(result.transactions);
state.last_tx_update = updatedAt;
cacheUpdate.lastTxBlockHeight = result.blockHeight;
for (auto& [txid, entry] : result.newViewTxEntries) {
bool hasOutgoingOutputs = !entry.outgoing_outputs.empty();
cacheUpdate.viewTxCache[txid] = std::move(entry);
if (hasOutgoingOutputs) cacheUpdate.sendTxids.erase(txid);
}
cacheUpdate.confirmedTxCache.clear();
cacheUpdate.confirmedTxIds.clear();
for (const auto& tx : state.transactions) {
if (tx.confirmations >= 10 && tx.timestamp != 0) {
cacheUpdate.confirmedTxIds.insert(tx.txid);
cacheUpdate.confirmedTxCache.push_back(tx);
}
}
cacheUpdate.confirmedCacheBlock = result.blockHeight;
}
bool NetworkRefreshService::shouldRefreshTransactions(int lastTxBlockHeight,
int currentBlockHeight,
bool transactionsDirty) const
{
return scheduler_.shouldRefreshTransactions(lastTxBlockHeight,
currentBlockHeight,
transactionsDirty);
}
bool NetworkRefreshService::beginJob(Job job)
{
return beginDispatch(job).accepted;
}
bool NetworkRefreshService::beginJob(Job job, std::size_t queuedWork, std::size_t maxQueuedWork)
{
return beginDispatch(job, queuedWork, maxQueuedWork).accepted;
}
void NetworkRefreshService::finishJob(Job job)
{
jobFlag(job).store(false, std::memory_order_release);
}
bool NetworkRefreshService::jobInProgress(Job job) const
{
return jobFlag(job).load(std::memory_order_acquire);
}
void NetworkRefreshService::resetJobs()
{
coreInProgress_.store(false, std::memory_order_release);
addressesInProgress_.store(false, std::memory_order_release);
transactionsInProgress_.store(false, std::memory_order_release);
miningInProgress_.store(false, std::memory_order_release);
peersInProgress_.store(false, std::memory_order_release);
priceInProgress_.store(false, std::memory_order_release);
encryptionInProgress_.store(false, std::memory_order_release);
connectionInitInProgress_.store(false, std::memory_order_release);
}
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