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

@@ -9,7 +9,6 @@
// - Recent received at bottom
#include "receive_tab.h"
#include "send_tab.h"
#include "../../app.h"
#include "../../config/settings.h"
#include "../../util/i18n.h"
@@ -360,145 +359,99 @@ static void DrawRecvIcon(ImDrawList* dl, float cx, float cy, float s, ImU32 col)
// ============================================================================
// Recent received transactions — styled to match transactions list
// ============================================================================
static void RenderRecentReceived(ImDrawList* dl, const AddressInfo& /* addr */,
static void RenderRecentReceived(const AddressInfo& /* addr */,
const WalletState& state, float width,
ImFont* capFont, App* app) {
auto& S = schema::UI();
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("recent_received"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
float hs = Layout::hScale(width);
float glassRound = Layout::glassRounding();
ImFont* body2 = Type().body2();
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
float iconSz = std::max(schema::UI().drawElement("tabs.receive", "recent-icon-min-size").size, schema::UI().drawElement("tabs.receive", "recent-icon-size").size * hs);
float vs = Layout::vScale(std::max(1.0f, ImGui::GetContentRegionAvail().y));
float dp = Layout::dpiScale();
float rowH = std::max(18.0f * dp, S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f) * vs);
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
ImU32 recvCol = Success();
ImU32 greenCol = WithAlpha(Success(), (int)schema::UI().drawElement("tabs.receive", "recent-green-alpha").size);
float rowPadLeft = Layout::spacingLg();
// Collect matching transactions
std::vector<const TransactionInfo*> recvs;
for (const auto& tx : state.transactions) {
if (tx.type != "receive") continue;
if (tx.type != "receive" && tx.type != "mined") continue;
recvs.push_back(&tx);
if (recvs.size() >= (size_t)schema::UI().drawElement("tabs.receive", "max-recent-receives").size) break;
}
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
ImGuiWindowFlags_NoBackground);
ImDrawList* rowDL = ImGui::GetWindowDrawList();
char buf[64];
if (recvs.empty()) {
ImGui::SetCursorPosY(Layout::spacingMd());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_recent_receives"));
ImGui::EndChild();
return;
}
// Outer glass panel wrapping all rows
float itemSpacingY = ImGui::GetStyle().ItemSpacing.y;
float listH = rowH * (float)recvs.size() + itemSpacingY * (float)(recvs.size() - 1);
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
ImVec2 listPanelMax(listPanelMin.x + width, listPanelMin.y + listH);
GlassPanelSpec glassSpec;
glassSpec.rounding = glassRound;
DrawGlassPanel(dl, listPanelMin, listPanelMax, glassSpec);
// Clip draw commands to panel bounds to prevent overflow
dl->PushClipRect(listPanelMin, listPanelMax, true);
char buf[64];
for (size_t ri = 0; ri < recvs.size(); ri++) {
const auto& tx = *recvs[ri];
ImVec2 rowPos = ImGui::GetCursorScreenPos();
ImVec2 rowEnd(rowPos.x + width, rowPos.y + rowH);
float rowY = rowPos.y + rowH * 0.5f;
// Hover glow
bool hovered = material::IsRectHovered(rowPos, rowEnd);
if (hovered) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.receive", "row-hover-alpha").size), schema::UI().drawElement("tabs.receive", "row-hover-rounding").size);
// Icon
DrawRecvIcon(rowDL, rowPos.x + Layout::spacingMd(), rowY, iconSz * 0.5f, recvCol);
// Type label (first line)
float txX = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
const char* typeText = tx.type == "mined" ? TR("mined_type") : TR("received_label");
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(txX, rowPos.y + 2.0f * dp), OnSurfaceMedium(), typeText);
// Address (second line)
float addrX = txX + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
std::string addrDisplay = TruncateAddress(tx.address,
(size_t)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(addrX, rowPos.y + 2.0f * dp), OnSurfaceDisabled(), addrDisplay.c_str());
// Amount (right-aligned, first line)
snprintf(buf, sizeof(buf), "+%.4f %s", std::abs(tx.amount), DRAGONX_TICKER);
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, buf);
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
float amtX = rightEdge - amtSz.x - std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size,
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(amtX, rowPos.y + 2.0f * dp), recvCol, buf);
// Time ago
std::string ago = recvTimeAgo(tx.timestamp);
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, ago.c_str());
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f),
rowPos.y + 2.0f * dp),
OnSurfaceDisabled(), ago.c_str());
// Clickable row — hover highlight + navigate to History
float rowW = ImGui::GetContentRegionAvail().x;
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
if (material::IsRectHovered(rowPos, rowEnd)) {
rowDL->AddRectFilled(rowPos, rowEnd,
IM_COL32(255, 255, 255, 15),
S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0)) {
app->setCurrentPage(ui::NavPage::History);
}
}
float cx = rowPos.x + rowPadLeft;
float cy = rowPos.y + Layout::spacingMd();
// Icon
DrawRecvIcon(dl, cx + iconSz, cy + body2->LegacySize * 0.5f, iconSz, recvCol);
// Type label (first line)
float labelX = cx + iconSz * 2.0f + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), recvCol, TR("received_label"));
// Time (next to type)
std::string ago = recvTimeAgo(tx.timestamp);
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, TR("received_label")).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX + typeW + Layout::spacingLg(), cy),
OnSurfaceDisabled(), ago.c_str());
// Address (second line)
std::string addr_display = TruncateAddress(tx.address, (int)schema::UI().drawElement("tabs.receive", "recent-addr-trunc-len").size);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), addr_display.c_str());
// Amount (right-aligned, first line)
snprintf(buf, sizeof(buf), "+%.8f", tx.amount);
ImVec2 amtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, buf);
float amtX = rowPos.x + width - amtSz.x - Layout::spacingLg();
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(amtX, cy), recvCol, buf,
schema::UI().drawElement("tabs.receive", "text-shadow-offset-x").size, schema::UI().drawElement("tabs.receive", "text-shadow-offset-y").size, IM_COL32(0, 0, 0, (int)schema::UI().drawElement("tabs.receive", "text-shadow-alpha").size));
// USD equivalent (right-aligned, second line)
double priceUsd = state.market.price_usd;
if (priceUsd > 0.0) {
double usdVal = tx.amount * priceUsd;
if (usdVal >= 1.0)
snprintf(buf, sizeof(buf), "$%.2f", usdVal);
else if (usdVal >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", usdVal);
else
snprintf(buf, sizeof(buf), "$%.6f", usdVal);
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rowPos.x + width - usdSz.x - Layout::spacingLg(), cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceDisabled(), buf);
}
// Status badge
{
const char* statusStr;
ImU32 statusCol;
if (tx.confirmations == 0) {
statusStr = TR("pending"); statusCol = Warning();
} else if (tx.confirmations < (int)schema::UI().drawElement("tabs.receive", "confirmed-threshold").size) {
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
statusStr = buf; statusCol = Warning();
} else {
statusStr = TR("confirmed"); statusCol = greenCol;
}
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
float statusX = amtX - sSz.x - Layout::spacingXxl();
float minStatusX = cx + width * schema::UI().drawElement("tabs.receive", "status-min-x-ratio").size;
if (statusX < minStatusX) statusX = minStatusX;
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>((int)schema::UI().drawElement("tabs.receive", "status-pill-bg-alpha").size) << 24);
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + (int)schema::UI().drawElement("tabs.receive", "status-pill-y-offset").size);
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.receive", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
}
ImGui::Dummy(ImVec2(0, rowH));
// Subtle divider between rows
if (ri < recvs.size() - 1) {
ImVec2 divStart = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(divStart.x + rowPadLeft + iconSz * 2.0f, divStart.y),
ImVec2(divStart.x + width - Layout::spacingLg(), divStart.y),
IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.receive", "row-divider-alpha").size));
}
}
dl->PopClipRect();
ImGui::EndChild();
}
// ============================================================================
@@ -843,23 +796,6 @@ void RenderReceiveTab(App* app)
ImGui::Dummy(ImVec2(addrColW, capFont->LegacySize + Layout::spacingSm()));
}
// Clear button
{
bool hasData = (s_request_amount > 0 || s_request_memo[0]);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "clear-btn-hover-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text,
ImGui::ColorConvertU32ToFloat4(hasData ? OnSurfaceMedium() : OnSurfaceDisabled()));
ImGui::BeginDisabled(!hasData);
if (TactileSmallButton(TrId("clear_request", "recv").c_str(), S.resolveFont("button"))) {
s_request_amount = 0.0;
s_request_usd_amount = 0.0;
s_request_memo[0] = '\0';
}
ImGui::EndDisabled();
ImGui::PopStyleColor(3);
}
}
float leftBottom = ImGui::GetCursorScreenPos().y;
@@ -869,9 +805,14 @@ void RenderReceiveTab(App* app)
float rx = qrColX;
float ry = sectionTop.y;
float maxQrSize = std::min(qrAvailW - Layout::spacingMd() * 2, S.drawElement("tabs.receive", "qr-max-size").size);
float qrSize = std::max(S.drawElement("tabs.receive", "qr-min-size").size, maxQrSize);
float qrPadding = Layout::spacingMd();
float qrWidthBound = std::max(0.0f, qrAvailW - qrPadding * 2.0f);
float qrHeightBound = std::max(0.0f, leftBottom - sectionTop.y - qrPadding * 2.0f);
float qrMaxSize = qrHeightBound > 0.0f
? std::min(qrWidthBound, qrHeightBound)
: qrWidthBound;
float qrMinSize = std::min(S.drawElement("tabs.receive", "qr-min-size").size, qrWidthBound);
float qrSize = std::max(qrMinSize, qrMaxSize);
float totalQrSize = qrSize + qrPadding * 2;
float qrOffsetX = std::max(0.0f, (qrAvailW - totalQrSize) * 0.5f);
ImVec2 qrPanelMin(rx + qrOffsetX, ry);
@@ -917,6 +858,18 @@ void RenderReceiveTab(App* app)
}
// Divider before action buttons
{
float actionBtnH = std::max(S.drawElement("tabs.receive", "action-btn-min-height").size,
S.drawElement("tabs.receive", "action-btn-height").size * vScale);
float footerH = innerGap + actionBtnH + pad;
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
float targetCardH = Layout::mainCardTargetH(formW, vScale);
float footerTopH = targetCardH - footerH;
if (currentCardH < footerTopH) {
ImGui::Dummy(ImVec2(0, footerTopH - currentCardH));
}
}
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
{
ImVec2 divPos = ImGui::GetCursorScreenPos();
@@ -964,6 +917,25 @@ void RenderReceiveTab(App* app)
ImGui::PopStyleColor(3);
}
{
bool hasData = (s_request_amount > 0 || s_request_memo[0]);
if (!firstBtn) ImGui::SameLine(0, btnGap);
firstBtn = false;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "clear-btn-hover-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text,
ImGui::ColorConvertU32ToFloat4(hasData ? OnSurfaceMedium() : OnSurfaceDisabled()));
ImGui::BeginDisabled(!hasData);
if (TactileButton(TrId("clear_request", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
s_request_amount = 0.0;
s_request_usd_amount = 0.0;
s_request_memo[0] = '\0';
}
ImGui::EndDisabled();
ImGui::PopStyleColor(3);
}
if (!firstBtn) ImGui::SameLine(0, btnGap);
firstBtn = false;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
@@ -978,20 +950,6 @@ void RenderReceiveTab(App* app)
ImGui::PopStyleVar(); // FrameBorderSize
ImGui::PopStyleColor(4);
if (selected.balance > 0) {
if (!firstBtn) ImGui::SameLine(0, btnGap);
firstBtn = false;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "btn-hover-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()));
if (TactileButton(TrId("send", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
SetSendFromAddress(selected.address);
app->setCurrentPage(NavPage::Send);
}
ImGui::PopStyleColor(3);
}
}
// Bottom padding
@@ -1020,7 +978,7 @@ void RenderReceiveTab(App* app)
// ================================================================
// RECENT RECEIVED
// ================================================================
RenderRecentReceived(dl, selected, state, formW, capFont, app);
RenderRecentReceived(selected, state, formW, capFont, app);
ImGui::EndGroup();
float measuredH = ImGui::GetCursorPosY() - contentStartY;