6 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 e40c6c0a7c perf(market): clip off-screen rows in the portfolio editor
The portfolio-editor address list drew every row every frame inside the
fade-scroll child. It now runs through an ImGuiListClipper; the fixed
step (the InvisibleButton height plus item spacing) keeps each row's
cursor exactly where the unclipped loop put it, so the scroll extent for
the fade shader, the check marks, the selection and the "selected rows
float to the top" ordering are all unchanged.

Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, including skin switch and resize with the dialog open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 16:55:07 -05:00
DanSandClaude Opus 4.8 4d95ef68f6 perf(contacts): clip off-screen rows in the Cards and List views
The contacts Cards/List row loop submitted every contact every frame.
It now runs through an ImGuiListClipper, so only the on-screen rows are
built; the fixed step (row height plus the item spacing pushed above)
matches the per-row cursor exactly, so the scroll extent, fade, hover
actions, selection and keyboard navigation are unchanged. Table view is
left unclipped (its rows can wrap).

Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, in both Cards and List views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 16:55:07 -05:00
DanSandClaude Opus 4.8 3f85953452 perf(ui): stop backdrop effects requesting frames behind a modal
While a blur overlay (a modal dialog or the lock screen) covers the
window, the theme backdrop effects kept requesting animation frames
even though they are not visible, so the app never went idle and
re-rendered continuously.

The ambient animation predicate now drops the backdrop-effects term
when a blur overlay was active last frame. Reduce Motion and the
unfocused/hidden throttle were already handled, and the overlay's own
moving elements (the lock screen's unlock dots, a spinner) still request
frames through the progress term, so nothing on screen freezes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:36:43 -05:00
DanSandClaude Opus 4.8 5a3d36904c test(litebe): make the retire test deterministic under the change-gate
The change-gated refresh worker only runs `balance` on a full-refresh
cycle, so testLiteWalletControllerRetire could no longer reliably park
the worker inside a blocked `balance` (it passed only by timing). Bump
the fake scanned height to force a full-refresh cycle, which enters the
now-blocked `balance` deterministically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:36:43 -05:00
DanSandClaude Opus 4.8 ef14518089 perf(litebe): change-gate the idle lite refresh worker
The background 2 s refresh worker re-queried and re-copied the whole
wallet every cycle (a fresh TLS refresh plus a multi-megabyte JSON
build, copies and DOM parse), even when nothing had changed, so CPU and
the network never went idle.

The worker now skips the full refresh and the re-publish on a cycle that
is provably unchanged, while never letting the UI show stale confirmed
balance or transactions:

- The lite bridge keeps a monotonic mutation epoch, bumped after any
  command not on a read-only allow-list, so a local change made on any
  thread (send, shield, import, new address, encrypt/unlock/lock,
  console) is detected at one choke point.
- Each cycle the worker runs one cheap sync probe (one connection, the
  mandatory block ingest) and a local height read, and refreshes only
  when the scanned height advanced, the mutation epoch moved, the
  encryption state changed, a sync is in progress, or a ~10 s heartbeat
  elapsed; otherwise it skips. Direct/synchronous callers are not gated.
- A failed probe backs off instead of running a second sync, so a dead
  server is not retried back-to-back.

Confirmed funds can never be stale beyond one cycle (every confirmed
change advances the height or bumps the epoch); only unconfirmed mempool
rows can lag up to the heartbeat, and the spend path re-queries the
backend directly rather than reading the gated model. Average idle cost
drops from three data connections per cycle to about one.

Tests: a bridge mutation-epoch test and a worker change-gate test
(skip-when-unchanged; refresh on height advance, local mutation and
encryption change) against the fake backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:00:26 -05:00
DanSandClaude Opus 4.8 39292b1b1f feat(litebe): salted Argon2id KDF for new encrypted wallets
New Lite wallets now encrypt their seed and keys with a salted Argon2id
key instead of the unsalted double-SHA256 of the passphrase, which was
cheap to brute-force from a stolen wallet file.

- A shared derive_wallet_key() replaces double-SHA256 across encrypt,
  unlock, remove-encryption and seed recovery. KDF 0 is the old
  double-SHA256 (unchanged); KDF 1 is Argon2id (moderate: 3 passes,
  256 MiB), using sodiumoxide's existing pwhash -- no new dependency.
- The KDF id, salt and parameters are stored in the wallet. A wallet is
  written at version 10 only while it is actually Argon2id-encrypted;
  every pre-existing wallet (plaintext or double-SHA256) is read and
  written byte-for-byte as version 9.
- New-wallets-only: a freshly created or restored wallet encrypts with
  Argon2id; a wallet read from disk never changes its KDF, and removing
  encryption never changes it. Set DRAGONX_LITE_KDF=legacy to keep new
  wallets on double-SHA256.
- An older build (or SilentDragonXLite) opening a version-10 wallet
  fails cleanly with "don't know how to read wallet version 10" before
  parsing anything, never a silent misread; the seed phrase stays the
  cross-wallet recovery path.

Compatibility break (by design, opt-in): a wallet newly encrypted with
Argon2id cannot be opened by older ObsidianDragon or SilentDragonXLite.

Tests: the existing encryption tests now exercise the Argon2id path,
plus the version-10 header, legacy byte-identity and derive_wallet_key
parameter bounds. Validated by the lite oracle suite and the old-vs-new
serialization differential (legacy wallets byte-identical; version-10
round-trips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 10:37:34 -05:00
12 changed files with 386 additions and 32 deletions
+5 -1
View File
@@ -2359,8 +2359,12 @@ int main(int argc, char* argv[])
const bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
&& !opaqueBackground;
const bool reduceMotion = app.settings() && app.settings()->getReduceMotion();
// C128: suppress backdrop theme-effect frame requests while a blur overlay (a modal dialog or
// the lock screen) covers them -- they aren't visible, and the overlay's own moving elements
// request frames via RequestProgressFrame(), so this never freezes anything on screen.
const bool effectsAnimating = !reduceMotion
&& dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation();
&& dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
&& !dragonx::ui::material::AnyBlurOverlayActiveLastFrame();
const bool ambient = effectsAnimating
|| backdropNeedsFrames
|| avatarAnimating
+10 -2
View File
@@ -1310,8 +1310,15 @@ void RenderContactsTab(App* app)
const float lblSz = lblF->LegacySize * sc; // scaled label + address text
const float adrSz = adrF->LegacySize * sc;
bool rowDeleteRequested = false; // per-row delete is deferred until after the loop
for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
size_t i = visibleRows[vi];
// C183(c): clip the Cards/List rows. Fixed step = the Selectable height (rowH) plus the
// ItemSpacing.y pushed above (8*dp card mode, 0 list mode); each row restores the cursor to
// `afterRow`, so the step is exact and the scroll extent is unchanged. (Table mode is NOT
// clipped -- its rows can wrap.)
ImGuiListClipper rowClipper;
rowClipper.Begin(static_cast<int>(visibleRows.size()), rowH + (asCard ? 8.0f * dp : 0.0f));
while (rowClipper.Step()) {
for (int vi = rowClipper.DisplayStart; vi < rowClipper.DisplayEnd; ++vi) {
size_t i = visibleRows[static_cast<size_t>(vi)];
const auto& entry = book.entries()[i];
ImGui::PushID(static_cast<int>(i));
const bool selected = (s_selected_index == static_cast<int>(i));
@@ -1424,6 +1431,7 @@ void RenderContactsTab(App* app)
ImGui::SetCursorScreenPos(afterRow); // undo the action buttons' cursor moves
ImGui::PopID();
}
} // while (rowClipper.Step())
// Run the deferred per-row delete now that the loop over book.entries() has finished
// (doDelete's two-stage confirm arms on the first click and removes on the second).
if (rowDeleteRequested) doDelete();
+9 -2
View File
@@ -694,8 +694,14 @@ static void pfDrawAddressSection(App* app)
float lw = ImGui::GetContentRegionAvail().x;
if (filtered.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
for (const PfPick& pk : filtered) {
const AddressInfo& a = *pk.a;
// C181: clip the portfolio-editor rows. Fixed step = InvisibleButton height (rowH) + ItemSpacing.y,
// so each row's cursor (and the Layout density tokens that read the content region) is exactly where
// the unclipped loop put it; the scroll extent stays exact for the fade shader.
ImGuiListClipper rowClipper;
rowClipper.Begin(static_cast<int>(filtered.size()), rowH + ImGui::GetStyle().ItemSpacing.y);
while (rowClipper.Step()) {
for (int ri = rowClipper.DisplayStart; ri < rowClipper.DisplayEnd; ++ri) {
const AddressInfo& a = *filtered[static_cast<size_t>(ri)].a;
bool inSet = selSet.count(a.address) != 0;
ImVec2 rmn = ImGui::GetCursorScreenPos();
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
@@ -750,6 +756,7 @@ static void pfDrawAddressSection(App* app)
}
ImGui::PopID();
}
} // while (rowClipper.Step())
material::EndFadeScrollChild(s_pfEdit.addrFade, ldl);
}
+23 -2
View File
@@ -94,7 +94,8 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
: api_(other.api_),
unavailableReason_(std::move(other.unavailableReason_)),
shutdownCalled_(other.shutdownCalled_)
shutdownCalled_(other.shutdownCalled_),
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
{
other.api_ = {};
other.shutdownCalled_ = true;
@@ -107,6 +108,8 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
api_ = other.api_;
unavailableReason_ = std::move(other.unavailableReason_);
shutdownCalled_ = other.shutdownCalled_;
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
std::memory_order_release);
other.api_ = {};
other.shutdownCalled_ = true;
return *this;
@@ -158,11 +161,29 @@ LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, cons
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
}
bool LiteClientBridge::commandIsReadOnly(const std::string& c)
{
// Allow-list of commands that never alter wallet-visible data. sync/rescan are included: their
// effect (new blocks) is observed through the worker's height signal, and the worker's own sync
// probe must not self-dirty. UNKNOWN or omitted commands default to "mutating" (bump), so a
// missing entry only ever costs one extra refresh -- never staleness.
static const char* const kReadOnly[] = {
"balance", "list", "notes", "addresses", "info", "height", "syncstatus",
"encryptionstatus", "seed", "export", "save", "sync", "rescan", "help",
"coinsupply", "saplingtree"};
for (const char* r : kReadOnly)
if (c == r) return true;
return false;
}
LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, const std::string& args)
{
if (!available()) return unavailableResult();
if (command.empty()) return {false, {}, "lite command is empty"};
return takeOwnedString(api_.execute(command.c_str(), args.c_str()));
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
return result;
}
void LiteClientBridge::shutdown()
+11
View File
@@ -5,6 +5,8 @@
#pragma once
#include <string>
#include <atomic>
#include <cstdint>
namespace dragonx {
namespace wallet {
@@ -69,15 +71,24 @@ public:
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
void shutdown();
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
// local mutations made on ANY thread without hooking each controller method. Compare-only (never
// cleared): the worker stores a baseline and refreshes whenever it differs, so a mutation can
// never be "consumed"/lost in a race.
std::uint64_t mutationEpoch() const { return mutationEpoch_.load(std::memory_order_acquire); }
private:
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
LiteBridgeStringResult unavailableResult() const;
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
static bool commandIsReadOnly(const std::string& command);
LiteClientBridgeApi api_;
std::string unavailableReason_;
bool shutdownCalled_ = false;
std::atomic<std::uint64_t> mutationEpoch_{0};
};
} // namespace wallet
+90 -10
View File
@@ -1587,25 +1587,105 @@ void LiteWalletController::stopWorker()
void LiteWalletController::workerLoop()
{
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
while (running_.load()) {
if (walletOpen_.load()) {
bool syncReadFailed = false;
auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed);
if (model && running_.load()) {
std::lock_guard<std::mutex> lock(modelMutex_);
pendingModel_ = std::move(model);
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
if (decision == GateDecision::Skip) {
// Nothing changed since the last full model: no gateway refresh, no re-publish.
// pendingModel_ is left untouched (the UI keeps the last applied model).
} else if (decision == GateDecision::ProbeFailed) {
// The cheap `sync` probe failed (server unreachable / behind / terminal reorg). Do NOT
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
// applies and we don't double the dead-server stall. Next cycle takes the full path.
failStreak += 1;
} else { // DoFull
// Sample the change baselines BEFORE the data commands (race rule).
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
const auto height0 = readScannedHeight();
bool syncReadFailed = false;
auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed);
if (model && running_.load()) {
const auto& m = *model;
const bool full = m.hasBalance && m.hasSyncStatus && m.sync.complete
&& m.syncError.empty() && !syncReadFailed && height0.has_value();
gate.haveFull = full;
gate.height = height0.value_or(0);
gate.epoch = epoch0;
gate.encOk = m.hasEncryptionStatus;
gate.encrypted = m.encrypted;
gate.locked = m.locked;
gate.fullAt = std::chrono::steady_clock::now();
std::lock_guard<std::mutex> lock(modelMutex_);
pendingModel_ = std::move(model);
} else {
gate.haveFull = false; // syncing / failed / cancelled: next cycle is a full path
}
failStreak = syncReadFailed ? failStreak + 1 : 0;
}
failStreak = syncReadFailed ? failStreak + 1 : 0;
}
// Healthy: every 2 s. After a failed syncing read: 4 s, 8 s, then 15 s, so a dead server isn't retried
// back-to-back (each attempt is one do_sync with up to 62 s of backend retry back-off).
// Healthy: every 2 s. After a failed read (or a failed probe): 4 s, 8 s, then 15 s, so a dead
// server isn't retried back-to-back (each attempt is one do_sync with up to 62 s of back-off).
std::unique_lock<std::mutex> lock(wakeMutex_);
wakeCv_.wait_for(lock, std::chrono::milliseconds(liteRefreshDelayMs(failStreak, kRefreshIntervalMs)),
wakeCv_.wait_for(lock,
std::chrono::milliseconds(liteRefreshDelayMs(failStreak, refreshIntervalMs_.load())),
[this] { return !running_.load(); });
}
}
// Worker-thread only. Decide whether this cycle can skip the full refresh. A cycle is a Skip ONLY when
// the wallet is synced, healthy, and every cheap signal proves nothing changed since the last published
// full model. ANY failed/unparsable read, or any doubt, returns DoFull (fail toward freshness). Runs
// exactly one server-bound `sync` probe (the mandatory confirmed-block ingest); a probe failure returns
// ProbeFailed so the caller backs off without a second sync.
LiteWalletController::GateDecision
LiteWalletController::evaluateRefreshGate(RefreshGate& g, int failStreak)
{
if (!g.haveFull || failStreak != 0 || !bridge_ || !running_.load())
return GateDecision::DoFull;
if (!syncDone_->load() || scan_->failed.load()) {
g.haveFull = false;
return GateDecision::DoFull;
}
if (std::chrono::steady_clock::now() - g.fullAt
>= std::chrono::milliseconds(gateHeartbeatMs_.load()))
return GateDecision::DoFull; // heartbeat: bounded-staleness backstop
if (bridge_->mutationEpoch() != g.epoch) // local mutation since the baseline
return GateDecision::DoFull;
// The mandatory one-connection confirmed-block ingest. Without it the wallet never advances.
const auto probe = bridge_->execute("sync", "");
if (!running_.load()) return GateDecision::DoFull;
if (!probe.ok || !parseLiteSyncOpResponse(probe.value).ok) {
g.haveFull = false;
return GateDecision::ProbeFailed;
}
const auto h = readScannedHeight();
if (!h.has_value() || *h == 0 || *h != g.height)
return GateDecision::DoFull;
const auto enc = encryptionStatus();
if (!enc.ok || !g.encOk || enc.encrypted != g.encrypted || enc.locked != g.locked)
return GateDecision::DoFull;
if (bridge_->mutationEpoch() != g.epoch) return GateDecision::DoFull;
if (!syncDone_->load() || !running_.load()) { g.haveFull = false; return GateDecision::DoFull; }
return GateDecision::Skip; // provably unchanged
}
std::optional<std::uint64_t> LiteWalletController::readScannedHeight()
{
if (!bridge_) return std::nullopt;
const auto h = bridge_->execute("height", ""); // local read in the backend (no TLS connection)
if (!h.ok) return std::nullopt;
const auto parsed = parseLiteHeightResponse(h.value);
if (!parsed.ok || parsed.height.height == 0) return std::nullopt;
return static_cast<std::uint64_t>(parsed.height.height);
}
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
{
auto result = lifecycle_.createWallet(request);
+19 -1
View File
@@ -259,6 +259,8 @@ public:
std::string scanError() const { return scan_->errorCopy(); }
// Tests only: first sync-retry delay in ms (default 5000); call before createWallet()/startSync().
void setSyncRetryBaseMsForTest(int ms) { scan_->retryBaseMs.store(ms); }
void setRefreshIntervalMsForTest(int ms) { refreshIntervalMs_.store(ms); }
void setGateHeartbeatMsForTest(int ms) { gateHeartbeatMs_.store(ms); }
// Launch the backend sync on a detached background thread (NON-blocking; the backend's
// `sync` command runs a full, uninterruptible chain scan). Auto-invoked when a lifecycle
@@ -513,7 +515,23 @@ private:
std::condition_variable wakeCv_;
std::mutex modelMutex_;
std::optional<LiteWalletAppRefreshModel> pendingModel_; // guarded by modelMutex_
static constexpr int kRefreshIntervalMs = 2000;
std::atomic<int> refreshIntervalMs_{2000};
// C23: max time an UNCHANGED synced wallet may go without a full refresh+publish. Bounds the
// staleness of same-height changes the cheap signals can't see (incoming mempool txs; pending
// t-address scans) and backstops any external state reset. Atomic for test override.
std::atomic<int> gateHeartbeatMs_{10000};
// Worker-thread-only snapshot of the last published FULL synced model (only workerLoop() touches it).
struct RefreshGate {
bool haveFull = false;
std::uint64_t height = 0;
std::uint64_t epoch = 0;
bool encOk = false, encrypted = false, locked = false;
std::chrono::steady_clock::time_point fullAt{};
};
enum class GateDecision { Skip, DoFull, ProbeFailed };
GateDecision evaluateRefreshGate(RefreshGate& gate, int failStreak);
std::optional<std::uint64_t> readScannedHeight();
};
} // namespace wallet
+4 -1
View File
@@ -43,6 +43,7 @@ inline std::atomic<long> g_liteFakeSyncCalls{0};
inline std::atomic<bool> g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
inline std::atomic<unsigned long long> g_liteFakeHeight{1000}; // settable last-scanned height (C23 gate test)
inline std::atomic<bool> g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...)
inline std::atomic<bool> g_liteFakeLocked{false}; // spending-keys-locked state
inline std::atomic<bool> g_liteFakeBlockNew{false}; // "new" blocks (models do_save waiting on sync_lock)
@@ -87,6 +88,7 @@ inline void resetLiteFakeCounters()
{
g_liteFakeAlloc = 0;
g_liteFakeFreed = 0;
g_liteFakeHeight = 1000;
g_liteFakeShutdownCalled = false;
g_liteFakeDeadServerSubstr.clear();
g_liteFakeWarmupServerSubstr.clear();
@@ -215,7 +217,8 @@ inline char* liteFakeExecute(const char* command, const char* args)
if (std::strcmp(c, "list") == 0)
return liteFakeDup("[{\"txid\":\"faketx\",\"datetime\":1700000000,\"block_height\":990,"
"\"unconfirmed\":false,\"address\":\"zs1fakeaddr\",\"amount\":150000000,\"memo\":\"\"}]");
if (std::strcmp(c, "height") == 0) return liteFakeDup("{\"height\":1000}");
if (std::strcmp(c, "height") == 0)
return liteFakeDup(("{\"height\":" + std::to_string(g_liteFakeHeight.load()) + "}").c_str());
if (std::strcmp(c, "info") == 0)
return liteFakeDup("{\"chain_name\":\"main\",\"version\":\"sdxl-fake\",\"latest_block_height\":1000}");
// M4 spend/backup commands. send/shield report failure via {"error":..} (NOT an
+97
View File
@@ -11762,6 +11762,98 @@ void testLiteWalletControllerWorkerProducesModel()
EXPECT_FALSE(idle.takeRefreshedModel(none));
}
// C23: the background worker change-gates the refresh -- an unchanged, synced cycle skips the full
// gateway refresh + re-publish, but any real change (height advance, local mutation, encryption flip)
// forces a full refresh within a cycle. Confirmed-funds freshness is the overriding rule.
void testLiteWalletControllerWorkerChangeGate()
{
using namespace dragonx::wallet;
namespace t = dragonx::test;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
t::resetLiteFakeCounters();
t::g_liteFakeLogEnabled = true;
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(t::makeFakeLiteApi()));
controller.setGateHeartbeatMsForTest(600000); // disable the heartbeat so only real signals refresh
controller.setRefreshIntervalMsForTest(20); // fast cycles
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
// Arm: wait for the first full (balance-bearing) model.
auto waitFull = [&](int maxMs) {
LiteWalletAppRefreshModel m; bool got = false;
for (int i = 0; i < maxMs / 5 && !got; ++i) {
if (controller.takeRefreshedModel(m) && m.hasBalance) got = true;
else std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
return got;
};
EXPECT_TRUE(waitFull(3000));
// (1) Skip when nothing changed: settle, then over a window no data commands run and nothing republishes.
std::this_thread::sleep_for(std::chrono::milliseconds(120)); // settle into the Skip steady state
(void)t::liteFakeTakeLog();
{ LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} }
std::this_thread::sleep_for(std::chrono::milliseconds(200));
auto idleLog = t::liteFakeTakeLog();
EXPECT_EQ(t::liteFakeCount(idleLog, "balance"), 0L);
EXPECT_EQ(t::liteFakeCount(idleLog, "list"), 0L);
EXPECT_EQ(t::liteFakeCount(idleLog, "notes"), 0L);
EXPECT_TRUE(t::liteFakeCount(idleLog, "sync") >= 1L); // the one-connection probe still runs each idle cycle
LiteWalletAppRefreshModel noPub;
EXPECT_FALSE(controller.takeRefreshedModel(noPub)); // no re-publish on idle cycles
// (2) Height advance -> full refresh + publish.
(void)t::liteFakeTakeLog();
t::g_liteFakeHeight = 1001;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
auto advLog = t::liteFakeTakeLog();
EXPECT_TRUE(t::liteFakeCount(advLog, "balance") >= 1L);
EXPECT_TRUE(waitFull(2000));
// (3) Local mutation (new address) bumps the bridge epoch -> full refresh, even at the same height.
std::this_thread::sleep_for(std::chrono::milliseconds(120));
(void)t::liteFakeTakeLog();
{ LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} }
controller.newAddress(/*shielded*/ true);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
EXPECT_TRUE(t::liteFakeCount(t::liteFakeTakeLog(), "balance") >= 1L);
// (4) Encryption state change with no bridge mutation (signal 7, epoch unchanged) -> full refresh.
// The fake reports `locked` only while `encrypted`, so flip `encrypted` directly (not via a bridge
// command, so the mutation epoch stays put and ONLY the enc-status compare can trigger the refresh).
std::this_thread::sleep_for(std::chrono::milliseconds(120));
(void)t::liteFakeTakeLog();
{ LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} }
t::g_liteFakeEncrypted = true;
t::g_liteFakeLocked = true;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
EXPECT_TRUE(t::liteFakeCount(t::liteFakeTakeLog(), "balance") >= 1L);
t::g_liteFakeEncrypted = false;
t::g_liteFakeLocked = false;
t::g_liteFakeLogEnabled = false;
}
// C23: the lite bridge bumps its mutation epoch for wallet-mutating commands and leaves it unchanged
// for read-only ones, so the refresh worker can detect local mutations from any thread.
void testLiteClientBridgeMutationEpoch()
{
using namespace dragonx::wallet;
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
const auto e0 = bridge.mutationEpoch();
for (const char* ro : {"balance", "height", "sync", "syncstatus", "encryptionstatus",
"save", "addresses", "list", "notes"})
bridge.execute(ro, "");
EXPECT_TRUE(bridge.mutationEpoch() == e0); // no read-only command bumps
for (const char* mut : {"send", "shield", "new", "import", "timport",
"encrypt", "unlock", "lock", "decrypt", "frobnicate"}) {
const auto before = bridge.mutationEpoch();
bridge.execute(mut, "");
EXPECT_TRUE(bridge.mutationEpoch() == before + 1); // each mutating (or unknown) command bumps exactly once
}
}
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
@@ -12047,6 +12139,9 @@ void testLiteWalletControllerRetire()
EXPECT_TRUE(c->importKeyAsync("secret-extended-key-x", [&](const LiteImportResult& r) { ++n2; ok2 = r.ok; }));
const long entered = dragonx::test::g_liteFakeBalanceEntered.load();
dragonx::test::g_liteFakeBlockBalance = true;
// The refresh worker change-gates (C23): it only runs `balance` on a full-refresh cycle. Bump the
// scanned height so the gate forces a DoFull, which enters the now-blocked `balance` and parks there.
dragonx::test::g_liteFakeHeight = dragonx::test::g_liteFakeHeight.load() + 1;
for (int i = 0; i < 1000 && dragonx::test::g_liteFakeBalanceEntered.load() == entered; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(dragonx::test::g_liteFakeBalanceEntered.load() > entered);
@@ -18944,6 +19039,8 @@ int main()
testLiteWalletControllerRefreshPopulatesState();
testLiteWalletGatewayRefreshSkipsFailedCommand();
testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerWorkerChangeGate();
testLiteClientBridgeMutationEpoch();
testLiteWalletControllerShutdownDoesNotHangDuringSync();
testSerialJobLane();
testLiteGatewayRefreshPolicyHelpers();
@@ -112,14 +112,14 @@ fn create_private_file_new(p: &Path) -> io::Result<File> {
/// X3: the encryption flag in a wallet file's header (u64 LE version, then the flag byte that LightWallet::read reads
/// for every version >= 4). None when the header cannot tell: a short read or an I/O error, v5 (gzip: its byte 8 is
/// not the flag), v<=3 (no flag) or v>9 (a format this build does not know).
/// not the flag), v<=3 (no flag) or v>10 (a format this build does not know).
fn wallet_header_encrypted(p: &Path) -> Option<bool> {
let mut h = [0u8; 9];
File::open(p).and_then(|mut f| f.read_exact(&mut h)).ok()?;
let mut v = [0u8; 8];
v.copy_from_slice(&h[..8]);
match u64::from_le_bytes(v) {
4 | 6 | 7 | 8 | 9 => Some(h[8] != 0),
4 | 6 | 7 | 8 | 9 | 10 => Some(h[8] != 0),
_ => None,
}
}
@@ -757,12 +757,24 @@ impl LightClient {
vec![]
};
// C253: a version-10 wallet carries the Argon2id descriptor here, between the nonce and the seed.
let mut kdf = 0u8;
let mut kdf_salt = [0u8; 16];
let mut kdf_ops = 0u32;
let mut kdf_mem = 0u32;
if version >= 10 {
kdf = reader.read_u8().unwrap();
reader.read_exact(&mut kdf_salt).unwrap();
kdf_ops = reader.read_u32::<LittleEndian>().unwrap();
kdf_mem = reader.read_u32::<LittleEndian>().unwrap();
}
let phrase = if encrypted {
use sodiumoxide::crypto::secretbox;
use crate::lightwallet::double_sha256;
// Get the doublesha256 of the password, which is the right length
let key = secretbox::Key::from_slice(&double_sha256(password.unwrap().as_bytes())).unwrap();
let key = crate::lightwallet::LightWallet::derive_wallet_key(kdf, password.unwrap().as_bytes(), &kdf_salt, kdf_ops, kdf_mem).map_err(|e| e.to_string())?;
let nonce = secretbox::Nonce::from_slice(&nonce).unwrap();
let seed = match secretbox::open(&enc_seed, &nonce, &key) {
@@ -2759,6 +2771,7 @@ pub fn test_bad_import() {
(b[..8].to_vec(), b[8])
};
let v9 = 9u64.to_le_bytes().to_vec();
let v10 = 10u64.to_le_bytes().to_vec();
let lc = LightClient::new_from_phrase(TEST_SEED.to_string(), &config, 0, 0, false).unwrap();
lc.do_save().unwrap();
assert_eq!(header(&bak), (v9.clone(), 0), "precondition: a plaintext v9 .bak");
@@ -2766,8 +2779,8 @@ pub fn test_bad_import() {
lc.wallet.write().unwrap().encrypt("password".to_string()).unwrap();
lc.do_save().unwrap();
assert_eq!(header(&wallet), (v9.clone(), 1), "the wallet is saved encrypted, as v9");
assert_eq!(header(&bak), (v9.clone(), 1), "the .bak still holds the plaintext wallet after the encrypt save");
assert_eq!(header(&wallet), (v10.clone(), 1), "the wallet is saved encrypted; a new wallet uses Argon2id (v10)");
assert_eq!(header(&bak), (v10.clone(), 1), "after the encrypt save the .bak is the encrypted copy too (v10)");
assert_eq!(wallet_header_encrypted(&bak), Some(true));
assert_eq!(std::fs::read(&bak).unwrap(), std::fs::read(&wallet).unwrap());
let mut from_bak = LightWallet::read(std::io::BufReader::new(std::fs::File::open(&bak).unwrap()), &config).unwrap();
@@ -2796,13 +2809,13 @@ pub fn test_bad_import() {
std::fs::write(&p, &b).unwrap();
wallet_header_encrypted(&p)
};
for &v in &[4u64, 6, 7, 8, 9] {
for &v in &[4u64, 6, 7, 8, 9, 10] {
assert_eq!(probe(v, &[0, 7, 7]), Some(false), "v{}", v);
assert_eq!(probe(v, &[1]), Some(true), "v{}", v);
assert_eq!(probe(v, &[2]), Some(true), "v{}: any non-zero flag, as LightWallet::read", v);
assert_eq!(probe(v, &[]), None, "v{} without a flag byte", v);
}
for &v in &[0u64, 1, 2, 3, 5, 10, u64::MAX] {
for &v in &[0u64, 1, 2, 3, 5, 11, u64::MAX] {
assert_eq!(probe(v, &[0]), None, "v{}", v);
assert_eq!(probe(v, &[1]), None, "v{}", v);
}
@@ -26,6 +26,7 @@ use pairing::bls12_381::{Bls12};
use sha2::{Sha256, Digest};
use sodiumoxide::crypto::secretbox;
use sodiumoxide::crypto::pwhash::argon2id13;
use zcash_client_backend::{
encoding::{encode_payment_address, encode_extended_spending_key, encode_extended_full_viewing_key, decode_extended_spending_key, decode_extended_full_viewing_key},
@@ -146,6 +147,12 @@ pub struct LightWallet {
enc_seed: [u8; 48], // If locked, this contains the encrypted seed
nonce: Vec<u8>, // Nonce used to encrypt the wallet.
// C253: passphrase KDF descriptor (non-secret; all zero unless kdf == 1 and encrypted).
kdf: u8,
kdf_salt: [u8; 16],
kdf_ops: u32,
kdf_mem: u32,
seed: [u8; 32], // Seed phrase for this wallet. If wallet is locked, this is 0
// List of keys, actually in this wallet. This is a combination of HD keys derived from the seed,
@@ -178,6 +185,35 @@ impl LightWallet {
return 9;
}
/// C253: the version written ONLY for a wallet actually protected by the salted Argon2id KDF.
pub const ARGON2_WALLET_VERSION: u64 = 10;
/// Highest wallet version this build can read.
pub fn max_read_version() -> u64 {
LightWallet::ARGON2_WALLET_VERSION
}
/// C253: derive the secretbox key from the passphrase. kdf 0 = legacy unsalted double SHA-256; kdf 1 = Argon2id13 with the stored salt/ops/mem.
pub(crate) fn derive_wallet_key(kdf: u8, passwd: &[u8], salt: &[u8; 16], ops: u32, mem: u32) -> io::Result<secretbox::Key> {
if kdf == 0 {
return secretbox::Key::from_slice(&double_sha256(passwd))
.ok_or_else(|| io::Error::new(ErrorKind::InvalidData, "bad key length"));
}
if ops < 1 || ops > 12 || mem < (8u32 << 20) || mem > (1u32 << 30) {
return Err(io::Error::new(ErrorKind::InvalidData, "Wallet key-derivation parameters are corrupt"));
}
let _ = sodiumoxide::init();
let mut kb = [0u8; secretbox::KEYBYTES];
let r = argon2id13::derive_key(&mut kb, passwd, &argon2id13::Salt(*salt),
argon2id13::OpsLimit(ops as usize), argon2id13::MemLimit(mem as usize));
let key = match r {
Ok(_) => secretbox::Key::from_slice(&kb),
Err(_) => None,
};
sodiumoxide::utils::memzero(&mut kb[..]);
key.ok_or_else(|| io::Error::new(ErrorKind::Other, "Argon2id key derivation failed"))
}
fn get_taddr_from_bip39seed(config: &LightClientConfig, bip39_seed: &[u8], pos: u32) -> SecretKey {
assert_eq!(bip39_seed.len(), 64);
@@ -279,6 +315,10 @@ impl LightWallet {
unlocked: true,
enc_seed: [0u8; 48],
nonce: vec![],
kdf: if std::env::var("DRAGONX_LITE_KDF").map(|v| v == "legacy").unwrap_or(false) { 0 } else { 1 },
kdf_salt: [0u8; 16],
kdf_ops: 0,
kdf_mem: 0,
seed: seed_bytes,
zkeys: Arc::new(RwLock::new(vec![WalletZKey::new_hdkey(hdkey_num, extsk)])),
tkeys: Arc::new(RwLock::new(vec![WalletTKey::new_hdkey(tpk, taddr)])),
@@ -306,7 +346,7 @@ impl LightWallet {
pub fn read<R: Read>(mut inp: R, config: &LightClientConfig) -> io::Result<Self> {
let version = inp.read_u64::<LittleEndian>()?;
if version > LightWallet::serialized_version() {
if version > LightWallet::max_read_version() {
let e = format!("Don't know how to read wallet version {}. Do you have the latest version?", version);
error!("{}", e);
return Err(io::Error::new(ErrorKind::Unsupported, e));
@@ -341,6 +381,21 @@ impl LightWallet {
vec![]
};
// C253: version 10 carries the passphrase KDF descriptor between the nonce and the seed. v<=9 is always legacy double SHA-256.
let mut kdf = 0u8;
let mut kdf_salt = [0u8; 16];
let mut kdf_ops = 0u32;
let mut kdf_mem = 0u32;
if version >= LightWallet::ARGON2_WALLET_VERSION {
kdf = reader.read_u8()?;
reader.read_exact(&mut kdf_salt)?;
kdf_ops = reader.read_u32::<LittleEndian>()?;
kdf_mem = reader.read_u32::<LittleEndian>()?;
if kdf != 1 || !encrypted || kdf_ops < 1 || kdf_ops > 12 || kdf_mem < (8u32 << 20) || kdf_mem > (1u32 << 30) {
return Err(io::Error::new(ErrorKind::InvalidData, "Wallet key-derivation data is corrupt"));
}
}
// Seed
let mut seed_bytes = [0u8; 32];
reader.read_exact(&mut seed_bytes)?;
@@ -449,6 +504,10 @@ impl LightWallet {
unlocked: !encrypted, // When reading from disk, if wallet is encrypted, it starts off locked.
enc_seed: enc_seed,
nonce: nonce,
kdf,
kdf_salt,
kdf_ops,
kdf_mem,
seed: seed_bytes,
zkeys: Arc::new(RwLock::new(zkeys)),
tkeys: Arc::new(RwLock::new(wallet_tkeys)),
@@ -478,7 +537,9 @@ impl LightWallet {
}
// Write the version
writer.write_u64::<LittleEndian>(LightWallet::serialized_version())?;
// C253: v10 only for a wallet actually protected by the Argon2id KDF; everything else stays v9 (readable by older builds and SDXL).
let argon = self.encrypted && self.kdf == 1;
writer.write_u64::<LittleEndian>(if argon { LightWallet::ARGON2_WALLET_VERSION } else { LightWallet::serialized_version() })?;
// Write if it is locked
writer.write_u8(if self.encrypted {1} else {0})?;
@@ -489,6 +550,13 @@ impl LightWallet {
// Write the nonce
Vector::write(&mut writer, &self.nonce, |w, b| w.write_u8(*b))?;
if argon {
writer.write_u8(1)?;
writer.write_all(&self.kdf_salt)?;
writer.write_u32::<LittleEndian>(self.kdf_ops)?;
writer.write_u32::<LittleEndian>(self.kdf_mem)?;
}
// Write the seed
writer.write_all(&self.seed)?;
@@ -995,14 +1063,22 @@ impl LightWallet {
return Err(io::Error::new(ErrorKind::AlreadyExists, "Wallet is already encrypted"));
}
// Get the doublesha256 of the password, which is the right length
let key = secretbox::Key::from_slice(&double_sha256(passwd.as_bytes())).unwrap();
// C253: legacy wallets keep the unsalted double SHA-256; new wallets use salted Argon2id (a fresh salt per encryption).
let (salt, ops, mem) = if self.kdf == 1 {
(argon2id13::gen_salt().0, argon2id13::OPSLIMIT_MODERATE.0 as u32, argon2id13::MEMLIMIT_MODERATE.0 as u32)
} else {
([0u8; 16], 0u32, 0u32)
};
let key = LightWallet::derive_wallet_key(self.kdf, passwd.as_bytes(), &salt, ops, mem)?;
let nonce = secretbox::gen_nonce();
let cipher = secretbox::seal(&self.seed, &nonce, &key);
self.enc_seed.copy_from_slice(&cipher);
self.nonce = nonce.as_ref().to_vec();
self.kdf_salt = salt;
self.kdf_ops = ops;
self.kdf_mem = mem;
// Encrypt the individual keys
@@ -1068,7 +1144,7 @@ impl LightWallet {
}
// Get the doublesha256 of the password, which is the right length
let key = secretbox::Key::from_slice(&double_sha256(passwd.as_bytes())).unwrap();
let key = LightWallet::derive_wallet_key(self.kdf, passwd.as_bytes(), &self.kdf_salt, self.kdf_ops, self.kdf_mem)?;
let nonce = match secretbox::Nonce::from_slice(&self.nonce) {
Some(n) => n,
None => return Err(io::Error::new(ErrorKind::InvalidData, "Wallet encryption data is corrupt")),
@@ -1124,7 +1200,7 @@ impl LightWallet {
// Always prove the passphrase, also when the wallet is already unlocked: from the next save on, the
// seed and every key are written in plaintext.
{
let key = secretbox::Key::from_slice(&double_sha256(passwd.as_bytes())).unwrap();
let key = LightWallet::derive_wallet_key(self.kdf, passwd.as_bytes(), &self.kdf_salt, self.kdf_ops, self.kdf_mem)?;
let nonce = match secretbox::Nonce::from_slice(&self.nonce) {
Some(n) => n,
None => return Err(io::Error::new(ErrorKind::InvalidData, "Wallet encryption data is corrupt")),
@@ -1153,6 +1229,9 @@ impl LightWallet {
self.encrypted = false;
self.nonce = vec![];
self.enc_seed.copy_from_slice(&[0u8; 48]);
self.kdf_salt = [0u8; 16];
self.kdf_ops = 0;
self.kdf_mem = 0;
Ok(())
}
@@ -3983,3 +3983,16 @@ fn test_wallet_read_never_panics_on_corrupt_input() {
assert_eq!(kind("a u64::MAX string length", run("a u64::MAX string length", || utils::read_string(&s[..]))),
ErrorKind::UnexpectedEof);
}
#[test]
fn test_derive_wallet_key_param_bounds() {
// C253: kdf 0 is legacy double_sha256 (ignores ops/mem); kdf 1 rejects out-of-bounds Argon2id params
// BEFORE deriving, so this test never runs a heavy derivation.
let salt = [7u8; 16];
assert!(LightWallet::derive_wallet_key(0, b"pw", &salt, 0, 0).is_ok());
let bad = |ops: u32, mem: u32| LightWallet::derive_wallet_key(1, b"pw", &salt, ops, mem).unwrap_err().kind();
assert_eq!(bad(0, 256 * 1024 * 1024), std::io::ErrorKind::InvalidData); // ops too low
assert_eq!(bad(13, 256 * 1024 * 1024), std::io::ErrorKind::InvalidData); // ops too high
assert_eq!(bad(3, 4 * 1024 * 1024), std::io::ErrorKind::InvalidData); // mem below 8 MiB
assert_eq!(bad(3, (1u32 << 30) + 1), std::io::ErrorKind::InvalidData); // mem above 1 GiB
}