fix(resilience,perf): gate send on a known tip, surface dropped/stale txs, advance cached confs, memoize explorer

Backlog mediums (display/gating/caching only — no send/sweep/encrypt call or args changed):

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 19:12:32 -05:00
co-authored by Claude Opus 4.8
parent b404bb2aff
commit 3ee90993ed
8 changed files with 117 additions and 9 deletions
+12
View File
@@ -75,6 +75,13 @@ struct TransactionInfo {
std::string getTypeDisplay() const;
bool isConfirmed() const { return confirmations >= 1; }
bool isMature() const { return confirmations >= 100; }
// gettransaction reports confirmations < 0 for a tx conflicted with the chain (double-spent / reorged out).
bool isConflicted() const { return confirmations < 0; }
// Unconfirmed for longer than maxAgeSec since the wallet first saw it: most likely dropped from the
// mempool or expired. A display heuristic only (timestamp 0 = unknown -> never flagged).
bool isStalePending(int64_t now, int64_t maxAgeSec = 3600) const {
return confirmations == 0 && timestamp > 0 && now - timestamp > maxAgeSec;
}
};
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
@@ -198,6 +205,11 @@ struct SyncInfo {
bool tip_known = false;
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
// Send precondition: the wallet is caught up to a KNOWN peer-derived tip. A fresh (re)connect or a
// peerless node has tip_known == false, so its local height can't be trusted as the network tip.
// The lite backend never populates tip_known, so lite is exempt (it has its own sync gate).
bool sendTipReady(bool liteBuild) const { return liteBuild || (tip_known && !syncing && blocks > 0); }
};
/**
+9 -3
View File
@@ -1056,8 +1056,13 @@ NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseVie
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
const std::string& txid,
const TransactionViewCacheEntry& entry)
const TransactionViewCacheEntry& entry,
int currentHeight)
{
// A mined entry's confirmations grow by one per block since it was sampled; never go backwards.
int entryConfirmations = entry.confirmations;
if (entry.confirmations >= 1 && entry.observed_height >= 0 && currentHeight > entry.observed_height)
entryConfirmations += currentHeight - entry.observed_height;
for (const auto& out : entry.outgoing_outputs) {
bool alreadyTracked = false;
for (const auto& existing : transactions) {
@@ -1089,7 +1094,7 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
}
}
if (!foundExistingMetadata) {
info.confirmations = entry.confirmations;
info.confirmations = entryConfirmations;
info.timestamp = entry.timestamp;
}
transactions.push_back(std::move(info));
@@ -1476,7 +1481,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
auto cached = snapshot.viewTxCache.find(txid);
if (cached != snapshot.viewTxCache.end()) {
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
appendViewTransactionOutputs(result.transactions, txid, cached->second);
appendViewTransactionOutputs(result.transactions, txid, cached->second, currentBlockHeight);
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
@@ -1509,6 +1514,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
}
auto entry = parseViewTransactionCacheEntry(viewTransaction);
entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip
const bool storedRow = rowBacklog.count(txid) > 0;
if (storedRow && entry.timestamp == 0) {
const auto settled = settledStoredMeta.find(txid);
+5 -1
View File
@@ -224,6 +224,7 @@ public:
std::string from_address;
std::int64_t timestamp = 0;
int confirmations = 0;
int observed_height = -1; // chain tip when `confirmations` was read (-1 unknown): lets a cached entry advance
struct Output {
std::string address;
double value = 0.0;
@@ -427,9 +428,12 @@ public:
const std::set<std::string>& miningAddresses = {},
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
// currentHeight (>=0): the present chain tip; a cached entry that was already mined then reports
// confirmations advanced by the blocks added since it was observed (display only).
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
const std::string& txid,
const TransactionViewCacheEntry& entry);
const TransactionViewCacheEntry& entry,
int currentHeight = -1);
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
+3 -2
View File
@@ -846,7 +846,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
std::map<int, BlockSummary> cachedBlocks;
if (!pageHeights.empty()) {
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front());
// Memoized on the cache revision: SQLite is only hit after the cache changed, not every frame.
cachedBlocks = s_recent_block_cache.loadRangeCached(pageHeights.back(), pageHeights.front());
}
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
@@ -890,7 +891,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
}
std::map<int, BlockSummary> nextCachedBlocks;
if (!nextPageHeights.empty()) {
nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front());
nextCachedBlocks = s_recent_block_cache.loadRangeCached(nextPageHeights.back(), nextPageHeights.front());
}
for (int height : nextPageHeights) {
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
+6 -2
View File
@@ -917,7 +917,8 @@ void RenderSendConfirmPopup(App* app) {
// since Review. Don't broadcast a now-invalid transaction.
const auto& liveState = app->getWalletState();
double liveAvail = GetAvailableBalance(app);
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 ||
if (!app->isConnected() || liveState.sync.syncing ||
!liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 ||
!util::canAfford(s_amount, s_fee, liveAvail)) {
s_tx_status = TR("send_cannot_send_now");
s_status_success = false;
@@ -1048,6 +1049,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
}
bool can_send = app->isConnected() &&
!state.sync.syncing &&
state.sync.sendTipReady(app->isLiteBuild()) &&
is_valid_address &&
s_amount > 0 &&
s_from_address[0] != '\0' &&
@@ -1072,7 +1074,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
// Explain why Review/Send is greyed while syncing (the fields above are usable now).
if (state.sync.syncing) {
if (state.sync.syncing || (app->isConnected() && !state.sync.sendTipReady(app->isLiteBuild()))) {
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}
@@ -1101,6 +1103,8 @@ static void RenderActionButtons(App* app, float width, float vScale,
material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
else if (state.sync.syncing)
material::Tooltip("%s", TR("send_tooltip_syncing"));
else if (!state.sync.sendTipReady(app->isLiteBuild()))
material::Tooltip("%s", TR("send_tooltip_no_tip"));
else if (s_from_address[0] == '\0')
material::Tooltip("%s", TR("send_tooltip_select_source"));
else if (is_mining_addr)
+6 -1
View File
@@ -855,7 +855,12 @@ void RenderTransactionsTab(App* app)
{
const char* statusStr;
ImU32 statusCol;
if (tx.confirmations == 0) {
const int64_t nowSec = static_cast<int64_t>(std::time(nullptr));
if (tx.confirmations < 0) { // gettransaction < 0: conflicted / double-spent / reorged out
statusStr = TR("tx_dropped"); statusCol = Error();
} else if (tx.confirmations == 0 && tx.timestamp > 0 && nowSec - tx.timestamp > 3600) {
statusStr = TR("tx_stale_pending"); statusCol = Warning();
} else if (tx.confirmations == 0) {
statusStr = TR("pending"); statusCol = Warning();
} else if (tx.confirmations < 10) {
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
+3
View File
@@ -1511,6 +1511,8 @@ void I18n::loadBuiltinEnglish()
strings_["confirmations"] = "Confirmations";
strings_["confirmed"] = "Confirmed";
strings_["pending"] = "Pending";
strings_["tx_dropped"] = "Dropped";
strings_["tx_stale_pending"] = "Expired?";
strings_["baltab_pending_chip"] = "Pending +%.4f";
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
strings_["sent"] = "sent";
@@ -2703,6 +2705,7 @@ void I18n::loadBuiltinEnglish()
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
strings_["send_tooltip_select_source"] = "Select a source address first";
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
strings_["send_tooltip_no_tip"] = "Waiting for peers to report the network height. Sending is disabled until the wallet knows it is caught up.";
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
strings_["send_total"] = "Total";
+73
View File
@@ -3042,6 +3042,58 @@ void testNetworkRefreshResultModels()
}
}
// Resilience mediums: the send tip-gate, cached view-tx confirmations advancing, and the dropped /
// stale-pending display predicates.
void testResilienceMediums()
{
{ // 1) send tip gate
dragonx::SyncInfo s;
s.blocks = 100; s.headers = 100; s.syncing = false; s.tip_known = false;
EXPECT_FALSE(s.sendTipReady(false)); // peerless / fresh connect: tip unknown
EXPECT_TRUE(s.sendTipReady(true)); // lite exempt
s.tip_known = true;
EXPECT_TRUE(s.sendTipReady(false));
s.syncing = true;
EXPECT_FALSE(s.sendTipReady(false));
s.syncing = false; s.blocks = 0;
EXPECT_FALSE(s.sendTipReady(false));
}
{ // 2) cached view entry confirmations advance (never backwards; pending not extrapolated)
using R = dragonx::services::NetworkRefreshService;
R::TransactionViewCacheEntry e;
e.from_address = "zs-from"; e.confirmations = 3; e.timestamp = 500; e.observed_height = 100;
R::TransactionViewCacheEntry::Output o; o.address = "zs-dest"; o.value = 1.0; e.outgoing_outputs.push_back(o);
std::vector<dragonx::TransactionInfo> rows;
R::appendViewTransactionOutputs(rows, "tx1", e, 105);
EXPECT_EQ(rows.size(), static_cast<size_t>(1));
EXPECT_EQ(rows[0].confirmations, 8); // 3 + (105-100)
std::vector<dragonx::TransactionInfo> rows2;
R::appendViewTransactionOutputs(rows2, "tx1", e); // no tip -> unchanged
EXPECT_EQ(rows2[0].confirmations, 3);
std::vector<dragonx::TransactionInfo> rows3;
R::appendViewTransactionOutputs(rows3, "tx1", e, 90); // reorg/older tip: never backwards
EXPECT_EQ(rows3[0].confirmations, 3);
e.confirmations = 0; // pending entries are not extrapolated
std::vector<dragonx::TransactionInfo> rows4;
R::appendViewTransactionOutputs(rows4, "tx1", e, 110);
EXPECT_EQ(rows4[0].confirmations, 0);
}
{ // 3) dropped / stale-pending predicates
dragonx::TransactionInfo t;
t.confirmations = -1;
EXPECT_TRUE(t.isConflicted());
EXPECT_FALSE(t.isStalePending(100000));
t.confirmations = 0; t.timestamp = 1000;
EXPECT_FALSE(t.isConflicted());
EXPECT_FALSE(t.isStalePending(1000 + 3600)); // exactly the limit: still pending
EXPECT_TRUE(t.isStalePending(1000 + 3601));
t.timestamp = 0;
EXPECT_FALSE(t.isStalePending(999999)); // unknown time never flagged
t.timestamp = 1000; t.confirmations = 1;
EXPECT_FALSE(t.isStalePending(999999)); // mined
}
}
void testOperationStatusPollParsing()
{
using Refresh = dragonx::services::NetworkRefreshService;
@@ -9103,6 +9155,26 @@ void testExplorerBlockCache()
EXPECT_TRUE(cache.storeBlock(block12));
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
{ // loadRangeCached memoizes on revision(): reads don't bump; a store invalidates; updates are seen.
// Isolated on its own cache so storing blocks here can't perturb the shared `cache`'s count assertions.
fs::path mdir = makeTempDir();
ExplorerBlockCache mcache((mdir / "memo.sqlite").string(), (mdir / "memo.json").string());
EXPECT_TRUE(mcache.ensureOpen());
ExplorerBlockSummary m12; m12.height = 12; m12.hash = "m-12"; m12.tx_count = 5; m12.size = 1200; m12.time = 5200; m12.difficulty = 1.4;
EXPECT_TRUE(mcache.storeBlock(m12));
const std::uint64_t rev0 = mcache.revision();
EXPECT_EQ(mcache.loadRangeCached(12, 12).size(), static_cast<size_t>(1));
EXPECT_EQ(mcache.revision(), rev0); // reading does not bump
ExplorerBlockSummary m13 = m12; m13.height = 13; m13.hash = "m-13";
EXPECT_TRUE(mcache.storeBlock(m13));
EXPECT_TRUE(mcache.revision() != rev0); // a store invalidates the memo
EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast<size_t>(2));
EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast<size_t>(2)); // memo hit, same answer
m13.tx_count = 99;
EXPECT_TRUE(mcache.storeBlock(m13));
EXPECT_EQ(mcache.loadRangeCached(13, 13).at(13).tx_count, 99); // sees the update, not a stale memo
}
{
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
EXPECT_TRUE(reopened.ensureOpen());
@@ -19891,6 +19963,7 @@ int main()
testResolveExchangeSelection();
testTransactionCsv();
testEarningsSeries();
testResilienceMediums();
if (g_failures != 0) {
std::cerr << g_failures << " test assertion(s) failed\n";