Compare commits
8 Commits
master
...
d188a08db7
| Author | SHA1 | Date | |
|---|---|---|---|
| d188a08db7 | |||
| ff5f5ddf23 | |||
| 56f9802fb9 | |||
| efb271cb9a | |||
| eb69e491b9 | |||
| 2675b8ab93 | |||
| b3444e0a89 | |||
| 45b652f514 |
58
CHANGELOG.md
Normal file
58
CHANGELOG.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable user-facing changes to ObsidianDragon are documented here. The format loosely
|
||||||
|
follows [Keep a Changelog](https://keepachangelog.com/); the project uses Conventional Commits.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### ⚠️ Breaking changes
|
||||||
|
|
||||||
|
- **Remote RPC over plain HTTP is now refused by default.** If your wallet is configured to
|
||||||
|
reach a **remote** `rpchost`/`rpcconnect` **without TLS**, it will no longer connect — it
|
||||||
|
previously sent your `rpcuser`/`rpcpassword` in cleartext (capturable by anyone on the
|
||||||
|
network path) after only a dismissible warning. To reconnect, either:
|
||||||
|
- add **`rpctls=1`** to `DRAGONX.conf` (preferred, if your daemon supports TLS), or
|
||||||
|
- add **`rpcallowplaintext=1`** to `DRAGONX.conf` to explicitly accept the plaintext link.
|
||||||
|
|
||||||
|
Local and embedded daemons (`127.0.0.0/8`, `localhost`, `::1`) are unaffected.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Refuse remote plaintext RPC credential transmission by default (see Breaking changes above).
|
||||||
|
- Tightened localhost detection: a hostname that merely *starts* with `127.` (e.g.
|
||||||
|
`127.evil.com`) is no longer mistaken for a loopback address, so it can no longer bypass the
|
||||||
|
plaintext-RPC protection.
|
||||||
|
- Sapling parameters are now integrity-checked (SHA-256) against pinned canonical digests
|
||||||
|
before use, instead of only checking that the files exist. A truncated or corrupt parameter
|
||||||
|
file is caught up front rather than surfacing later as a confusing shielded-operation failure.
|
||||||
|
(Cached via a `size:mtime` marker so it doesn't re-hash ~48 MB on every launch.)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Daemon crashes are no longer occasionally missed: a race between the UI thread and the
|
||||||
|
process monitor could consume the daemon's exit status, hiding a crash and defeating the
|
||||||
|
automatic-restart cap. The monitor is now the sole reaper.
|
||||||
|
- A daemon that fails to launch (missing execute permission, wrong architecture, corrupt
|
||||||
|
binary) now reports a precise error immediately instead of briefly showing "running" and
|
||||||
|
then a generic "exited unexpectedly (exit code 127)".
|
||||||
|
- A quick stop→start no longer triggers a restart storm: the wallet now waits briefly for a
|
||||||
|
previous daemon to release the data-directory lock and shows a clear, non-crash message
|
||||||
|
instead of exhausting the crash-restart budget.
|
||||||
|
- Failures while writing the daemon binaries or Sapling parameters (disk full, permission
|
||||||
|
denied) are now surfaced clearly up front instead of failing opaquely when the daemon later
|
||||||
|
can't start.
|
||||||
|
- Directory-creation failures on startup (read-only home, permission denied) now produce a
|
||||||
|
clear "Cannot create <dir>" message instead of a confusing downstream "config missing" /
|
||||||
|
"binary not found" error (or, in one path, an uncaught exception).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A "Taking longer than expected" notice now appears if the daemon is reachable but hasn't
|
||||||
|
finished initializing after ~45 s (configurable via `ui.toml`), with guidance to restart the
|
||||||
|
daemon or open the Console — instead of an indefinite silent spinner. It clears itself
|
||||||
|
automatically once the daemon connects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Engineering detail and the finding-by-finding rationale for this batch live in
|
||||||
|
`docs/daemon-startup-hardening.md`.
|
||||||
@@ -1129,6 +1129,7 @@ if(BUILD_TESTING)
|
|||||||
src/data/address_book.cpp
|
src/data/address_book.cpp
|
||||||
src/data/wallet_index.cpp
|
src/data/wallet_index.cpp
|
||||||
src/daemon/lifecycle_adapters.cpp
|
src/daemon/lifecycle_adapters.cpp
|
||||||
|
src/daemon/embedded_daemon.cpp
|
||||||
src/rpc/connection.cpp
|
src/rpc/connection.cpp
|
||||||
src/config/settings.cpp
|
src/config/settings.cpp
|
||||||
src/resources/embedded_resources.cpp
|
src/resources/embedded_resources.cpp
|
||||||
|
|||||||
549
docs/daemon-startup-hardening.md
Normal file
549
docs/daemon-startup-hardening.md
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
# 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> 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 `<params_dir>/.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.
|
||||||
@@ -734,6 +734,9 @@
|
|||||||
"lite_working": "In Arbeit…",
|
"lite_working": "In Arbeit…",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
"loading_addresses": "Adressen werden geladen...",
|
"loading_addresses": "Adressen werden geladen...",
|
||||||
|
"loading_stall_body": "Der Daemon initialisiert seit %.0f s. Das kann nach einem Update oder beim ersten Start normal sein (Laden des Blockindex oder erneutes Scannen) – die Verbindung wird automatisch hergestellt, sobald er bereit ist.",
|
||||||
|
"loading_stall_hint": "Hängt es noch? Öffne die Einstellungen und nutze „Daemon neu starten“ oder sieh in der Konsole nach Details.",
|
||||||
|
"loading_stall_title": "Dauert länger als erwartet",
|
||||||
"loading_transactions": "Transaktionen werden geladen",
|
"loading_transactions": "Transaktionen werden geladen",
|
||||||
"local_hashrate": "Lokale Hashrate",
|
"local_hashrate": "Lokale Hashrate",
|
||||||
"low_spec_mode": "Energiesparmodus",
|
"low_spec_mode": "Energiesparmodus",
|
||||||
@@ -1154,6 +1157,8 @@
|
|||||||
"sb_connecting_external": "Verbindung zu externem Daemon...",
|
"sb_connecting_external": "Verbindung zu externem Daemon...",
|
||||||
"sb_connecting_generic": "Verbindung zum Daemon...",
|
"sb_connecting_generic": "Verbindung zum Daemon...",
|
||||||
"sb_daemon_crashed": "Daemon ist %d mal abgestürzt",
|
"sb_daemon_crashed": "Daemon ist %d mal abgestürzt",
|
||||||
|
"sb_daemon_extract_failed": "Daemon-Dateien konnten nicht geschrieben werden – prüfe freien Speicherplatz und Berechtigungen.",
|
||||||
|
"sb_daemon_files_failed": "Daemon-Dateien konnten nicht nach %s geschrieben werden – prüfe freien Speicherplatz und Berechtigungen.",
|
||||||
"sb_daemon_not_found": "Daemon nicht gefunden",
|
"sb_daemon_not_found": "Daemon nicht gefunden",
|
||||||
"sb_daemon_start_failed": "dragonxd konnte nicht gestartet werden",
|
"sb_daemon_start_failed": "dragonxd konnte nicht gestartet werden",
|
||||||
"sb_dragonxd_running": "dragonxd läuft",
|
"sb_dragonxd_running": "dragonxd läuft",
|
||||||
@@ -1169,6 +1174,7 @@
|
|||||||
"sb_net_mhs": "Netz: %.2f MH/s",
|
"sb_net_mhs": "Netz: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf nicht gefunden",
|
"sb_no_conf": "DRAGONX.conf nicht gefunden",
|
||||||
"sb_peers": "Peers: %zu",
|
"sb_peers": "Peers: %zu",
|
||||||
|
"sb_plaintext_remote_blocked": "RPC-Anmeldedaten werden nicht im Klartext an einen entfernten Host gesendet. Füge rpcallowplaintext=1 zu DRAGONX.conf hinzu, um dies zu erlauben, oder aktiviere TLS mit rpctls=1.",
|
||||||
"sb_rescanning": "Neuscan",
|
"sb_rescanning": "Neuscan",
|
||||||
"sb_rescanning_pct": "Neuscan %.0f%%",
|
"sb_rescanning_pct": "Neuscan %.0f%%",
|
||||||
"sb_restarting_daemon": "Daemon wird neu gestartet...",
|
"sb_restarting_daemon": "Daemon wird neu gestartet...",
|
||||||
|
|||||||
@@ -734,6 +734,9 @@
|
|||||||
"lite_working": "Trabajando…",
|
"lite_working": "Trabajando…",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
"loading_addresses": "Cargando direcciones...",
|
"loading_addresses": "Cargando direcciones...",
|
||||||
|
"loading_stall_body": "El daemon lleva %.0f s inicializándose. Esto puede ser normal tras una actualización o en el primer inicio (cargando el índice de bloques o reescaneando); se conectará automáticamente cuando esté listo.",
|
||||||
|
"loading_stall_hint": "¿Sigue bloqueado? Abre Ajustes y usa Reiniciar daemon, o revisa la Consola para más detalles.",
|
||||||
|
"loading_stall_title": "Está tardando más de lo esperado",
|
||||||
"loading_transactions": "Cargando transacciones",
|
"loading_transactions": "Cargando transacciones",
|
||||||
"local_hashrate": "Tasa Hash Local",
|
"local_hashrate": "Tasa Hash Local",
|
||||||
"low_spec_mode": "Modo bajo rendimiento",
|
"low_spec_mode": "Modo bajo rendimiento",
|
||||||
@@ -1154,6 +1157,8 @@
|
|||||||
"sb_connecting_external": "Conectando a daemon externo...",
|
"sb_connecting_external": "Conectando a daemon externo...",
|
||||||
"sb_connecting_generic": "Conectando al daemon...",
|
"sb_connecting_generic": "Conectando al daemon...",
|
||||||
"sb_daemon_crashed": "El daemon se bloqueó %d veces",
|
"sb_daemon_crashed": "El daemon se bloqueó %d veces",
|
||||||
|
"sb_daemon_extract_failed": "No se pudieron escribir los archivos del daemon: comprueba el espacio libre en disco y los permisos.",
|
||||||
|
"sb_daemon_files_failed": "No se pudieron escribir los archivos del daemon en %s: comprueba el espacio libre en disco y los permisos.",
|
||||||
"sb_daemon_not_found": "Daemon no encontrado",
|
"sb_daemon_not_found": "Daemon no encontrado",
|
||||||
"sb_daemon_start_failed": "No se pudo iniciar dragonxd",
|
"sb_daemon_start_failed": "No se pudo iniciar dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd ejecutándose",
|
"sb_dragonxd_running": "dragonxd ejecutándose",
|
||||||
@@ -1169,6 +1174,7 @@
|
|||||||
"sb_net_mhs": "Red: %.2f MH/s",
|
"sb_net_mhs": "Red: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf no encontrado",
|
"sb_no_conf": "DRAGONX.conf no encontrado",
|
||||||
"sb_peers": "Pares: %zu",
|
"sb_peers": "Pares: %zu",
|
||||||
|
"sb_plaintext_remote_blocked": "Se rechaza enviar credenciales RPC en texto plano a un host remoto. Añade rpcallowplaintext=1 a DRAGONX.conf para permitirlo, o habilita TLS con rpctls=1.",
|
||||||
"sb_rescanning": "Reescaneando",
|
"sb_rescanning": "Reescaneando",
|
||||||
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
||||||
"sb_restarting_daemon": "Reiniciando daemon...",
|
"sb_restarting_daemon": "Reiniciando daemon...",
|
||||||
|
|||||||
@@ -734,6 +734,9 @@
|
|||||||
"lite_working": "En cours…",
|
"lite_working": "En cours…",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"loading_addresses": "Chargement des adresses...",
|
"loading_addresses": "Chargement des adresses...",
|
||||||
|
"loading_stall_body": "Le démon s'initialise depuis %.0f s. Cela peut être normal après une mise à jour ou au premier lancement (chargement de l'index des blocs ou nouvelle analyse) — la connexion se fera automatiquement une fois prêt.",
|
||||||
|
"loading_stall_hint": "Toujours bloqué ? Ouvrez les Paramètres et utilisez Redémarrer le démon, ou consultez la Console pour plus de détails.",
|
||||||
|
"loading_stall_title": "Cela prend plus de temps que prévu",
|
||||||
"loading_transactions": "Chargement des transactions",
|
"loading_transactions": "Chargement des transactions",
|
||||||
"local_hashrate": "Hashrate local",
|
"local_hashrate": "Hashrate local",
|
||||||
"low_spec_mode": "Mode économie",
|
"low_spec_mode": "Mode économie",
|
||||||
@@ -1154,6 +1157,8 @@
|
|||||||
"sb_connecting_external": "Connexion au daemon externe...",
|
"sb_connecting_external": "Connexion au daemon externe...",
|
||||||
"sb_connecting_generic": "Connexion au daemon...",
|
"sb_connecting_generic": "Connexion au daemon...",
|
||||||
"sb_daemon_crashed": "Le daemon a planté %d fois",
|
"sb_daemon_crashed": "Le daemon a planté %d fois",
|
||||||
|
"sb_daemon_extract_failed": "Échec de l'écriture des fichiers du démon — vérifiez l'espace disque libre et les permissions.",
|
||||||
|
"sb_daemon_files_failed": "Échec de l'écriture des fichiers du démon dans %s — vérifiez l'espace disque libre et les permissions.",
|
||||||
"sb_daemon_not_found": "Daemon introuvable",
|
"sb_daemon_not_found": "Daemon introuvable",
|
||||||
"sb_daemon_start_failed": "Impossible de démarrer dragonxd",
|
"sb_daemon_start_failed": "Impossible de démarrer dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd en cours",
|
"sb_dragonxd_running": "dragonxd en cours",
|
||||||
@@ -1169,6 +1174,7 @@
|
|||||||
"sb_net_mhs": "Rés: %.2f MH/s",
|
"sb_net_mhs": "Rés: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf introuvable",
|
"sb_no_conf": "DRAGONX.conf introuvable",
|
||||||
"sb_peers": "Pairs : %zu",
|
"sb_peers": "Pairs : %zu",
|
||||||
|
"sb_plaintext_remote_blocked": "Refus d'envoyer les identifiants RPC en clair vers un hôte distant. Ajoutez rpcallowplaintext=1 à DRAGONX.conf pour l'autoriser, ou activez TLS avec rpctls=1.",
|
||||||
"sb_rescanning": "Rescan",
|
"sb_rescanning": "Rescan",
|
||||||
"sb_rescanning_pct": "Rescan %.0f%%",
|
"sb_rescanning_pct": "Rescan %.0f%%",
|
||||||
"sb_restarting_daemon": "Redémarrage du daemon...",
|
"sb_restarting_daemon": "Redémarrage du daemon...",
|
||||||
|
|||||||
@@ -734,6 +734,9 @@
|
|||||||
"lite_working": "処理中…",
|
"lite_working": "処理中…",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
"loading_addresses": "アドレスを読み込み中...",
|
"loading_addresses": "アドレスを読み込み中...",
|
||||||
|
"loading_stall_body": "デーモンは %.0f 秒間初期化しています。アップデート後や初回起動時(ブロックインデックスの読み込みや再スキャン)は正常な場合があります。準備ができ次第、自動的に接続します。",
|
||||||
|
"loading_stall_hint": "まだ動かない場合は、設定を開いて「デーモンを再起動」を使うか、コンソールで詳細を確認してください。",
|
||||||
|
"loading_stall_title": "予想より時間がかかっています",
|
||||||
"loading_transactions": "トランザクションを読み込み中",
|
"loading_transactions": "トランザクションを読み込み中",
|
||||||
"local_hashrate": "ローカルハッシュレート",
|
"local_hashrate": "ローカルハッシュレート",
|
||||||
"low_spec_mode": "省電力モード",
|
"low_spec_mode": "省電力モード",
|
||||||
|
|||||||
@@ -734,6 +734,8 @@
|
|||||||
"lite_working": "작업 중…",
|
"lite_working": "작업 중…",
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
"loading_addresses": "주소 로딩 중...",
|
"loading_addresses": "주소 로딩 중...",
|
||||||
|
"loading_stall_body": "데몬이 %.0f초 동안 초기화 중입니다. 업데이트 후나 첫 실행 시(블록 인덱스 로드 또는 재스캔)에는 정상일 수 있습니다. 준비되면 자동으로 연결됩니다.",
|
||||||
|
"loading_stall_title": "예상보다 오래 걸리고 있습니다",
|
||||||
"loading_transactions": "거래를 불러오는 중",
|
"loading_transactions": "거래를 불러오는 중",
|
||||||
"local_hashrate": "로컬 해시레이트",
|
"local_hashrate": "로컬 해시레이트",
|
||||||
"low_spec_mode": "저사양 모드",
|
"low_spec_mode": "저사양 모드",
|
||||||
@@ -1154,6 +1156,8 @@
|
|||||||
"sb_connecting_external": "외부 데몬에 연결 중...",
|
"sb_connecting_external": "외부 데몬에 연결 중...",
|
||||||
"sb_connecting_generic": "데몬에 연결 중...",
|
"sb_connecting_generic": "데몬에 연결 중...",
|
||||||
"sb_daemon_crashed": "데몬이 %d회 충돌함",
|
"sb_daemon_crashed": "데몬이 %d회 충돌함",
|
||||||
|
"sb_daemon_extract_failed": "데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
|
||||||
|
"sb_daemon_files_failed": "%s에 데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
|
||||||
"sb_daemon_not_found": "데몬을 찾을 수 없음",
|
"sb_daemon_not_found": "데몬을 찾을 수 없음",
|
||||||
"sb_daemon_start_failed": "dragonxd를 시작할 수 없습니다",
|
"sb_daemon_start_failed": "dragonxd를 시작할 수 없습니다",
|
||||||
"sb_dragonxd_running": "dragonxd 실행 중",
|
"sb_dragonxd_running": "dragonxd 실행 중",
|
||||||
@@ -1169,6 +1173,7 @@
|
|||||||
"sb_net_mhs": "네트: %.2f MH/s",
|
"sb_net_mhs": "네트: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
|
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
|
||||||
"sb_peers": "피어: %zu",
|
"sb_peers": "피어: %zu",
|
||||||
|
"sb_plaintext_remote_blocked": "원격 호스트로 RPC 자격 증명을 평문으로 보내는 것을 거부했습니다. 허용하려면 DRAGONX.conf에 rpcallowplaintext=1을 추가하거나 rpctls=1로 TLS를 활성화하세요.",
|
||||||
"sb_rescanning": "재스캔",
|
"sb_rescanning": "재스캔",
|
||||||
"sb_rescanning_pct": "재스캔 %.0f%%",
|
"sb_rescanning_pct": "재스캔 %.0f%%",
|
||||||
"sb_restarting_daemon": "데몬 재시작 중...",
|
"sb_restarting_daemon": "데몬 재시작 중...",
|
||||||
|
|||||||
@@ -734,6 +734,9 @@
|
|||||||
"lite_working": "Processando…",
|
"lite_working": "Processando…",
|
||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
"loading_addresses": "Carregando endereços...",
|
"loading_addresses": "Carregando endereços...",
|
||||||
|
"loading_stall_body": "O daemon está inicializando há %.0f s. Isso pode ser normal após uma atualização ou no primeiro início (carregando o índice de blocos ou reescaneando) — ele se conectará automaticamente quando estiver pronto.",
|
||||||
|
"loading_stall_hint": "Ainda travado? Abra as Configurações e use Reiniciar daemon, ou verifique o Console para mais detalhes.",
|
||||||
|
"loading_stall_title": "Está demorando mais do que o esperado",
|
||||||
"loading_transactions": "Carregando transações",
|
"loading_transactions": "Carregando transações",
|
||||||
"local_hashrate": "Hashrate Local",
|
"local_hashrate": "Hashrate Local",
|
||||||
"low_spec_mode": "Modo econômico",
|
"low_spec_mode": "Modo econômico",
|
||||||
@@ -1154,6 +1157,8 @@
|
|||||||
"sb_connecting_external": "Conectando ao daemon externo...",
|
"sb_connecting_external": "Conectando ao daemon externo...",
|
||||||
"sb_connecting_generic": "Conectando ao daemon...",
|
"sb_connecting_generic": "Conectando ao daemon...",
|
||||||
"sb_daemon_crashed": "O daemon travou %d vezes",
|
"sb_daemon_crashed": "O daemon travou %d vezes",
|
||||||
|
"sb_daemon_extract_failed": "Falha ao gravar os arquivos do daemon — verifique o espaço livre em disco e as permissões.",
|
||||||
|
"sb_daemon_files_failed": "Falha ao gravar os arquivos do daemon em %s — verifique o espaço livre em disco e as permissões.",
|
||||||
"sb_daemon_not_found": "Daemon não encontrado",
|
"sb_daemon_not_found": "Daemon não encontrado",
|
||||||
"sb_daemon_start_failed": "Não foi possível iniciar o dragonxd",
|
"sb_daemon_start_failed": "Não foi possível iniciar o dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd em execução",
|
"sb_dragonxd_running": "dragonxd em execução",
|
||||||
@@ -1169,6 +1174,7 @@
|
|||||||
"sb_net_mhs": "Rede: %.2f MH/s",
|
"sb_net_mhs": "Rede: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf não encontrado",
|
"sb_no_conf": "DRAGONX.conf não encontrado",
|
||||||
"sb_peers": "Pares: %zu",
|
"sb_peers": "Pares: %zu",
|
||||||
|
"sb_plaintext_remote_blocked": "Recusando enviar credenciais RPC em texto simples para um host remoto. Adicione rpcallowplaintext=1 ao DRAGONX.conf para permitir, ou habilite TLS com rpctls=1.",
|
||||||
"sb_rescanning": "Reescaneando",
|
"sb_rescanning": "Reescaneando",
|
||||||
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
||||||
"sb_restarting_daemon": "Reiniciando daemon...",
|
"sb_restarting_daemon": "Reiniciando daemon...",
|
||||||
|
|||||||
@@ -734,6 +734,9 @@
|
|||||||
"lite_working": "Обработка…",
|
"lite_working": "Обработка…",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
"loading_addresses": "Загрузка адресов...",
|
"loading_addresses": "Загрузка адресов...",
|
||||||
|
"loading_stall_body": "Демон инициализируется уже %.0f с. Это может быть нормально после обновления или при первом запуске (загрузка индекса блоков или повторное сканирование) — соединение установится автоматически, когда он будет готов.",
|
||||||
|
"loading_stall_hint": "Всё ещё не отвечает? Откройте Настройки и нажмите «Перезапустить демон» или посмотрите подробности в Консоли.",
|
||||||
|
"loading_stall_title": "Занимает больше времени, чем ожидалось",
|
||||||
"loading_transactions": "Загрузка транзакций",
|
"loading_transactions": "Загрузка транзакций",
|
||||||
"local_hashrate": "Локальный хешрейт",
|
"local_hashrate": "Локальный хешрейт",
|
||||||
"low_spec_mode": "Режим экономии",
|
"low_spec_mode": "Режим экономии",
|
||||||
@@ -1154,6 +1157,8 @@
|
|||||||
"sb_connecting_external": "Подключение к внешнему демону...",
|
"sb_connecting_external": "Подключение к внешнему демону...",
|
||||||
"sb_connecting_generic": "Подключение к демону...",
|
"sb_connecting_generic": "Подключение к демону...",
|
||||||
"sb_daemon_crashed": "Демон упал %d раз",
|
"sb_daemon_crashed": "Демон упал %d раз",
|
||||||
|
"sb_daemon_extract_failed": "Не удалось записать файлы демона — проверьте свободное место на диске и права доступа.",
|
||||||
|
"sb_daemon_files_failed": "Не удалось записать файлы демона в %s — проверьте свободное место на диске и права доступа.",
|
||||||
"sb_daemon_not_found": "Демон не найден",
|
"sb_daemon_not_found": "Демон не найден",
|
||||||
"sb_daemon_start_failed": "Не удалось запустить dragonxd",
|
"sb_daemon_start_failed": "Не удалось запустить dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd запущен",
|
"sb_dragonxd_running": "dragonxd запущен",
|
||||||
@@ -1169,6 +1174,7 @@
|
|||||||
"sb_net_mhs": "Сеть: %.2f MH/s",
|
"sb_net_mhs": "Сеть: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf не найден",
|
"sb_no_conf": "DRAGONX.conf не найден",
|
||||||
"sb_peers": "Пиры: %zu",
|
"sb_peers": "Пиры: %zu",
|
||||||
|
"sb_plaintext_remote_blocked": "Отправка учётных данных RPC открытым текстом на удалённый узел запрещена. Добавьте rpcallowplaintext=1 в DRAGONX.conf, чтобы разрешить, или включите TLS с помощью rpctls=1.",
|
||||||
"sb_rescanning": "Пересканирование",
|
"sb_rescanning": "Пересканирование",
|
||||||
"sb_rescanning_pct": "Пересканирование %.0f%%",
|
"sb_rescanning_pct": "Пересканирование %.0f%%",
|
||||||
"sb_restarting_daemon": "Перезапуск демона...",
|
"sb_restarting_daemon": "Перезапуск демона...",
|
||||||
|
|||||||
@@ -734,6 +734,8 @@
|
|||||||
"lite_working": "处理中…",
|
"lite_working": "处理中…",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"loading_addresses": "正在加载地址...",
|
"loading_addresses": "正在加载地址...",
|
||||||
|
"loading_stall_body": "守护进程已初始化 %.0f 秒。更新后或首次启动时(加载区块索引或重新扫描)这可能是正常现象——就绪后会自动连接。",
|
||||||
|
"loading_stall_title": "耗时超出预期",
|
||||||
"loading_transactions": "正在加载交易",
|
"loading_transactions": "正在加载交易",
|
||||||
"local_hashrate": "本地算力",
|
"local_hashrate": "本地算力",
|
||||||
"low_spec_mode": "低配模式",
|
"low_spec_mode": "低配模式",
|
||||||
@@ -1154,6 +1156,8 @@
|
|||||||
"sb_connecting_external": "正在连接外部守护进程...",
|
"sb_connecting_external": "正在连接外部守护进程...",
|
||||||
"sb_connecting_generic": "正在连接守护进程...",
|
"sb_connecting_generic": "正在连接守护进程...",
|
||||||
"sb_daemon_crashed": "守护进程崩溃 %d 次",
|
"sb_daemon_crashed": "守护进程崩溃 %d 次",
|
||||||
|
"sb_daemon_extract_failed": "无法写入守护进程文件——请检查磁盘剩余空间和权限。",
|
||||||
|
"sb_daemon_files_failed": "无法将守护进程文件写入 %s——请检查磁盘剩余空间和权限。",
|
||||||
"sb_daemon_not_found": "未找到守护进程",
|
"sb_daemon_not_found": "未找到守护进程",
|
||||||
"sb_daemon_start_failed": "无法启动 dragonxd",
|
"sb_daemon_start_failed": "无法启动 dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd 运行中",
|
"sb_dragonxd_running": "dragonxd 运行中",
|
||||||
|
|||||||
@@ -1503,6 +1503,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
|
|||||||
progress-width = { size = 260.0 }
|
progress-width = { size = 260.0 }
|
||||||
backdrop-alpha = { opacity = 0.80 }
|
backdrop-alpha = { opacity = 0.80 }
|
||||||
vertical-gap = { size = 8.0 }
|
vertical-gap = { size = 8.0 }
|
||||||
|
stall-timeout-sec = { size = 45.0 }
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# First-Run Wizard Screens
|
# First-Run Wizard Screens
|
||||||
|
|||||||
85
src/app.cpp
85
src/app.cpp
@@ -69,6 +69,7 @@
|
|||||||
#include "ui/widgets/copy_field.h"
|
#include "ui/widgets/copy_field.h"
|
||||||
#include "ui/notifications.h"
|
#include "ui/notifications.h"
|
||||||
#include "util/i18n.h"
|
#include "util/i18n.h"
|
||||||
|
#include "util/connect_stall.h"
|
||||||
#include "util/platform.h"
|
#include "util/platform.h"
|
||||||
#include "util/text_format.h"
|
#include "util/text_format.h"
|
||||||
#include "util/payment_uri.h"
|
#include "util/payment_uri.h"
|
||||||
@@ -4149,7 +4150,11 @@ bool App::startEmbeddedDaemon()
|
|||||||
if (resources::hasEmbeddedResources()) {
|
if (resources::hasEmbeddedResources()) {
|
||||||
DEBUG_LOGF("Extracting embedded Sapling params...\n");
|
DEBUG_LOGF("Extracting embedded Sapling params...\n");
|
||||||
daemon_status_ = TR("sb_extracting_sapling");
|
daemon_status_ = TR("sb_extracting_sapling");
|
||||||
resources::extractEmbeddedResources();
|
if (!resources::extractEmbeddedResources()) {
|
||||||
|
daemon_status_ = TR("sb_daemon_extract_failed");
|
||||||
|
DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Check again after extraction
|
// Check again after extraction
|
||||||
if (!rpc::Connection::verifySaplingParams()) {
|
if (!rpc::Connection::verifySaplingParams()) {
|
||||||
@@ -4168,8 +4173,13 @@ bool App::startEmbeddedDaemon()
|
|||||||
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
|
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
|
||||||
bool copied = false;
|
bool copied = false;
|
||||||
if (!exe_dir.empty()) {
|
if (!exe_dir.empty()) {
|
||||||
|
std::string dirErr;
|
||||||
|
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
||||||
|
daemon_status_ = dirErr;
|
||||||
|
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
fs::create_directories(daemon_dir, ec);
|
|
||||||
|
|
||||||
// On macOS .app bundles, params are in Contents/Resources/
|
// On macOS .app bundles, params are in Contents/Resources/
|
||||||
// while the executable is in Contents/MacOS/
|
// while the executable is in Contents/MacOS/
|
||||||
@@ -4214,8 +4224,13 @@ bool App::startEmbeddedDaemon()
|
|||||||
std::string exe_dir = util::Platform::getExecutableDirectory();
|
std::string exe_dir = util::Platform::getExecutableDirectory();
|
||||||
std::string daemon_dir = resources::getDaemonDirectory();
|
std::string daemon_dir = resources::getDaemonDirectory();
|
||||||
if (!exe_dir.empty()) {
|
if (!exe_dir.empty()) {
|
||||||
|
std::string dirErr;
|
||||||
|
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
||||||
|
daemon_status_ = dirErr;
|
||||||
|
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
fs::create_directories(daemon_dir, ec);
|
|
||||||
|
|
||||||
std::vector<std::string> searchDirs = { exe_dir };
|
std::vector<std::string> searchDirs = { exe_dir };
|
||||||
#ifdef __APPLE__
|
#ifdef __APPLE__
|
||||||
@@ -4226,18 +4241,31 @@ bool App::startEmbeddedDaemon()
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
|
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
|
||||||
|
bool copyFailed = false;
|
||||||
for (const char* name : extraFiles) {
|
for (const char* name : extraFiles) {
|
||||||
fs::path dst = fs::path(daemon_dir) / name;
|
fs::path dst = fs::path(daemon_dir) / name;
|
||||||
if (fs::exists(dst)) continue;
|
if (fs::exists(dst)) continue;
|
||||||
for (const auto& dir : searchDirs) {
|
for (const auto& dir : searchDirs) {
|
||||||
fs::path src = fs::path(dir) / name;
|
fs::path src = fs::path(dir) / name;
|
||||||
if (fs::exists(src)) {
|
if (fs::exists(src)) { // an absent source is optional; only a real copy error counts
|
||||||
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
|
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
|
||||||
fs::copy_file(src, dst, ec);
|
fs::copy_file(src, dst, ec);
|
||||||
|
if (ec) {
|
||||||
|
DEBUG_LOGF("[ERROR] Failed to copy %s: %s\n", name, ec.message().c_str());
|
||||||
|
copyFailed = true;
|
||||||
|
ec.clear();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (copyFailed) {
|
||||||
|
char buf[512];
|
||||||
|
snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
|
||||||
|
daemon_status_ = buf;
|
||||||
|
DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5269,6 +5297,55 @@ void App::renderLoadingOverlay(float contentH)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// 3d. "Taking longer than expected" notice — the daemon is reachable/launching but
|
||||||
|
// hasn't become ready within the stall threshold. The connect loop keeps retrying
|
||||||
|
// underneath (this notice clears itself the instant it connects); it just stops the
|
||||||
|
// user staring at a silent spinner forever. Guarded off while the daemon is in the
|
||||||
|
// Error state — that case is owned by the crash block (3c) above.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
if (connect_stall_since_ > 0.0 &&
|
||||||
|
!(daemon_controller_ &&
|
||||||
|
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error) &&
|
||||||
|
util::connectHasStalled(connect_stall_since_, ImGui::GetTime(),
|
||||||
|
loadElem("stall-timeout-sec", util::kConnectStallDefaultSeconds))) {
|
||||||
|
curY += gap;
|
||||||
|
ImFont* bodyFont2 = Type().body2();
|
||||||
|
if (!bodyFont2) bodyFont2 = ImGui::GetFont();
|
||||||
|
ImFont* capFont = Type().caption();
|
||||||
|
if (!capFont) capFont = ImGui::GetFont();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const char* title = TR("loading_stall_title");
|
||||||
|
ImVec2 ts = bodyFont2->CalcTextSizeA(bodyFont2->LegacySize, FLT_MAX, 0.0f, title);
|
||||||
|
dl->AddText(bodyFont2, bodyFont2->LegacySize,
|
||||||
|
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
|
||||||
|
IM_COL32(255, 210, 90, 235), title);
|
||||||
|
curY += ts.y + gap * 0.5f;
|
||||||
|
|
||||||
|
// Body (wrapped) — reassure + show elapsed seconds
|
||||||
|
char stallBody[256];
|
||||||
|
snprintf(stallBody, sizeof(stallBody), TR("loading_stall_body"),
|
||||||
|
(float)(ImGui::GetTime() - connect_stall_since_));
|
||||||
|
float wrapW = ws.x * 0.8f;
|
||||||
|
if (wrapW > 640.0f) wrapW = 640.0f;
|
||||||
|
ImVec2 bs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, stallBody);
|
||||||
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
|
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
|
||||||
|
IM_COL32(200, 200, 200, 210), stallBody, nullptr, wrapW);
|
||||||
|
curY += bs.y + gap * 0.5f;
|
||||||
|
|
||||||
|
// Actionable guidance (full-node only — lite has no daemon to restart)
|
||||||
|
if (supportsFullNodeLifecycleActions()) {
|
||||||
|
const char* hint = TR("loading_stall_hint");
|
||||||
|
ImVec2 hs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint);
|
||||||
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
|
ImVec2(wp.x + cx - hs.x * 0.5f, curY),
|
||||||
|
IM_COL32(180, 180, 180, 190), hint);
|
||||||
|
curY += hs.y + gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// 4. Daemon output snippet (last few lines, if embedded)
|
// 4. Daemon output snippet (last few lines, if embedded)
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1023,6 +1023,7 @@ private:
|
|||||||
std::uint64_t clipboard_secret_hash_ = 0;
|
std::uint64_t clipboard_secret_hash_ = 0;
|
||||||
double clipboard_clear_deadline_ = 0.0;
|
double clipboard_clear_deadline_ = 0.0;
|
||||||
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
||||||
|
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
|
||||||
|
|
||||||
// Current page (sidebar navigation)
|
// Current page (sidebar navigation)
|
||||||
ui::NavPage current_page_ = ui::NavPage::Overview;
|
ui::NavPage current_page_ = ui::NavPage::Overview;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
#include "rpc/connection.h"
|
#include "rpc/connection.h"
|
||||||
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
|
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
|
||||||
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
|
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
|
||||||
|
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
||||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
@@ -241,6 +242,16 @@ void App::tryConnect()
|
|||||||
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
||||||
auto config = rpc::Connection::autoDetectConfig();
|
auto config = rpc::Connection::autoDetectConfig();
|
||||||
|
|
||||||
|
if (!config.dir_error.empty()) {
|
||||||
|
// The data directory could not be created (read-only home, permission denied,
|
||||||
|
// disk full). Retrying won't fix it, so surface it in the status line instead of
|
||||||
|
// mislabelling it as "waiting for config" below.
|
||||||
|
connection_in_progress_ = false;
|
||||||
|
connection_status_ = config.dir_error;
|
||||||
|
VERBOSE_LOGF("[connect #%d] data dir error: %s\n", connect_attempt, config.dir_error.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (config.rpcuser.empty() || config.rpcpassword.empty()) {
|
if (config.rpcuser.empty() || config.rpcpassword.empty()) {
|
||||||
connection_in_progress_ = false;
|
connection_in_progress_ = false;
|
||||||
std::string confPath = rpc::Connection::getDefaultConfPath();
|
std::string confPath = rpc::Connection::getDefaultConfPath();
|
||||||
@@ -310,11 +321,21 @@ void App::tryConnect()
|
|||||||
VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
|
VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
|
||||||
connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());
|
connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());
|
||||||
|
|
||||||
if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) {
|
if (rpc::Connection::usesPlaintextRemote(config) &&
|
||||||
|
!rpc::Connection::allowsPlaintextRemote(config)) {
|
||||||
|
// Refuse to send Basic-auth credentials in cleartext to a remote host — a local-network
|
||||||
|
// MITM would otherwise capture rpcuser:rpcpassword. This is a deliberate behaviour change
|
||||||
|
// from the old warn-and-proceed: opt in explicitly with rpcallowplaintext=1 in
|
||||||
|
// DRAGONX.conf (or enable TLS with rpctls=1) if the plaintext link is intended.
|
||||||
|
connection_in_progress_ = false;
|
||||||
|
connection_status_ = TR("sb_plaintext_remote_blocked");
|
||||||
|
if (!remote_rpc_plaintext_warning_shown_) {
|
||||||
remote_rpc_plaintext_warning_shown_ = true;
|
remote_rpc_plaintext_warning_shown_ = true;
|
||||||
ui::Notifications::instance().warning(
|
ui::Notifications::instance().warning(TR("sb_plaintext_remote_blocked"), 20.0f);
|
||||||
"Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.",
|
}
|
||||||
10.0f);
|
VERBOSE_LOGF("[connect #%d] refusing plaintext-remote RPC to %s:%s (set rpcallowplaintext=1 to override)\n",
|
||||||
|
connect_attempt, config.host.c_str(), config.port.c_str());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the blocking rpc_->connect() on the worker thread so the UI
|
// Run the blocking rpc_->connect() on the worker thread so the UI
|
||||||
@@ -385,6 +406,7 @@ void App::tryConnect()
|
|||||||
// fail until warmup completes. Set the warmup state so
|
// fail until warmup completes. Set the warmup state so
|
||||||
// the UI shows status instead of a blocking overlay.
|
// the UI shows status instead of a blocking overlay.
|
||||||
state_.warming_up = true;
|
state_.warming_up = true;
|
||||||
|
if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock
|
||||||
auto wt = translateWarmup(warmupStatus);
|
auto wt = translateWarmup(warmupStatus);
|
||||||
state_.warmup_status = wt.title;
|
state_.warmup_status = wt.title;
|
||||||
state_.warmup_description = wt.description;
|
state_.warmup_description = wt.description;
|
||||||
@@ -526,6 +548,7 @@ void App::onConnected()
|
|||||||
}
|
}
|
||||||
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
|
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
|
||||||
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
|
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
|
||||||
|
connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock
|
||||||
daemon_start_error_shown_ = false;
|
daemon_start_error_shown_ = false;
|
||||||
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
|
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
|
||||||
connection_status_ = TR("connected");
|
connection_status_ = TR("connected");
|
||||||
@@ -606,6 +629,7 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
state_.connected = false;
|
state_.connected = false;
|
||||||
state_.warming_up = false;
|
state_.warming_up = false;
|
||||||
state_.warmup_status.clear();
|
state_.warmup_status.clear();
|
||||||
|
connect_stall_since_ = 0.0; // reset the "taking too long" clock (App member, untouched by state_.clear())
|
||||||
state_.clear();
|
state_.clear();
|
||||||
connection_status_ = reason;
|
connection_status_ = reason;
|
||||||
|
|
||||||
@@ -660,6 +684,7 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
std::string App::applyDaemonInitStatus(bool reachableButBusy)
|
std::string App::applyDaemonInitStatus(bool reachableButBusy)
|
||||||
{
|
{
|
||||||
state_.daemon_initializing = true;
|
state_.daemon_initializing = true;
|
||||||
|
if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock
|
||||||
|
|
||||||
// Find the most recent console line that names an init phase, so we can tell the user exactly
|
// Find the most recent console line that names an init phase, so we can tell the user exactly
|
||||||
// what the node is doing (loading the block index, verifying, activating best chain, …).
|
// what the node is doing (loading the block index, verifying, activating best chain, …).
|
||||||
@@ -1488,6 +1513,7 @@ void App::refreshCoreData()
|
|||||||
state_.warming_up = false;
|
state_.warming_up = false;
|
||||||
state_.warmup_status.clear();
|
state_.warmup_status.clear();
|
||||||
state_.warmup_description.clear();
|
state_.warmup_description.clear();
|
||||||
|
connect_stall_since_ = 0.0; // warmup finished — clear the "taking too long" clock
|
||||||
connection_status_ = TR("connected");
|
connection_status_ = TR("connected");
|
||||||
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
|
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
|
||||||
|
|
||||||
@@ -2288,27 +2314,23 @@ void App::startPoolMining(int threads)
|
|||||||
cfg.tls = settings_->getPoolTls();
|
cfg.tls = settings_->getPoolTls();
|
||||||
cfg.hugepages = settings_->getPoolHugepages();
|
cfg.hugepages = settings_->getPoolHugepages();
|
||||||
|
|
||||||
// Use first shielded address as the mining wallet address, fall back to transparent
|
// xmrig "user" is the pool login the block rewards are credited to. The user's
|
||||||
|
// "Payout Address" field (cfg.worker_name = getPoolWorker) is exactly that, so it
|
||||||
|
// takes priority — otherwise a payout address that differs from the wallet's own
|
||||||
|
// first z-address is silently ignored and rewards go to the wrong address. Only when
|
||||||
|
// no payout address is set do we fall back to the wallet's own first shielded, then
|
||||||
|
// transparent, address (available even before the daemon is connected/synced).
|
||||||
|
std::string firstShielded, firstTransparent;
|
||||||
for (const auto& addr : state_.z_addresses) {
|
for (const auto& addr : state_.z_addresses) {
|
||||||
if (!addr.address.empty()) {
|
if (!addr.address.empty()) { firstShielded = addr.address; break; }
|
||||||
cfg.wallet_address = addr.address;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (cfg.wallet_address.empty()) {
|
|
||||||
for (const auto& addr : state_.addresses) {
|
for (const auto& addr : state_.addresses) {
|
||||||
if (addr.type == "transparent" && !addr.address.empty()) {
|
if (addr.type == "transparent" && !addr.address.empty()) {
|
||||||
cfg.wallet_address = addr.address;
|
firstTransparent = addr.address;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
cfg.wallet_address = ui::resolveMiningUserAddress(cfg.worker_name, firstShielded, firstTransparent);
|
||||||
|
|
||||||
// Fallback: use pool worker address from settings (available even before
|
|
||||||
// the daemon is connected or the blockchain is synced).
|
|
||||||
if (cfg.wallet_address.empty() && !cfg.worker_name.empty()) {
|
|
||||||
cfg.wallet_address = cfg.worker_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cfg.wallet_address.empty()) {
|
if (cfg.wallet_address.empty()) {
|
||||||
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
||||||
|
|||||||
@@ -489,6 +489,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
}
|
}
|
||||||
external_daemon_detected_ = false;
|
external_daemon_detected_ = false;
|
||||||
|
|
||||||
|
// A previous dragonxd can release the RPC port well before it releases the datadir
|
||||||
|
// .lock — a graceful shutdown can take up to ~90s (see isDaemonProcessRunning). Starting
|
||||||
|
// into a still-held lock spawns a process that dies instantly with "Cannot obtain a lock
|
||||||
|
// on data directory"; the crash monitor reports that generically and, three times in
|
||||||
|
// ~12s, that is enough to trip the 3-strike restart cap before the lock's ~90s life
|
||||||
|
// elapses. Gate on the process actually still being alive, with a SHORT bounded wait
|
||||||
|
// (not the full ~90s — start() runs on the UI thread). Isolated starts (migrate-to-seed:
|
||||||
|
// skip_port_check_ / -datadir override) are exempt; they run their own datadir+port.
|
||||||
|
{
|
||||||
|
constexpr int kDatadirLockWaitPollMs = 100;
|
||||||
|
constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
|
||||||
|
bool stillRunning = false;
|
||||||
|
if (!skip_port_check_ && override_datadir_.empty()) {
|
||||||
|
stillRunning = isDaemonProcessRunning();
|
||||||
|
for (int i = 0; stillRunning && i < kDatadirLockWaitMaxPolls; ++i) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
|
||||||
|
stillRunning = isDaemonProcessRunning();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const StartLockGateDecision gate =
|
||||||
|
evaluateDatadirLockGate(skip_port_check_, !override_datadir_.empty(), stillRunning);
|
||||||
|
if (!gate.proceed) {
|
||||||
|
VERBOSE_LOGF("[INFO] %s\n", gate.errorMessage);
|
||||||
|
setState(State::Error, gate.errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(State::Starting, "Looking for dragonxd binary...");
|
setState(State::Starting, "Looking for dragonxd binary...");
|
||||||
|
|
||||||
std::string daemon_path = binary_path;
|
std::string daemon_path = binary_path;
|
||||||
@@ -557,8 +585,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
override_extra_args_.clear();
|
override_extra_args_.clear();
|
||||||
|
|
||||||
if (!startProcess(daemon_path, args)) {
|
if (!startProcess(daemon_path, args)) {
|
||||||
DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str());
|
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
|
||||||
setState(State::Error, "Failed to start dragonxd process");
|
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
||||||
|
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
||||||
|
// string that would then be all getLastError()/the UI ever sees.
|
||||||
|
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
|
||||||
|
: last_error_;
|
||||||
|
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
||||||
|
setState(State::Error, detail);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,17 +997,37 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-pipe used purely as an exec-success/failure handshake, separate from
|
||||||
|
// the stdout pipe above. Both ends are close-on-exec, so a successful execv()
|
||||||
|
// closes the write end for free (parent reads EOF); on execv() failure the
|
||||||
|
// child writes errno here, so the parent learns synchronously instead of
|
||||||
|
// reporting State::Running for a child that never became dragonxd. We use
|
||||||
|
// pipe()+FD_CLOEXEC (not pipe2) because this POSIX branch is shared with
|
||||||
|
// macOS, which has no pipe2().
|
||||||
|
int execpipe[2];
|
||||||
|
if (pipe(execpipe) == -1) {
|
||||||
|
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
|
||||||
|
close(pipefd[0]);
|
||||||
|
close(pipefd[1]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
|
||||||
|
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
|
||||||
|
|
||||||
pid_t pid = fork();
|
pid_t pid = fork();
|
||||||
if (pid == -1) {
|
if (pid == -1) {
|
||||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
|
close(execpipe[0]);
|
||||||
|
close(execpipe[1]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pid == 0) {
|
if (pid == 0) {
|
||||||
// Child process
|
// Child process
|
||||||
close(pipefd[0]); // Close read end
|
close(pipefd[0]); // Close read end of the stdout pipe
|
||||||
|
close(execpipe[0]); // Child only writes the exec-status pipe
|
||||||
|
|
||||||
// Put child in its own process group so we can kill the entire
|
// Put child in its own process group so we can kill the entire
|
||||||
// group later (including dragonxd spawned by a wrapper script).
|
// group later (including dragonxd spawned by a wrapper script).
|
||||||
@@ -1040,17 +1094,56 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
execv(binary_path.c_str(), argv.data());
|
execv(binary_path.c_str(), argv.data());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, exec failed
|
// If we get here, execv() failed — the child never became dragonxd.
|
||||||
fprintf(stderr, "execv failed: %s\n", strerror(errno));
|
// Capture errno before fprintf/strerror can clobber it, report it to
|
||||||
|
// the parent over the exec-status pipe (EINTR-safe), then exit.
|
||||||
|
int exec_errno = errno;
|
||||||
|
fprintf(stderr, "execv failed: %s\n", strerror(exec_errno));
|
||||||
|
ssize_t w;
|
||||||
|
do {
|
||||||
|
w = write(execpipe[1], &exec_errno, sizeof(exec_errno));
|
||||||
|
} while (w < 0 && errno == EINTR);
|
||||||
_exit(127);
|
_exit(127);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent process
|
// Parent process
|
||||||
close(pipefd[1]); // Close write end
|
close(pipefd[1]); // Close our copy of the stdout write end
|
||||||
|
close(execpipe[1]); // Must close our copy, or the read() below never sees EOF
|
||||||
|
|
||||||
|
// Exec-status handshake: EOF => execv() succeeded (its write end was closed
|
||||||
|
// on exec); a full sizeof(int) => execv() failed and the child sent errno.
|
||||||
|
int child_errno = 0;
|
||||||
|
size_t got = 0;
|
||||||
|
char* ep = reinterpret_cast<char*>(&child_errno);
|
||||||
|
for (;;) {
|
||||||
|
ssize_t n = read(execpipe[0], ep + got, sizeof(child_errno) - got);
|
||||||
|
if (n == 0) break; // EOF: exec succeeded
|
||||||
|
if (n < 0) { if (errno == EINTR) continue; break; } // other error: assume success
|
||||||
|
got += static_cast<size_t>(n);
|
||||||
|
if (got >= sizeof(child_errno)) break; // full errno: exec failed
|
||||||
|
}
|
||||||
|
close(execpipe[0]);
|
||||||
|
|
||||||
|
if (got >= sizeof(child_errno)) {
|
||||||
|
// execv() never replaced the child; it fprintf'd and _exit(127)'d. Reap
|
||||||
|
// the already-dead zombie here — monitorProcess() is only started after
|
||||||
|
// this function returns true, so there is no competing reaper.
|
||||||
|
close(pipefd[0]);
|
||||||
|
int status;
|
||||||
|
waitpid(pid, &status, 0);
|
||||||
|
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
||||||
|
" — not executable or wrong architecture";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
stdout_fd_ = pipefd[0];
|
stdout_fd_ = pipefd[0];
|
||||||
|
|
||||||
// Also set process group from parent side (race with child's setpgid)
|
// Best-effort: the child already calls setpgid(0, 0); this parent-side call
|
||||||
setpgid(pid, pid);
|
// just closes the fork/exec race window. A failure here is not fatal to
|
||||||
|
// startup, so we log rather than abort.
|
||||||
|
if (setpgid(pid, pid) != 0) {
|
||||||
|
DEBUG_LOGF("[WARN] setpgid(%d) from parent failed: %s\n", (int)pid, strerror(errno));
|
||||||
|
}
|
||||||
|
|
||||||
// Set non-blocking
|
// Set non-blocking
|
||||||
int flags = fcntl(stdout_fd_, F_GETFL, 0);
|
int flags = fcntl(stdout_fd_, F_GETFL, 0);
|
||||||
@@ -1135,17 +1228,21 @@ double EmbeddedDaemon::getMemoryUsageMB() const
|
|||||||
|
|
||||||
bool EmbeddedDaemon::isRunning() const
|
bool EmbeddedDaemon::isRunning() const
|
||||||
{
|
{
|
||||||
|
// Read the atomic state_ instead of calling waitpid() here. monitorProcess()
|
||||||
|
// is the sole thread allowed to waitpid() process_pid_ during normal operation.
|
||||||
|
// Calling waitpid() from this method too (as it used to, and this is invoked
|
||||||
|
// from the UI thread nearly every frame) meant whichever thread reaped the
|
||||||
|
// child's exit first consumed the status; if isRunning() won that race,
|
||||||
|
// monitorProcess() never saw the exit, so crash_count_ / the decoded exit
|
||||||
|
// code / the State::Error transition were all silently lost. Mirrors the
|
||||||
|
// fix already in XmrigManager::isRunning().
|
||||||
if (process_pid_ <= 0) return false;
|
if (process_pid_ <= 0) return false;
|
||||||
|
|
||||||
int status;
|
const State s = state_.load(std::memory_order_relaxed);
|
||||||
pid_t result = waitpid(process_pid_, &status, WNOHANG);
|
// State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
|
||||||
|
// isRunning() while state_ == Stopping — before the process has actually
|
||||||
if (result == 0) {
|
// terminated — and must keep seeing "alive" to wait/escalate correctly.
|
||||||
// Still running
|
return (s == State::Running || s == State::Stopping);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EmbeddedDaemon::drainOutput()
|
void EmbeddedDaemon::drainOutput()
|
||||||
|
|||||||
@@ -235,6 +235,32 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool isDaemonProcessRunning();
|
static bool isDaemonProcessRunning();
|
||||||
|
|
||||||
|
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
|
||||||
|
struct StartLockGateDecision {
|
||||||
|
bool proceed = true; // false => bail before spawning
|
||||||
|
const char* errorMessage = ""; // set (a string literal) when proceed == false
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Pure decision for start(): bail because a previous dragonxd still holds the
|
||||||
|
* shared datadir lock? Isolated instances (skip_port_check_ / an active -datadir
|
||||||
|
* override) are exempt — they run their own throwaway datadir+port and can coexist
|
||||||
|
* with the main daemon. Does no process/fs I/O itself (the caller does the probing),
|
||||||
|
* so it is directly unit-testable; defined inline so tests need only this header.
|
||||||
|
*/
|
||||||
|
static StartLockGateDecision evaluateDatadirLockGate(bool skipPortCheck,
|
||||||
|
bool isolatedOverride,
|
||||||
|
bool stillRunningAfterWait)
|
||||||
|
{
|
||||||
|
if (skipPortCheck || isolatedOverride) return {true, ""};
|
||||||
|
if (stillRunningAfterWait) {
|
||||||
|
return {false,
|
||||||
|
"A previous dragonxd is still shutting down and holding the data "
|
||||||
|
"directory lock. Retrying shortly…"};
|
||||||
|
}
|
||||||
|
return {true, ""};
|
||||||
|
}
|
||||||
|
|
||||||
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
|
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
|
||||||
static bool tcpPortInUse(int port);
|
static bool tcpPortInUse(int port);
|
||||||
|
|
||||||
|
|||||||
12
src/main.cpp
12
src/main.cpp
@@ -726,8 +726,16 @@ int main(int argc, char* argv[])
|
|||||||
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
||||||
{
|
{
|
||||||
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
|
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
|
||||||
std::error_code ec;
|
std::string odErr;
|
||||||
std::filesystem::create_directories(odDir, ec);
|
if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) {
|
||||||
|
// Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the
|
||||||
|
// Windows log redirect below isn't set up yet — report loudly before any setup.
|
||||||
|
std::fprintf(stderr, "%s\n", odErr.c_str());
|
||||||
|
#ifdef _WIN32
|
||||||
|
MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR);
|
||||||
|
#endif
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
|||||||
@@ -14,8 +14,12 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <vector>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
|
#include "../util/platform.h"
|
||||||
|
#include "../util/xmrig_updater.h" // util::sha256Hex
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <shlobj.h>
|
#include <shlobj.h>
|
||||||
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
|
|||||||
return resources::getDaemonDirectory();
|
return resources::getDaemonDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Connection::verifySaplingParams()
|
namespace {
|
||||||
|
|
||||||
|
std::string joinParamPath(const std::string& dir, const std::string& file) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
return dir + "\\" + file;
|
||||||
|
#else
|
||||||
|
return dir + "/" + file;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<size>:<mtime>" fingerprint used to skip re-hashing an unchanged file. Empty on error.
|
||||||
|
std::string paramStatLine(const std::string& path) {
|
||||||
|
std::error_code ec;
|
||||||
|
auto sz = fs::file_size(path, ec);
|
||||||
|
if (ec) return {};
|
||||||
|
auto mtime = fs::last_write_time(path, ec);
|
||||||
|
long long ticks = ec ? 0 :
|
||||||
|
std::chrono::duration_cast<std::chrono::seconds>(mtime.time_since_epoch()).count();
|
||||||
|
return std::to_string(static_cast<unsigned long long>(sz)) + ":" + std::to_string(ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool paramHashMatches(const std::string& path, const std::string& expectedHex) {
|
||||||
|
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||||
|
if (!f) return false;
|
||||||
|
std::streamsize sz = f.tellg();
|
||||||
|
if (sz <= 0) return false;
|
||||||
|
f.seekg(0, std::ios::beg);
|
||||||
|
std::vector<char> buf(static_cast<size_t>(sz));
|
||||||
|
if (!f.read(buf.data(), sz)) return false;
|
||||||
|
std::string got = util::sha256Hex(buf.data(), buf.size());
|
||||||
|
return !got.empty() && got == expectedHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verification cache: <params_dir>/.sapling_verified holds one paramStatLine per param,
|
||||||
|
// in list order, from the last successful hash check.
|
||||||
|
bool saplingMarkerMatches(const std::string& markerPath, const std::vector<std::string>& expected) {
|
||||||
|
for (const auto& s : expected) if (s.empty()) return false; // couldn't stat -> don't trust
|
||||||
|
std::ifstream f(markerPath);
|
||||||
|
if (!f) return false;
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
std::string l;
|
||||||
|
while (std::getline(f, l)) lines.push_back(l);
|
||||||
|
return lines == expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeSaplingMarker(const std::string& markerPath, const std::vector<std::string>& lines) {
|
||||||
|
std::ofstream f(markerPath, std::ios::trunc);
|
||||||
|
if (!f) return;
|
||||||
|
for (const auto& l : lines) f << l << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical Zcash-family Sapling trusted-setup param digests — identical bytes across every
|
||||||
|
// fork/platform. Source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params().
|
||||||
|
// Keep in sync if the params are ever rotated.
|
||||||
|
const std::pair<std::string, std::string> kSaplingParamDigests[] = {
|
||||||
|
{ "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" },
|
||||||
|
{ "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" },
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool Connection::verifySaplingParamsIn(
|
||||||
|
const std::string& dir,
|
||||||
|
const std::vector<std::pair<std::string, std::string>>& digests)
|
||||||
{
|
{
|
||||||
std::string params_dir = getSaplingParamsDir();
|
if (dir.empty()) {
|
||||||
if (params_dir.empty()) {
|
|
||||||
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
|
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (digests.empty()) return false;
|
||||||
|
|
||||||
#ifdef _WIN32
|
// 1) Every param must exist.
|
||||||
std::string spend_path = params_dir + "\\sapling-spend.params";
|
std::vector<std::string> paths;
|
||||||
std::string output_path = params_dir + "\\sapling-output.params";
|
paths.reserve(digests.size());
|
||||||
#else
|
for (const auto& d : digests) {
|
||||||
std::string spend_path = params_dir + "/sapling-spend.params";
|
std::string p = joinParamPath(dir, d.first);
|
||||||
std::string output_path = params_dir + "/sapling-output.params";
|
if (!fs::exists(p)) {
|
||||||
#endif
|
DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
paths.push_back(std::move(p));
|
||||||
|
}
|
||||||
|
|
||||||
bool spend_exists = fs::exists(spend_path);
|
// 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
|
||||||
bool output_exists = fs::exists(output_path);
|
// the previous successful hash instead of re-hashing ~48MB on every startup.
|
||||||
|
const std::string markerPath = joinParamPath(dir, ".sapling_verified");
|
||||||
|
std::vector<std::string> current;
|
||||||
|
current.reserve(paths.size());
|
||||||
|
for (const auto& p : paths) current.push_back(paramStatLine(p));
|
||||||
|
if (saplingMarkerMatches(markerPath, current)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str());
|
// 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
|
||||||
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING");
|
// (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
|
||||||
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING");
|
// here instead of being handed to the daemon and failing later on a shielded operation.
|
||||||
|
for (size_t i = 0; i < paths.size(); ++i) {
|
||||||
|
if (!paramHashMatches(paths[i], digests[i].second)) {
|
||||||
|
DEBUG_LOGF("verifySaplingParams: %s FAILED integrity check (truncated or corrupt)\n",
|
||||||
|
paths[i].c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return spend_exists && output_exists;
|
// 4) Record the verified state so later startups take the fast path.
|
||||||
|
writeSaplingMarker(markerPath, current);
|
||||||
|
DEBUG_LOGF("verifySaplingParams: %zu params verified (sha256)\n", paths.size());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Connection::verifySaplingParams()
|
||||||
|
{
|
||||||
|
std::vector<std::pair<std::string, std::string>> digests;
|
||||||
|
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
|
||||||
|
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
|
||||||
}
|
}
|
||||||
|
|
||||||
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||||
@@ -195,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
|
|||||||
config.proxy = value;
|
config.proxy = value;
|
||||||
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
||||||
config.use_tls = parseBoolValue(value);
|
config.use_tls = parseBoolValue(value);
|
||||||
|
} else if (key == "rpcallowplaintext") {
|
||||||
|
config.allow_plaintext_remote = parseBoolValue(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +306,14 @@ ConnectionConfig Connection::autoDetectConfig()
|
|||||||
{
|
{
|
||||||
ConnectionConfig config;
|
ConnectionConfig config;
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure the data directory exists. Use the non-throwing helper and report any failure
|
||||||
|
// via config.dir_error so callers can surface it — the old throwing create_directories()
|
||||||
|
// overload could raise an uncaught filesystem_error straight through autoDetectConfig()'s
|
||||||
|
// callers (read-only home, permission denied, etc.).
|
||||||
std::string data_dir = getDefaultDataDir();
|
std::string data_dir = getDefaultDataDir();
|
||||||
if (!fs::exists(data_dir)) {
|
if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
|
||||||
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str());
|
DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
|
||||||
fs::create_directories(data_dir);
|
return config; // data dir unusable — bail early with dir_error set
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find DRAGONX.conf
|
// Try to find DRAGONX.conf
|
||||||
@@ -268,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True only for a well-formed IPv4 loopback literal (127.0.0.0/8): exactly four dot-separated
|
||||||
|
// 0-255 octets with the first == 127. Rejects "127.evil.com", "127.0.0.1.attacker",
|
||||||
|
// "127.300.0.1", "1270.0.0.1", etc. — the old rfind("127.",0)==0 prefix matched all of those.
|
||||||
|
static bool isExactIPv4Loopback(const std::string& host)
|
||||||
|
{
|
||||||
|
int octets = 0, value = 0, digits = 0;
|
||||||
|
bool firstIs127 = false;
|
||||||
|
for (size_t i = 0; i <= host.size(); ++i) {
|
||||||
|
const char c = (i < host.size()) ? host[i] : '.'; // trailing sentinel flushes the last octet
|
||||||
|
if (c == '.') {
|
||||||
|
if (digits == 0 || digits > 3 || value > 255) return false;
|
||||||
|
if (octets == 0) firstIs127 = (value == 127);
|
||||||
|
++octets;
|
||||||
|
value = 0;
|
||||||
|
digits = 0;
|
||||||
|
} else if (c >= '0' && c <= '9') {
|
||||||
|
value = value * 10 + (c - '0');
|
||||||
|
++digits;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return octets == 4 && firstIs127;
|
||||||
|
}
|
||||||
|
|
||||||
bool Connection::isLocalHost(const std::string& host)
|
bool Connection::isLocalHost(const std::string& host)
|
||||||
{
|
{
|
||||||
std::string lowered = lowercase(host);
|
std::string lowered = lowercase(host);
|
||||||
@@ -277,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
|
|||||||
|
|
||||||
return lowered == "localhost" || lowered == "localhost." ||
|
return lowered == "localhost" || lowered == "localhost." ||
|
||||||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
||||||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
|
isExactIPv4Loopback(lowered);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||||
@@ -285,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
|||||||
return !config.use_tls && !isLocalHost(config.host);
|
return !config.use_tls && !isLocalHost(config.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Connection::allowsPlaintextRemote(const ConnectionConfig& config)
|
||||||
|
{
|
||||||
|
// Explicit opt-in (DRAGONX.conf: rpcallowplaintext=1) to send credentials over a plaintext
|
||||||
|
// link to a remote host. Off by default — see usesPlaintextRemote().
|
||||||
|
return config.allow_plaintext_remote;
|
||||||
|
}
|
||||||
|
|
||||||
const char* Connection::authSourceName(AuthSource source)
|
const char* Connection::authSourceName(AuthSource source)
|
||||||
{
|
{
|
||||||
switch (source) {
|
switch (source) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace rpc {
|
namespace rpc {
|
||||||
@@ -27,7 +29,11 @@ struct ConnectionConfig {
|
|||||||
std::string proxy; // SOCKS5 proxy for Tor
|
std::string proxy; // SOCKS5 proxy for Tor
|
||||||
bool use_embedded = true;
|
bool use_embedded = true;
|
||||||
bool use_tls = false;
|
bool use_tls = false;
|
||||||
|
bool allow_plaintext_remote = false; // rpcallowplaintext=1 — opt in to plaintext creds to a remote host
|
||||||
AuthSource auth_source = AuthSource::Missing;
|
AuthSource auth_source = AuthSource::Missing;
|
||||||
|
// Non-empty when autoDetectConfig() could not create the data directory; callers
|
||||||
|
// should surface it and abort the connect rather than proceeding blindly.
|
||||||
|
std::string dir_error;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +75,14 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool verifySaplingParams();
|
static bool verifySaplingParams();
|
||||||
|
|
||||||
|
// Verify the Sapling params in `dir` against a { filename, expected-sha256-hex } list.
|
||||||
|
// Exposed with an injectable dir + digest list so the integrity + marker-cache logic is
|
||||||
|
// unit-testable without the real ~48MB params; verifySaplingParams() calls it with the
|
||||||
|
// pinned production digests and getSaplingParamsDir().
|
||||||
|
static bool verifySaplingParamsIn(
|
||||||
|
const std::string& dir,
|
||||||
|
const std::vector<std::pair<std::string, std::string>>& digests);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get the Sapling params directory
|
* @brief Get the Sapling params directory
|
||||||
*/
|
*/
|
||||||
@@ -119,6 +133,11 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
||||||
|
|
||||||
|
// Whether plaintext credentials to a remote host are explicitly allowed (opt-in via the
|
||||||
|
// DRAGONX.conf rpcallowplaintext key). Off by default: usesPlaintextRemote() && !this
|
||||||
|
// means the connect is refused.
|
||||||
|
static bool allowsPlaintextRemote(const ConnectionConfig& config);
|
||||||
|
|
||||||
static const char* authSourceName(AuthSource source);
|
static const char* authSourceName(AuthSource source);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses)
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string resolveMiningUserAddress(const std::string& payoutAddress,
|
||||||
|
const std::string& firstShieldedAddress,
|
||||||
|
const std::string& firstTransparentAddress)
|
||||||
|
{
|
||||||
|
// The configured payout address is the pool login rewards go to, so it wins over
|
||||||
|
// the wallet's own addresses. "x" is the placeholder for an unset field.
|
||||||
|
if (!payoutAddress.empty() && payoutAddress != "x") return payoutAddress;
|
||||||
|
if (!firstShieldedAddress.empty()) return firstShieldedAddress;
|
||||||
|
return firstTransparentAddress; // may be empty -> caller reports "no address"
|
||||||
|
}
|
||||||
|
|
||||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||||
const std::string& value)
|
const std::string& value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ namespace ui {
|
|||||||
|
|
||||||
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
|
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
|
||||||
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses);
|
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses);
|
||||||
|
|
||||||
|
// The xmrig "user" — the pool login block rewards are credited to. The user-entered
|
||||||
|
// payout address wins; otherwise fall back to the wallet's own first shielded, then
|
||||||
|
// transparent, address. "x" is the empty-field placeholder and counts as unset. The
|
||||||
|
// result may be empty (no address anywhere), which the caller treats as an error.
|
||||||
|
std::string resolveMiningUserAddress(const std::string& payoutAddress,
|
||||||
|
const std::string& firstShieldedAddress,
|
||||||
|
const std::string& firstTransparentAddress);
|
||||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||||
const std::string& value);
|
const std::string& value);
|
||||||
const char* defaultPoolUrl();
|
const char* defaultPoolUrl();
|
||||||
|
|||||||
@@ -251,11 +251,15 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
|||||||
}
|
}
|
||||||
y += gap * 0.5f;
|
y += gap * 0.5f;
|
||||||
|
|
||||||
|
// The pool list = official pools ∪ user-saved favorites ∪ the current custom pool.
|
||||||
|
const auto effective = util::effectivePools(app->settings()->getPoolUrl(),
|
||||||
|
app->settings()->getSavedPoolUrls());
|
||||||
|
|
||||||
// --- POOLS (N) header + Refresh ---
|
// --- POOLS (N) header + Refresh ---
|
||||||
{
|
{
|
||||||
char hdr[48];
|
char hdr[48];
|
||||||
snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"),
|
snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"),
|
||||||
(int)util::knownPools().size());
|
(int)effective.size());
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr);
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr);
|
||||||
|
|
||||||
float btnS = ovFont->LegacySize + 6 * dp;
|
float btnS = ovFont->LegacySize + 6 * dp;
|
||||||
@@ -278,11 +282,11 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
|||||||
{
|
{
|
||||||
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
||||||
const auto snap = app->poolStatsSnapshot();
|
const auto snap = app->poolStatsSnapshot();
|
||||||
const util::KnownPool* current = util::findKnownPoolByUrl(app->settings()->getPoolUrl());
|
const util::KnownPool* current = util::findPoolByUrl(effective, app->settings()->getPoolUrl());
|
||||||
const float childW = ImGui::GetContentRegionAvail().x;
|
const float childW = ImGui::GetContentRegionAvail().x;
|
||||||
const float listRowH = capFont->LegacySize + 10 * dp;
|
const float listRowH = capFont->LegacySize + 10 * dp;
|
||||||
|
|
||||||
for (const auto& kp : util::knownPools()) {
|
for (const auto& kp : effective) {
|
||||||
ImGui::PushID(kp.id.c_str());
|
ImGui::PushID(kp.id.c_str());
|
||||||
const bool isCurrent = current && current->id == kp.id;
|
const bool isCurrent = current && current->id == kp.id;
|
||||||
const auto it = snap.byId.find(kp.id);
|
const auto it = snap.byId.find(kp.id);
|
||||||
@@ -315,7 +319,17 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
|||||||
|
|
||||||
char right[64];
|
char right[64];
|
||||||
std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("—");
|
std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("—");
|
||||||
snprintf(right, sizeof(right), "%s %.0f%% fee", hrStr.c_str(), kp.feePercent);
|
// Prefer the live fee the pool reports; fall back to the compile-time
|
||||||
|
// KnownPool.feePercent. A synthetic user pool has an unknown (<0) fee, so
|
||||||
|
// we show just its hashrate placeholder for it.
|
||||||
|
double feePct = (it != snap.byId.end() && it->second.feePercent >= 0.0)
|
||||||
|
? it->second.feePercent
|
||||||
|
: kp.feePercent;
|
||||||
|
if (feePct >= 0.0)
|
||||||
|
snprintf(right, sizeof(right), "%s %s%% fee", hrStr.c_str(),
|
||||||
|
FormatFeePercent(feePct).c_str());
|
||||||
|
else
|
||||||
|
snprintf(right, sizeof(right), "%s", hrStr.c_str());
|
||||||
ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right);
|
ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right);
|
||||||
cdl->AddText(capFont, capFont->LegacySize,
|
cdl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);
|
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ std::string FormatHashrate(double hashrate)
|
|||||||
return std::string(buffer);
|
return std::string(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string FormatFeePercent(double feePercent)
|
||||||
|
{
|
||||||
|
// Whole fees read "1"; fractional ones keep only their significant decimals
|
||||||
|
// ("1.5", "0.9", "1.25") with no trailing zeros. Capped at 2 dp — finer than
|
||||||
|
// any pool advertises, and the caller appends the "%".
|
||||||
|
char buffer[32];
|
||||||
|
snprintf(buffer, sizeof(buffer), "%.2f", feePercent);
|
||||||
|
std::string s(buffer);
|
||||||
|
if (s.find('.') != std::string::npos) {
|
||||||
|
s.erase(s.find_last_not_of('0') + 1);
|
||||||
|
if (!s.empty() && s.back() == '.') s.pop_back();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
|
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
|
||||||
{
|
{
|
||||||
(void)difficulty;
|
(void)difficulty;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ int GetMaxMiningThreads();
|
|||||||
int ClampMiningThreads(int requestedThreads, int maxThreads);
|
int ClampMiningThreads(int requestedThreads, int maxThreads);
|
||||||
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning);
|
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning);
|
||||||
std::string FormatHashrate(double hashrate);
|
std::string FormatHashrate(double hashrate);
|
||||||
|
std::string FormatFeePercent(double feePercent);
|
||||||
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty);
|
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty);
|
||||||
std::string FormatEstTime(double estimatedHours);
|
std::string FormatEstTime(double estimatedHours);
|
||||||
|
|
||||||
|
|||||||
27
src/util/connect_stall.h
Normal file
27
src/util/connect_stall.h
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
// Default "taking longer than expected" threshold (seconds) for the daemon connect loop,
|
||||||
|
// overridable via ui.toml [screens.loading].stall-timeout-sec. Kept as a free function with
|
||||||
|
// no ImGui/App dependency so it is directly unit-testable from tests/test_phase4.cpp.
|
||||||
|
constexpr float kConnectStallDefaultSeconds = 45.0f;
|
||||||
|
|
||||||
|
// True once a daemon that is reachable-but-not-ready has stayed that way past the threshold.
|
||||||
|
// stallSince : timestamp (same clock as `now`) when the stall began; <= 0 means "not stalling".
|
||||||
|
// now : current time in the same units as stallSince.
|
||||||
|
// thresholdSec: how long to wait before considering it stalled; <= 0 disables the feature.
|
||||||
|
inline bool connectHasStalled(double stallSince, double now, float thresholdSec)
|
||||||
|
{
|
||||||
|
if (stallSince <= 0.0) return false; // not currently in a stall-tracked state
|
||||||
|
if (thresholdSec <= 0.0f) return false; // 0/negative disables the notice defensively
|
||||||
|
return (now - stallSince) >= static_cast<double>(thresholdSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
@@ -1316,6 +1316,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_extracting_sapling"] = "Extracting Sapling parameters...";
|
strings_["sb_extracting_sapling"] = "Extracting Sapling parameters...";
|
||||||
strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters.";
|
strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters.";
|
||||||
strings_["sb_sapling_not_found"] = "Sapling parameters not found.";
|
strings_["sb_sapling_not_found"] = "Sapling parameters not found.";
|
||||||
|
strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions.";
|
||||||
|
strings_["sb_daemon_files_failed"] = "Failed to write daemon files to %s — check free disk space and permissions.";
|
||||||
|
strings_["loading_stall_title"] = "Taking longer than expected";
|
||||||
|
strings_["loading_stall_body"] = "The daemon has been initializing for %.0fs. This can be normal after an update or on first launch (loading the block index or rescanning) — it will connect automatically once ready.";
|
||||||
|
strings_["loading_stall_hint"] = "Still stuck? Open Settings and use Restart Daemon, or check the Console for details.";
|
||||||
|
strings_["sb_plaintext_remote_blocked"] = "Refusing to send RPC credentials over plaintext to a remote host. Add rpcallowplaintext=1 to DRAGONX.conf to allow it, or enable TLS with rpctls=1.";
|
||||||
strings_["sb_dragonxd_running"] = "dragonxd running";
|
strings_["sb_dragonxd_running"] = "dragonxd running";
|
||||||
strings_["sb_dragonxd_stopping"] = "Stopping dragonxd...";
|
strings_["sb_dragonxd_stopping"] = "Stopping dragonxd...";
|
||||||
strings_["sb_dragonxd_stopped"] = "dragonxd stopped";
|
strings_["sb_dragonxd_stopped"] = "dragonxd stopped";
|
||||||
|
|||||||
@@ -126,6 +126,27 @@ bool Platform::openUrl(const std::string& url)
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Platform::ensureDirectory(const std::string& dir, std::string* outError)
|
||||||
|
{
|
||||||
|
if (dir.empty()) {
|
||||||
|
if (outError) *outError = "Cannot create directory: empty path.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
DEBUG_LOGF("[ERROR] ensureDirectory failed for %s: %s\n", dir.c_str(), ec.message().c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool Platform::openFolder(const std::string& path, bool createIfMissing)
|
bool Platform::openFolder(const std::string& path, bool createIfMissing)
|
||||||
{
|
{
|
||||||
if (path.empty()) return false;
|
if (path.empty()) return false;
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ public:
|
|||||||
*/
|
*/
|
||||||
static void ensureObsidianDragonSetup();
|
static void ensureObsidianDragonSetup();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Create a directory (and parents) if missing, with a clear error on failure.
|
||||||
|
*
|
||||||
|
* Uses the non-throwing std::error_code overload internally. On failure sets *outError
|
||||||
|
* (when non-null) to one consistent, user-facing message:
|
||||||
|
* "Cannot create <dir>: <reason>. Check permissions / free space."
|
||||||
|
*
|
||||||
|
* @return true if the directory exists (already did, or was just created).
|
||||||
|
*/
|
||||||
|
static bool ensureDirectory(const std::string& dir, std::string* outError = nullptr);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get total system RAM in megabytes
|
* @brief Get total system RAM in megabytes
|
||||||
* @return Total physical RAM in MB, or 0 on failure
|
* @return Total physical RAM in MB, or 0 on failure
|
||||||
|
|||||||
@@ -45,16 +45,30 @@ struct PoolHashrate {
|
|||||||
std::string id;
|
std::string id;
|
||||||
double hashrateHs = 0.0;
|
double hashrateHs = 0.0;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
|
// Live pool fee (%) read from the same stats JSON. <0 means "not available" —
|
||||||
|
// callers fall back to the compile-time KnownPool.feePercent.
|
||||||
|
double feePercent = -1.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is
|
// The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is
|
||||||
// meaningless to balance against). Stable order.
|
// meaningless to balance against). Stable order.
|
||||||
const std::vector<KnownPool>& knownPools();
|
const std::vector<KnownPool>& knownPools();
|
||||||
|
|
||||||
// The known pool whose stratum matches `url` (host, and port when both specify one),
|
// The pool in `pools` whose stratum matches `url` (host, and port when both specify
|
||||||
// or nullptr. `url` may be a bare host, host:port, or carry a scheme/userinfo/path.
|
// one), or nullptr. `url` may be a bare host, host:port, or carry a scheme/path.
|
||||||
|
const KnownPool* findPoolByUrl(const std::vector<KnownPool>& pools, const std::string& url);
|
||||||
|
|
||||||
|
// Same, over the built-in official pools only.
|
||||||
const KnownPool* findKnownPoolByUrl(const std::string& url);
|
const KnownPool* findKnownPoolByUrl(const std::string& url);
|
||||||
|
|
||||||
|
// The full list the UI should show: the official knownPools(), plus a row for every
|
||||||
|
// user-saved pool URL and for `currentPoolUrl` when it isn't one of those — so a
|
||||||
|
// custom/bookmarked pool is a first-class, selectable row. Synthetic (user) rows are
|
||||||
|
// official=false and carry no statsUrl (feePercent<0, no live hashrate), and endpoints
|
||||||
|
// are de-duplicated so a saved URL that equals an official pool isn't listed twice.
|
||||||
|
std::vector<KnownPool> effectivePools(const std::string& currentPoolUrl,
|
||||||
|
const std::vector<std::string>& savedPoolUrls);
|
||||||
|
|
||||||
// The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`.
|
// The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`.
|
||||||
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback);
|
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback);
|
||||||
|
|
||||||
@@ -64,6 +78,13 @@ std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
|
|||||||
double parsePoolHashrate(PoolStatsSchema schema, const std::string& json,
|
double parsePoolHashrate(PoolStatsSchema schema, const std::string& json,
|
||||||
const std::string& miningcorePoolId, bool& ok);
|
const std::string& miningcorePoolId, bool& ok);
|
||||||
|
|
||||||
|
// Parse a pool's advertised fee (%) out of the same stats JSON (DragonXIs:
|
||||||
|
// pools.<name>.poolFee; Miningcore: pools[id].poolFeePercent). Selects the same
|
||||||
|
// pool entry as parsePoolHashrate. Sets ok=false and returns 0 when the field is
|
||||||
|
// absent / malformed, so the caller keeps the compile-time fallback.
|
||||||
|
double parsePoolFee(PoolStatsSchema schema, const std::string& json,
|
||||||
|
const std::string& miningcorePoolId, bool& ok);
|
||||||
|
|
||||||
// Weighted-random pick among the usable (ok==true) pools: probability is inversely
|
// Weighted-random pick among the usable (ok==true) pools: probability is inversely
|
||||||
// proportional to hashrate (smaller pools favored), so miners spread out instead of
|
// proportional to hashrate (smaller pools favored), so miners spread out instead of
|
||||||
// all stampeding to the single lowest pool. The current pool (`currentId`, may be
|
// all stampeding to the single lowest pool. The current pool (`currentId`, may be
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const std::vector<KnownPool>& knownPools()
|
|||||||
KnownPool{
|
KnownPool{
|
||||||
"dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush",
|
"dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush",
|
||||||
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
|
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
|
||||||
/*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true,
|
/*miningcorePoolId=*/"", /*feePercent=*/1.0, /*official=*/true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return pools;
|
return pools;
|
||||||
@@ -83,13 +83,62 @@ bool sameEndpoint(const std::string& a, const std::string& b)
|
|||||||
return pa == pb;
|
return pa == pb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a synthetic, selectable pool row for a user-supplied URL (a saved favorite
|
||||||
|
// or the current custom pool). We don't know its stats API, so it carries no
|
||||||
|
// statsUrl / live hashrate and an unknown (<0) fee — the UI falls back to "—".
|
||||||
|
KnownPool makeUserPool(const std::string& url)
|
||||||
|
{
|
||||||
|
KnownPool p;
|
||||||
|
const std::string hp = hostPortOf(url);
|
||||||
|
std::string host, port;
|
||||||
|
splitHostPort(hp, host, port);
|
||||||
|
p.id = "user:" + trimmed(url); // stable + unique (used as the ImGui id)
|
||||||
|
p.label = host.empty() ? hp : host;
|
||||||
|
p.stratum = trimmed(url); // what the miner connects to / a row-click restores
|
||||||
|
p.algo = ""; // unknown; xmrig resolves via resolvePoolAlgo's fallback
|
||||||
|
p.statsUrl = ""; // no known stats endpoint -> no live hashrate/fee
|
||||||
|
p.schema = PoolStatsSchema::DragonXIs;
|
||||||
|
p.miningcorePoolId = "";
|
||||||
|
p.feePercent = -1.0; // unknown fee
|
||||||
|
p.official = false;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
const KnownPool* findPoolByUrl(const std::vector<KnownPool>& pools, const std::string& url)
|
||||||
|
{
|
||||||
|
for (const auto& p : pools)
|
||||||
|
if (sameEndpoint(p.stratum, url)) return &p;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
const KnownPool* findKnownPoolByUrl(const std::string& url)
|
const KnownPool* findKnownPoolByUrl(const std::string& url)
|
||||||
{
|
{
|
||||||
for (const auto& p : knownPools())
|
return findPoolByUrl(knownPools(), url);
|
||||||
if (sameEndpoint(p.stratum, url)) return &p;
|
}
|
||||||
return nullptr;
|
|
||||||
|
std::vector<KnownPool> effectivePools(const std::string& currentPoolUrl,
|
||||||
|
const std::vector<std::string>& savedPoolUrls)
|
||||||
|
{
|
||||||
|
std::vector<KnownPool> pools = knownPools();
|
||||||
|
|
||||||
|
// Skip anything whose endpoint already appears (official or an earlier user row).
|
||||||
|
auto listed = [&](const std::string& url) {
|
||||||
|
return findPoolByUrl(pools, url) != nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto& url : savedPoolUrls) {
|
||||||
|
if (trimmed(url).empty() || listed(url)) continue;
|
||||||
|
pools.push_back(makeUserPool(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool currently being mined, if not already shown, so the active pool is
|
||||||
|
// always visible even before it's bookmarked.
|
||||||
|
if (!trimmed(currentPoolUrl).empty() && !listed(currentPoolUrl))
|
||||||
|
pools.push_back(makeUserPool(currentPoolUrl));
|
||||||
|
|
||||||
|
return pools;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
|
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
|
||||||
@@ -159,6 +208,64 @@ double parsePoolHashrate(PoolStatsSchema schema, const std::string& jsonStr,
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double parsePoolFee(PoolStatsSchema schema, const std::string& jsonStr,
|
||||||
|
const std::string& miningcorePoolId, bool& ok)
|
||||||
|
{
|
||||||
|
ok = false;
|
||||||
|
try {
|
||||||
|
const json j = json::parse(jsonStr);
|
||||||
|
|
||||||
|
if (schema == PoolStatsSchema::DragonXIs) {
|
||||||
|
// { "pools": { "dragonx": { "poolFee": <num>, ... }, ... } }
|
||||||
|
if (j.contains("pools") && j["pools"].is_object()) {
|
||||||
|
const auto& pools = j["pools"];
|
||||||
|
auto readFee = [&](const json& pool, double& out) -> bool {
|
||||||
|
if (pool.is_object() && pool.contains("poolFee") &&
|
||||||
|
pool["poolFee"].is_number()) {
|
||||||
|
out = pool["poolFee"].get<double>();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
double fee = 0.0;
|
||||||
|
if (pools.contains("dragonx") && readFee(pools["dragonx"], fee)) {
|
||||||
|
ok = true;
|
||||||
|
return fee;
|
||||||
|
}
|
||||||
|
for (auto it = pools.begin(); it != pools.end(); ++it) {
|
||||||
|
if (readFee(it.value(), fee)) {
|
||||||
|
ok = true;
|
||||||
|
return fee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // Miningcore: pools[id].poolFeePercent
|
||||||
|
if (j.contains("pools") && j["pools"].is_array()) {
|
||||||
|
const json* chosen = nullptr;
|
||||||
|
for (const auto& pool : j["pools"]) {
|
||||||
|
if (!pool.is_object()) continue;
|
||||||
|
if (!miningcorePoolId.empty()) {
|
||||||
|
if (pool.value("id", std::string{}) == miningcorePoolId) {
|
||||||
|
chosen = &pool;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (!chosen) {
|
||||||
|
chosen = &pool; // first pool when no id requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chosen && chosen->contains("poolFeePercent") &&
|
||||||
|
(*chosen)["poolFeePercent"].is_number()) {
|
||||||
|
ok = true;
|
||||||
|
return (*chosen)["poolFeePercent"].get<double>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
// fall through — ok stays false
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
|
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
|
||||||
const std::string& currentId,
|
const std::string& currentId,
|
||||||
std::mt19937& rng)
|
std::mt19937& rng)
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ void PoolStatsService::run(std::vector<KnownPool> pools)
|
|||||||
const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok);
|
const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok);
|
||||||
hr.ok = ok;
|
hr.ok = ok;
|
||||||
hr.hashrateHs = ok ? v : 0.0;
|
hr.hashrateHs = ok ? v : 0.0;
|
||||||
|
|
||||||
|
bool feeOk = false;
|
||||||
|
const double fee = parsePoolFee(p.schema, body, p.miningcorePoolId, feeOk);
|
||||||
|
// Only trust a sane fee; anything else leaves feePercent < 0 so the UI
|
||||||
|
// falls back to the compile-time KnownPool.feePercent.
|
||||||
|
if (feeOk && fee >= 0.0 && fee <= 100.0) hr.feePercent = fee;
|
||||||
}
|
}
|
||||||
results[p.id] = hr;
|
results[p.id] = hr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
#include "chat/chat_service.h"
|
#include "chat/chat_service.h"
|
||||||
#include "chat/chat_database.h"
|
#include "chat/chat_database.h"
|
||||||
#include "daemon/daemon_controller.h"
|
#include "daemon/daemon_controller.h"
|
||||||
|
#include "daemon/embedded_daemon.h"
|
||||||
|
#include "util/connect_stall.h"
|
||||||
#include "data/transaction_history_cache.h"
|
#include "data/transaction_history_cache.h"
|
||||||
#include "data/address_book.h"
|
#include "data/address_book.h"
|
||||||
#include "data/wallet_index.h"
|
#include "data/wallet_index.h"
|
||||||
@@ -2477,6 +2479,236 @@ void testDaemonShutdownPolicy()
|
|||||||
EXPECT_TRUE(bootstrap.disconnectRpc);
|
EXPECT_TRUE(bootstrap.disconnectRpc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testIsLocalHost()
|
||||||
|
{
|
||||||
|
using dragonx::rpc::Connection;
|
||||||
|
// Genuine loopback / local hosts.
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("127.0.0.1"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("127.1.2.3"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("localhost"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("LocalHost"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("::1"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("[::1]"));
|
||||||
|
// The regression this fix targets: a hostname merely starting "127." is NOT loopback.
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("127.evil.com"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("127.0.0.1.attacker.example"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("127.300.0.1"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("1270.0.0.1"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("10.0.0.5"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAllowsPlaintextRemote()
|
||||||
|
{
|
||||||
|
using dragonx::rpc::Connection;
|
||||||
|
using dragonx::rpc::ConnectionConfig;
|
||||||
|
|
||||||
|
ConnectionConfig local;
|
||||||
|
local.host = "127.0.0.1";
|
||||||
|
local.use_tls = false;
|
||||||
|
EXPECT_TRUE(!Connection::usesPlaintextRemote(local)); // local is never "plaintext remote"
|
||||||
|
|
||||||
|
ConnectionConfig remote;
|
||||||
|
remote.host = "10.0.0.5";
|
||||||
|
remote.use_tls = false;
|
||||||
|
EXPECT_TRUE(Connection::usesPlaintextRemote(remote)); // remote + no TLS
|
||||||
|
EXPECT_TRUE(!Connection::allowsPlaintextRemote(remote)); // blocked by default → connect refused
|
||||||
|
|
||||||
|
remote.allow_plaintext_remote = true;
|
||||||
|
EXPECT_TRUE(Connection::allowsPlaintextRemote(remote)); // explicit opt-in
|
||||||
|
|
||||||
|
ConnectionConfig remoteTls;
|
||||||
|
remoteTls.host = "10.0.0.5";
|
||||||
|
remoteTls.use_tls = true;
|
||||||
|
EXPECT_TRUE(!Connection::usesPlaintextRemote(remoteTls)); // TLS → not plaintext, never refused
|
||||||
|
}
|
||||||
|
|
||||||
|
void testConnectHasStalled()
|
||||||
|
{
|
||||||
|
using dragonx::util::connectHasStalled;
|
||||||
|
EXPECT_TRUE(connectHasStalled(100.0, 145.0, 45.0f)); // exactly at threshold
|
||||||
|
EXPECT_TRUE(connectHasStalled(100.0, 300.0, 45.0f)); // well over
|
||||||
|
EXPECT_TRUE(!connectHasStalled(100.0, 144.0, 45.0f)); // just under
|
||||||
|
EXPECT_TRUE(!connectHasStalled(0.0, 1000.0, 45.0f)); // sentinel: not stalling
|
||||||
|
EXPECT_TRUE(!connectHasStalled(-1.0, 1000.0, 45.0f)); // sentinel: not stalling
|
||||||
|
EXPECT_TRUE(!connectHasStalled(10.0, 20.0, 0.0f)); // disabled: threshold 0
|
||||||
|
EXPECT_TRUE(!connectHasStalled(10.0, 20.0, -5.0f)); // disabled: negative threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
void testVerifySaplingParams()
|
||||||
|
{
|
||||||
|
using dragonx::rpc::Connection;
|
||||||
|
namespace fsn = std::filesystem;
|
||||||
|
|
||||||
|
fsn::path dir = fsn::temp_directory_path() / "od_sapling_test";
|
||||||
|
std::error_code rmec;
|
||||||
|
fsn::remove_all(dir, rmec);
|
||||||
|
fsn::create_directories(dir);
|
||||||
|
|
||||||
|
auto writeFile = [](const fsn::path& p, const std::string& content) {
|
||||||
|
std::ofstream(p.string(), std::ios::binary) << content;
|
||||||
|
};
|
||||||
|
const std::string spendContent = "fake-spend-params-contents";
|
||||||
|
const std::string outputContent = "fake-output-params-contents";
|
||||||
|
writeFile(dir / "sapling-spend.params", spendContent);
|
||||||
|
writeFile(dir / "sapling-output.params", outputContent);
|
||||||
|
|
||||||
|
const std::string spendHash = dragonx::util::sha256Hex(spendContent.data(), spendContent.size());
|
||||||
|
const std::string outputHash = dragonx::util::sha256Hex(outputContent.data(), outputContent.size());
|
||||||
|
const std::vector<std::pair<std::string, std::string>> good = {
|
||||||
|
{ "sapling-spend.params", spendHash },
|
||||||
|
{ "sapling-output.params", outputHash },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Valid params → pass, and a verification marker is written.
|
||||||
|
EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));
|
||||||
|
EXPECT_TRUE(fsn::exists(dir / ".sapling_verified"));
|
||||||
|
|
||||||
|
// Second call → marker fast-path, still true (round-trips the cache).
|
||||||
|
EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));
|
||||||
|
|
||||||
|
// Wrong expected hash → integrity failure (fresh dir so no marker can short-circuit it).
|
||||||
|
fsn::path dir2 = fsn::temp_directory_path() / "od_sapling_test2";
|
||||||
|
fsn::remove_all(dir2, rmec);
|
||||||
|
fsn::create_directories(dir2);
|
||||||
|
writeFile(dir2 / "sapling-spend.params", spendContent);
|
||||||
|
writeFile(dir2 / "sapling-output.params", outputContent);
|
||||||
|
const std::vector<std::pair<std::string, std::string>> wrong = {
|
||||||
|
{ "sapling-spend.params", std::string(64, 'a') },
|
||||||
|
{ "sapling-output.params", outputHash },
|
||||||
|
};
|
||||||
|
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir2.string(), wrong));
|
||||||
|
|
||||||
|
// Truncated content (size change) invalidates the marker AND fails the hash.
|
||||||
|
writeFile(dir / "sapling-spend.params", std::string("x"));
|
||||||
|
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));
|
||||||
|
|
||||||
|
// A missing param → fail.
|
||||||
|
fsn::remove(dir / "sapling-output.params", rmec);
|
||||||
|
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));
|
||||||
|
|
||||||
|
fsn::remove_all(dir, rmec);
|
||||||
|
fsn::remove_all(dir2, rmec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testPlatformEnsureDirectory()
|
||||||
|
{
|
||||||
|
using dragonx::util::Platform;
|
||||||
|
|
||||||
|
// An existing directory → true (temp_directory_path always exists).
|
||||||
|
{
|
||||||
|
std::string err = "sentinel";
|
||||||
|
EXPECT_TRUE(Platform::ensureDirectory(std::filesystem::temp_directory_path().string(), &err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh nested path → created, no error.
|
||||||
|
{
|
||||||
|
std::filesystem::path base = std::filesystem::temp_directory_path() / "od_ensuredir_test";
|
||||||
|
std::error_code rmec; std::filesystem::remove_all(base, rmec);
|
||||||
|
std::filesystem::path nested = base / "a" / "b" / "c";
|
||||||
|
std::string err;
|
||||||
|
EXPECT_TRUE(Platform::ensureDirectory(nested.string(), &err));
|
||||||
|
EXPECT_TRUE(std::filesystem::is_directory(nested));
|
||||||
|
EXPECT_TRUE(err.empty());
|
||||||
|
std::filesystem::remove_all(base, rmec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty path → false with a message.
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
EXPECT_TRUE(!Platform::ensureDirectory("", &err));
|
||||||
|
EXPECT_TRUE(!err.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path whose parent component is a regular file cannot be created. This fails the
|
||||||
|
// same way for root and non-root, so it's a stable negative case across environments.
|
||||||
|
{
|
||||||
|
std::filesystem::path f = std::filesystem::temp_directory_path() / "od_ensuredir_file";
|
||||||
|
std::error_code rmec; std::filesystem::remove_all(f, rmec);
|
||||||
|
{ std::ofstream(f.string()) << "x"; }
|
||||||
|
std::string err;
|
||||||
|
bool ok = Platform::ensureDirectory((f / "child").string(), &err);
|
||||||
|
std::filesystem::remove_all(f, rmec);
|
||||||
|
EXPECT_TRUE(!ok);
|
||||||
|
EXPECT_TRUE(err.find("Cannot create") != std::string::npos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifndef _WIN32
|
||||||
|
// Integration tests that drive the REAL EmbeddedDaemon fork/exec/waitpid paths (POSIX only).
|
||||||
|
void testExecFailureReported()
|
||||||
|
{
|
||||||
|
using dragonx::daemon::EmbeddedDaemon;
|
||||||
|
namespace fsn = std::filesystem;
|
||||||
|
|
||||||
|
// A present-but-non-executable file: execv() must fail, and the F2 self-pipe handshake
|
||||||
|
// must report it as a start FAILURE with a precise reason — not a transient "Running".
|
||||||
|
fsn::path bin = fsn::temp_directory_path() / "od_fake_daemon_bin";
|
||||||
|
{ std::ofstream(bin.string(), std::ios::binary) << "this is not an executable"; }
|
||||||
|
fsn::permissions(bin, fsn::perms::owner_read, fsn::perm_options::replace); // 0400, no +x
|
||||||
|
|
||||||
|
EmbeddedDaemon d;
|
||||||
|
d.setSkipPortCheck(true); // bypass the port + datadir-lock gates so we reach startProcess()
|
||||||
|
EXPECT_TRUE(!d.start(bin.string()));
|
||||||
|
EXPECT_TRUE(d.getLastError().find("not executable or wrong architecture") != std::string::npos);
|
||||||
|
EXPECT_TRUE(!d.isRunning());
|
||||||
|
|
||||||
|
std::error_code ec; fsn::remove(bin, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testDaemonCrashDetected()
|
||||||
|
{
|
||||||
|
using dragonx::daemon::EmbeddedDaemon;
|
||||||
|
namespace fsn = std::filesystem;
|
||||||
|
|
||||||
|
// A tiny script that ignores the injected daemon args, lives briefly, then exits abnormally
|
||||||
|
// — standing in for a daemon that crashes. is_script detection runs it via /bin/bash.
|
||||||
|
fsn::path script = fsn::temp_directory_path() / "od_fake_daemon.sh";
|
||||||
|
{ std::ofstream(script.string()) << "#!/bin/bash\nsleep 0.2\nexit 7\n"; }
|
||||||
|
fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace); // +x
|
||||||
|
|
||||||
|
EmbeddedDaemon d;
|
||||||
|
d.setSkipPortCheck(true);
|
||||||
|
EXPECT_TRUE(d.start(script.string()));
|
||||||
|
EXPECT_TRUE(d.isRunning()); // reads the atomic state_, not a racy waitpid()
|
||||||
|
|
||||||
|
// Hammer isRunning() the way the UI thread does while the child exits and monitorProcess()
|
||||||
|
// reaps it. Pre-fix (F1), isRunning()'s own waitpid() could steal the reap and hide the
|
||||||
|
// crash; with the fix the monitor is the sole reaper and always sees it.
|
||||||
|
for (int i = 0; i < 400 && d.getCrashCount() == 0; ++i) {
|
||||||
|
(void)d.isRunning();
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
EXPECT_TRUE(d.getCrashCount() >= 1); // the unexpected exit was detected and counted
|
||||||
|
EXPECT_TRUE(!d.isRunning()); // state_ flipped to Error
|
||||||
|
|
||||||
|
d.stop(); // join the monitor thread cleanly
|
||||||
|
std::error_code ec; fsn::remove(script, ec);
|
||||||
|
}
|
||||||
|
#endif // !_WIN32
|
||||||
|
|
||||||
|
void testDatadirLockGate()
|
||||||
|
{
|
||||||
|
using dragonx::daemon::EmbeddedDaemon;
|
||||||
|
|
||||||
|
// Normal start, no lingering daemon after the bounded wait → proceed.
|
||||||
|
auto clear = EmbeddedDaemon::evaluateDatadirLockGate(false, false, false);
|
||||||
|
EXPECT_TRUE(clear.proceed);
|
||||||
|
|
||||||
|
// A previous dragonxd still alive after the wait → bail with a distinct, non-crash msg.
|
||||||
|
auto locked = EmbeddedDaemon::evaluateDatadirLockGate(false, false, true);
|
||||||
|
EXPECT_TRUE(!locked.proceed);
|
||||||
|
EXPECT_TRUE(std::string(locked.errorMessage).find("data directory lock") != std::string::npos);
|
||||||
|
|
||||||
|
// Isolated instance via skip_port_check_ is exempt even if a sibling dragonxd is running.
|
||||||
|
auto skipPort = EmbeddedDaemon::evaluateDatadirLockGate(true, false, true);
|
||||||
|
EXPECT_TRUE(skipPort.proceed);
|
||||||
|
|
||||||
|
// Isolated instance via -datadir override is exempt even if a sibling is running.
|
||||||
|
auto isolated = EmbeddedDaemon::evaluateDatadirLockGate(false, true, true);
|
||||||
|
EXPECT_TRUE(isolated.proceed);
|
||||||
|
}
|
||||||
|
|
||||||
void testDaemonLifecycleExecution()
|
void testDaemonLifecycleExecution()
|
||||||
{
|
{
|
||||||
using dragonx::daemon::DaemonController;
|
using dragonx::daemon::DaemonController;
|
||||||
@@ -3231,6 +3463,19 @@ void testRendererHelpers()
|
|||||||
EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
|
EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
|
||||||
EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
|
EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
|
||||||
EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, ""));
|
EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, ""));
|
||||||
|
|
||||||
|
// resolveMiningUserAddress: the configured payout address is the xmrig "user"
|
||||||
|
// (where rewards go) and must win over the wallet's own addresses.
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("zs-payout", "zs-own", "R-own"),
|
||||||
|
std::string("zs-payout")); // explicit payout wins
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "zs-own", "R-own"),
|
||||||
|
std::string("zs-own")); // unset -> own shielded
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "zs-own", "R-own"),
|
||||||
|
std::string("zs-own")); // "x" placeholder counts as unset
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "", "R-own"),
|
||||||
|
std::string("R-own")); // no shielded -> transparent
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "", ""),
|
||||||
|
std::string("")); // nothing anywhere -> caller errors
|
||||||
EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433"));
|
EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433"));
|
||||||
|
|
||||||
dragonx::TransactionInfo tx;
|
dragonx::TransactionInfo tx;
|
||||||
@@ -5828,6 +6073,94 @@ void testPoolHashrateParsing()
|
|||||||
EXPECT_FALSE(ok);
|
EXPECT_FALSE(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schema-aware pool fee parsing (fed to the mining-tab "N% fee" display).
|
||||||
|
void testPoolFeeParsing()
|
||||||
|
{
|
||||||
|
using namespace dragonx::util;
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
// pool.dragonx.is custom schema: pools.dragonx.poolFee (a whole-percent number).
|
||||||
|
const std::string isJson =
|
||||||
|
R"({"pools":{"dragonx":{"hashrate":27670.14,"poolFee":1,"soloFee":3}}})";
|
||||||
|
double fee = parsePoolFee(PoolStatsSchema::DragonXIs, isJson, "", ok);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_NEAR(fee, 1.0, 0.001);
|
||||||
|
|
||||||
|
// Fractional fees survive (display rounds, but the parse must not).
|
||||||
|
const std::string isFrac = R"({"pools":{"dragonx":{"poolFee":1.5}}})";
|
||||||
|
fee = parsePoolFee(PoolStatsSchema::DragonXIs, isFrac, "", ok);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_NEAR(fee, 1.5, 0.001);
|
||||||
|
|
||||||
|
// Miningcore schema: the requested pool id's poolFeePercent.
|
||||||
|
const std::string ccJson =
|
||||||
|
R"({"pools":[)"
|
||||||
|
R"({"id":"dragonx-solo","poolFeePercent":2.0,"poolStats":{"poolHashrate":88780.0}},)"
|
||||||
|
R"({"id":"dragonx-pplns","poolFeePercent":0.9,"poolStats":{"poolHashrate":1585.9}}]})";
|
||||||
|
fee = parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_NEAR(fee, 0.9, 0.001);
|
||||||
|
|
||||||
|
// Missing field / malformed / wrong-schema input all fail closed (caller keeps
|
||||||
|
// the compile-time fallback rather than showing a bogus 0%).
|
||||||
|
parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"hashrate":1.0}}})", "", ok);
|
||||||
|
EXPECT_FALSE(ok); // no poolFee key
|
||||||
|
parsePoolFee(PoolStatsSchema::DragonXIs, "not json", "", ok);
|
||||||
|
EXPECT_FALSE(ok);
|
||||||
|
parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok);
|
||||||
|
EXPECT_FALSE(ok);
|
||||||
|
parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"poolFee":"1"}}})", "", ok);
|
||||||
|
EXPECT_FALSE(ok); // string, not number
|
||||||
|
}
|
||||||
|
|
||||||
|
// The effective pool list = official pools ∪ saved favorites ∪ current custom pool,
|
||||||
|
// endpoint-deduped, with synthetic user rows flagged official=false.
|
||||||
|
void testEffectivePools()
|
||||||
|
{
|
||||||
|
using namespace dragonx::util;
|
||||||
|
const int base = (int)knownPools().size();
|
||||||
|
|
||||||
|
// Current pool is the official one, nothing saved -> just the official pools.
|
||||||
|
auto a = effectivePools("pool.dragonx.is:3433", {});
|
||||||
|
EXPECT_EQ((int)a.size(), base);
|
||||||
|
|
||||||
|
// A custom current pool (neither official nor saved) appears as an extra row.
|
||||||
|
auto b = effectivePools("my.pool.example:3333", {});
|
||||||
|
EXPECT_EQ((int)b.size(), base + 1);
|
||||||
|
const KnownPool* custom = findPoolByUrl(b, "my.pool.example:3333");
|
||||||
|
EXPECT_TRUE(custom != nullptr);
|
||||||
|
EXPECT_FALSE(custom->official);
|
||||||
|
EXPECT_TRUE(custom->feePercent < 0.0); // unknown fee
|
||||||
|
|
||||||
|
// Saved pools are appended; an official one among them and a duplicate collapse.
|
||||||
|
auto c = effectivePools("pool.dragonx.is:3433",
|
||||||
|
{"pool.dragonx.is:3433", "alt.pool:1", "alt.pool:1"});
|
||||||
|
EXPECT_EQ((int)c.size(), base + 1);
|
||||||
|
EXPECT_TRUE(findPoolByUrl(c, "alt.pool:1") != nullptr);
|
||||||
|
|
||||||
|
// Current pool equal to a saved one is not listed twice.
|
||||||
|
auto d = effectivePools("alt.pool:1", {"alt.pool:1"});
|
||||||
|
EXPECT_EQ((int)d.size(), base + 1);
|
||||||
|
|
||||||
|
// Blank/whitespace URLs are ignored (no phantom rows).
|
||||||
|
auto e = effectivePools(" ", {"", " "});
|
||||||
|
EXPECT_EQ((int)e.size(), base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee formatting: whole numbers stay clean, fractional fees keep their decimals.
|
||||||
|
void testFormatFeePercent()
|
||||||
|
{
|
||||||
|
using dragonx::ui::FormatFeePercent;
|
||||||
|
EXPECT_TRUE(FormatFeePercent(1.0) == "1");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(0.0) == "0");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(3.0) == "3");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(1.5) == "1.5");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(0.9) == "0.9");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(1.25) == "1.25");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(2.50) == "2.5"); // trailing zero trimmed
|
||||||
|
EXPECT_TRUE(FormatFeePercent(100.0) == "100");
|
||||||
|
}
|
||||||
|
|
||||||
// Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe.
|
// Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe.
|
||||||
void testPoolWeightedSelection()
|
void testPoolWeightedSelection()
|
||||||
{
|
{
|
||||||
@@ -6518,6 +6851,16 @@ int main()
|
|||||||
testWalletSecurityWorkflow();
|
testWalletSecurityWorkflow();
|
||||||
testWalletSecurityWorkflowExecutor();
|
testWalletSecurityWorkflowExecutor();
|
||||||
testDaemonShutdownPolicy();
|
testDaemonShutdownPolicy();
|
||||||
|
testDatadirLockGate();
|
||||||
|
#ifndef _WIN32
|
||||||
|
testExecFailureReported();
|
||||||
|
testDaemonCrashDetected();
|
||||||
|
#endif
|
||||||
|
testPlatformEnsureDirectory();
|
||||||
|
testVerifySaplingParams();
|
||||||
|
testConnectHasStalled();
|
||||||
|
testIsLocalHost();
|
||||||
|
testAllowsPlaintextRemote();
|
||||||
testDaemonLifecycleExecution();
|
testDaemonLifecycleExecution();
|
||||||
testDaemonLifecycleAdapters();
|
testDaemonLifecycleAdapters();
|
||||||
testConsoleTextLayout();
|
testConsoleTextLayout();
|
||||||
@@ -6590,6 +6933,9 @@ int main()
|
|||||||
testLiteOfficialServerDetection();
|
testLiteOfficialServerDetection();
|
||||||
testPoolRegistryLookup();
|
testPoolRegistryLookup();
|
||||||
testPoolHashrateParsing();
|
testPoolHashrateParsing();
|
||||||
|
testPoolFeeParsing();
|
||||||
|
testEffectivePools();
|
||||||
|
testFormatFeePercent();
|
||||||
testPoolWeightedSelection();
|
testPoolWeightedSelection();
|
||||||
testAtomicFileWrite();
|
testAtomicFileWrite();
|
||||||
testHushChatCrypto();
|
testHushChatCrypto();
|
||||||
|
|||||||
Reference in New Issue
Block a user