Compare commits
6
Commits
6d4ed96f6d
...
e40c6c0a7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e40c6c0a7c | ||
|
|
4d95ef68f6 | ||
|
|
3f85953452 | ||
|
|
5a3d36904c | ||
|
|
ef14518089 | ||
|
|
39292b1b1f |
+5
-1
@@ -2359,8 +2359,12 @@ int main(int argc, char* argv[])
|
|||||||
const bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
|
const bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
|
||||||
&& !opaqueBackground;
|
&& !opaqueBackground;
|
||||||
const bool reduceMotion = app.settings() && app.settings()->getReduceMotion();
|
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
|
const bool effectsAnimating = !reduceMotion
|
||||||
&& dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation();
|
&& dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|
||||||
|
&& !dragonx::ui::material::AnyBlurOverlayActiveLastFrame();
|
||||||
const bool ambient = effectsAnimating
|
const bool ambient = effectsAnimating
|
||||||
|| backdropNeedsFrames
|
|| backdropNeedsFrames
|
||||||
|| avatarAnimating
|
|| avatarAnimating
|
||||||
|
|||||||
@@ -1310,8 +1310,15 @@ void RenderContactsTab(App* app)
|
|||||||
const float lblSz = lblF->LegacySize * sc; // scaled label + address text
|
const float lblSz = lblF->LegacySize * sc; // scaled label + address text
|
||||||
const float adrSz = adrF->LegacySize * sc;
|
const float adrSz = adrF->LegacySize * sc;
|
||||||
bool rowDeleteRequested = false; // per-row delete is deferred until after the loop
|
bool rowDeleteRequested = false; // per-row delete is deferred until after the loop
|
||||||
for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
|
// C183(c): clip the Cards/List rows. Fixed step = the Selectable height (rowH) plus the
|
||||||
size_t i = visibleRows[vi];
|
// 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];
|
const auto& entry = book.entries()[i];
|
||||||
ImGui::PushID(static_cast<int>(i));
|
ImGui::PushID(static_cast<int>(i));
|
||||||
const bool selected = (s_selected_index == 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::SetCursorScreenPos(afterRow); // undo the action buttons' cursor moves
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
|
} // while (rowClipper.Step())
|
||||||
// Run the deferred per-row delete now that the loop over book.entries() has finished
|
// 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).
|
// (doDelete's two-stage confirm arms on the first click and removes on the second).
|
||||||
if (rowDeleteRequested) doDelete();
|
if (rowDeleteRequested) doDelete();
|
||||||
|
|||||||
@@ -694,8 +694,14 @@ static void pfDrawAddressSection(App* app)
|
|||||||
float lw = ImGui::GetContentRegionAvail().x;
|
float lw = ImGui::GetContentRegionAvail().x;
|
||||||
if (filtered.empty())
|
if (filtered.empty())
|
||||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
|
||||||
for (const PfPick& pk : filtered) {
|
// C181: clip the portfolio-editor rows. Fixed step = InvisibleButton height (rowH) + ItemSpacing.y,
|
||||||
const AddressInfo& a = *pk.a;
|
// 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;
|
bool inSet = selSet.count(a.address) != 0;
|
||||||
ImVec2 rmn = ImGui::GetCursorScreenPos();
|
ImVec2 rmn = ImGui::GetCursorScreenPos();
|
||||||
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
|
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
|
||||||
@@ -750,6 +756,7 @@ static void pfDrawAddressSection(App* app)
|
|||||||
}
|
}
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
|
} // while (rowClipper.Step())
|
||||||
material::EndFadeScrollChild(s_pfEdit.addrFade, ldl);
|
material::EndFadeScrollChild(s_pfEdit.addrFade, ldl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
|
|||||||
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
||||||
: api_(other.api_),
|
: api_(other.api_),
|
||||||
unavailableReason_(std::move(other.unavailableReason_)),
|
unavailableReason_(std::move(other.unavailableReason_)),
|
||||||
shutdownCalled_(other.shutdownCalled_)
|
shutdownCalled_(other.shutdownCalled_),
|
||||||
|
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
||||||
{
|
{
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_ = true;
|
||||||
@@ -107,6 +108,8 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
|||||||
api_ = other.api_;
|
api_ = other.api_;
|
||||||
unavailableReason_ = std::move(other.unavailableReason_);
|
unavailableReason_ = std::move(other.unavailableReason_);
|
||||||
shutdownCalled_ = other.shutdownCalled_;
|
shutdownCalled_ = other.shutdownCalled_;
|
||||||
|
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
||||||
|
std::memory_order_release);
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_ = true;
|
||||||
return *this;
|
return *this;
|
||||||
@@ -158,11 +161,29 @@ LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, cons
|
|||||||
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
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)
|
LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, const std::string& args)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
if (command.empty()) return {false, {}, "lite command is empty"};
|
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()
|
void LiteClientBridge::shutdown()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace wallet {
|
namespace wallet {
|
||||||
@@ -69,15 +71,24 @@ public:
|
|||||||
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
||||||
void shutdown();
|
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:
|
private:
|
||||||
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
||||||
|
|
||||||
LiteBridgeStringResult unavailableResult() const;
|
LiteBridgeStringResult unavailableResult() const;
|
||||||
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
||||||
|
static bool commandIsReadOnly(const std::string& command);
|
||||||
|
|
||||||
LiteClientBridgeApi api_;
|
LiteClientBridgeApi api_;
|
||||||
std::string unavailableReason_;
|
std::string unavailableReason_;
|
||||||
bool shutdownCalled_ = false;
|
bool shutdownCalled_ = false;
|
||||||
|
std::atomic<std::uint64_t> mutationEpoch_{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace wallet
|
} // namespace wallet
|
||||||
|
|||||||
@@ -1587,25 +1587,105 @@ void LiteWalletController::stopWorker()
|
|||||||
|
|
||||||
void LiteWalletController::workerLoop()
|
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()) {
|
while (running_.load()) {
|
||||||
if (walletOpen_.load()) {
|
if (walletOpen_.load()) {
|
||||||
bool syncReadFailed = false;
|
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
||||||
auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed);
|
if (decision == GateDecision::Skip) {
|
||||||
if (model && running_.load()) {
|
// Nothing changed since the last full model: no gateway refresh, no re-publish.
|
||||||
std::lock_guard<std::mutex> lock(modelMutex_);
|
// pendingModel_ is left untouched (the UI keeps the last applied model).
|
||||||
pendingModel_ = std::move(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
|
// Healthy: every 2 s. After a failed read (or a failed probe): 4 s, 8 s, then 15 s, so a dead
|
||||||
// back-to-back (each attempt is one do_sync with up to 62 s of backend retry back-off).
|
// 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_);
|
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(); });
|
[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)
|
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
|
||||||
{
|
{
|
||||||
auto result = lifecycle_.createWallet(request);
|
auto result = lifecycle_.createWallet(request);
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ public:
|
|||||||
std::string scanError() const { return scan_->errorCopy(); }
|
std::string scanError() const { return scan_->errorCopy(); }
|
||||||
// Tests only: first sync-retry delay in ms (default 5000); call before createWallet()/startSync().
|
// Tests only: first sync-retry delay in ms (default 5000); call before createWallet()/startSync().
|
||||||
void setSyncRetryBaseMsForTest(int ms) { scan_->retryBaseMs.store(ms); }
|
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
|
// 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
|
// `sync` command runs a full, uninterruptible chain scan). Auto-invoked when a lifecycle
|
||||||
@@ -513,7 +515,23 @@ private:
|
|||||||
std::condition_variable wakeCv_;
|
std::condition_variable wakeCv_;
|
||||||
std::mutex modelMutex_;
|
std::mutex modelMutex_;
|
||||||
std::optional<LiteWalletAppRefreshModel> pendingModel_; // guarded by 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
|
} // namespace wallet
|
||||||
|
|||||||
@@ -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_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
|
||||||
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
|
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<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_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...)
|
||||||
inline std::atomic<bool> g_liteFakeLocked{false}; // spending-keys-locked state
|
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)
|
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_liteFakeAlloc = 0;
|
||||||
g_liteFakeFreed = 0;
|
g_liteFakeFreed = 0;
|
||||||
|
g_liteFakeHeight = 1000;
|
||||||
g_liteFakeShutdownCalled = false;
|
g_liteFakeShutdownCalled = false;
|
||||||
g_liteFakeDeadServerSubstr.clear();
|
g_liteFakeDeadServerSubstr.clear();
|
||||||
g_liteFakeWarmupServerSubstr.clear();
|
g_liteFakeWarmupServerSubstr.clear();
|
||||||
@@ -215,7 +217,8 @@ inline char* liteFakeExecute(const char* command, const char* args)
|
|||||||
if (std::strcmp(c, "list") == 0)
|
if (std::strcmp(c, "list") == 0)
|
||||||
return liteFakeDup("[{\"txid\":\"faketx\",\"datetime\":1700000000,\"block_height\":990,"
|
return liteFakeDup("[{\"txid\":\"faketx\",\"datetime\":1700000000,\"block_height\":990,"
|
||||||
"\"unconfirmed\":false,\"address\":\"zs1fakeaddr\",\"amount\":150000000,\"memo\":\"\"}]");
|
"\"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)
|
if (std::strcmp(c, "info") == 0)
|
||||||
return liteFakeDup("{\"chain_name\":\"main\",\"version\":\"sdxl-fake\",\"latest_block_height\":1000}");
|
return liteFakeDup("{\"chain_name\":\"main\",\"version\":\"sdxl-fake\",\"latest_block_height\":1000}");
|
||||||
// M4 spend/backup commands. send/shield report failure via {"error":..} (NOT an
|
// M4 spend/backup commands. send/shield report failure via {"error":..} (NOT an
|
||||||
|
|||||||
@@ -11762,6 +11762,98 @@ void testLiteWalletControllerWorkerProducesModel()
|
|||||||
EXPECT_FALSE(idle.takeRefreshedModel(none));
|
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
|
// 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).
|
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
|
||||||
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
||||||
@@ -12047,6 +12139,9 @@ void testLiteWalletControllerRetire()
|
|||||||
EXPECT_TRUE(c->importKeyAsync("secret-extended-key-x", [&](const LiteImportResult& r) { ++n2; ok2 = r.ok; }));
|
EXPECT_TRUE(c->importKeyAsync("secret-extended-key-x", [&](const LiteImportResult& r) { ++n2; ok2 = r.ok; }));
|
||||||
const long entered = dragonx::test::g_liteFakeBalanceEntered.load();
|
const long entered = dragonx::test::g_liteFakeBalanceEntered.load();
|
||||||
dragonx::test::g_liteFakeBlockBalance = true;
|
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)
|
for (int i = 0; i < 1000 && dragonx::test::g_liteFakeBalanceEntered.load() == entered; ++i)
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
EXPECT_TRUE(dragonx::test::g_liteFakeBalanceEntered.load() > entered);
|
EXPECT_TRUE(dragonx::test::g_liteFakeBalanceEntered.load() > entered);
|
||||||
@@ -18944,6 +19039,8 @@ int main()
|
|||||||
testLiteWalletControllerRefreshPopulatesState();
|
testLiteWalletControllerRefreshPopulatesState();
|
||||||
testLiteWalletGatewayRefreshSkipsFailedCommand();
|
testLiteWalletGatewayRefreshSkipsFailedCommand();
|
||||||
testLiteWalletControllerWorkerProducesModel();
|
testLiteWalletControllerWorkerProducesModel();
|
||||||
|
testLiteWalletControllerWorkerChangeGate();
|
||||||
|
testLiteClientBridgeMutationEpoch();
|
||||||
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
||||||
testSerialJobLane();
|
testSerialJobLane();
|
||||||
testLiteGatewayRefreshPolicyHelpers();
|
testLiteGatewayRefreshPolicyHelpers();
|
||||||
|
|||||||
+20
-7
@@ -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
|
/// 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
|
/// 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> {
|
fn wallet_header_encrypted(p: &Path) -> Option<bool> {
|
||||||
let mut h = [0u8; 9];
|
let mut h = [0u8; 9];
|
||||||
File::open(p).and_then(|mut f| f.read_exact(&mut h)).ok()?;
|
File::open(p).and_then(|mut f| f.read_exact(&mut h)).ok()?;
|
||||||
let mut v = [0u8; 8];
|
let mut v = [0u8; 8];
|
||||||
v.copy_from_slice(&h[..8]);
|
v.copy_from_slice(&h[..8]);
|
||||||
match u64::from_le_bytes(v) {
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -757,12 +757,24 @@ impl LightClient {
|
|||||||
vec![]
|
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 {
|
let phrase = if encrypted {
|
||||||
use sodiumoxide::crypto::secretbox;
|
use sodiumoxide::crypto::secretbox;
|
||||||
use crate::lightwallet::double_sha256;
|
use crate::lightwallet::double_sha256;
|
||||||
|
|
||||||
// Get the doublesha256 of the password, which is the right length
|
// 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 nonce = secretbox::Nonce::from_slice(&nonce).unwrap();
|
||||||
|
|
||||||
let seed = match secretbox::open(&enc_seed, &nonce, &key) {
|
let seed = match secretbox::open(&enc_seed, &nonce, &key) {
|
||||||
@@ -2759,6 +2771,7 @@ pub fn test_bad_import() {
|
|||||||
(b[..8].to_vec(), b[8])
|
(b[..8].to_vec(), b[8])
|
||||||
};
|
};
|
||||||
let v9 = 9u64.to_le_bytes().to_vec();
|
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();
|
let lc = LightClient::new_from_phrase(TEST_SEED.to_string(), &config, 0, 0, false).unwrap();
|
||||||
lc.do_save().unwrap();
|
lc.do_save().unwrap();
|
||||||
assert_eq!(header(&bak), (v9.clone(), 0), "precondition: a plaintext v9 .bak");
|
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.wallet.write().unwrap().encrypt("password".to_string()).unwrap();
|
||||||
lc.do_save().unwrap();
|
lc.do_save().unwrap();
|
||||||
assert_eq!(header(&wallet), (v9.clone(), 1), "the wallet is saved encrypted, as v9");
|
assert_eq!(header(&wallet), (v10.clone(), 1), "the wallet is saved encrypted; a new wallet uses Argon2id (v10)");
|
||||||
assert_eq!(header(&bak), (v9.clone(), 1), "the .bak still holds the plaintext wallet after the encrypt save");
|
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!(wallet_header_encrypted(&bak), Some(true));
|
||||||
assert_eq!(std::fs::read(&bak).unwrap(), std::fs::read(&wallet).unwrap());
|
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();
|
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();
|
std::fs::write(&p, &b).unwrap();
|
||||||
wallet_header_encrypted(&p)
|
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, &[0, 7, 7]), Some(false), "v{}", v);
|
||||||
assert_eq!(probe(v, &[1]), Some(true), "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, &[2]), Some(true), "v{}: any non-zero flag, as LightWallet::read", v);
|
||||||
assert_eq!(probe(v, &[]), None, "v{} without a flag byte", 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, &[0]), None, "v{}", v);
|
||||||
assert_eq!(probe(v, &[1]), None, "v{}", v);
|
assert_eq!(probe(v, &[1]), None, "v{}", v);
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-6
@@ -26,6 +26,7 @@ use pairing::bls12_381::{Bls12};
|
|||||||
use sha2::{Sha256, Digest};
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
use sodiumoxide::crypto::secretbox;
|
use sodiumoxide::crypto::secretbox;
|
||||||
|
use sodiumoxide::crypto::pwhash::argon2id13;
|
||||||
|
|
||||||
use zcash_client_backend::{
|
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},
|
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
|
enc_seed: [u8; 48], // If locked, this contains the encrypted seed
|
||||||
nonce: Vec<u8>, // Nonce used to encrypt the wallet.
|
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
|
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,
|
// 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;
|
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 {
|
fn get_taddr_from_bip39seed(config: &LightClientConfig, bip39_seed: &[u8], pos: u32) -> SecretKey {
|
||||||
assert_eq!(bip39_seed.len(), 64);
|
assert_eq!(bip39_seed.len(), 64);
|
||||||
|
|
||||||
@@ -279,6 +315,10 @@ impl LightWallet {
|
|||||||
unlocked: true,
|
unlocked: true,
|
||||||
enc_seed: [0u8; 48],
|
enc_seed: [0u8; 48],
|
||||||
nonce: vec![],
|
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,
|
seed: seed_bytes,
|
||||||
zkeys: Arc::new(RwLock::new(vec![WalletZKey::new_hdkey(hdkey_num, extsk)])),
|
zkeys: Arc::new(RwLock::new(vec![WalletZKey::new_hdkey(hdkey_num, extsk)])),
|
||||||
tkeys: Arc::new(RwLock::new(vec![WalletTKey::new_hdkey(tpk, taddr)])),
|
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> {
|
pub fn read<R: Read>(mut inp: R, config: &LightClientConfig) -> io::Result<Self> {
|
||||||
let version = inp.read_u64::<LittleEndian>()?;
|
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);
|
let e = format!("Don't know how to read wallet version {}. Do you have the latest version?", version);
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
return Err(io::Error::new(ErrorKind::Unsupported, e));
|
return Err(io::Error::new(ErrorKind::Unsupported, e));
|
||||||
@@ -341,6 +381,21 @@ impl LightWallet {
|
|||||||
vec![]
|
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
|
// Seed
|
||||||
let mut seed_bytes = [0u8; 32];
|
let mut seed_bytes = [0u8; 32];
|
||||||
reader.read_exact(&mut seed_bytes)?;
|
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.
|
unlocked: !encrypted, // When reading from disk, if wallet is encrypted, it starts off locked.
|
||||||
enc_seed: enc_seed,
|
enc_seed: enc_seed,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
|
kdf,
|
||||||
|
kdf_salt,
|
||||||
|
kdf_ops,
|
||||||
|
kdf_mem,
|
||||||
seed: seed_bytes,
|
seed: seed_bytes,
|
||||||
zkeys: Arc::new(RwLock::new(zkeys)),
|
zkeys: Arc::new(RwLock::new(zkeys)),
|
||||||
tkeys: Arc::new(RwLock::new(wallet_tkeys)),
|
tkeys: Arc::new(RwLock::new(wallet_tkeys)),
|
||||||
@@ -478,7 +537,9 @@ impl LightWallet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write the version
|
// 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
|
// Write if it is locked
|
||||||
writer.write_u8(if self.encrypted {1} else {0})?;
|
writer.write_u8(if self.encrypted {1} else {0})?;
|
||||||
@@ -489,6 +550,13 @@ impl LightWallet {
|
|||||||
// Write the nonce
|
// Write the nonce
|
||||||
Vector::write(&mut writer, &self.nonce, |w, b| w.write_u8(*b))?;
|
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
|
// Write the seed
|
||||||
writer.write_all(&self.seed)?;
|
writer.write_all(&self.seed)?;
|
||||||
|
|
||||||
@@ -995,14 +1063,22 @@ impl LightWallet {
|
|||||||
return Err(io::Error::new(ErrorKind::AlreadyExists, "Wallet is already encrypted"));
|
return Err(io::Error::new(ErrorKind::AlreadyExists, "Wallet is already encrypted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the doublesha256 of the password, which is the right length
|
// C253: legacy wallets keep the unsalted double SHA-256; new wallets use salted Argon2id (a fresh salt per encryption).
|
||||||
let key = secretbox::Key::from_slice(&double_sha256(passwd.as_bytes())).unwrap();
|
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 nonce = secretbox::gen_nonce();
|
||||||
|
|
||||||
let cipher = secretbox::seal(&self.seed, &nonce, &key);
|
let cipher = secretbox::seal(&self.seed, &nonce, &key);
|
||||||
|
|
||||||
self.enc_seed.copy_from_slice(&cipher);
|
self.enc_seed.copy_from_slice(&cipher);
|
||||||
self.nonce = nonce.as_ref().to_vec();
|
self.nonce = nonce.as_ref().to_vec();
|
||||||
|
self.kdf_salt = salt;
|
||||||
|
self.kdf_ops = ops;
|
||||||
|
self.kdf_mem = mem;
|
||||||
|
|
||||||
// Encrypt the individual keys
|
// Encrypt the individual keys
|
||||||
|
|
||||||
@@ -1068,7 +1144,7 @@ impl LightWallet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the doublesha256 of the password, which is the right length
|
// 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) {
|
let nonce = match secretbox::Nonce::from_slice(&self.nonce) {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => return Err(io::Error::new(ErrorKind::InvalidData, "Wallet encryption data is corrupt")),
|
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
|
// 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.
|
// 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) {
|
let nonce = match secretbox::Nonce::from_slice(&self.nonce) {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => return Err(io::Error::new(ErrorKind::InvalidData, "Wallet encryption data is corrupt")),
|
None => return Err(io::Error::new(ErrorKind::InvalidData, "Wallet encryption data is corrupt")),
|
||||||
@@ -1153,6 +1229,9 @@ impl LightWallet {
|
|||||||
self.encrypted = false;
|
self.encrypted = false;
|
||||||
self.nonce = vec![];
|
self.nonce = vec![];
|
||||||
self.enc_seed.copy_from_slice(&[0u8; 48]);
|
self.enc_seed.copy_from_slice(&[0u8; 48]);
|
||||||
|
self.kdf_salt = [0u8; 16];
|
||||||
|
self.kdf_ops = 0;
|
||||||
|
self.kdf_mem = 0;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -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[..]))),
|
assert_eq!(kind("a u64::MAX string length", run("a u64::MAX string length", || utils::read_string(&s[..]))),
|
||||||
ErrorKind::UnexpectedEof);
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user