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.
This commit is contained in:
2026-05-05 03:22:14 -05:00
parent 948ef419ac
commit 975743f754
43 changed files with 3732 additions and 702 deletions

View File

@@ -104,7 +104,7 @@ static void DrawTxIcon(ImDrawList* dl, const std::string& type,
} else if (type == "receive") {
icon = ICON_MD_CALL_RECEIVED;
} else if (type == "shield") {
icon = ICON_MD_SHIELD;
icon = ICON_MD_CALL_MADE;
} else {
icon = ICON_MD_CONSTRUCTION;
}
@@ -147,6 +147,8 @@ void RenderTransactionsTab(App* app)
ImU32 greenCol = Success();
ImU32 redCol = Error();
ImU32 goldCol = Warning();
std::string txLoadingText = app->transactionRefreshProgressText();
bool txLoading = !txLoadingText.empty();
// Expanded row index for inline detail
static int s_expanded_row = -1;
@@ -319,6 +321,18 @@ void RenderTransactionsTab(App* app)
ExportTransactionsDialog::show();
}
if (txLoading) {
ImGui::SameLine(0, filterGap);
ImGui::PushFont(Type().iconSmall());
float pulse = 0.55f + 0.45f * std::sin((float)ImGui::GetTime() * 3.0f);
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_HOURGLASS_EMPTY);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4;
const char* dotStr[] = {"", ".", "..", "..."};
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", txLoadingText.c_str(), dotStr[dots]);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm() + Layout::spacingXs()));
// ================================================================
@@ -415,8 +429,8 @@ void RenderTransactionsTab(App* app)
for (size_t i = 0; i < display_txns.size(); i++) {
const auto& dtx = display_txns[i];
if (type_filter != 0) {
if (type_filter == 1 && dtx.display_type != "send") continue;
if (type_filter == 2 && dtx.display_type != "receive" && dtx.display_type != "shield") continue;
if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue;
if (type_filter == 2 && dtx.display_type != "receive") continue;
if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue;
}
if (!search_str.empty()) {
@@ -567,7 +581,14 @@ void RenderTransactionsTab(App* app)
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("not_connected"));
} else if (state.transactions.empty()) {
ImGui::Dummy(ImVec2(0, 20));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions"));
if (txLoading) {
int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4;
const char* dotStr[] = {"", ".", "..", "..."};
snprintf(buf, sizeof(buf), "%s%s", txLoadingText.c_str(), dotStr[dots]);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
} else {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions"));
}
} else if (filtered_indices.empty()) {
ImGui::Dummy(ImVec2(0, 20));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_matching"));
@@ -596,10 +617,11 @@ void RenderTransactionsTab(App* app)
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
// Determine type info
bool shieldedDisplay = tx.display_type == "shield";
ImU32 iconCol;
const char* typeStr;
if (tx.display_type == "shield") {
iconCol = Primary(); typeStr = TR("shielded_type");
if (shieldedDisplay) {
iconCol = redCol; typeStr = TR("sent_type");
} else if (tx.display_type == "receive") {
iconCol = greenCol; typeStr = TR("recv_type");
} else if (tx.display_type == "send") {
@@ -627,7 +649,8 @@ void RenderTransactionsTab(App* app)
float cy = rowPos.y + Layout::spacingMd();
// Icon
DrawTxIcon(dl, tx.display_type, cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
DrawTxIcon(dl, shieldedDisplay ? "send" : tx.display_type,
cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
// Type label
float labelX = cx + rowIconSz * 2.0f + Layout::spacingSm();
@@ -687,16 +710,35 @@ void RenderTransactionsTab(App* app)
}
// Position status badge in the middle-right area
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
float statusX = amtX - sSz.x - Layout::spacingXxl();
const char* shieldedStr = TR("shielded_type");
ImVec2 shieldSz = shieldedDisplay
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, shieldedStr)
: ImVec2(0, 0);
float shieldPillW = shieldSz.x + Layout::spacingSm() * 2.0f;
float stackW = shieldedDisplay ? std::max(sSz.x, shieldPillW) : sSz.x;
float statusX = amtX - stackW - Layout::spacingXxl();
float minStatusX = cx + innerW * 0.25f; // don't overlap address
if (statusX < minStatusX) statusX = minStatusX;
float statusTextX = statusX + (stackW - sSz.x) * 0.5f;
if (shieldedDisplay) {
float shieldX = statusX + (stackW - shieldSz.x) * 0.5f;
ImU32 shieldCol = Primary();
ImU32 shieldBg = (shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
ImVec2 shieldPillMin(shieldX - Layout::spacingSm(), cy - 1.0f);
ImVec2 shieldPillMax(shieldX + shieldSz.x + Layout::spacingSm(),
shieldPillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(shieldPillMin, shieldPillMax, shieldBg,
schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(shieldX, cy), shieldCol, shieldedStr);
}
// Background pill
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + 1);
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
ImVec2 pillMin(statusTextX - Layout::spacingSm(), cy + body2->LegacySize + 1);
ImVec2 pillMax(statusTextX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
ImVec2(statusTextX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
}
// Click to expand/collapse + invisible button for interaction