feat(wallets): show created date + sort the wallet list
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) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -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<ProbeResult> probeSnap;
|
||||
if (s_probe) { std::lock_guard<std::mutex> 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<long long> 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<std::mutex> 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<std::time_t>(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<WalletRow> s_rows;
|
||||
static inline std::shared_ptr<ProbeBatch> s_probe; // current async probe batch (index-aligned w/ s_rows)
|
||||
static inline std::vector<std::size_t> 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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<const char*>(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<long long>(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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user