From 54a14485a5bd9ecdfe560d466d69ff1c0364a08c Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 11 Jul 2026 16:37:36 -0500 Subject: [PATCH] feat(wallets): show created date + sort the wallet list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the earliest keymeta nCreateTime out of the wallet.dat btree (the daemon's "wallet birthday") and surface it in each row's metadata line ("created Aug 2025"). Add a sort control above the list — Date created / Address count / Transaction count / Wallet size, with an ascending/descending toggle (descending default: newest / most / largest first). Sorting reorders a display-index array (s_order) rather than s_rows, so the async, index-aligned probe batch is untouched; sort keys read a single frame-consistent snapshot of the probe results. Address count prefers the authoritative cached index value, else the btree key count. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/wallets_dialog.h | 70 ++++++++++++++++++++++++++++++--- src/util/i18n.cpp | 8 ++++ src/util/wallet_file_probe.h | 18 ++++++++- tests/test_phase4.cpp | 22 ++++++++--- 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index cdadd43..af36472 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -86,7 +87,8 @@ public: + ctrlRow // footer buttons + 6.0f * style.ItemSpacing.y; // uncounted inter-item gaps const float headH = Type().h6()->LegacySize + Layout::spacingXs() // framework h6 title - + Type().caption()->LegacySize + Layout::spacingSm(); // intro + gap + + Type().caption()->LegacySize + Layout::spacingSm() // intro + gap + + ctrlRow + Layout::spacingSm(); // sort control row + gap const float padV = 48.0f; // content-child padding (top+bottom) + margin // Size to content, but cap at a viewport fraction: with many wallets on a small / HiDPI // screen the content-sized card could exceed the window (the framework would clip a too-tall @@ -110,6 +112,54 @@ public: const std::string active = app->settings() ? app->settings()->getActiveWalletFile() : "wallet.dat"; + // ---- Frame-consistent snapshot of the async probe results (badges / counts / sort all read + // the same view; one lock instead of one-per-row). ---------------------------------- + std::vector probeSnap; + if (s_probe) { std::lock_guard lk(s_probe->mtx); probeSnap = s_probe->results; } + auto probeAt = [&](std::size_t idx) -> ProbeResult { + return idx < probeSnap.size() ? probeSnap[idx] : ProbeResult{}; + }; + + // ---- Sort controls: pick the key; the arrow flips ascending/descending ----------------- + { + ImGui::AlignTextToFramePadding(); + Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_sort_by")); + ImGui::SameLine(); + const char* items[] = { TR("wallets_sort_created"), TR("wallets_sort_addresses"), + TR("wallets_sort_txs"), TR("wallets_sort_size") }; + ImGui::SetNextItemWidth(190.0f * dp); + ImGui::Combo("##walletSort", &s_sortMode, items, IM_ARRAYSIZE(items)); + ImGui::SameLine(); + const float dbH = ImGui::GetFrameHeight(); + if (StyledButton(s_sortDesc ? ICON_MD_ARROW_DOWNWARD : ICON_MD_ARROW_UPWARD, + ImVec2(dbH, dbH), Type().iconSmall())) + s_sortDesc = !s_sortDesc; + if (ImGui::IsItemHovered()) Tooltip("%s", TR(s_sortDesc ? "wallets_sort_desc" : "wallets_sort_asc")); + } + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + + // ---- Compute the display order for the active sort key (stable, so ties keep scan order). + s_order.resize(s_rows.size()); + for (std::size_t k = 0; k < s_rows.size(); ++k) s_order[k] = k; + { + std::vector sortVal(s_rows.size(), 0); + for (std::size_t k = 0; k < s_rows.size(); ++k) { + const WalletRow& r = s_rows[k]; + const ProbeResult pr = probeAt(k); + switch (s_sortMode) { + case 1: { const auto* m = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr; + sortVal[k] = (m && m->cachedAddressCount >= 0) ? (long long)m->cachedAddressCount + : pr.keyCount; break; } + case 2: sortVal[k] = pr.txCount; break; + case 3: sortVal[k] = r.sizeBytes; break; + default: sortVal[k] = pr.createdEpoch; break; // 0 = date created + } + } + std::stable_sort(s_order.begin(), s_order.end(), [&](std::size_t a, std::size_t b) { + return s_sortDesc ? sortVal[a] > sortVal[b] : sortVal[a] < sortVal[b]; + }); + } + // ---- Wallet cards (Material-style rows; no table) ------------------------------------- const float listW = ImGui::GetContentRegionAvail().x; // Flex only when the card was viewport-capped: give the list the space left above the @@ -142,7 +192,8 @@ public: return s; }; - for (std::size_t i = 0; i < s_rows.size(); ++i) { + for (std::size_t k = 0; k < s_order.size(); ++k) { + const std::size_t i = s_order[k]; // sorted display order → actual s_rows index const WalletRow& r = s_rows[i]; // The wallet index is keyed by bare filename and is only ever written for the active // DATADIR wallet — so an external row (esp. a same-named one surfaced by the recursive @@ -187,9 +238,7 @@ public: // file was scanned (probeComplete): seed iff hdSeed; "legacy" only when complete & no seed; // "encrypted" iff mkey found; and an "unknown" row when a truncated scan couldn't confirm // encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet. - ProbeResult pres; // filled in asynchronously by the background probe thread - if (s_probe) { std::lock_guard lk(s_probe->mtx); - if (i < s_probe->results.size()) pres = s_probe->results[i]; } + const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above const bool bLock = pres.probed && pres.encrypted; const bool bSeed = pres.probed && pres.hdSeed; const bool bLegacy = pres.probed && pres.complete && !pres.hdSeed; @@ -239,6 +288,11 @@ public: if (meta && meta->cachedAddressCount >= 0) { snprintf(b, sizeof(b), "%s%lld %s", dot, (long long)meta->cachedAddressCount, TR("wallets_col_addresses")); ms += b; } else if (pres.hasCounts && pres.keyCount > 0) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.keyCount, TR("wallets_col_keys")); ms += b; } if (pres.hasCounts) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.txCount, TR("wallets_col_txs")); ms += b; } + if (pres.hasCounts && pres.createdEpoch > 0) { // wallet birthday, e.g. "created Aug 2025" + std::time_t ct = static_cast(pres.createdEpoch); + char cd[24]; std::strftime(cd, sizeof(cd), "%b %Y", std::localtime(&ct)); + snprintf(b, sizeof(b), "%s%s %s", dot, TR("wallets_created"), cd); ms += b; + } if (meta && meta->cachedBalance >= 0.0) { snprintf(b, sizeof(b), "%s%.4f %s", dot, meta->cachedBalance, DRAGONX_TICKER); ms += b; } ms += dot; ms += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never")); @@ -361,6 +415,7 @@ private: bool hasCounts = false; // an exact btree walk produced the counts below int keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change) int txCount = 0; // wallet transaction records + long long createdEpoch = 0; // wallet birthday (earliest keymeta nCreateTime); 0 = unknown }; struct ProbeBatch { std::mutex mtx; @@ -518,7 +573,7 @@ private: // byte-scan for badges only (no counts). const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile)); if (bt.parsed && bt.complete) { - res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount }; + res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch }; budget -= std::min(budget, bt.bytesRead); } else { const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile)); @@ -545,6 +600,9 @@ private: static inline char s_newName[128] = ""; static inline std::vector s_rows; static inline std::shared_ptr s_probe; // current async probe batch (index-aligned w/ s_rows) + static inline std::vector s_order; // display order (indices into s_rows) for the active sort + static inline int s_sortMode = 0; // 0 created · 1 addresses · 2 txs · 3 size + static inline bool s_sortDesc = true; // newest / most / largest first }; } // namespace ui diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 043ca9e..4052b58 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -260,6 +260,14 @@ void I18n::loadBuiltinEnglish() strings_["wallets_col_addresses"] = "Addresses"; strings_["wallets_col_txs"] = "txs"; strings_["wallets_col_keys"] = "keys"; + strings_["wallets_created"] = "created"; + strings_["wallets_sort_by"] = "Sort:"; + strings_["wallets_sort_created"] = "Date created"; + strings_["wallets_sort_addresses"] = "Address count"; + strings_["wallets_sort_txs"] = "Transaction count"; + strings_["wallets_sort_size"] = "Wallet size"; + strings_["wallets_sort_asc"] = "Ascending (oldest / fewest / smallest first)"; + strings_["wallets_sort_desc"] = "Descending (newest / most / largest first)"; strings_["wallets_col_balance"] = "Balance"; strings_["wallets_col_opened"] = "Last opened"; strings_["wallets_current"] = "current"; diff --git a/src/util/wallet_file_probe.h b/src/util/wallet_file_probe.h index 1ae49de..24c7a37 100644 --- a/src/util/wallet_file_probe.h +++ b/src/util/wallet_file_probe.h @@ -135,6 +135,7 @@ struct WalletBtreeStats { int shieldedKeys = 0; ///< zkey + czkey + sapzkey + csapzkey (≈ shielded addresses) int addressBook = 0; ///< name records (labeled/received addresses) int txCount = 0; ///< tx records (wallet transactions) + long long createdEpoch = 0; ///< earliest keymeta nCreateTime (wallet birthday); 0 = unknown bool encrypted = false; ///< saw an mkey record bool hdSeed = false; ///< saw hdseed/chdseed/hdchain std::size_t bytesRead = 0; ///< bytes actually read (for budgeting) @@ -275,7 +276,8 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path, if (aborted) return st; if (subRoots.empty()) subRoots.push_back(root); // single-database file - auto countKey = [&](const unsigned char* kp, uint32_t kl) { + auto countRecord = [&](const unsigned char* kp, uint32_t kl, + const unsigned char* dp, uint32_t dl, uint8_t dtype) { const uint32_t nlen = kp[0]; // CompactSize length of the type string if (nlen < 2 || nlen > 20 || 1u + nlen > kl) return; const char* nm = reinterpret_cast(kp + 1); @@ -286,9 +288,21 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path, else if (is("tx")) st.txCount++; else if (is("mkey")) st.encrypted = true; else if (is("hdseed") || is("chdseed") || is("hdchain")) st.hdSeed = true; + else if (is("keymeta")) { + // CKeyMetadata data = nVersion(int32) + nCreateTime(int64) + …, all little-endian (Bitcoin + // serialization). The earliest non-zero nCreateTime is the wallet birthday (daemon's + // nTimeFirstKey). B_KEYDATA(1) only; overflow values aren't inline. + if (dtype == B_KEYDATA && dp && dl >= 12) { + long long t = 0; + for (int b = 0; b < 8; ++b) t |= static_cast(dp[4 + b]) << (8 * b); + if (t > 0 && (st.createdEpoch == 0 || t < st.createdEpoch)) st.createdEpoch = t; + } + } }; for (const uint32_t sr : subRoots) { - traverse(sr, [&](const unsigned char* kp, uint32_t kl, const unsigned char*, uint32_t, uint8_t) { countKey(kp, kl); }); + traverse(sr, [&](const unsigned char* kp, uint32_t kl, const unsigned char* dp, uint32_t dl, uint8_t dt) { + countRecord(kp, kl, dp, dl, dt); + }); if (aborted) return st; } st.parsed = true; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 3aa1bff..021714d 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -790,27 +790,39 @@ void testWalletFileProbe() EXPECT_TRUE(p.isBerkeleyDB); EXPECT_TRUE(p.scanComplete); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); } // 7) Btree record counting (tier 2): hand-build a minimal single-database BDB btree — a 512-byte - // metapage (magic/pagesize/root) + one leaf page holding a "tx" record and a "key" record — and - // confirm parseWalletBtree walks it and tallies them. Also that a non-BDB file yields parsed=false. + // metapage (magic/pagesize/root) + one leaf page holding "tx", "key", and a "keymeta" record — and + // confirm parseWalletBtree walks it, tallies the counts, and reads the keymeta nCreateTime (wallet + // birthday). Also that a non-BDB file yields parsed=false. { std::string w(1024, '\0'); auto put16 = [&](std::size_t o, unsigned v) { w[o] = (char)(v & 0xff); w[o+1] = (char)((v >> 8) & 0xff); }; auto put32 = [&](std::size_t o, unsigned v) { for (int k=0;k<4;k++) w[o+k] = (char)((v >> (8*k)) & 0xff); }; put32(12, 0x00053162u); put32(20, 512); w[25] = (char)9; put32(88, 1); // page 0: metapage, root = page 1 const std::size_t P = 512; // page 1: a btree leaf - put16(P + 20, 4); w[P + 24] = (char)1; w[P + 25] = (char)5; // entries=4 (2 pairs), level 1, P_LBTREE - put16(P + 26, 500); put16(P + 28, 496); put16(P + 30, 488); put16(P + 32, 484); // index array → item offsets + put16(P + 20, 6); w[P + 24] = (char)1; w[P + 25] = (char)5; // entries=6 (3 pairs), level 1, P_LBTREE + // index array → (key,data) item offsets, laid out high→low without overlap: + put16(P + 26, 500); put16(P + 28, 496); // tx + put16(P + 30, 488); put16(P + 32, 484); // key + put16(P + 34, 472); put16(P + 36, 456); // keymeta auto keyItem = [&](std::size_t off, const std::string& type) { // BKEYDATA holding CompactSize(len)+type std::string kd; kd += (char)type.size(); kd += type; put16(P + off, (unsigned)kd.size()); w[P + off + 2] = (char)1; for (std::size_t k = 0; k < kd.size(); ++k) w[P + off + 3 + k] = kd[k]; }; auto dataItem = [&](std::size_t off) { put16(P + off, 1); w[P + off + 2] = (char)1; w[P + off + 3] = 0; }; - keyItem(500, "tx"); dataItem(496); keyItem(488, "key"); dataItem(484); + keyItem(500, "tx"); dataItem(496); + keyItem(488, "key"); dataItem(484); + keyItem(472, "keymeta"); + // keymeta data = CKeyMetadata: nVersion(int32=10) + nCreateTime(int64) — little-endian. + const long long kCreated = 1700000000LL; + put16(P + 456, 12); w[P + 456 + 2] = (char)1; // BKEYDATA len=12, type=1, data@459 + put32(456 + 3 + P, 10); // nVersion + for (int k = 0; k < 8; ++k) w[P + 456 + 3 + 4 + k] = (char)((kCreated >> (8 * k)) & 0xff); writef(dir / "btree.dat", w); auto s = dragonx::util::parseWalletBtree((dir / "btree.dat").string()); EXPECT_TRUE(s.parsed); EXPECT_TRUE(s.complete); EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1); + EXPECT_EQ(s.createdEpoch, kCreated); EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed); }