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

@@ -652,6 +652,7 @@ static void RenderBalanceClassic(App* app)
bool isZ;
bool hidden;
bool favorite;
bool mining;
};
std::vector<AddrRow> rows;
rows.reserve(state.z_addresses.size() + state.t_addresses.size());
@@ -665,7 +666,7 @@ static void RenderBalanceClassic(App* app)
bool isFav = app->isAddressFavorite(a.address);
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav)
continue;
rows.push_back({&a, true, isHidden, isFav});
rows.push_back({&a, true, isHidden, isFav, app->isMiningAddress(a.address)});
}
for (const auto& a : state.t_addresses) {
std::string filter(addr_search);
@@ -677,7 +678,7 @@ static void RenderBalanceClassic(App* app)
bool isFav = app->isAddressFavorite(a.address);
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav)
continue;
rows.push_back({&a, false, isHidden, isFav});
rows.push_back({&a, false, isHidden, isFav, app->isMiningAddress(a.address)});
}
// Sort: favorites first, then Z addresses, then by balance descending
@@ -951,8 +952,9 @@ static void RenderBalanceClassic(App* app)
const char* typeLabel = row.isZ ? "Shielded" : "Transparent";
const char* hiddenTag = row.hidden ? " (hidden)" : "";
const char* viewOnlyTag = (!addr.has_spending_key) ? " (view-only)" : "";
const char* miningTag = row.mining ? TR("mining_tag") : "";
char typeBuf[64];
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, viewOnlyTag);
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s%s", typeLabel, hiddenTag, viewOnlyTag, miningTag);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
// Label (if present, next to type)
@@ -1033,6 +1035,9 @@ static void RenderBalanceClassic(App* app)
app->setCurrentPage(NavPage::Send);
}
ImGui::Separator();
if (ImGui::MenuItem(row.mining ? TR("unmark_mining_address") : TR("mark_mining_address"))) {
app->setMiningAddress(addr.address, !row.mining);
}
if (ImGui::MenuItem(TR("export_private_key"))) {
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private);
}
@@ -1111,18 +1116,19 @@ static void RenderBalanceClassic(App* app)
}
ImGui::Spacing();
const auto& txs = state.transactions;
int maxTx = kRecentTxCount;
int count = (int)txs.size();
if (count > maxTx) count = maxTx;
ImFont* capFont = Type().caption();
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
ImGui::BeginChild("##BalanceClassicRecentRows", ImVec2(0, listH), false,
ImGuiWindowFlags_NoBackground);
const auto& txs = state.transactions;
int count = (int)txs.size();
if (count == 0) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
"No transactions yet");
} else {
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* capFont = Type().caption();
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * 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);
for (int i = 0; i < count; i++) {
@@ -1176,6 +1182,7 @@ static void RenderBalanceClassic(App* app)
ImGui::Dummy(ImVec2(0, rowH));
}
}
ImGui::EndChild();
}
}
@@ -1297,7 +1304,8 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
std::string addrLabel = app->getAddressLabel(a.address);
bool isHidden = app->isAddressHidden(a.address);
bool isFav = app->isAddressFavorite(a.address);
rowInputs.push_back({&a, isZ, isHidden, isFav,
bool isMining = app->isMiningAddress(a.address);
rowInputs.push_back({&a, isZ, isHidden, isFav, isMining,
addrLabel, app->getAddressIcon(a.address),
app->getAddressSortOrder(a.address)});
}
@@ -1646,8 +1654,9 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
{
const char* typeLabel = row.isZ ? TR("shielded") : TR("transparent");
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
const char* miningTag = row.mining ? TR("mining_tag") : "";
char typeBuf[64];
snprintf(typeBuf, sizeof(typeBuf), "%s%s", typeLabel, hiddenTag);
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
// User label next to type
@@ -1761,6 +1770,9 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
if (ImGui::MenuItem(TR("set_label"))) {
AddressLabelDialog::show(app, addr.address, row.isZ);
}
if (ImGui::MenuItem(row.mining ? TR("unmark_mining_address") : TR("mark_mining_address"))) {
app->setMiningAddress(addr.address, !row.mining);
}
if (ImGui::MenuItem(TR("export_private_key")))
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private);
if (row.isZ) {
@@ -1869,10 +1881,13 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
ImGui::Spacing();
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
int maxTx = std::clamp((int)(recentH / scaledRowH), 2, 5);
float availableListH = ImGui::GetContentRegionAvail().y;
float listH = std::max({scaledRowH, recentH, availableListH});
ImGui::BeginChild("##BalanceSharedRecentRows", ImVec2(0, listH), false,
ImGuiWindowFlags_NoBackground);
const auto& txs = state.transactions;
int count = (int)txs.size();
if (count > maxTx) count = maxTx;
if (count == 0) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), "No transactions yet");
@@ -1923,6 +1938,7 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
ImGui::Dummy(ImVec2(0, rowH));
}
}
ImGui::EndChild();
}
// Render sync progress bar (used by multiple layouts)