diff --git a/docs/daemon-startup-hardening.md b/docs/daemon-startup-hardening.md deleted file mode 100644 index d4a2818..0000000 --- a/docs/daemon-startup-hardening.md +++ /dev/null @@ -1,549 +0,0 @@ -# Daemon Startup Hardening — Implementation Plan - -Eight verified edge-case defects in how ObsidianDragon brings up (and watches) the -`dragonxd` daemon at launch. Each entry is a buildable fix: the defect (with exact -line references), the chosen approach, the call sites, a representative change, and how -to verify it. - -- **Scope:** full-node startup path (`--lite` excludes the embedded daemon entirely). -- **Source:** line references are exact against branch `dev` @ `45b652f`. -- **Provenance:** findings verified by direct source read; each fix designed by an - independent agent grounded in the cited files, with a sequencing pass for ordering, - shared helpers, and merge conflicts. - -**Severity:** 2 High, 6 Medium · **Effort:** ≈ 25–35 engineering-hours · **7 landing steps.** - -Status legend: ☐ not started · ◐ in progress · ☑ landed & verified - -**Status: all 8 landed & verified** (build-clean, `ctest` green after each) across four commits on -`dev` — lifecycle cluster (F1/F2/F4), filesystem+params cluster (F7/F6/F5), F3, and F8. Six new -pure-helper unit tests added. - -**Wrap-up done:** release notes added (`CHANGELOG.md`, F8 breaking change front and center); i18n -back-fill applied additively to `res/lang/*.json` (42 keys — all 6 for es/de/fr/pt/ru; 6 zh/ja/ko -entries whose glyphs aren't in the current `NotoSansCJK-Subset.ttf` were left on English fallback -rather than render as tofu). - -**Still owed before release:** a **CJK subset-font rebuild** (`scripts/build_cjk_subset.py`, needs -the Noto CJK source font) to cover the 6 deferred zh/ja/ko strings. *(F1 and F2 now have headless -integration-test coverage — see the progress log — so their GUI repros are optional, not blocking.)* - ---- - -## Recommended rollout sequence - -A real dependency order, not a checklist. The daemon-lifecycle cluster lands first -because it makes the `State::Error` / `crash_count_` contract trustworthy — which the -connect-stall panel and the lock gate both build on. The filesystem cluster lands -around a single shared helper. The connectivity-breaking security flip lands last. - -| Step | Finding(s) | Site | Why here | Status | -|------|-----------|------|----------|--------| -| 1 | **F1** | `embedded_daemon.cpp` · `isRunning()` | Smallest/highest-severity; establishes the reliable Error/crash-count transition steps 3 & 6 depend on. | ☑ | -| 2 | **F2** | `embedded_daemon.cpp` · `startProcess()` | Same file family, different function; test the F1+F2 pair together with `kill -SEGV` / bad-binary repros. | ☑ | -| 3 | **F4** | `embedded_daemon.cpp` · `start()` | After F1/F2 so crash-count semantics are settled; its bail deliberately stays out of the crash path. | ☑ | -| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | ☑ | -| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | ☑ | -| 6 | **F3** | `app.cpp` · `renderLoadingOverlay()` | After F1 — panel is guarded off during `State::Error` (owned by the crash-count hint). | ☑ | -| 7 | **F8** | `connection.cpp` · `tryConnect()` | Largest; only connectivity-breaking default flip — land last, with release notes. | ☑ | - ---- - -## F1 — Double-`waitpid` race can swallow a daemon crash - -**Severity:** High · **Effort:** S (~1–2h) · **Status:** ☑ landed & verified - -### The defect -`EmbeddedDaemon::isRunning()` (`embedded_daemon.cpp:1136`, POSIX branch) calls -`waitpid(WNOHANG)` — from the **UI thread, nearly every frame** — racing -`monitorProcess()`'s own reap at `:1244`. `waitpid` is one-shot: if the UI thread wins, -the monitor never decodes the exit, so `crash_count_` never increments, `State::Error` -never fires, and the 3-strike auto-restart cap (`app_network.cpp:479`) is defeated. The -sibling `XmrigManager::isRunning()` (`xmrig_manager.cpp:512`) already fixed exactly this -with an atomic read. - -### The fix -Make `isRunning()` read the existing `std::atomic state_` (member at -`embedded_daemon.h:253`) instead of calling `waitpid`, leaving `monitorProcess()` as the -sole reaper. Predicate is `Running || Stopping` — `Stopping` must stay "alive" because -`stop()`'s graceful/SIGTERM wait loops poll `isRunning()` before the process has exited. - -### Files touched -- `src/daemon/embedded_daemon.cpp` — `isRunning()`, POSIX branch (~1136) - -### Core change -```cpp -bool EmbeddedDaemon::isRunning() const // POSIX branch -{ - // Read the atomic state_ instead of waitpid() — monitorProcess() is the - // sole reaper. Previously both threads reaped; if the UI thread won, the - // monitor never saw the exit (crash_count_ / exit code / Error all lost). - if (process_pid_ <= 0) return false; - - State s = state_.load(std::memory_order_relaxed); - // Stopping stays "alive": stop()'s wait loops poll isRunning() while - // state_ == Stopping, before the process has actually terminated. - return (s == State::Running || s == State::Stopping); -} -``` - -### Verification -- Manual: `kill -SEGV` the daemon 10–20×; the monitor must report the exit and increment `crash_count_` every time (previously intermittent). -- Regression: a normal Settings-driven stop still escalates SIGTERM→SIGKILL (the `Stopping` predicate). -- Not unit-testable (real fork/exec/waitpid) — consistent with the no-process-spawn harness. - -### Dependencies -Mirrors `XmrigManager::isRunning()`. Flags a separate latent hazard (out of scope): -`stop()`'s final blocking `waitpid` (`:1220`) can still race a mid-sleep monitor -iteration — file as its own ticket. - ---- - -## F2 — exec-after-fork silent failure: "Running" for a daemon that never started - -**Severity:** High · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified - -### The defect -In `startProcess()` (`embedded_daemon.cpp:957–1061`, POSIX) the parent runs -`process_pid_ = pid; return true;` **unconditionally** after `fork()` — with no -exec-status handshake. On a non-executable / wrong-arch / corrupt binary the child's -`execv` fails and it `_exit(127)`s, but `start()` has already set `State::Running` -(`:565`). The real cause never reaches `last_error_`; it surfaces later, generically, -as "exited unexpectedly (exit code 127)". - -### The fix -Add a **close-on-exec self-pipe** handshake — `pipe() + fcntl(FD_CLOEXEC)`, deliberately -**not** `pipe2()` (macOS lacks it; the POSIX branch is shared). The child writes `errno` -only on `execv` failure; a successful exec closes the write end for free. Parent reads: -EOF ⇒ success; 4 bytes ⇒ reap the zombie, set a precise `last_error_` ("not executable -or wrong architecture"), and return `false` so `start()` never reports Running. EINTR-safe -on both ends. Also comments the unchecked parent-side `setpgid` at `:1053`. - -### Files touched -- `src/daemon/embedded_daemon.cpp` — `startProcess()` parent read path -- `src/daemon/embedded_daemon.cpp` — child `execv`-failure write (~1043) -- `src/daemon/embedded_daemon.cpp` — `setpgid` best-effort comment (~1053) - -### Core change -```cpp -// Self-pipe exec handshake (pipe()+FD_CLOEXEC; NOT pipe2 — macOS lacks it). -int execpipe[2]; pipe(execpipe); -fcntl(execpipe[0], F_SETFD, FD_CLOEXEC); -fcntl(execpipe[1], F_SETFD, FD_CLOEXEC); - -pid_t pid = fork(); -if (pid == 0) { // child - close(execpipe[0]); - /* setpgid / chdir / dup2 / argv … */ - execv(binary_path.c_str(), argv.data()); - int e = errno; // execv failed - while (write(execpipe[1], &e, sizeof e) < 0 && errno == EINTR) {} - _exit(127); -} - -close(execpipe[1]); // parent: must close or read() never EOFs -int child_errno = 0, total = 0; -for (;;) { // EOF ⇒ exec ok; 4 bytes ⇒ exec failed - ssize_t n = read(execpipe[0], (char*)&child_errno + total, sizeof(int) - total); - if (n == 0) break; - if (n < 0) { if (errno == EINTR) continue; break; } - if ((total += n) >= (int)sizeof(int)) break; -} -close(execpipe[0]); -if (total >= (int)sizeof(int)) { // exec never happened - waitpid(pid, nullptr, 0); // reap the zombie - last_error_ = "dragonxd could not be executed: " + - std::string(strerror(child_errno)) + - " — not executable or wrong architecture"; - return false; // start() no longer reports Running -} -``` - -### Verification -- Point at a `chmod -x` / wrong-arch file → `start()` returns false immediately, precise message, no leftover zombie. -- Success path: real binary still starts with no perceptible added latency. -- Optional pure `formatExecFailureError(errno)` helper for a `test_phase4.cpp` unit test. - -### Dependencies -F1 (same function family; sequence F1→F2). **Highest-risk mistake:** forgetting -`FD_CLOEXEC` makes every successful start hang the parent read forever. - ---- - -## F4 — Stale datadir-lock start → restart storm that wedges the UI - -**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified - -### The defect -`start()` (`embedded_daemon.cpp:466`) gates only on the RPC port (`:482`), never on -`isDaemonProcessRunning()` (`:1292`). A graceful shutdown frees the port but keeps the -datadir `.lock` for up to ~90s. A rapid stop→start spawns a daemon that dies "Cannot -obtain a lock on data directory" — routed to the generic crash path. With a ~4s retry -cadence, **three lock races in ~12s exhaust the 3-strike budget** and wedge the UI long -before the lock actually clears. - -### The fix -Fail-fast with a **short bounded local wait (~300ms), not a 90s block**. After the port -bail, consult `isDaemonProcessRunning()` — gated by `!skip_port_check_` and exempt when -`override_datadir_` is set, so the isolated migrate-to-seed daemon still works. A pure -`evaluateDatadirLockGate()` returns a **distinct non-crash Error** that never increments -`crash_count_`. The connect loop's own retry then absorbs the transient. - -### Files touched -- `src/daemon/embedded_daemon.h` — decision struct, helper decl, poll constants -- `src/daemon/embedded_daemon.cpp` — `start()` gate + `evaluateDatadirLockGate()` - -### Core change -```cpp -static StartLockGateDecision evaluateDatadirLockGate( - bool skipPortCheck, bool isolatedOverride, bool stillRunningAfterWait) { - if (skipPortCheck || isolatedOverride) return {true, ""}; // migrate-to-seed exempt - if (!stillRunningAfterWait) return {true, ""}; - return {false, "A previous dragonxd is still shutting down and holding the " - "data directory lock. Retrying shortly…"}; -} - -// start() — after the isPortInUse() bail, before setState(Starting): -if (!skip_port_check_ && override_datadir_.empty()) { - bool stillLocked = false; // ~300ms bounded wait, NOT ~90s - for (int i = 0; i < kDatadirLockWaitMaxPolls; ++i) { - if (!isDaemonProcessRunning()) { stillLocked = false; break; } - stillLocked = true; - std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs)); - } - auto gate = evaluateDatadirLockGate(false, false, stillLocked); - if (!gate.proceed) { setState(State::Error, gate.errorMessage); return false; } -} -``` - -### Verification -- Unit: `evaluateDatadirLockGate()` across the skip / isolated / still-running matrix. -- Manual: rapid restart into a lingering lock → distinct message, no crash-cap wedge. -- Migrate-to-seed second daemon still starts (isolated exemption). - -### Dependencies -F1/F2 (must not touch `crash_count_`; wording must not collide with the monitor's -"exited unexpectedly"). Same TU, different function. - ---- - -## F5 — Extraction / copy write-failures never surfaced up front - -**Severity:** Medium · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified - -### The defect -`startEmbeddedDaemon()` discards `extractEmbeddedResources()`'s `bool` return -(`app.cpp:4152`) and the second copy-fallback loop drops `copy_file`'s `error_code` -entirely (`:4236`). Only Sapling params **existence** is re-checked — never the daemon -binary/CLI/tx/asmap. A disk-full or truncated `dragonxd` write falls straight through to -spawn and fails opaquely. The innermost write already returns `false` -(`embedded_resources.cpp:307`) — the signal is simply thrown away. - -### The fix -Minimal, surgical wiring — no new abstraction. Capture the extraction return and, on -failure, set `daemon_status_ = TR("sb_daemon_extract_failed")` and `return false` before -spawning. In the second copy loop, check `ec` after each `copy_file`, track `copyFailed`, -and abort with a dir-parameterized `sb_daemon_files_failed`. An **absent source** stays -fine (optional files); only an actual `error_code` counts. Written so F6/F7 slot in later -without re-touching this control flow. - -### Files touched -- `src/app.cpp` — `startEmbeddedDaemon()` extraction check (~4152) -- `src/app.cpp` — second copy-fallback loop (~4210–4242) -- `src/util/i18n.cpp` + `res/lang/*.json` — 2 additive keys - -### Core change -```cpp -// stop discarding the extraction result (~4152) -if (!resources::extractEmbeddedResources()) { - daemon_status_ = TR("sb_daemon_extract_failed"); // disk full / permission denied - return false; // abort before spawning -} - -// second copy-fallback loop — was dropping ec entirely (~4236) -bool copyFailed = false; -for (const char* name : { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }) { - fs::path dst = fs::path(daemon_dir) / name; - if (fs::exists(dst)) continue; // already present — skip - for (const auto& dir : searchDirs) { - fs::path src = fs::path(dir) / name; - if (!fs::exists(src)) continue; // absent source is OK, not a failure - fs::copy_file(src, dst, ec); - if (ec) { copyFailed = true; ec.clear(); } - break; - } -} -if (copyFailed) { - char buf[512]; - snprintf(buf, sizeof buf, TR("sb_daemon_files_failed"), daemon_dir.c_str()); - daemon_status_ = buf; - return false; // don't fall through to spawn -} -``` - -### Verification -- Unit: `extractEmbeddedResources()` returns false without embedded resources. -- Extract the copy loop into a testable helper; force one dst write to fail (dst is an existing directory). -- Manual: near-full tmpfs / read-only dir → clear status, daemon controller never constructed. - -### Dependencies -Shares the `daemon_status_` surfacing convention with F6; its early-return pattern is the -template F7 matches. Open item: remove truncated dst files so a retry re-copies. - ---- - -## F6 — Sapling params validated by existence/size only, never hashed - -**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified - -> **As-built note.** `verifySaplingParams()` now delegates to a public, injectable -> `verifySaplingParamsIn(dir, digests)` so the integrity + marker-cache logic is unit-testable -> with synthetic small files (the real 48 MB params aren't in the repo). i18n keys for F5 were -> added to `i18n.cpp` (English source of truth); the `res/lang/*.json` back-fill via -> `scripts/add_missing_translations.py` is deferred to a single run at the end of the batch, -> per the cross-cutting note. Non-English locales fall back to English until then. - -### The defect -`verifySaplingParams()` (`connection.cpp:123`) only calls `fs::exists()`; -`resourceNeedsUpdate()` (`embedded_resources.cpp:250`) is size-only. On Linux (no -embedded resources) a **truncated-but-present** param passes and is handed to the daemon, -which then fails to build shielded proofs mid-operation — far from the real cause. - -### The fix -Add a pinned `{ filename → size, sha256 }` table (one source of truth, cross-referenced -to `scripts/build-lite-backend-artifact.sh`) and hash-check each param after the -existence check, reusing the existing `util::sha256Hex` (no second implementation). Since -these are ~48 MB, **cache the result** via a `.sapling_verified` marker keyed on -`size:mtime` — re-hash only when the stat line changes, so startup isn't slowed. - -### Files touched -- `src/rpc/connection.h` — `verifySaplingParams` decl -- `src/rpc/connection.cpp` — digest table, marker helpers, rewrite - -### Core change -```cpp -// connection.cpp — pinned known-good digests -// (source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params) -constexpr SaplingParamDigest kSaplingParamDigests[] = { - { "sapling-spend.params", 47958396, "8e48ffd2…efc13" }, - { "sapling-output.params", 3592860, "2f0ebbcb…fb0e4" }, -}; - -bool Connection::verifySaplingParams() { - // existence check (unchanged) … - // cache: skip re-hashing a ~48 MB file unless size:mtime changed - if (readMarkerMatches(marker, statLines)) return true; - for (auto& d : kSaplingParamDigests) - if (util::sha256Hex(bytes) != d.sha256) return false; // reuse existing helper - writeMarker(marker, statLines); - return true; -} -``` - -### Verification -- Unit: good params pass; truncated / wrong-bytes rejected; marker cache short-circuits re-hash unless size/mtime changed. Real temp-file fixtures (matches existing `sha256Hex` tests). - -### Dependencies -F7 (reuse fs-error idiom; shares the `startEmbeddedDaemon`/`verifySaplingParams` block). -Third caller of the existing `util::sha256Hex`. - ---- - -## F7 — Directory-create errors universally ignored on the daemon-env path - -**Severity:** Medium · **Effort:** S (~3–4h) · **Status:** ☑ landed & verified - -> **As-built notes.** Two deviations from the original design, both confirmed against the code: -> (1) `embedded_resources.cpp:270` already checks its `error_code` and returns `false` on failure — it was **not** a bug, so it is left untouched. -> (2) Of the four `autoDetectConfig` callers, only the primary connect path (`app_network.cpp:243`) was wired to check `dir_error`; the other three degrade gracefully on their own — `app.cpp:4306` and `app_wizard.cpp:912` are stop paths that already gate on empty creds, and `settings_page.cpp:434` is read-only display. `dir_error` is set by `autoDetectConfig`, so they can be wired later if desired. - -### The defect -Five startup directory-create sites either drop the `error_code` or use the throwing -overload with no `catch`: `main.cpp:730`, `connection.cpp:216` (can throw **uncaught** -through its callers), `embedded_resources.cpp:270`, `app.cpp:4172`/`4218`. A read-only -home or permission-denied yields a confusing "conf missing" / "binary not found" -downstream — or an uncaught `filesystem_error` — instead of a clear cause. - -### The fix -One shared, non-throwing `Platform::ensureDirectory(dir, outError)` in -`util/platform.{h,cpp}` that produces a single consistent message. Replace all five -sites; `autoDetectConfig()` moves off the throwing overload and sets a new -`ConnectionConfig::dir_error` that its four callers check and bail on. This is the -**structural owner** of the fs-error idiom that F5 and F6 reuse. - -### Files touched -- `src/util/platform.h` / `.cpp` — `ensureDirectory()` -- `src/rpc/connection.h` / `.cpp` — `dir_error` + `autoDetectConfig` -- `main.cpp`, `app.cpp`, `app_network.cpp`, `app_wizard.cpp`, `settings_page.cpp`, `embedded_resources.cpp` — 5 sites + 4 callers -- `tests/test_phase4.cpp` — `TestPlatformEnsureDirectory` - -### Core change -```cpp -// util/platform.cpp — one shared, non-throwing helper -bool Platform::ensureDirectory(const std::string& dir, std::string* outError) { - std::error_code ec; - if (std::filesystem::is_directory(dir, ec)) return true; - ec.clear(); - std::filesystem::create_directories(dir, ec); - if (ec) { - if (outError) - *outError = "Cannot create " + dir + ": " + ec.message() + - ". Check permissions / free space."; - return false; - } - return true; -} -// Replaces 5 ad-hoc sites; autoDetectConfig() now sets ConnectionConfig::dir_error, -// and its 4 callers bail on it. -``` - -### Verification -- Unit `TestPlatformEnsureDirectory`: existing dir → true; fresh nested → created; POSIX unwritable → false + message. -- All four `autoDetectConfig` callers tolerate `dir_error`. Pre-App-init site (main.cpp) reports via stderr / MessageBox. - -### Dependencies -**Owns** `Platform::ensureDirectory` (used by F5, F6) and the `ConnectionConfig` -extension (coordinated with F8). Land before F5/F6/F8. - ---- - -## F8 — Plaintext-remote RPC credential transmission is warn-only - -**Severity:** Medium · **Effort:** M (~6–9h) · **Status:** ☑ landed & verified - -> **⚠️ RELEASE NOTES REQUIRED — breaking default flip.** A wallet configured to talk to a -> **remote** `rpchost` over **plain HTTP** (no `rpctls=1`) will now be **refused** at connect -> time instead of warned. Affected users must add **`rpcallowplaintext=1`** to `DRAGONX.conf` -> (or switch to `rpctls=1`) to reconnect. Local/embedded daemons (`127.0.0.0/8`, `localhost`, -> `::1`) are unaffected. Call this out prominently in the release notes. -> -> **As-built note.** Shipped the security-complete core: `isLocalHost` tightened to exact -> loopback (`isExactIPv4Loopback` — `127.evil.com` no longer passes), refuse-by-default in -> `tryConnect`, and the `rpcallowplaintext` conf-key opt-in. The **Settings toggle UI was -> deferred** — the RPC section of `settings_page.cpp` is read-only display and a security -> toggle there is riskier surface; the conf-key opt-in fully covers recovery, and the refusal -> status/notification tells the user exactly what to add. The toggle can be added later -> (persist a `Settings` flag and OR it into `allowsPlaintextRemote`). - -### The defect -A remote `rpchost` without `rpctls=1` sends Basic-auth `rpcuser:rpcpassword` over -cleartext HTTP. `tryConnect()` (`app_network.cpp:314`) only shows a **dismissible -warning** then proceeds — a local-network MITM sees the credentials. Compounding it, -`isLocalHost()`'s naive `rfind("127.",0)==0` misclassifies `127.evil.com` as local, -suppressing even the warning. - -### The fix -Change the policy to **refuse-by-default with an explicit, persisted opt-in** — a -`rpcallowplaintext=1` conf key (for hand-editors) and a Settings toggle. Block the -connect and show a **blocking modal** explaining the risk and how to enable TLS or opt -in; localhost is unaffected. Tighten `isLocalHost()` to exact `127.x.y.z` / `::1` / -`localhost` via `isExactIPv4Loopback()`. **Back-compat:** default off ⇒ existing remote -users hit a hard stop until they opt in — **ship with prominent release notes.** - -### Files touched -- `src/rpc/connection.h` / `.cpp` — `isLocalHost`, `allow_plaintext_remote`, `parseConfFile` -- `src/config/settings.h` / `.cpp` — persisted opt-in -- `src/app_network.cpp`, `src/app.h` — refuse + modal dispatch -- `src/ui/windows/plaintext_remote_rpc_dialog.h` — new blocking modal -- `src/ui/pages/settings_page.cpp` — toggle UI - -### Core change -```cpp -// Tightened loopback test — "127.evil.com" is NOT local -bool Connection::isLocalHost(const std::string& host) { - std::string h = stripBrackets(lowercase(host)); - return h == "localhost" || h == "::1" || isExactIPv4Loopback(h); // exact 127.x.y.z -} - -// Refuse-by-default with an explicit, persisted opt-in -const bool plaintextRemote = rpc::Connection::usesPlaintextRemote(config); -const bool plaintextAllowed = config.allow_plaintext_remote // rpcallowplaintext=1 - || settings_.getAllowPlaintextRemoteRpc(); // Settings toggle -if (plaintextRemote && !plaintextAllowed) { - connection_status_ = TR("sb_plaintext_remote_blocked"); - showPlaintextRemoteRpcDialog(config.host + ":" + config.port); // blocking modal - return; // no creds sent -} -``` - -### Verification -- Unit: `isLocalHost` — `127.evil.com` false, `127.0.0.1`/`::1`/`localhost` true; `allowsPlaintextRemote` honors conf key + settings flag. -- Manual: remote plaintext blocked; modal fires; opt-in persists across restart. - -### Dependencies -F7 (second extender of `ConnectionConfig`/`parseConfFile`; land after so the struct grows -once). Wire `renderPlaintextRemoteRpcDialog` into the app modal-dispatch list. - ---- - -## Shared helpers & coordination points - -| Helper | Purpose | Used by | -|--------|---------|---------| -| `Platform::ensureDirectory()` | Single non-throwing directory-create with one consistent message; replaces five ad-hoc sites. Owned by F7. | F7, F5, F6 | -| `ConnectionConfig` extension | Coordination point, not a function: F7 adds `dir_error`, F8 adds `allow_plaintext_remote`. Land F7→F8 so it grows once per step. | F7, F8 | -| `util::sha256Hex` *(existing)* | Already-compiled, curl-free SHA-256. F6 becomes its third caller — no second hash routine. | F6 | -| `connectHasStalled()` *(new, pure)* | Stall predicate split out of the ImGui/App code for unit testing, per the `*_updater_core.cpp` precedent. | F3 | -| `evaluateDatadirLockGate()` *(new, pure)* | Lock-gate decision as `{proceed, message}` from three booleans — unit-testable without real process/fs I/O. | F4 | - -## F3 — Unbounded connect spinner (deferred to step 6) - -**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified - -> **As-built note.** `renderLoadingOverlay()` is a pure draw-list overlay with **no interactive -> widgets** (the existing crash case at ~5289 already communicates via guidance *text*, relying on -> the sidebar staying reachable). So rather than inject `ActionButton`s — which would fight the -> non-interactive overlay — the stall notice follows that same idiom: a "Taking longer than -> expected" title + a reassuring body (with elapsed seconds) + a full-node-gated hint ("Open -> Settings → Restart Daemon, or check the Console"). This let me drop the planned -> `WalletState::connect_stalled` flag too: the stalled state is computed locally in the overlay -> from `connect_stall_since_`, so the only new member is `App::connect_stall_since_`. - -The connect loop retries forever while `!state_.connected` (`app.cpp:1239`); -`loading_timer_` only animates the spinner. Stamp `connect_stall_since_` when -"reachable but not ready" is first seen; a pure `connectHasStalled()` helper (new -`util/connect_stall.h`, default 45s from `ui.toml`) flips `state_.connect_stalled` at -threshold, and `renderLoadingOverlay()` shows a "Taking longer than expected" panel with -Retry / Restart daemon / Open console (full-node gated). The background retry keeps -firing — recovery clears the panel automatically. Guarded off while the daemon is in -`State::Error` (owned by F1's crash-count hint). Full detail lives in the sequencing/ -design record; see the shared-helper table above. - ---- - -## Cross-cutting notes - -- **One TU, three functions.** `embedded_daemon.cpp` is edited by F1 (`isRunning`), - F2 (`startProcess`) and F4 (`start`) — no literal hunk overlap, but land in order to - keep "monitorProcess is the sole reaper" coherent. -- **Connection struct grows twice.** `connection.h/.cpp` is touched by F6, F7 and F8; - F7 and F8 both extend `ConnectionConfig` and `parseConfFile` — highest collision risk. - Sequence F7→F6→F8. -- **Testability split.** The three new pure predicates all get `tests/test_phase4.cpp` - coverage. F1/F2's fork/exec/waitpid changes are **not** unit-testable — they rely on - manual `kill` / non-executable-binary repros, consistent with the no-process-spawn harness. -- **i18n is additive-only.** Add each finding's English keys to `strings_`, then run - `scripts/add_missing_translations.py` **once at the very end** - (`json.dump indent=4, sort_keys=True, ensure_ascii=False`) — never bulk-regenerate a - `res/lang/*.json`. -- **F8 is a breaking default flip.** Refuse-plaintext-by-default stops existing - remote-RPC users cold until they opt in. Lands last, gated behind a persisted opt-in, - with release notes calling out the new `rpcallowplaintext` key and the Settings toggle. -- **Latent hazard, out of scope.** F1 surfaces (but doesn't fix) a second - double-`waitpid` window between `stop()`'s final blocking reap (`:1220`) and a - mid-sleep monitor iteration — file it as its own ticket. - ---- - -## Progress log - -- **F1/F2 integration tests** — ☑ added `testExecFailureReported` (F2) and `testDaemonCrashDetected` (F1) to `test_phase4.cpp`, driving the **real** `EmbeddedDaemon` fork/exec/waitpid code headlessly (POSIX; required linking `embedded_daemon.cpp` into the test target — its deps were already there). The F1 test hammers `isRunning()` from the test thread while the child exits, so it's a genuine regression test for the reap race. **The F2 test caught a real bug:** `start()`'s failure branch overwrote `startProcess()`'s precise `last_error_` ("…not executable or wrong architecture") with a generic "Failed to start dragonxd process" (because `setState(Error, …)` stores its message into `last_error_`), so the precise reason never reached `getLastError()`/the UI — **fixed** to preserve the detail (now also surfaced via the state callback / crash panel). Build-clean; `ctest` 1/1. - -- **F1** — ☑ landed: `isRunning()` (POSIX) now reads the atomic `state_` (predicate `Running || Stopping`) instead of calling `waitpid`, leaving `monitorProcess()` the sole reaper. Clean build (all targets link); `ctest` 1/1 passing. Not unit-testable — needs the manual `kill -SEGV` repro before release. -- **F2** — ☑ landed: `startProcess()` (POSIX) now creates a `FD_CLOEXEC` self-pipe before `fork()`; the child writes `errno` to it on `execv` failure, the parent reads EOF-vs-errno and, on failure, reaps the zombie + sets a precise `last_error_` ("not executable or wrong architecture") + returns `false` (so `start()` no longer reports `Running` for a daemon that never started). Parent-side `setpgid` is now best-effort with a `DEBUG_LOGF` on failure. Clean build; `ctest` 1/1 passing. Not unit-testable — needs the manual non-executable / wrong-arch-binary repro before release. -- **F8** — ☑ landed: `isLocalHost()` tightened to exact loopback via `isExactIPv4Loopback` (a `127.`-prefixed *hostname* like `127.evil.com` is no longer misclassified as local). `tryConnect()` now **refuses** a plaintext connection to a remote host instead of warn-and-proceeding — a local-network MITM can no longer capture `rpcuser:rpcpassword` — unless the user opts in with `rpcallowplaintext=1` in `DRAGONX.conf` (new `ConnectionConfig::allow_plaintext_remote` + `allowsPlaintextRemote()` policy). The refusal surfaces via status line + a one-time notification. New `testIsLocalHost` (12 assertions) + `testAllowsPlaintextRemote` (5). Clean build; `ctest` 1/1 passing. **Breaking — needs release notes; Settings-toggle UI deferred (see as-built note).** -- **F3** — ☑ landed: the connect loop now stamps `connect_stall_since_ = ImGui::GetTime()` the moment the daemon first goes "reachable but not ready" (warmup branch + `applyDaemonInitStatus`), and clears it in `onConnected` / `onDisconnected` / warmup-complete — all in `app_network.cpp`. The pure `util::connectHasStalled(stallSince, now, threshold)` helper (new `util/connect_stall.h`, default 45 s from `ui.toml`) drives a draw-list "Taking longer than expected" notice in `renderLoadingOverlay()` (title + elapsed-seconds body + full-node hint), guarded off while the daemon is in `State::Error`. Background retry continues, so the notice self-clears on connect. New `testConnectHasStalled` unit test (7 assertions). Clean build; `ctest` 1/1 passing. (Draw-list text, not buttons — see as-built note above.) -- **F6** — ☑ landed: `verifySaplingParams()` now hash-verifies each Sapling param against its pinned canonical SHA-256 (from `build-lite-backend-artifact.sh`), replacing the existence-only check, so a truncated/corrupt-but-present param is rejected instead of failing later on a shielded op. A `/.sapling_verified` marker keyed on `size:mtime` skips re-hashing ~48 MB on every startup. Logic extracted to the injectable `verifySaplingParamsIn(dir, digests)`; new `testVerifySaplingParams` unit test (valid / marker fast-path / wrong-hash / truncated / missing). Clean build; `ctest` 1/1 passing. -- **F5** — ☑ landed: `startEmbeddedDaemon()` now checks `extractEmbeddedResources()`'s return (abort with `sb_daemon_extract_failed` on failure) and the previously-dropped `copy_file` `error_code` in the daemon-binary fallback loop (abort with `sb_daemon_files_failed` incl. the dir), so a disk-full / truncated `dragonxd` write is surfaced up front instead of failing opaquely at spawn. An absent source file stays non-fatal. Two i18n keys added to `i18n.cpp`. Clean build; `ctest` 1/1 passing. -- **F7** — ☑ landed: new non-throwing `Platform::ensureDirectory(dir, outError)` in `util/platform.{h,cpp}` with one consistent message. Replaces the unchecked/throwing directory-create sites at `main.cpp:730` (pre-init: now logs + `MessageBoxA` on Windows + `return 1`), `connection.cpp:216` (autoDetectConfig now uses the ec overload — **no more uncaught `filesystem_error`** — and sets the new `ConnectionConfig::dir_error`), and both `app.cpp` daemon-dir sites (surface via `daemon_status_` + `return false`). Primary connect path (`app_network.cpp:243`) checks `dir_error` and bails to the status line instead of mislabelling it "waiting for config". `embedded_resources.cpp:270` left as-is (already correct). New `testPlatformEnsureDirectory` unit test (existing-dir / fresh-nested / empty / parent-is-file). Clean build; `ctest` 1/1 passing. -- **F4** — ☑ landed: `start()` now gates on a lingering datadir lock after the port bail. When `!skip_port_check_ && override_datadir_.empty()`, it polls `isDaemonProcessRunning()` with a bounded ~300 ms wait (3 × 100 ms, breaks early), then a pure header-inline `evaluateDatadirLockGate()` decides: if a sibling `dragonxd` is still alive it bails with a distinct **non-crash** `State::Error` ("…holding the data directory lock. Retrying shortly…") that never touches `crash_count_`, so the 3-strike cap can't trip; the connect loop's retry resumes once the lock clears. Isolated migrate-to-seed starts are exempt. New `testDatadirLockGate` unit test (5 assertions, proceed/bail/2× exempt) added to `test_phase4.cpp`. Clean build; `ctest` 1/1 passing. diff --git a/docs/release-notes/lite-v1.0.0.md b/docs/release-notes/lite-v1.0.0.md deleted file mode 100644 index 49cea28..0000000 --- a/docs/release-notes/lite-v1.0.0.md +++ /dev/null @@ -1,61 +0,0 @@ -**DragonX (DRGX) Lite Desktop Wallet — v1.0.0** - -ObsidianDragonLite is a lightweight companion to the ObsidianDragon full-node wallet. It skips the embedded full node entirely — **no multi-gigabyte blockchain download and near-instant startup** — by connecting to a DragonX lite-wallet (lightwalletd-style) server, while keeping the same native ImGui interface and shielded-first workflow. - -This is the **first release** of the Lite variant. - ---- - -## Why Lite? - -- **No blockchain to download.** Sync in seconds instead of hours; a few MB of state instead of many GB. -- **Same wallet, lighter footprint.** The familiar ObsidianDragon UI, shielded and transparent addresses, and address book — without running a node or miner. -- **Portable.** A single self-contained binary that stores its data in its own `ObsidianDragonLite` folder, so it coexists cleanly alongside the full-node wallet. - ---- - -## Features - -- **Fast, node-free operation** — connect to a DragonX lite-wallet server and sync in seconds; minimal disk and RAM. -- **Wallet lifecycle** — create a new wallet, restore from a seed phrase, or open an existing one; wallets auto-open on startup, with a first-run welcome prompt and **guided seed backup** on creation. -- **Shielded + transparent** — send, shield, and receive DRGX; per-address balances computed from unspent notes and UTXOs. -- **Keys & seed** — export your seed phrase and keys, and import keys, from Settings. -- **Passphrase encryption** — encrypt, unlock, lock, and decrypt the wallet; you're prompted to unlock at send time and on startup when the wallet is locked. -- **Encrypted messaging (HushChat)** — built-in **Contacts** and **Chat** with a seed-derived, **SilentDragonXLite-compatible** identity and a seed-encrypted local message store, so you can message other DragonX users end-to-end. -- **Server management** — a built-in **server browser with automatic failover**; switch servers with live reconnect/recovery, and a **"Redownload blocks"** action to rescan from the server. -- **Status & diagnostics** — a **Network tab** showing connection and sync status, plus an **interactive Console** for running backend commands and copyable error output. -- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese. -- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (DMG + .app), x86-64. -- QR code generation for receiving addresses -- Extensive theme and appearance options - ---- - -## How it differs from the full-node wallet - -- **No embedded `dragonxd`, no mining, no local blockchain explorer.** Chain data comes from the lite-wallet server rather than being verified locally by a full node — a lighter footprint in exchange for trusting the server for chain state. -- Everything key-related stays **on your device**: seed, keys, and (when enabled) the encryption passphrase never leave the machine. - ---- - -## Downloads - -| File | SHA-256 | -|------|---------| -| ObsidianDragonLite-1.0.0.AppImage | `a1459d6081124a6b8df47aa898b60c0237875e8cbb132e4fd8ae637673055ed4` | -| ObsidianDragonLite-1.0.0-Linux-x64.zip | `573af502a6372334572e4dc88bd26e0cf36ed543b9df1e835fd8e4abd295108d` | -| ObsidianDragonLite-1.0.0.exe | `28659efb770fa573bd1b1d0fcab1c1a3f9c7e46574185c3916cc9f669684852c` | -| ObsidianDragonLite-1.0.0-Windows-x64.zip | `f6434a931e873e5936ebe249b044d6c7a413694767c667374c91ee438259b50c` | -| ObsidianDragonLite-1.0.0-macOS-x86_64.dmg | `10f976f1453e6b1978cb7a85026fd033c85a10c1e66f436475aa628379a298c7` | -| ObsidianDragonLite-1.0.0-macOS-x86_64.app.zip | `3e7259c363f7be2e9027c5282c94b742f4be280c998203752dc0a0551f6ab68b` | - -## System Requirements - -- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.) -- **Windows:** x86-64, Windows 10 or later -- **macOS:** x86-64 (Intel; runs on Apple Silicon via Rosetta 2), macOS 10.15 Catalina or later -- Network access to a DragonX lite-wallet server. - -## License - -Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details. diff --git a/docs/release-notes/v2.0.0.md b/docs/release-notes/v2.0.0.md deleted file mode 100644 index 8100a1a..0000000 --- a/docs/release-notes/v2.0.0.md +++ /dev/null @@ -1,86 +0,0 @@ -**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.0** - -This is ObsidianDragon, a native ImGui-based wallet for the DragonX network. It ships with an embedded full-node daemon (`dragonxd`) and an integrated CPU miner (DRG-XMRig), giving users a complete, self-contained experience on Linux, Windows, and macOS. - ---- - -## What's New in v2.0.0 - -v2.0.0 is a major release. It adds **end-to-end encrypted HushChat messaging** and **BIP39 seed-phrase wallets** (with migrate-to-seed for legacy wallets), lets the wallet **update its own node and miner** with each install cryptographically verified, and ships a **security-hardening pass** across the updater, RPC, and secret-storage surfaces — plus full support for the **new multi-threaded `dragonxd`** and a large stability and UX overhaul. - -### New Features - -- **Encrypted messaging (HushChat)** — new **Contacts** and **Chat** tabs bring DRGX-native, end-to-end encrypted messaging. Your chat identity is derived from your wallet seed, messages are encrypted with libsodium (XChaCha20-Poly1305 / secretstream) and stored in a **seed-encrypted local database**, and the wire format is interoperable with SilentDragonX / SilentDragonXLite. -- **BIP39 seed-phrase wallets & migrate-to-seed** — new wallets are backed by a mnemonic **seed phrase** you can back up from Settings. Existing **legacy wallets can be migrated into a seed wallet** — the wallet mints a new mnemonic wallet, sweeps your funds to it, and adopts it only once the sweep is mined, keeping a timestamped `wallet.dat` backup throughout. (The bundled `dragonxd` supports the required mnemonic RPCs; older nodes degrade gracefully.) -- **In-app daemon updater** — Settings → **Node & Security → Daemon binary → "Check for updates…"** downloads the latest `dragonxd` from the project Gitea, verifies its **SHA-256 and a detached ed25519 signature** before installing, and replaces the binary **atomically while the node keeps running** (the new build takes effect on the next daemon start). A two-pane version picker lets you pin, downgrade, or install any published/pre-release build. -- **In-app miner updater** — an **"Update miner…"** action in the Mining tab fetches, verifies (SHA-256 + ed25519), and installs the latest DRG-XMRig, with the same version picker and a display of current vs. latest. -- **Daemon binary management** — the wallet no longer auto-overwrites a node you've dropped in; a dedicated Settings panel shows the managed binary and gathers all node actions (restart, rescan, repair) onto one toolbar. -- **Repair Wallet** — a one-click `-zapwallettxes=2` recovery in Settings, plus automatic wallet reconciliation after a bootstrap and runtime rescan support for pruned nodes. -- **Explorer search** — fuzzy, live (debounced) filtering of the block list by partial hash or height. -- **History sorting & fast load** — sort transactions by date or amount, a "Loading older history (N%)" indicator during the initial bulk load, and persisted history that surfaces pending sends immediately. -- **Smarter mining** — the thread benchmark now measures **sustained (thermally-throttled) hashrate** instead of an inflated initial burst, with GPU-aware idle mining and corrected idle thread scaling. - -### Security & Integrity - -- **Mandatory signature verification** — both the daemon and miner updaters **require** a valid ed25519 signature (checked against a key pinned in the wallet) in addition to the SHA-256; an install is refused if the signature is missing or invalid. -- **Hardened secret handling** — RPC credentials are wiped from memory after use, RPC responses are size-capped and scrubbed of secrets in logs, generated node config is written **owner-only (0600)**, and secrets copied to the clipboard **auto-clear**. -- **Safe on-disk writes** — settings, address book, and secret files are written **atomically with owner-only permissions**; the PIN vault fsyncs its secure-delete overwrite; SQLite cache growth is bounded. -- **Recipient validation** — sends validate recipient address checksums (Base58Check + Bech32) before building a transaction. -- **Path-traversal protection** — archive extraction (chain bootstrap and the updaters) rejects any entry that would escape the target directory (zip-slip), and the bootstrap fails closed on a missing checksum rather than trusting an unverified archive. -- **Robustness** — malformed RPC error JSON is guarded and sends are single-flight, preventing duplicate/ill-formed submissions. - -### Improvements - -- **New multi-threaded daemon support** — live Sapling note-witness rebuild progress, accurate sync-speed display, reliable rescan-completion detection, and RPC polling throttled during sync so block download isn't slowed. -- **Networking** — DragonX DNS seed nodes, `-maxconnections` passed to the daemon, and a peer count that stays current on every tab. -- **Node resilience** — non-blocking warmup so you can connect while the daemon is still initializing, a live daemon-console tail on the startup overlay, fast-failing connect probes, and mid-session disconnect detection that keeps the UI responsive (in-flight RPC calls abort cleanly on disconnect/shutdown). -- **Address list** — modernized with drag-to-transfer, labels, and view-only handling. -- **UI / DPI** — overlay dialogs scale correctly with the font/DPI setting, improved CJK font rendering, native language names in the language picker, and format-incompatible translations are rejected. -- **Settings** — an "Open data folder" button and confirmation modals for rescan and restart-daemon. - -### Bug Fixes - -Extensive fixes across history (shielded-tx ordering, stuck "refreshing history" banners, unconfirmed-badge stickiness), sends (correct fee passed to `z_sendmany`, fast-lane worker restart on reconnect, note-selection fee-gap workaround), rescan (accurate completion detection, no false "complete", no per-second error flood), RPC (mid-session disconnect handling, stale-refresh invalidation), and UI layout/i18n. See the commit history for the full list. - ---- - -## Features - -- **Full-node wallet** — send, receive, and verify DRGX transactions with a bundled `dragonxd` daemon; no external setup required. -- **Self-updating** — verify-and-install the latest node and miner from within the app (SHA-256 + ed25519 signature enforced). -- **Encrypted messaging** — built-in HushChat with a seed-derived identity, end-to-end encryption, and a seed-encrypted local message store, interoperable with SilentDragonX / SilentDragonXLite. -- **Seed-phrase wallets** — BIP39 mnemonic backup, and migrate-to-seed to upgrade a legacy wallet into a seed wallet. -- **Built-in CPU mining** — start/stop DRG-XMRig from the Mining tab with real-time hashrate and pool statistics, mine-when-idle support with configurable delay, and sustained-hashrate benchmarking. -- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese. -- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (**universal** DMG + .app); Linux/Windows x86-64, macOS Intel + Apple Silicon. -- Shielded (z-address) and transparent (t-address) send/receive -- QR code generation for receiving addresses -- Transaction history with memo support, date/amount sorting, and pending-send tracking -- Blockchain explorer with live block/transaction search -- Blockchain rescan and Sapling witness-rebuild with status-bar progress -- Repair Wallet and daemon-binary management in Settings -- Built-in console -- Extensive theme and appearance options - ---- - -## Downloads - -| File | SHA-256 | -|------|---------| -| ObsidianDragon-2.0.0.AppImage | `34c9ea57ec27bf415e59d2890d995ed9069bfce04d979d7d8d58aa18f4570aa1` | -| ObsidianDragon-2.0.0-Linux-x64.zip | `563004b4e45650dbfa9411d62fa1c59a3ca2199bd43a647b1614f2683bbdb5fa` | -| ObsidianDragon-2.0.0.exe | `56100ae59dc3c67445d015cac5412ca40465bf459ba182c5ad2477a3b95ff548` | -| ObsidianDragon-2.0.0-Windows-x64.zip | `ce9d067f36c5296288a473d3c2cceca7fcf807672a93a2084afc53c3b6e780d9` | -| ObsidianDragon-2.0.0-macOS-universal.dmg | `8a01b6c0c2b5bebd64a222ba4a5ad04a1b8851138f28458b5c7f767fbb65db66` | -| ObsidianDragon-2.0.0-macOS-universal.app.zip | `1bce1e5be15d9a2f3c6f42c95b23a0e89df142a73d65895252e4d8f50d19f541` | - -## System Requirements - -- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.) -- **Windows:** x86-64, Windows 10 or later -- **macOS:** Intel & Apple Silicon (universal binary), macOS 10.15 Catalina or later - -## License - -Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details. diff --git a/docs/wallet-hardening.md b/docs/wallet-hardening.md deleted file mode 100644 index 79994ad..0000000 --- a/docs/wallet-hardening.md +++ /dev/null @@ -1,167 +0,0 @@ -# Wallet Loading & Management — Hardening Plan - -Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings + -diagnosability QoL). Companion to the findings artifact. Line references are against `dev`. - -- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the - code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted - (W1-5), 1 raised (W5-3 Low→Med). -- **Severity:** 8 High · 12 Medium · 13 Low. - -Status legend: ☐ not started · ◐ in progress · ☑ landed & verified - ---- - -## Roadmap (ordered by risk; shared fixes grouped) - -| Phase | Findings | Theme | Status | -|-------|----------|-------|--------| -| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 | -| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ | -| **P1-A** | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) | -| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ | -| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 | -| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ | - ---- - -## P0-A — Secret hardening - -Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed -key/passphrase paths, plus console redaction and deleting the plaintext export. - -- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an - allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`, - `encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`, - `signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and - keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for - unit testing. **← implementing first (self-contained + testable).** -- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport` plaintext - key dump after `z_importwallet` succeeds (overwrite-then-unlink). -- **W4-3 (High)** `app_network.cpp:4481` — `sodium_memzero` the concatenated all-keys string in - `exportAllKeys`; write the backup 0600. (Also unify with `ExportAllKeysDialog` — QoL.) -- **W4-1 (High)** `app_network.cpp:3801` — zero the key copies in `importPrivateKey`/`sweepPrivateKey` - (local + worker-lambda copies). -- **W2-3 (Med)** `app_security.cpp:1481` — zero the passphrase threaded through the decrypt lambda chain. -- **W4-5 (Med)** `app.cpp:3577` — the seed-backup `.txt` is a permanent predictable cleartext seed; - at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase. -- **W5-3 (Med)** `lite_wallet_lifecycle_service.cpp:322` — remove the dead `passphrase` field from the - lite create/open/restore requests (unused; a secret copied for nothing). - -## P0-B — Encryption integrity - -- **W2-2 / W4-2 (High)** `wallet_security_controller.h:89` — the wizard's deferred encryption is - in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a - wallet the user believes is encrypted stays plaintext. Fix: persist a lightweight - `encryption_requested_but_incomplete` settings flag (NEVER the passphrase) when - `beginDeferredEncryption` is called; surface a persistent warning banner while it's set; clear it - only on confirmed `encryptwallet` success; on next connect, if set, re-prompt for the passphrase to - complete it. -- **W2-4 (Med)** `app_security.cpp:480` — `lockWallet` only sets `locked` on RPC success; log the - failure and notify (currently a silent no-op that can leave the wallet unlocked). - -## P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully) - -- **W3-1 (High)** `app_network.cpp:4327` — adopt hardcodes `datadir + "/wallet.dat"`; use - `settings_->getActiveWalletFile()` so migrating a non-default active wallet swaps the right file. -- **W3-2 (High)** `seed_wallet_creator.cpp:57` — `remove_all(/seed-migrate)` unconditionally - at Phase-1 start; refuse to wipe if a temp `DRAGONX/wallet.dat` already exists (a prior un-adopted - swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry. -- **W3-4 (Med)** `app_network.cpp:1124` — block wallet switching while a migration is *pending* - (`getSeedMigrationPending()`), not only while the dialog is open. -- **W3-3 (Med)** `app_network.cpp:4231` — persist the sweep opid so an app-close mid-Sweeping can - resume/re-poll it instead of silently dropping the txid. - -## P1-B — Missing/wrong wallet-file safety - -- **W1-1 (High)** `app_network.cpp:1109` — `fs::exists()`-check the target wallet file in - `switchToWallet()` and before the first daemon launch at startup; if missing, block with an explicit - "Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the - daemon fabricate an empty wallet. -- **W1-3 (Med)** `app_network.cpp:1095` — defer the `syncedHere=true` stamp to the first successful - address/balance readback (idHash non-empty), not bare `onConnected()`. -- **W1-2 (Med)** `app_network.cpp:198` — split `DB_CORRUPT`-specific strings from the generic "Error - loading wallet" fallback; give `DB_TOO_NEW` its own message/action (not a salvage offer). -- **W1-4 (Low)** `wallets_dialog.h:393` — re-`fs::exists()` the in-datadir row before switching (match - the out-of-datadir path). - -## P2 — State & lite persistence - -- **W6-2 (Med)** `network_refresh_service.cpp:1183` — record a per-field last-success timestamp / a - "refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current. -- **W5-1 / W5-2 (Med)** `lite_wallet_controller.cpp:78,603` — `liteLog()` the failed save and bubble a - one-shot UI warning (both call sites currently discard the bool). -- **W6-1 (Med)** `wallet_state.h:313` — reset `mining`/`pool_mining` in `clear()` (or comment why not). -- **W6-3 (Low)** `address_book.cpp:46` — per-entry try/catch: skip + count malformed entries instead - of discarding the whole list. - -## F — Diagnostics foundation + QoL - -Land W7-2 first — it unblocks the rest. - -- **W7-2 (Med)** `logger.cpp:31` — call `Logger::instance().init(/dragonx-debug.log)` early in - `main()` on all platforms; add an "Open log folder" action. -- **W7-3 (Med)** `main.cpp:144` — add a `sigaction`-based crash handler writing `dragonx-crash.log` on - POSIX (mirror the Windows SEH path). -- **W7-4 (Low)** `logger.cpp:39` — size-cap/rotate the log on `init()`. -- **QoL** — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner; - refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured - switch/migration audit logging; restore-from-seed entry point (W4-4, effort L). - ---- - -## Progress log - -- **Adversarial review of the 3 diagnostics UI features** — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found **no** Push/Pop imbalance. Fixes landed: - - **(Med) Alert popup grew off the right edge** — pivot `(0,1)` pinned the panel's *left* edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicit `SetNextWindowPos` pivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-*right* corner at the bell (pivot `(1,1)`, at `bellMax.x`) so it grows left over the canvas. - - **(Low) Staleness badge could flash red on reconnect** — `WalletState::clear()` reset everything *except* the four `last_*_update` stamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps in `clear()` (all readers treat 0 as "never"; verified `app_network.cpp:1473` guards on `!= 0`). - - **(Low) Banner min-height floor wasn't DPI-scaled** — `std::max(minH, baseH*vScale())` compared a raw-px floor against a scaled value; now `minH * dpiScale()`. - - **(Low) New i18n keys weren't in `res/lang/`** — back-filled all 16 diagnostics/QoL keys (this session's node_banner_*/data_stale_*/alerts_*/settings_*/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font. -- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 1–4s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.** -- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).** -- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge. -- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge. -- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works): - - **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed). - - **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter. - - **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded. - Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge. -- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed: - - **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss). - - **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads). - - **W6-1 (Med):** `WalletState::clear()` didn't reset `mining`/`pool_mining`, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset in `clear()` (the daemon restarts on switch, so mining genuinely stops). - - **W6-3 (Low):** `AddressBook::load()` did `entries_.clear()` then threw on the first non-object element — discarding **every** contact. Now it guards `is_object()` + per-entry try/catch, skipping and counting malformed entries. - Build-clean; `ctest` 1/1. **Remaining P2:** W6-2 (surface refresh staleness — the timestamps exist in `WalletState`; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle). -- **P1-B / W1-3 + startup wallet-existence guard** — ☑ landed: - - **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open. - - **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened. - Build-clean; `ctest` 1/1. -- **P1-A / W3-3 (sweep opid persistence)** — ☑ **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang. - - **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error. - - **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale". - - **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct. - - **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform. -- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed: - - **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU). - - **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version". - Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch). -- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully): - - **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race). - - **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(/seed-migrate)` unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) when `DRAGONX/wallet.dat` already exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one. - - **W3-4 (Med):** `switchToWallet` only blocked switching while the migration *dialog* was open; closing it via "Later" mid-migration dropped the guard. Now also blocks while `getSeedMigrationPending()`. - Build-clean; `ctest` 1/1. **Remaining P1-A:** W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid). - -- **P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail)** — ☑ landed: - - **W2-2:** the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with **no record it was ever requested** — the user believing it was encrypted. Now a persisted `encryption_pending` settings flag is set the moment encryption is requested (**never the passphrase** — only the fact). `refreshWalletEncryptionState()` reconciles it on every connect: wallet observed **encrypted** → clear the flag; wallet **not** encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session **"your wallet is NOT encrypted — open Settings to finish"** warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice. - - **W2-4:** `lockWallet()`'s continuation only handled success — a failed `walletlock` silently left the wallet **unlocked** (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed. - Touches `settings.{h,cpp}`, `app_wizard.cpp`, `app_security.cpp`, `app.h`. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean, `ctest` 1/1. - -- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore** → `encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open** → `unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)* -- **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to ". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: ", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision. -- **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code: - - **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy. - - **W4-3** `exportAllKeys`/`backupWallet`: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written via `Platform::writeFileAtomically(..., restrictPermissions=true)` (atomic + 0600) instead of a umask-default `ofstream`. - - **W2-3** decrypt-wallet passphrase: `std::move`-captured into the worker lambda (so no plaintext copy is left in the calling frame) and `sodium_memzero`'d right after `unlockWallet` (its only use). - Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean; `ctest` 1/1 (no regression). **Remaining in P0-A:** W5-3 (remove the dead lite `passphrase` field), W4-5 (predictable plaintext seed-backup file). -- **P0-A / W2-1** — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (`obsidiandecryptexport…`) as soon as the `z_importwallet` attempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (`wallet.dat.encrypted.bak`). `app_security.cpp` (after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean, `ctest` 1/1 (no regression). -- **P0-A / W7-1** — ☑ landed: `RedactConsoleCommand`/`ConsoleCommandCarriesSecret` in `console_tab_helpers` redact secret-bearing commands (an allowlist of 13 first-tokens: `walletpassphrase`, `encryptwallet`, `z_importkey`, …) to `> walletpassphrase ****` before they hit the console echo AND the recall history; the real command still executes unredacted. Wired into `submitConsoleCommand` (`console_tab.cpp`). New `testConsoleSecretRedaction` (11 assertions). Clean build; `ctest` 1/1. (Output-secret commands like `z_exportkey` — result redaction — remain a follow-up.) diff --git a/overview_mockup.html b/overview_mockup.html deleted file mode 100644 index 3b051ac..0000000 --- a/overview_mockup.html +++ /dev/null @@ -1,270 +0,0 @@ - - - - - -ObsidianDragon — Overview (Marble) · mockup - - - -
- - -
- - ObsidianDragon -
- - - -
-
- -
- - - - - -
- -
-
- -
-
0.00000000DRGX
-
$-.-- USD
-
-
$0.0130
-0.4%
-
-
-
-
SHIELDED
-
-
0.00000000 (50%)
-
-
-
TRANSPARENT
-
-
0.00000000 (50%)
-
-
-
- -
Your Addresses
-
-
Filter…
-
- - Hide 0 Balances -
-
- - -
-
- -
No addresses yet
- -
Showing 0 of 2 addresses
-
RECENT TRANSACTIONS
-
No transactions yet
-
-
-
- - -
- Connected - |Block: 3132104 - |Peers: 8 - |Net: 352.77 KH/s - v2.0.0 -
-
- - - -