10 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 6d4ed96f6d fix(litebe): make HD t-address derivation atomic under scan
Deriving the next HD transparent address read the key count under a
read lock, released it, derived the address, and only then took a
write lock to append it. Between reading the position and appending,
another caller - for instance a scan deriving an address at the same
time - could read the same position, derive the same address, and
append it too, leaving a duplicate transparent key in the wallet.

- The whole read-modify-write now runs under a single write guard, so
  the position a caller derives from reflects every key already
  added. The slow seed stretch is computed once before the guard is
  taken, so holding the lock does not block on it.
- As a further guard, derivation skips a position whose address is
  already present and advances to the next, so no path can produce a
  duplicate even if positions were ever to overlap.
- The starting index is computed exactly as before (it still counts
  imported keys too), so a wallet with no contention derives the same
  addresses in the same order.

Uncontended derivation produces the same addresses in the same order,
verified against the differential harness. The wallet file format is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 b49f85bcbe perf(litebe): take a read lock for wallet serialization in do_save
Saving the wallet serializes it to a temporary file under the sync
lock, but it took a write guard on the wallet to do so. Serialization
only reads wallet state, so the write guard needlessly excluded other
readers for the whole duration of the write, buffering, and flush.

- The save path now takes a read guard to serialize. A read guard
  still excludes writers, so nothing can mutate the wallet mid-write;
  it only lets other readers proceed in parallel. The surrounding
  sync lock still serializes saves against syncs, and the atomic
  temp-file-then-rename sequence is unchanged.

The serialized bytes are identical, verified against the differential
harness. The wallet file format is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 0bdea68337 perf(litebe): memoize per-note viewing-key decode on wallet open
Opening a wallet reads each shielded note from disk, and every note
record embeds a 169-byte extended full viewing key that was decoded
from scratch for every note. That decode is an elliptic-curve parse;
a wallet with many notes sharing a handful of keys paid for it once
per note, which dominated open time.

- Note reads now go through a small content-addressed cache keyed on
  the exact 169 raw key bytes. The first note with a given key
  decodes it; the rest reuse the result. Because the cache is keyed
  on the raw bytes and the decoder is deterministic, the cached key
  is identical to a fresh parse.
- The cache is thread-local and naturally bounded by the few distinct
  keys a wallet holds, so it adds no cross-thread state and cannot
  grow without bound.

The bytes on disk are read exactly as before - this only avoids
re-parsing identical bytes - so the loaded wallet is byte-identical,
verified against the differential harness. The wallet file format is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 c045e56d4e perf(litebe-sync): bound the gRPC block channel with backpressure
Each block range is streamed over one gRPC connection into a channel
that a worker thread drains, encodes, and scans. That channel was
unbounded, so when the server delivered blocks faster than the
scanner consumed them the backlog piled up in memory with no ceiling
- on a fast link against a large range it could grow without limit.

- The channel is now a bounded synchronous channel holding at most
  2048 blocks. When it fills, the fetch side waits, applying
  backpressure up the stream instead of buffering endlessly, so peak
  memory is capped no matter how far ahead the server runs.
- The batch size is untouched: it still streams a whole range over a
  single connection, which is what keeps the connection count low
  enough to avoid socket-buffer exhaustion on Windows. Only the
  in-flight depth between fetch and scan is now bounded.

The same blocks are fetched and scanned in the same order, so the
resulting wallet is byte-identical, verified against the differential
harness. The wallet file format is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 be69c9bafe fix(litebe-sync): bound the reorg recovery window to the max depth
When sync hit a reorg it stepped the scan back to just before the
invalidated block, then set the top of the re-scan range to that
point plus the full forward batch size. That window is far larger
than any reorg can be: the batch size is tuned for forward streaming
(tens of thousands of blocks), while a reorg is capped at the maximum
reorg depth and the pass aborts once it is exceeded. So the recovery
pass re-fetched and re-scanned a large span of already-good blocks
before it could resume moving forward.

- The recovery range now reaches only the maximum reorg depth (plus
  one) above the rewind point, which is the most a reorg can ever
  disturb. Forward syncing still uses the full batch size; only the
  pass immediately after a reorg is bounded this way.
- The set of blocks that ultimately get scanned is unchanged - the
  next pass picks up from where recovery stops - so the resulting
  wallet is identical, just reached with far less redundant fetching
  and scanning.

The wallet file format is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 d9dae45c39 fix(lite): flush the wallet save on quit before _Exit
On quit the lite controller's destructor never runs, because main()
ends the process with _Exit(). Any state the background refresh had
scanned since the last save was lost: the destructor is where that
final save would normally happen, and it was being skipped on the
one path that always runs.

- shutdown() now calls a new flushOnExit() that persists once on the
  way out, bounded to 4 s so the existing 15 s shutdown watchdog
  budget still holds. On timeout it abandons the save thread and lets
  _Exit follow.
- It is a plain save through the same backend path, which writes a
  temp file and renames it into place. That cannot corrupt the wallet
  if it is cut off or abandoned, and it does not change the wallet
  file format - it only persists sooner on the quit path.
- It reuses the destructor's guards and also skips when a wallet op
  is already in progress: shutdown() has already waited for ops to go
  idle, and a still-pending op holds the sync lock and issues its own
  save, so flushOnExit would only block the budget on a save that
  cannot take the lock.

Tests cover a save on the open, synced, idle path (exactly once), a
blocked save returning within the budget, and the retired and
never-opened guards skipping the save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:51:34 -05:00
DanSandClaude Opus 4.8 e2210cf5d3 fix(lite): refresh the sidebar unconfirmed badge on confirmations
The sidebar unconfirmed-tx badge was recomputed only when the tx list
changed, keyed on last_tx_update plus the list size. That missed the
one case that matters most: a transaction confirming without the list
count changing. last_tx_update did not move for a same-second content
change, and lite never stamped it at all, so the badge stuck on a
stale 0-conf count until some unrelated refresh happened to shift the
list size.

- The badge now keys on tx_revision, which the state bumps on every
  content change - including a confirmation flip at an unchanged tx
  count - and which lite stamps too, so the count tracks confirmations
  on both the full node and the lite wallet.
- The sentinel starts at 0, which tx_revision never takes (it starts
  at 1), so the first frame always computes once.
- The recompute still runs only when the revision actually moved, so
  the per-frame work the previous guard avoided is still avoided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:51:34 -05:00
DanSandClaude Opus 4.8 25ad23aee4 fix(tx): de-duplicate transparent rows across listtransactions pages
Transparent history is gathered by skip-paging listtransactions. The
underlying list can shift between the per-page RPCs - a new
transaction arriving pushes one page's tail into the next page's head
- so a row could be collected twice and show up as a duplicate in
History.

- Pages are now de-duplicated on the daemon's stable per-output
  identity: txid, send/non-send class, and vout. Keying on the class
  rather than the exact category means a coinbase that matures
  (immature to generate) between two fetches is still recognised as
  the same row; keeping vout means two equal-value outputs of one
  transaction stay distinct. No real transaction row is dropped or
  duplicated.
- Rows with no txid or vout ("move") are never keyed and pass through
  (they are dropped downstream anyway), so the de-dup fails open.
- The raw page size still drives the stop condition and the next-run
  row hint, independent of how many rows were unique.

Tests cover a shifted page whose head repeats the prior page's last
row (dropped), a sibling output of the same tx (kept), and an
un-keyable row (kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 6a6ac8cf99 fix(tx): list transactions with account "*" so labelled rows show
listtransactions was called with the default "" account, which
returns only rows in the unnamed account. Transactions tied to a
labelled address were left out of the History tab entirely.

- All three listtransactions call sites now pass "*", which spans
  every account, so labelled-address rows appear alongside the rest.
- With "*" the daemon also emits "move" accounting rows that have no
  txid, address or vout. Those are not wallet transactions, so they
  are skipped and never become blank History rows. No real
  transaction row is dropped by this filter.

Tests cover that a "move" row is skipped while a labelled receive row
is kept, and that the transparent collectors now page with "*".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 998788d351 perf(rpc): move the result subtree out of the parse tree
parseRpcResult copied the "result" node out of the parsed reply,
because indexing the JSON object yields an lvalue reference. On large
replies - full transaction lists, unspent-note sets - that duplicated
the entire subtree for no reason, and it also meant a secret-bearing
result then existed in two trees at once.

- The result node is now moved out of the parse tree instead of
  copied, so the secret lives only in the returned value that the
  calling code already wipes. The leftover result slot is moved-from
  (null), so the existing secret-scrub of the discarded tree still
  runs harmlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
12 changed files with 221 additions and 39 deletions
+7 -5
View File
@@ -2163,10 +2163,10 @@ void App::render()
// long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is
// confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf
// legs of already-confirmed transactions and double-counts multi-leg ones.
// Recompute only when the tx list actually changed (last_tx_update is bumped on every tx refresh;
// size() catches same-second content changes). Otherwise this built two unordered_sets over the whole
// Recompute only when the tx list actually changed. tx_revision moves on EVERY content change (incl. a
// confirmation flip at equal tx count) and, unlike last_tx_update, lite stamps it. Otherwise this built two unordered_sets over the whole
// wallet tx list every frame just to size a badge.
if (state_.last_tx_update != sb_unconf_key_ts_ || state_.transactions.size() != sb_unconf_key_n_) {
if (state_.tx_revision != sb_unconf_key_rev_) {
std::unordered_set<std::string> confirmedTxids;
for (const auto& tx : state_.transactions) {
if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid);
@@ -2178,8 +2178,7 @@ void App::render()
}
}
sb_unconf_count_ = static_cast<int>(unconfirmedTxids.size());
sb_unconf_key_ts_ = state_.last_tx_update;
sb_unconf_key_n_ = state_.transactions.size();
sb_unconf_key_rev_ = state_.tx_revision;
}
sbStatus.unconfirmedTxCount = sb_unconf_count_;
@@ -7743,6 +7742,9 @@ void App::shutdown()
// Lite: let an in-flight wallet op (e.g. Encrypt's save) finish. Bounded: the window is already hidden and
// main.cpp has a 15 s watchdog. The backend writes tmp + rename, so being cut off never corrupts the wallet.
if (lite_wallet_) lite_wallet_->waitWalletOpsIdle(std::chrono::milliseconds(2500));
// Persist refresh-scanned state: ~LiteWalletController never runs on the _Exit path. Bounded (4 s) so the
// 15 s watchdog budget holds; the save is tmp + rename, so an abandoned/cut-off save never corrupts.
if (lite_wallet_) lite_wallet_->flushOnExit(std::chrono::milliseconds(4000));
// Full node: persist whatever a refresh changed since beginShutdown() (everything, on the direct-exit path),
// then drain + join the history writer. Bounded like the settings flush: on a wedged disk the in-flight write
+3 -4
View File
@@ -1006,10 +1006,9 @@ private:
mutable std::uint64_t chat_unread_rev_ = ~0ull; // ~0 forces the first compute
mutable int chat_unread_cached_ = 0;
mutable double chat_unread_computed_at_ = 0.0;
// Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on last_tx_update +
// size), not every frame. See App::render().
std::int64_t sb_unconf_key_ts_ = -1;
std::size_t sb_unconf_key_n_ = 0;
// Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on tx_revision),
// not every frame. See App::render().
std::uint64_t sb_unconf_key_rev_ = 0; // sentinel 0 never matches: tx_revision starts at 1
int sb_unconf_count_ = 0;
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS): it runs on this sampler's own
// thread, at most once per 3 s and only while a reader asks (Mining tab memory bar, Peers tab Memory cell).
+3 -3
View File
@@ -451,8 +451,8 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scru
throw RpcError(errCode, "RPC error: " + err_msg, httpCode);
}
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
if (scrubSource) scrubJsonSecrets(response); // zero the discarded tree's secret before it frees (B7)
json result = std::move(response["result"]); // move the subtree out: the secret now lives only here
if (scrubSource) scrubJsonSecrets(response); // response["result"] is now moved-from (null); harmless (B7)
return result;
}
@@ -684,7 +684,7 @@ void RPCClient::z_getNewAddress(Callback cb, ErrorCallback err)
void RPCClient::listTransactions(int count, Callback cb, ErrorCallback err)
{
doRPC("listtransactions", {"", count}, cb, err);
doRPC("listtransactions", {"*", count}, cb, err);
}
void RPCClient::z_viewTransaction(const std::string& txid, Callback cb, ErrorCallback err)
+3 -3
View File
@@ -285,9 +285,9 @@ private:
// hold curl_mutex_ and have verified impl_->curl.
std::string performCall(const std::string& method, const json& params, long& httpCodeOut);
// Centralizes the HTTP-code check and JSON error->RpcError extraction, returning
// response["result"] on success. When scrubSource is true (secret-bearing calls), the intermediate
// parse tree's string values are zeroed before it is discarded — parseRpcResult returns a COPY of
// the result node, so its own tree would otherwise free the secret un-wiped (B7).
// response["result"] on success. The result subtree is moved out of the parse tree, so a secret lives
// only in the returned node (which callSecret/callSecretString callers wipe). When scrubSource is true
// (secret-bearing calls), any remaining parse-tree string values are zeroed before it is discarded (B7).
static json parseRpcResult(long httpCode, const std::string& body, bool scrubSource = false);
// A non-200 reply: RpcError when the body carries a JSON-RPC error, else RpcTransportError classified
// from the status and plain-text body. hideBody keeps the body out of the message (secret calls).
+30 -4
View File
@@ -12,6 +12,7 @@
#include <map>
#include <set>
#include <unordered_map>
#include <unordered_set>
using json = nlohmann::json;
@@ -932,6 +933,9 @@ void NetworkRefreshService::appendTransparentTransactions(std::vector<Transactio
for (const auto& transactionJson : result) {
TransactionInfo info;
if (auto value = readOptional<std::string>(transactionJson, "txid")) info.txid = *value;
// C266: listtransactions("*") also returns "move" accounting rows (no txid/address/vout) for
// every account. They are not wallet transactions - skip so they never become blank History rows.
if (info.txid.empty()) continue;
if (auto value = readOptional<std::string>(transactionJson, "category")) info.type = *value;
if (auto value = readOptional<double>(transactionJson, "amount")) info.amount = *value;
if (auto value = readOptional<std::int64_t>(transactionJson, "time")) {
@@ -1169,11 +1173,33 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
// The first page holds last time's count plus headroom, so most wallets get every row in one
// call (re-paging 1000 rows at a time formats ~rows^2/2000 rows); skip-paging stays the fallback.
const int pageSize = transparentPageSize(snapshot.transparentRowHint);
std::unordered_set<std::string> seenPagedRows;
for (int skip = 0;; skip += pageSize) {
json page = rpc.call("listtransactions", json::array({"", pageSize, skip}));
appendTransparentTransactions(result.transactions, knownTxids, page, snapshot.miningAddresses);
json page = rpc.call("listtransactions", json::array({"*", pageSize, skip}));
const std::size_t got = page.is_array() ? page.size() : 0;
result.transparentRowCount += got;
// C267: skip-paging walks a list that can shift between RPCs (a new tx pushes the
// previous page's tail into this page's head). De-dup on the daemon's stable per-output
// identity (txid, send/non-send class, vout). Key on the send/non-send CLASS, not the
// exact category, so a coinbase maturing (immature->generate) between the two fetches is
// still recognised as the same row; keep vout so two equal-value outputs of one tx stay
// distinct. Rows without txid or vout ("move") are never keyed and pass through
// (appendTransparentTransactions drops them anyway).
json uniq = json::array();
if (page.is_array()) {
for (const auto& row : page) {
auto txid = readOptional<std::string>(row, "txid");
auto vout = readOptional<int>(row, "vout");
if (txid && !txid->empty() && vout) {
const bool isSend =
readOptional<std::string>(row, "category").value_or("") == "send";
std::string key = *txid + '|' + (isSend ? 's' : 'r') + '|' + std::to_string(*vout);
if (!seenPagedRows.insert(std::move(key)).second) continue; // true duplicate
}
uniq.push_back(row);
}
}
appendTransparentTransactions(result.transactions, knownTxids, uniq, snapshot.miningAddresses);
result.transparentRowCount += got; // RAW page size: drives the stop test + next row hint
if (got < static_cast<std::size_t>(pageSize)) break;
}
result.transparentChainOk = true;
@@ -1555,7 +1581,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
try {
std::set<std::string> recentTxids;
std::vector<TransactionInfo> recentTransactions;
json transactions = rpc.call("listtransactions", json::array({"", pageSize, 0}));
json transactions = rpc.call("listtransactions", json::array({"*", pageSize, 0}));
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
// A coinbase changes category in place (immature -> generate at maturity, or -> orphan on a reorg): drop
+27 -1
View File
@@ -11,7 +11,11 @@
#include <algorithm>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <memory>
#include <mutex>
#include <thread>
#include <unordered_map>
#include <utility>
@@ -499,6 +503,27 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
status_ = lifecycle_.status();
}
void LiteWalletController::flushOnExit(std::chrono::milliseconds budget)
{
// Same guards as the destructor, plus walletOpInProgress(): App::shutdown() already ran
// waitWalletOpsIdle(); if an op is still pending it holds sync_lock and issues its own save,
// so skip rather than block the budget on a save that cannot acquire the lock.
if (retired_.load() || !walletOpen_.load() || !syncComplete() ||
broadcastInProgress() || walletOpInProgress() || !bridge_) return;
struct Done { std::mutex m; std::condition_variable cv; bool done = false; };
auto st = std::make_shared<Done>();
auto bridge = bridge_; // shared_ptr copy; never captures `this`
try {
std::thread([bridge, st]() {
bridge->execute("save", ""); // may block on sync_lock; abandoned on timeout
{ std::lock_guard<std::mutex> l(st->m); st->done = true; }
st->cv.notify_all();
}).detach();
} catch (...) { return; } // no thread: skip (pre-fix behaviour, no crash)
std::unique_lock<std::mutex> l(st->m);
st->cv.wait_for(l, budget, [&] { return st->done; }); // timeout: abandon the thread; _Exit follows
}
LiteWalletController::~LiteWalletController()
{
scan_->cancel.store(true); // the detached sync thread stops retrying (it may be in a back-off sleep)
@@ -515,7 +540,8 @@ LiteWalletController::~LiteWalletController()
}
// The sync thread may be blocked in an uninterruptible full scan; detach it. It holds
// shared refs (bridge_ + syncDone_), so it stays safe and the bridge survives until it
// finishes — the process is exiting, so a late litelib_shutdown is harmless.
// finishes — on the quit path the process is exiting, so a late litelib_shutdown is harmless
// (a server switch already shut the bridge down in retire()).
if (syncThread_.joinable()) syncThread_.detach();
// Likewise the broadcast thread (send/shield proving): it captures shared refs (bridge +
// running flag + result slot), never `this`, so detaching is safe.
+4
View File
@@ -175,6 +175,10 @@ public:
~LiteWalletController(); // stops + joins the background refresh worker
// Best-effort persist on the quit path (the destructor's save is skipped because main() _Exit()s).
// Bounded: abandons the save thread on timeout; the process is about to _Exit. Same guards as ~dtor.
void flushOnExit(std::chrono::milliseconds budget);
LiteWalletController(const LiteWalletController&) = delete;
LiteWalletController& operator=(const LiteWalletController&) = delete;
+106 -4
View File
@@ -1684,6 +1684,20 @@ void testNetworkRefreshSnapshotHelpers()
EXPECT_EQ(transactions[0].timestamp, static_cast<int64_t>(100));
EXPECT_EQ(transactions[1].timestamp, static_cast<int64_t>(300));
{
// C266: "move" accounting rows (no txid) are skipped; labelled-address receive rows are kept.
std::vector<dragonx::TransactionInfo> moveTx;
std::set<std::string> moveKnown;
Refresh::appendTransparentTransactions(moveTx, moveKnown, json::array({
json{{"category", "move"}, {"account", "x"}, {"amount", 0.5}, {"time", 50}},
json{{"txid", "labelled-recv"}, {"category", "receive"}, {"account", "savings"},
{"amount", 2.0}, {"time", 60}, {"confirmations", 3}, {"address", "R-labelled"}}
}));
EXPECT_EQ(moveTx.size(), static_cast<size_t>(1));
EXPECT_EQ(moveTx[0].txid, std::string("labelled-recv"));
EXPECT_EQ(moveKnown.count(""), static_cast<size_t>(0));
}
Refresh::appendShieldedReceivedTransactions(transactions, knownTxids, "zs-viewonly", json::array({
json{{"txid", "transparent-a"}, {"amount", 9.0}, {"confirmations", 1}},
json{{"txid", "shielded-change"}, {"change", true}, {"amount", 2.0}},
@@ -2411,11 +2425,41 @@ void testNetworkRefreshRpcCollectors()
}));
auto pagedResult = Refresh::collectTransactionRefreshResult(pagedRpc, pagedSnapshot, 327, 0);
EXPECT_TRUE(pagedRpc.methodNames() == std::vector<std::string>({"listtransactions", "listtransactions"}));
EXPECT_EQ(pagedRpc.calls[0].params, json::array({"", 1000, 0}));
EXPECT_EQ(pagedRpc.calls[1].params, json::array({"", 1000, 1000}));
EXPECT_EQ(pagedRpc.calls[0].params, json::array({"*", 1000, 0}));
EXPECT_EQ(pagedRpc.calls[1].params, json::array({"*", 1000, 1000}));
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));
{
// C267: a tx added between page RPCs shifts the list, so the previous page's last row reappears at
// the head of the next page. Rows (txid, send/non-send, vout) already taken are dropped; a sibling
// output of the same tx (different vout) and an un-keyable row (no vout) are kept (fail-open).
Refresh::TransactionRefreshSnapshot dedupSnapshot;
MockRefreshRpc dedupRpc;
json page1 = json::array();
for (int i = 0; i < 1000; ++i) {
page1.push_back(json{{"txid", "dd-" + std::to_string(i)}, {"vout", 0}, {"category", "receive"},
{"amount", 0.01}, {"time", i}, {"confirmations", 10},
{"address", "R-dd"}});
}
dedupRpc.addResponse("listtransactions", page1);
dedupRpc.addResponse("listtransactions", json::array({
json{{"txid", "dd-999"}, {"vout", 0}, {"category", "receive"}, {"amount", 0.01},
{"time", 999}, {"confirmations", 10}, {"address", "R-dd"}}, // repeat: dropped
json{{"txid", "dd-999"}, {"vout", 1}, {"category", "receive"}, {"amount", 0.01},
{"time", 999}, {"confirmations", 10}, {"address", "R-dd"}}, // sibling vout: kept
json{{"txid", "dd-novout"}, {"category", "receive"}, {"amount", 0.02},
{"time", 1001}, {"confirmations", 9}, {"address", "R-dd"}} // no vout: kept
}));
auto dedupResult = Refresh::collectTransactionRefreshResult(dedupRpc, dedupSnapshot, 328, 0);
EXPECT_EQ(dedupRpc.calls.size(), static_cast<size_t>(2));
EXPECT_EQ(dedupResult.transactions.size(), static_cast<size_t>(1002));
EXPECT_EQ(dedupResult.transparentRowCount, static_cast<size_t>(1003)); // RAW count, not deduped
size_t dd999 = 0;
for (const auto& t : dedupResult.transactions) if (t.txid == "dd-999") ++dd999;
EXPECT_EQ(dd999, static_cast<size_t>(2)); // vout 0 once + vout 1
}
// The pass floor: an address scanned below it is scanned again; without a floor (no pass running)
// only never-scanned addresses are. A pass's floor is its start height, so it completes however far
// the tip moves meanwhile (this is what unsticks the perpetual "refreshing history" state).
@@ -2469,7 +2513,7 @@ void testNetworkRefreshRpcCollectors()
}));
auto recent = Refresh::collectRecentTransactionRefreshResult(recentRpc, recentSnapshot, 123);
EXPECT_TRUE(recentRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(recentRpc.calls[0].params, json::array({"", 100, 0}));
EXPECT_EQ(recentRpc.calls[0].params, json::array({"*", 100, 0}));
EXPECT_EQ(recent.blockHeight, 123);
EXPECT_EQ(recent.transactions.size(), static_cast<size_t>(3));
EXPECT_EQ(recent.transactions[0].txid, std::string("recent-two"));
@@ -10383,6 +10427,63 @@ void testLiteWalletControllerM5Persistence()
}
}
// C30: flushOnExit() persists once on the quit path, is bounded when the save blocks, and obeys the guards.
void testLiteWalletControllerFlushOnExit()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
auto openSynced = [&]() {
dragonx::test::g_liteFakeSyncBlock = false;
auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
(void)c->createWallet(req);
for (int i = 0; i < 400 && !c->syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
return c;
};
// (a) open + synced + idle -> exactly one save
{
auto c = openSynced();
EXPECT_TRUE(c->syncComplete());
const long before = dragonx::test::g_liteFakeSaveCount.load();
c->flushOnExit(std::chrono::milliseconds(500));
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
}
// (b) a blocked save never hangs the caller past the budget
{
auto c = openSynced();
dragonx::test::g_liteFakeBlockSave = true;
const auto t0 = std::chrono::steady_clock::now();
c->flushOnExit(std::chrono::milliseconds(200));
const auto dt = std::chrono::steady_clock::now() - t0;
EXPECT_TRUE(dt >= std::chrono::milliseconds(150));
EXPECT_TRUE(dt < std::chrono::milliseconds(1500));
dragonx::test::g_liteFakeBlockSave = false; // let the abandoned save thread finish
std::this_thread::sleep_for(std::chrono::milliseconds(150));
}
// (c) retired -> no save
{
auto c = openSynced();
c->retire("switch");
const long before = dragonx::test::g_liteFakeSaveCount.load();
c->flushOnExit(std::chrono::milliseconds(300));
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
}
// (d) wallet never opened -> no save
{
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
const long before = dragonx::test::g_liteFakeSaveCount.load();
c.flushOnExit(std::chrono::milliseconds(300));
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
}
}
// Encryption: the controller drives encrypt/unlock/lock/decrypt + encryptionstatus through the
// (injected fake) backend, wipes passphrases, and folds status into WalletState (encrypted/locked).
void testLiteWalletControllerEncryption()
@@ -16194,7 +16295,7 @@ void testHistoryCollectorSweepAndEnrichment()
MockRefreshRpc rpc;
rpc.addResponse("listtransactions", json::array());
auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4);
EXPECT_EQ(rpc.calls[0].params, json::array({"", 1700, 0}));
EXPECT_EQ(rpc.calls[0].params, json::array({"*", 1700, 0}));
EXPECT_TRUE(result.transparentChainOk);
}
{ // Same tip, nothing changed: the transparent rows are reused, no listtransactions at all; a tracked
@@ -18868,6 +18969,7 @@ int main()
testLiteRolloutPolicyStagedRollout();
testLiteRolloutManifestLoader();
testLiteWalletControllerRolloutGate();
testLiteWalletControllerFlushOnExit();
testXmrigReleaseParsing();
testXmrigAssetSelection();
testXmrigChecksumParsing();
@@ -11,7 +11,7 @@ use tokio_rustls::{rustls::ClientConfig};
use tonic::{Request};
use threadpool::ThreadPool;
use std::sync::mpsc::channel;
use std::sync::mpsc::{channel, sync_channel};
use crate::PubCertificate;
use crate::grpc_client::compact_tx_streamer_client::CompactTxStreamerClient;
@@ -113,7 +113,7 @@ where F : Fn(&[u8], u64) {
let request = Request::new(BlockRange { start: Some(bs), end: Some(be) });
let (tx, rx) = channel::<Option<CompactBlock>>();
let (tx, rx) = sync_channel::<Option<CompactBlock>>(2048);
let (ftx, frx) = channel();
pool.execute(move || {
@@ -908,7 +908,7 @@ impl LightClient {
// Write to a temporary file first (atomic save). C254/s1-D9: a fresh owner-only file (0600 on unix); a stale
// .dat.tmp is unlinked first, never reused. The rename carries the mode to the wallet.
let wallet = self.wallet.write().unwrap();
let wallet = self.wallet.read().unwrap();
let mut file_buffer = BufWriter::with_capacity(
1_000_000, // 1 MB write buffer
create_private_file_new(&tmp_path).map_err(|e| format!("Failed to create temp file: {}", e))?);
@@ -1660,8 +1660,9 @@ pub fn start_mempool_monitor(lc: Arc<LightClient>) -> Result<(), String> {
// get_block_range streams an ENTIRE range over ONE gRPC connection, so the batch size is
// the dominant factor in how many TCP connections a sync opens. The old 1000 meant ~1300
// connections for a full sync, which on Windows exhausted socket buffers (WSAENOBUFS / os
// error 10055). A large batch (25k) cuts that ~50x (~52 connections for a full sync) with
// no extra memory — blocks are still streamed + scanned incrementally, not buffered.
// error 10055). A large batch (25k) cuts that ~50x (~52 connections for a full sync). Blocks
// are now buffered up to 2048 deep (bounded backpressure, C240), still one connection per
// 25k batch; scan_batch_size must NOT shrink or Windows WSAENOBUFS returns.
let scan_batch_size = 25000;
let mut end_height = std::cmp::min(last_scanned_height + scan_batch_size, latest_block);
@@ -1805,7 +1806,7 @@ pub fn start_mempool_monitor(lc: Arc<LightClient>) -> Result<(), String> {
self.taddr_note_unscanned(start_height, &block_times);
// Reset the scanning heights
last_scanned_height = (invalid_height - 1) as u64;
end_height = std::cmp::min(last_scanned_height + scan_batch_size, latest_block);
end_height = std::cmp::min(last_scanned_height + crate::lightwallet::MAX_REORG as u64 + 1, latest_block);
warn!("Reorg: reset scanning from {} to {}", last_scanned_height, end_height);
@@ -659,15 +659,17 @@ impl LightWallet {
return "Error: Can't add key while wallet is encrypted".to_string();
}
let pos = self.tkeys.read().unwrap().len() as u32;
// PBKDF2 (the slow part) outside the lock; hold ONE write guard across read-modify-write.
let bip39_seed = bip39::Seed::new(&Mnemonic::from_entropy(&self.seed, Language::English).unwrap(), "");
let sk = LightWallet::get_taddr_from_bip39seed(&self.config, &bip39_seed.as_bytes(), pos);
let address = self.address_from_sk(&sk);
self.tkeys.write().unwrap().push(WalletTKey::new_hdkey(sk, address.clone()));
address
let mut tkeys = self.tkeys.write().unwrap();
let mut pos = tkeys.len() as u32; // same start index as today (counts imported keys too - unchanged)
loop {
let sk = LightWallet::get_taddr_from_bip39seed(&self.config, &bip39_seed.as_bytes(), pos);
let address = self.address_from_sk(&sk);
if tkeys.iter().any(|t| t.address == address) { pos += 1; continue; }
tkeys.push(WalletTKey::new_hdkey(sk, address.clone()));
return address;
}
}
pub fn import_taddr(&mut self, sk: String, birthday: u64) -> String {
@@ -1,5 +1,7 @@
use std::io::{self, Read, Write};
use std::sync::Arc;
use std::cell::RefCell;
use std::collections::HashMap;
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use pairing::bls12_381::{Bls12};
@@ -26,6 +28,24 @@ use zcash_primitives::{
use zcash_primitives::zip32::ExtendedSpendingKey;
// C241: wallet open re-parsed the 169-byte ExtendedFullViewingKey once PER NOTE. Memoize the decode,
// keyed by the exact 169 raw bytes (content-addressed, so the result is always identical to a fresh
// parse). Thread-local and naturally bounded by the handful of distinct keys a wallet holds.
thread_local! {
static EFVK_CACHE: RefCell<HashMap<[u8; 169], ExtendedFullViewingKey>> = RefCell::new(HashMap::new());
}
fn read_efvk_cached<R: Read>(r: &mut R) -> io::Result<ExtendedFullViewingKey> {
let mut raw = [0u8; 169];
r.read_exact(&mut raw)?;
if let Some(k) = EFVK_CACHE.with(|c| c.borrow().get(&raw).cloned()) {
return Ok(k);
}
let k = ExtendedFullViewingKey::read(&raw[..])?; // identical parser on identical bytes
EFVK_CACHE.with(|c| c.borrow_mut().insert(raw, k.clone()));
Ok(k)
}
pub struct BlockData {
pub height: i32,
pub hash: BlockHash,
@@ -147,7 +167,7 @@ impl SaplingNoteData {
let account = reader.read_u64::<LittleEndian>()? as usize;
let extfvk = ExtendedFullViewingKey::read(&mut reader)?;
let extfvk = read_efvk_cached(&mut reader)?;
let mut diversifier_bytes = [0u8; 11];
reader.read_exact(&mut diversifier_bytes)?;