25 Commits

Author SHA1 Message Date
de1ae736de fix(wallet): guard against opening a missing/wrong wallet file (W1-1, W1-2, W1-4)
- W1-1 (High): switchToWallet never verified the target wallet file exists before
  switching. dragonxd auto-creates a fresh empty wallet for a missing -wallet=<name>, so a
  moved/deleted wallet file silently "opened" as a brand-new empty wallet with a zero
  balance — looking exactly like fund loss. It now std::filesystem::exists-checks
  datadir/<walletFile> before switching (ahead of the daemon-stop prompt) and blocks with a
  "not found (moved or deleted?)" warning. Because the check runs regardless of how
  switchToWallet is invoked, it also closes W1-4 (the stale switcher-row TOCTOU).

- W1-2 (Med): walletOutputLooksCorrupt matched the generic "Error loading wallet" string,
  which dragonxd also prints for DB_TOO_NEW (a newer-version wallet) — so a version mismatch
  was offered a -salvagewallet repair that cannot fix it. The generic match is now excluded
  when the output also contains "newer version".

Build-clean; ctest 1/1. Remaining P1-B: W1-3 (syncedHere timing) + the startup-path
existence check. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:56:59 -05:00
03c1b63e03 fix(migrate): correct fund-adjacent migrate-to-seed bugs (W3-1, W3-2, W3-4)
Migrate-to-seed (legacy -> mnemonic wallet) moves real funds; three correctness fixes:

- W3-1 (High): beginAdoptSeedWallet swapped a hardcoded datadir/wallet.dat instead of the
  ACTIVE wallet file. With a non-default active wallet (e.g. wallet-2.dat) it installed the
  swept seed wallet into an unloaded wallet.dat and left the daemon reloading the emptied
  legacy wallet — swept funds only recoverable via the seed phrase. Now swaps
  datadir + "/" + getActiveWalletFile(), captured on the main thread (switching is blocked
  during migration, so no race).

- W3-2 (High): SeedWalletCreator::create() ran remove_all(<config>/seed-migrate)
  unconditionally at the start, so a prior migration that swept funds into the temp wallet
  but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed.
  It now refuses (with a clear message) when DRAGONX/wallet.dat already exists — a completed
  migration removes the dir on adopt, so a leftover means an unfinished one.

- W3-4 (Med): switchToWallet blocked switching only while the migration dialog was open;
  closing it via "Later" mid-migration dropped the guard. Now also blocks while
  getSeedMigrationPending().

Build-clean; ctest 1/1. Remaining P1-A: W3-3 (persist the sweep opid). See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:53:58 -05:00
8c12b27c0a fix(security): don't silently leave a wallet unencrypted or unlocked (W2-2, W2-4)
P0-B encryption-integrity cluster.

W2-2: the first-run wizard's "encrypt" stored the passphrase only in memory and let the
user into the app immediately, so a quit/crash or a failed daemon connect before the
deferred encryption applied left the wallet unencrypted with NO record encryption was
ever requested — the user believing it was encrypted. A persisted encryption_pending
settings flag is now set the moment encryption is requested (never the passphrase, only
the fact). refreshWalletEncryptionState() reconciles it on every connect: wallet observed
encrypted -> clear the flag; wallet NOT encrypted while the flag is set and no deferred
encryption is pending/in-flight -> a once-per-session "your wallet is NOT encrypted — open
Settings to finish" warning (the flag stays set, so it recurs each launch until resolved).
The passphrase is deliberately never persisted to auto-complete — surfacing it is the
secure choice.

W2-4: lockWallet()'s continuation only handled success — a failed walletlock RPC silently
left the wallet UNLOCKED (an unfulfilled auto-lock). It now logs and warns once (reset on
the next successful lock) so a failing auto-lock is visible instead of leaving the wallet
exposed.

Touches settings.{h,cpp}, app_wizard.cpp, app_security.cpp, app.h. Not unit-testable at
this layer (RPC/connect-driven state). Build-clean; ctest 1/1. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:48:02 -05:00
c7d163f44a feat(lite): wire the create-time passphrase into the lite encrypt/unlock flow (W5-3)
The lite create/open/restore requests carried a passphrase field that the UI collected
(a labeled, masked "passphrase" input) but the backend initialize* calls never used —
so a user could believe their lite wallet was passphrase-protected at creation when it
did nothing. It now has a real meaning, wired in LiteWalletController:

- create / restore -> encryptWallet(passphrase): the backend encrypts + locks + saves
  the brand-new wallet.
- open -> unlockWallet(passphrase), but only when encryptionStatus() reports the existing
  wallet is actually encrypted + locked (no spurious unlock on an unencrypted wallet).

encryptWallet/unlockWallet take their own copy of the passphrase and wipe it; the
request copy is still wiped as before. A post-create encrypt failure is liteLog'd (the
wallet still exists, so the create is not failed).

Six existing lite-controller tests carried an incidental "hunter2" create passphrase from
when the field was dead; removed (they exercise non-encryption flows and want an
unencrypted wallet), and added testLiteWalletControllerCreateEncryptsWithPassphrase to
prove the new behavior. Completes the wallet-hardening P0-A cluster (7/7). ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:39:02 -05:00
7e4822c021 fix(security): warn that the seed-backup file is unencrypted plaintext (W4-5)
The seed-phrase "Save" already wrote the file 0600 and zeroed the in-memory buffer, but
the success message was a bare "Saved to <path>" — no hint that it's a permanent
UNENCRYPTED copy of the seed at a predictable location. The message now reads
"Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this
copy: <path>". English source updated; the res/lang back-fill of this changed key is
deferred to the batch i18n pass.

Also documents W5-3 (lite create-time passphrase) as a product decision rather than a
speculative change: the field is already wiped on every path (minimal security risk),
but the labeled masked "passphrase" input at lite create/open/restore is never consumed
by the backend — so either remove the dead UI or wire it into the lite encrypt flow.

Finishes the actionable part of the wallet-hardening P0-A cluster (docs/wallet-hardening.md).
Build-clean; ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:25:15 -05:00
f9b622cb25 fix(security): scrub in-memory key/passphrase copies in the wallet secret paths (W4-1, W4-3, W2-3)
The wallet-hardening memzero cluster. Uses the file's established sodium_memzero
pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and the JSON
scrub at :4025) rather than a new type, since importPrivateKey/sweepPrivateKey are
fund-moving code.

- W4-1 importPrivateKey / sweepPrivateKey: the spending/viewing key was copied ≥3×
  (calling frame -> worker-lambda capture -> JSON params) and never scrubbed. Now zeroed
  on all paths: the calling-frame copy after the worker post, the lambda's captured copy
  (lambda made mutable, zeroed once the request is sent), and the request params copy.

- W4-3 exportAllKeys / backupWallet: the concatenated all-keys buffer is now zeroed after
  the consumer uses it, and the backup is written via
  Platform::writeFileAtomically(..., restrictPermissions=true) — atomic and owner-only
  (0600) — instead of a umask-default std::ofstream that left it world-readable.

- W2-3 decrypt-wallet passphrase: std::move-captured into the worker lambda (no plaintext
  copy left in the calling frame) and sodium_memzero'd right after unlockWallet, its only
  use.

Not unit-testable (no observable RPC effect — the key value sent to the daemon is
unchanged; only post-use memory zeroing is added). Build-clean; ctest 1/1. See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:12:44 -05:00
9204fa148a fix(security): delete the plaintext key export after decrypt-wallet import (W2-1)
Removing wallet encryption z_exportwallet'd every private key in cleartext to
<datadir>/obsidiandecryptexport<ts>, re-imported it, and never deleted it — leaving a
full plaintext dump of every key on disk permanently. The decrypt flow now scrubs
(best-effort in-place zero-overwrite) and removes that file as soon as the
z_importwallet attempt resolves, on both the success and failure paths. Recovery, if
ever needed, remains the encrypted backup (wallet.dat.encrypted.bak), never this file.

Second fix in the wallet-hardening P0-A cluster (docs/wallet-hardening.md). Not
unit-testable (fs I/O in a deep worker lambda); build-clean, ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:02:45 -05:00
da0e9f5915 fix(console): redact secret-bearing commands from the console echo and history (W7-1)
The RPC console echoed and stored typed commands verbatim, so `walletpassphrase
<secret>`, `z_importkey <key>`, `encryptwallet <pass>` etc. left the secret in the
visible log AND the 100-entry recall history (copyable). Adds a pure, unit-testable
RedactConsoleCommand()/ConsoleCommandCarriesSecret() (allowlist of 13 secret-bearing
first-tokens) in console_tab_helpers; submitConsoleCommand() now echoes and stores
`> walletpassphrase ****` while still executing the real command unredacted. Bare
secret commands and non-secret commands pass through unchanged.

Output-secret commands (dumpprivkey / z_exportkey / z_exportmnemonic) — whose secret is
in the RESULT — are a separate redaction concern, tracked as a follow-up.

First fix in the wallet-hardening P0-A cluster (see docs/wallet-hardening.md). New
testConsoleSecretRedaction (11 assertions); ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 13:57:52 -05:00
d188a08db7 test(daemon): add F1/F2 process-lifecycle integration tests; fix clobbered start error
Links the real EmbeddedDaemon into the ObsidianDragonTests target (its deps were
already present) and adds two POSIX integration tests that exercise the actual
fork/exec/waitpid fixes headlessly:

- testExecFailureReported (F2): start() against a non-executable file must fail with a
  precise "not executable or wrong architecture" reason.
- testDaemonCrashDetected (F1): a short-lived child that exits abnormally is still
  detected (crash_count_ increments) while isRunning() is hammered from the test
  thread — a regression test for the reap race.

Writing the F2 test surfaced a real bug: start()'s failure branch called
setState(State::Error, "Failed to start dragonxd process"), and setState stores the
Error message into last_error_ — clobbering the precise message startProcess() had
just set, so getLastError()/the UI only ever saw the generic string. Fixed to pass the
preserved detail to setState, so the precise reason survives and now also reaches the
state callback (crash panel / status).

ctest 1/1, green including the two new integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 13:11:15 -05:00
ff5f5ddf23 docs: add CHANGELOG with daemon-startup hardening release notes
Introduces CHANGELOG.md (Keep a Changelog style, Unreleased section) covering this
batch, with F8's breaking change — remote plaintext RPC now refused by default —
called out front and center along with the rpctls=1 / rpcallowplaintext=1 recovery
steps. Also records the Security / Fixed / Added entries for F1-F7. Updates the
tracking doc's status to reflect the completed release-notes + i18n back-fill and the
remaining pre-release items (F1/F2 manual repros, CJK subset-font rebuild).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 12:06:02 -05:00
56f9802fb9 i18n: back-fill daemon-startup hardening strings
Additively adds translations for the 6 new keys from this batch
(sb_daemon_extract_failed, sb_daemon_files_failed, loading_stall_{title,body,hint},
sb_plaintext_remote_blocked) across res/lang/*.json. es/de/fr/pt/ru get all 6; for
zh/ja/ko a string is only added when every glyph is already in the current
NotoSansCJK-Subset.ttf, since the subset can't be rebuilt here — 6 zh/ja/ko entries
whose glyphs aren't yet subsetted are left on the English fallback rather than render
as tofu. Written sorted, indent=4, ensure_ascii=False (matching add_missing_translations.py);
purely additive (42 insertions, 0 removals). The remaining 6 need a
scripts/build_cjk_subset.py font rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 12:05:53 -05:00
efb271cb9a fix(rpc): refuse plaintext-remote RPC by default and tighten isLocalHost
F8 (security). Two related fixes to how the wallet decides whether an RPC target is
safe to send Basic-auth credentials to:

- isLocalHost() was matching any host that merely *starts* "127." via
  rfind("127.",0)==0, so "127.evil.com" (and "127.0.0.1.attacker", "127.300.0.1",
  "1270.0.0.1") were misclassified as loopback and treated as local. It now uses a
  strict isExactIPv4Loopback() parser: exactly four 0-255 dot-separated octets with
  the first == 127. localhost / ::1 / [::1] handling is unchanged.

- A remote rpchost over plain HTTP (no rpctls=1) previously only produced a
  dismissible warning and then sent rpcuser:rpcpassword in cleartext, where a
  local-network MITM could capture them. tryConnect() now REFUSES that connection
  (clear status line + one-time notification, no creds sent) unless the user opts in
  explicitly with rpcallowplaintext=1 in DRAGONX.conf (new
  ConnectionConfig::allow_plaintext_remote, parsed in parseConfFile; policy in the
  new allowsPlaintextRemote()). Local/embedded daemons and rpctls=1 remotes are
  unaffected.

BREAKING: a wallet configured for remote plaintext RPC will stop connecting until
rpcallowplaintext=1 (or rpctls=1) is added to DRAGONX.conf. Must be called out in the
release notes. The Settings-toggle UI is deferred (the conf-key opt-in is the recovery
path; see docs/daemon-startup-hardening.md).

Adds testIsLocalHost and testAllowsPlaintextRemote to test_phase4.cpp; one i18n key
(English) added to i18n.cpp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 11:40:35 -05:00
eb69e491b9 fix(startup): surface a "taking too long" notice when the daemon won't come up
F3: the daemon connect loop retried forever with only an animated spinner when the
daemon was reachable-but-never-ready (stuck in RPC warmup / -28, or an external daemon
that never finishes init) -- no error, no guidance, no escape. It now stamps
connect_stall_since_ the moment the daemon first goes "reachable but not ready" (the
warmup branch + applyDaemonInitStatus) and clears it on connect / disconnect /
warmup-complete. A pure, unit-testable util::connectHasStalled() helper (new
util/connect_stall.h, 45s default from ui.toml [screens.loading].stall-timeout-sec)
drives a "Taking longer than expected" notice in renderLoadingOverlay(): a title, a
reassuring body with elapsed seconds, and a full-node hint to Settings > Restart Daemon
or the Console. The background retry keeps running underneath, so the notice self-clears
the instant it connects. Guarded off while the daemon is in State::Error (that case is
owned by the existing crash-count hint).

The overlay is a pure draw-list layer with no interactive widgets, so this follows the
existing crash-hint idiom (guidance text, not injected buttons); the stalled state is
computed locally in the overlay, so the only new App member is connect_stall_since_.

Adds testConnectHasStalled to test_phase4.cpp and three i18n keys to i18n.cpp (English
source of truth; the res/lang/*.json back-fill is deferred to a single
add_missing_translations.py run at the end of the batch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 11:33:26 -05:00
2675b8ab93 fix(startup): surface filesystem failures and verify Sapling param integrity
Three verified daemon-startup edge-case fixes centered on the config/params
filesystem path:

- F7: new non-throwing Platform::ensureDirectory(dir, outError) with one
  consistent "Cannot create <dir>: <reason>. Check permissions / free space."
  message. Replaces the unchecked/throwing create_directories sites at main.cpp
  (pre-init: log + Windows MessageBox + return 1), connection.cpp's
  autoDetectConfig (was the *throwing* overload -- could raise an uncaught
  filesystem_error through its callers; now sets the new
  ConnectionConfig::dir_error), and both app.cpp daemon-dir sites (surface via
  daemon_status_ + return false). The primary connect path (app_network.cpp)
  checks dir_error and shows it instead of mislabelling it "waiting for config".
  embedded_resources.cpp already checked its error_code, so it is left as-is.

- F6: verifySaplingParams() now hash-verifies each param against its pinned
  canonical SHA-256 (source of truth: scripts/build-lite-backend-artifact.sh)
  instead of only checking existence, so a truncated / corrupt-but-present param
  is rejected up front rather than failing later on a shielded operation. A
  <params_dir>/.sapling_verified marker keyed on size:mtime avoids re-hashing
  ~48MB on every startup. Logic extracted to the injectable, unit-testable
  verifySaplingParamsIn(dir, digests); reuses util::sha256Hex (no new hash impl).

- F5: startEmbeddedDaemon() now checks extractEmbeddedResources()'s return and
  the previously-dropped copy_file error_code in the daemon-binary fallback loop,
  aborting with a clear status (sb_daemon_extract_failed / sb_daemon_files_failed)
  instead of failing opaquely at spawn. An absent source file stays non-fatal.

Adds testPlatformEnsureDirectory and testVerifySaplingParams to test_phase4.cpp.
i18n keys added to i18n.cpp (English source of truth); the res/lang/*.json
back-fill via add_missing_translations.py is deferred to a single run at the end
of the batch. Progress tracked in docs/daemon-startup-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 11:07:32 -05:00
b3444e0a89 fix(daemon): harden startup process lifecycle (crash race, exec failure, datadir lock)
Three verified daemon-startup edge-case fixes in the embedded-daemon process
lifecycle (all in embedded_daemon.{cpp,h}):

- F1: EmbeddedDaemon::isRunning() (POSIX) now reads the atomic state_ instead of
  calling waitpid(WNOHANG) from the UI thread, which raced monitorProcess()'s own
  reap. waitpid is one-shot: whichever thread won consumed the exit status; if
  isRunning() won, the monitor never saw the crash, so crash_count_/State::Error
  and the 3-strike restart cap were silently lost. monitorProcess() is now the sole
  reaper (predicate Running || Stopping keeps stop()'s wait loops correct). Mirrors
  the existing XmrigManager::isRunning() fix.

- F2: startProcess() (POSIX) adds a close-on-exec self-pipe exec handshake. On a
  non-executable / wrong-arch / corrupt binary, execv fails in the child and the
  parent now learns synchronously (reads errno vs EOF), reaps the zombie, sets a
  precise last_error_ ("not executable or wrong architecture"), and returns false
  -- instead of reporting State::Running for a daemon that never started. Uses
  pipe()+FD_CLOEXEC (not pipe2) so the branch stays shared with macOS. Parent-side
  setpgid is now best-effort + logged.

- F4: start() gates on a lingering datadir lock after the port check. A graceful
  shutdown releases the RPC port ~90s before the datadir .lock, so a rapid
  stop->start spawned a daemon that died on the lock and, three times in ~12s,
  tripped the 3-strike crash cap before the lock cleared. start() now polls
  isDaemonProcessRunning() with a bounded ~300ms wait and bails with a distinct
  non-crash Error (no crash_count_ bump) that the connect loop retries once the
  lock clears. Isolated migrate-to-seed starts (skip_port_check_ / -datadir
  override) are exempt.

Adds the testDatadirLockGate unit test (pure evaluateDatadirLockGate matrix) to
test_phase4.cpp. Plan and progress tracked in docs/daemon-startup-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 10:32:30 -05:00
45b652f514 feat(mining): live pool fee, saved/custom pool rows, and payout-address fix
Several related mining-tab pool improvements:

- Report the default pool fee correctly: pool.dragonx.is is 1%, not 0%.
  The registry constant was hardcoded to 0. It now also fetches the live
  poolFee from the pool's /api/stats alongside hashrate (no extra
  request), so the displayed fee self-corrects and falls back to the
  compile-time value only when the fetch hasn't landed.

- Show fractional fees: new FormatFeePercent trims trailing zeros so
  whole fees read "1%" and fractional ones keep their decimals ("1.5%").

- Surface saved + custom pools in the pool list card: the list is now
  the union of the official pools, the user's saved favorites, and the
  currently-mined pool (effectivePools), each a selectable, endpoint-
  deduped row. Previously the card only showed the hardcoded knownPools().

- Fix the xmrig "user" field: the "Payout Address" field now drives the
  pool login rewards are credited to (resolveMiningUserAddress), instead
  of being written only to "pass" while "user" was auto-derived from the
  wallet's own first z-address -- which silently ignored a configured
  payout address and could route rewards to the wrong address.

Unit tests cover parsePoolFee, FormatFeePercent, effectivePools, and
resolveMiningUserAddress; full app + ObsidianDragonTests build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 03:48:05 -05:00
fffee9f0b5 build(lite-backend): pin the SDXL backend to rustc 1.63 via rust-toolchain.toml
The pinned librustzcash / transitive crates (notably traitobject 0.1.0) rely on
pre-1.70 trait coherence and fail to compile on newer rustc (E0119), so the backend
must build with 1.63. Add a rust-toolchain.toml in the vendored backend so rustup
auto-selects 1.63 when cargo runs there — no more manual RUSTUP_TOOLCHAIN=1.63.0.
The pin is scoped to the backend tree (repo-root cargo keeps the default toolchain).
Also symlink the pin into the prepared build root so --silentdragonxlitelib-dir
builds honor it too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:30:16 -05:00
00ffc959e5 fix(build): name macOS DMGs after the app + stop wiping other variants' artifacts
- The macOS DMG filename used a separate hardcoded DMG_BASENAME ("DragonX_Wallet"),
  so the export didn't match the .app/zip (ObsidianDragon). Derive it from
  APP_BASENAME: full-node -> ObsidianDragon-*.dmg, lite -> ObsidianDragonLite-*.dmg.
  The mounted volume + CFBundleName keep the "DragonX Wallet" display branding.
- build_release_mac did `rm -rf "$out"`, wiping all of release/mac on every build, so
  building one variant destroyed the other's artifacts. Scope the cleanup to the
  current variant's files (as the Linux/Windows release paths already do) so the
  full-node and lite releases coexist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:24:54 -05:00
b561406c23 fix(build): make macOS release builds work natively
The --mac-release path (full-node and --lite-backend) had never run on a real
Mac and broke on several Linux-only assumptions:

- build.sh version parser used GNU sed \+ (BSD sed reads it literally), so it
  aborted before compiling — switched to POSIX [[:space:]][[:space:]]*.
- build.sh libsodium universal check used GNU grep \| — switched to grep -E.
- libwebp's cpu.cmake applies -mno-sse2 to its reference DSP files; under a
  universal build (-arch arm64;x86_64) that lands on the x86_64 slice, where it
  disables _Float16 and breaks the SDK 26 <math.h>. Added an idempotent
  FetchContent PATCH_COMMAND (cmake/patch-libwebp-simd.cmake) to neutralize it.
- The SDXL lite backend static lib needs Security + CoreFoundation frameworks on
  macOS; added them to the imported dragonx_lite_backend target for APPLE.
- Added a DRAGONX_MAC_ARCHS override plus auto-detect: with --lite-backend the
  app is built for the arch(es) the backend .a actually provides (its pinned
  ring 0.16.11 is x86_64-only), instead of failing to link a universal app.
- build-lite-backend-artifact.sh uses bash 4+ (mapfile); added a re-exec guard
  for macOS's stock bash 3.2 and added bash to setup.sh's macOS core deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:03:33 -05:00
5a0743a17b fix(setup): skip sudo apt when the Windows toolchain is already installed
setup.sh --win ran `sudo apt-get install` (and update-alternatives)
unconditionally, forcing the whole run under sudo even when mingw-w64 was
already present. Running setup as root makes the daemon cross-compile run as
root, leaving root-owned artifacts under external/dragonx that break `make
clean` on a later non-sudo build (stale objects relink -> mingw link failure
recurs). Gate the apt/update-alternatives block behind a presence check so
`./setup.sh --win` runs sudo-free (and the daemon build as the invoking user)
when the toolchain is already installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:59:05 -05:00
a7b0770ad0 feat(scripts): sign-daemon-release release command (package + sign prebuilt binaries)
Adds `sign-daemon-release.sh release <secret.key> <version>`: zips each staged
prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,
macos,win64}.zip, signs each (detached ed25519), and prints the SHA-256 checksum
table for the release body. Platforms with no staged daemon are skipped; warns
if the signing key doesn't match the pubkey pinned in daemon_updater.h. Keeps the
existing keygen/pubkey/sign subcommands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:59:05 -05:00
7d8323a622 Merge branch 'chore/rename-xmrig-hac-to-drg-xmrig' into dev
Point the miner build at DragonX/drg-xmrig and rename the prebuilt staging
dir xmrig-hac -> drg-xmrig across setup.sh, build.sh, the legacy Windows
script, .gitignore and README.
2026-07-23 14:56:28 -05:00
320944fd18 chore(setup): build miner from DragonX/drg-xmrig and rename staging dir
setup.sh now clones/builds the miner from git.dragonx.is/DragonX/drg-xmrig
(was dragonx/xmrig-hac), and the prebuilt staging dir is renamed
prebuilt-binaries/xmrig-hac -> prebuilt-binaries/drg-xmrig across build.sh, the
legacy Windows build script, .gitignore and README.

Also aligns setup.sh's section-8 directory list with section 7 (it previously
used a bare "xmrig" token, creating a stray empty prebuilt-binaries/xmrig/),
and updates the xmrig_manager.cpp header comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:55:42 -05:00
6d5e0ac614 Merge contacts table-view footer fix into dev (follow-up to #1) 2026-07-22 23:46:55 -05:00
21da9e75fc Merge pull request 'Security audit remediations + Overview/Mining UI polish' (#1) from fix/audit-remediations-dev into dev
Reviewed-on: #1
2026-07-22 17:19:29 -05:00
54 changed files with 2394 additions and 179 deletions

6
.gitignore vendored
View File

@@ -11,8 +11,8 @@ prebuilt-binaries/dragonxd-win/*
!prebuilt-binaries/dragonxd-win/.gitkeep
prebuilt-binaries/dragonxd-mac/*
!prebuilt-binaries/dragonxd-mac/.gitkeep
prebuilt-binaries/xmrig-hac/*
!prebuilt-binaries/xmrig-hac/.gitkeep
prebuilt-binaries/drg-xmrig/*
!prebuilt-binaries/drg-xmrig/.gitkeep
# External sources / toolchains (created by scripts/setup.sh)
@@ -33,7 +33,7 @@ imgui.ini
*.bak*
*.params
asmap.dat
/external/xmrig-hac
/external/drg-xmrig
/memory
/todo.md
/.github/

58
CHANGELOG.md Normal file
View 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`.

View File

@@ -136,6 +136,13 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
set_target_properties(dragonx_lite_backend PROPERTIES
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}"
)
if(APPLE)
# The Rust backend's TLS stack (security-framework / core-foundation crates)
# references Secure Transport (SSL*) + CoreFoundation symbols. Link the frameworks
# that provide them, or the static lib leaves ~130 symbols undefined at link time.
set_property(TARGET dragonx_lite_backend APPEND PROPERTY
INTERFACE_LINK_LIBRARIES "-framework Security" "-framework CoreFoundation")
endif()
if(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
if(NOT IS_DIRECTORY "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
@@ -284,6 +291,15 @@ FetchContent_Declare(
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
GIT_TAG v1.4.0
GIT_SHALLOW TRUE
# libwebp's cpu.cmake applies -mno-sse2/-mno-sse4.1 to its scalar reference DSP
# files when it can't probe SSE support. Under a macOS universal build
# (-arch arm64;x86_64) that probe fails, so the flags land on the x86_64 slice,
# where -mno-sse2 disables _Float16 and breaks the SDK's <math.h>. Neutralize
# those disable flags (SSE2 is x86_64 baseline). Portable + idempotent; a no-op
# for single-arch Linux/Windows/x86_64 builds. See cmake/patch-libwebp-simd.cmake.
PATCH_COMMAND ${CMAKE_COMMAND}
-DCPU_CMAKE=<SOURCE_DIR>/cmake/cpu.cmake
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch-libwebp-simd.cmake
)
set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE)
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
@@ -1113,6 +1129,7 @@ if(BUILD_TESTING)
src/data/address_book.cpp
src/data/wallet_index.cpp
src/daemon/lifecycle_adapters.cpp
src/daemon/embedded_daemon.cpp
src/rpc/connection.cpp
src/config/settings.cpp
src/resources/embedded_resources.cpp

View File

@@ -81,8 +81,8 @@ Download linux and windows binaries of latest releases and place in binary direc
- prebuilt-binaries/dragonxd-win/
- prebuilt-binaries/dragonxd-mac/
**xmrig HAC fork** (https://git.dragonx.is/dragonx/xmrig-hac):
- prebuilt-binaries/xmrig-hac/
**DRG-XMRig fork** (https://git.dragonx.is/DragonX/drg-xmrig):
- prebuilt-binaries/drg-xmrig/
## Build Steps

View File

@@ -131,7 +131,7 @@ fi
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
_cml="$SCRIPT_DIR/CMakeLists.txt"
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]][[:space:]]*\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
_full_suffix=$(sed -n 's/^set(DRAGONX_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
_lite_ver=$(sed -n 's/^set(DRAGONX_LITE_VERSION[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
_lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
@@ -386,7 +386,7 @@ build_release_linux() {
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
[[ -f "$XMRIG_LINUX" ]] && { cp "$XMRIG_LINUX" "$dist_dir/"; chmod +x "$dist_dir/xmrig"; info "Bundled xmrig"; } || warn "xmrig not found — mining unavailable in zip"
cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -419,7 +419,7 @@ build_release_linux() {
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
[[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
# Desktop entry
@@ -638,8 +638,8 @@ HDR
info "Lite mode: skipping embedded daemon binaries"
fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
# xmrig.exe. Extract it from the matching win-x64 zip if it isn't already staged — otherwise
# the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
@@ -791,7 +791,7 @@ HDR
fi
# Bundle xmrig for mining support
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig.exe"
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -901,8 +901,26 @@ build_release_mac() {
fi
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
else
# Native macOS: build universal binary (arm64 + x86_64)
MAC_ARCH="universal"
# Native macOS: build universal (arm64 + x86_64) by default. Override with
# DRAGONX_MAC_ARCHS (e.g. "x86_64").
MAC_ARCHS="${DRAGONX_MAC_ARCHS:-arm64;x86_64}"
# When linking the real lite backend, the app can only include architectures
# the backend static library actually provides. Its pinned ring 0.16.11 has no
# Apple-Silicon assembly, so that artifact is x86_64-only — constrain the app
# arch to the backend's (unless the user explicitly forced DRAGONX_MAC_ARCHS),
# otherwise the arm64 slice fails to link.
if $DO_LITE_BACKEND && [[ -z "${DRAGONX_MAC_ARCHS:-}" && -n "${lb_lib:-}" ]] && command -v lipo &>/dev/null; then
local _backend_archs; _backend_archs=$(lipo -archs "$lb_lib" 2>/dev/null | tr ' ' ';')
if [[ -n "$_backend_archs" && "$_backend_archs" != "$MAC_ARCHS" ]]; then
warn "Lite backend provides only [$_backend_archs] — building the app for that instead of universal."
MAC_ARCHS="$_backend_archs"
fi
fi
if [[ "$MAC_ARCHS" == *";"* || "$MAC_ARCHS" == *","* ]]; then
MAC_ARCH="universal"
else
MAC_ARCH="$MAC_ARCHS"
fi
export MACOSX_DEPLOYMENT_TARGET="11.0"
fi
@@ -990,7 +1008,7 @@ TOOLCHAIN
need_sodium=true
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
# Rebuild if existing lib is not universal (single-arch won't link)
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -q "arm64.*x86_64\|x86_64.*arm64"; then
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -Eq "arm64.*x86_64|x86_64.*arm64"; then
info "Existing libsodium is not universal — rebuilding ..."
rm -rf "$SCRIPT_DIR/libs/libsodium"
need_sodium=true
@@ -1001,13 +1019,13 @@ TOOLCHAIN
"$SCRIPT_DIR/scripts/fetch-libsodium.sh"
fi
info "Configuring (native universal arm64+x86_64) ..."
info "Configuring (native macOS, arch: $MAC_ARCHS) ..."
cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \
-DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
"${CMAKE_LITE_ARGS[@]}"
fi
@@ -1037,8 +1055,12 @@ TOOLCHAIN
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
# ── Create .app bundle ───────────────────────────────────────────────────
rm -rf "$out"
mkdir -p "$out"
# Clean only THIS variant's prior artifacts so full-node and lite releases can
# coexist in release/mac/ (Linux/Windows scope their cleanup the same way). The
# "ObsidianDragon-" glob never matches "ObsidianDragonLite-" (and vice versa),
# and the ".app" names are exact.
rm -rf "$out/${APP_BASENAME}.app" "$out/${APP_BASENAME}-"*.app.zip "$out/${APP_BASENAME}-"*.dmg
local APP="$out/${APP_BASENAME}.app"
local CONTENTS="$APP/Contents"
@@ -1088,8 +1110,8 @@ TOOLCHAIN
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
fi
# xmrig binary (from prebuilt-binaries/xmrig-hac/)
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
# xmrig binary (from prebuilt-binaries/drg-xmrig/)
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
if [[ -f "$XMRIG_MAC" ]]; then
cp "$XMRIG_MAC" "$MACOS/xmrig"
chmod +x "$MACOS/xmrig"
@@ -1238,8 +1260,10 @@ PLIST
fi
# ── Create DMG ───────────────────────────────────────────────────────────
local DMG_BASENAME="DragonX_Wallet"
$DO_LITE && DMG_BASENAME="DragonX_Wallet_Lite"
# DMG filename matches the app bundle name (ObsidianDragon / ObsidianDragonLite).
# The mounted volume + CFBundleName keep the "DragonX Wallet" display branding
# (APP_DISPLAY_NAME above).
local DMG_BASENAME="${APP_BASENAME}"
local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
if command -v create-dmg &>/dev/null; then

View File

@@ -0,0 +1,31 @@
# patch-libwebp-simd.cmake — portable, idempotent FetchContent patch for libwebp.
#
# libwebp's cmake/cpu.cmake compiles its scalar *reference* DSP files with the
# SSE-disable flags "-mno-sse4.1;-mno-sse2" whenever it can't positively detect
# SSE support. Under a macOS *universal* build (-arch arm64;x86_64) the per-arch
# SSE flag probe fails (a flag valid for x86_64 is invalid for arm64), so those
# disable flags get applied to the x86_64 slice. clang gates the _Float16 type on
# SSE2 for x86_64, and the macOS 15+/26 SDK's <math.h> declares _Float16 math
# functions unconditionally — so any TU including <math.h> fails to compile with
# "_Float16 is not supported on this target".
#
# SSE2 is part of the x86_64 baseline ABI, so disabling it on the reference files
# is unnecessary on every platform we target. Blanking the SSE entries (indices
# must stay aligned with WEBP_SIMD_FLAGS) fixes the universal build and is a no-op
# for single-arch Linux/Windows/x86_64 builds. Idempotent: re-running is a no-op.
if(NOT DEFINED CPU_CMAKE OR NOT EXISTS "${CPU_CMAKE}")
message(FATAL_ERROR "patch-libwebp-simd: cpu.cmake not found at '${CPU_CMAKE}'")
endif()
file(READ "${CPU_CMAKE}" _contents)
string(REPLACE
"set(SIMD_DISABLE_FLAGS \"-mno-sse4.1;-mno-sse2;;-mno-dspr2;;-mno-msa\")"
"set(SIMD_DISABLE_FLAGS \";;;-mno-dspr2;;-mno-msa\")"
_patched "${_contents}")
if(_patched STREQUAL _contents)
message(STATUS "patch-libwebp-simd: no change (already patched or pattern absent)")
else()
file(WRITE "${CPU_CMAKE}" "${_patched}")
message(STATUS "patch-libwebp-simd: neutralized x86 SSE-disable flags in cpu.cmake")
endif()

View 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:** ≈ 2535 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 (~12h) · **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 1020×; 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 (~23h) · **Status:** ☑ landed & verified
### The defect
In `startProcess()` (`embedded_daemon.cpp:9571061`, 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 (~35h) · **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 (~23h) · **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 (~42104242)
- `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 (~35h) · **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 (~34h) · **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 (~69h) · **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 (~35h) · **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.

138
docs/wallet-hardening.md Normal file
View File

@@ -0,0 +1,138 @@
# Wallet Loading & Management — Hardening Plan
Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
diagnosability QoL). Companion to the findings artifact. Line references are against `dev`.
- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the
code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted
(W1-5), 1 raised (W5-3 Low→Med).
- **Severity:** 8 High · 12 Medium · 13 Low.
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
---
## Roadmap (ordered by risk; shared fixes grouped)
| Phase | Findings | Theme | Status |
|-------|----------|-------|--------|
| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
| **P1-A** | W3-1, W3-2, W3-4 ✓ · W3-3 ☐ | Migrate-to-seed correctness (fund-adjacent) | ◐ 3/4 |
| **P1-B** | W1-1, W1-2, W1-4 ✓ · W1-3 ☐ | Missing/wrong wallet-file safety | ◐ 3/4 |
| **P2** | W6-2, W5-1, W5-2, W6-1, W6-3 | Stale state & lite save-failure surfacing | ☐ |
| **F** | W7-2, W7-3, W7-4, QoL | Diagnostics foundation + QoL bundle | ☐ |
---
## P0-A — Secret hardening
Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
key/passphrase paths, plus console redaction and deleting the plaintext export.
- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an
allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`,
`encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`,
`signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and
keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for
unit testing. **← implementing first (self-contained + testable).**
- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport<ts>` plaintext
key dump after `z_importwallet` succeeds (overwrite-then-unlink).
- **W4-3 (High)** `app_network.cpp:4481``sodium_memzero` the concatenated all-keys string in
`exportAllKeys`; write the backup 0600. (Also unify with `ExportAllKeysDialog` — QoL.)
- **W4-1 (High)** `app_network.cpp:3801` — zero the key copies in `importPrivateKey`/`sweepPrivateKey`
(local + worker-lambda copies).
- **W2-3 (Med)** `app_security.cpp:1481` — zero the passphrase threaded through the decrypt lambda chain.
- **W4-5 (Med)** `app.cpp:3577` — the seed-backup `.txt` is a permanent predictable cleartext seed;
at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase.
- **W5-3 (Med)** `lite_wallet_lifecycle_service.cpp:322` — remove the dead `passphrase` field from the
lite create/open/restore requests (unused; a secret copied for nothing).
## P0-B — Encryption integrity
- **W2-2 / W4-2 (High)** `wallet_security_controller.h:89` — the wizard's deferred encryption is
in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a
wallet the user believes is encrypted stays plaintext. Fix: persist a lightweight
`encryption_requested_but_incomplete` settings flag (NEVER the passphrase) when
`beginDeferredEncryption` is called; surface a persistent warning banner while it's set; clear it
only on confirmed `encryptwallet` success; on next connect, if set, re-prompt for the passphrase to
complete it.
- **W2-4 (Med)** `app_security.cpp:480``lockWallet` only sets `locked` on RPC success; log the
failure and notify (currently a silent no-op that can leave the wallet unlocked).
## P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully)
- **W3-1 (High)** `app_network.cpp:4327` — adopt hardcodes `datadir + "/wallet.dat"`; use
`settings_->getActiveWalletFile()` so migrating a non-default active wallet swaps the right file.
- **W3-2 (High)** `seed_wallet_creator.cpp:57``remove_all(<config>/seed-migrate)` unconditionally
at Phase-1 start; refuse to wipe if a temp `DRAGONX/wallet.dat` already exists (a prior un-adopted
swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry.
- **W3-4 (Med)** `app_network.cpp:1124` — block wallet switching while a migration is *pending*
(`getSeedMigrationPending()`), not only while the dialog is open.
- **W3-3 (Med)** `app_network.cpp:4231` — persist the sweep opid so an app-close mid-Sweeping can
resume/re-poll it instead of silently dropping the txid.
## P1-B — Missing/wrong wallet-file safety
- **W1-1 (High)** `app_network.cpp:1109``fs::exists()`-check the target wallet file in
`switchToWallet()` and before the first daemon launch at startup; if missing, block with an explicit
"Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the
daemon fabricate an empty wallet.
- **W1-3 (Med)** `app_network.cpp:1095` — defer the `syncedHere=true` stamp to the first successful
address/balance readback (idHash non-empty), not bare `onConnected()`.
- **W1-2 (Med)** `app_network.cpp:198` — split `DB_CORRUPT`-specific strings from the generic "Error
loading wallet" fallback; give `DB_TOO_NEW` its own message/action (not a salvage offer).
- **W1-4 (Low)** `wallets_dialog.h:393` — re-`fs::exists()` the in-datadir row before switching (match
the out-of-datadir path).
## P2 — State & lite persistence
- **W6-2 (Med)** `network_refresh_service.cpp:1183` — record a per-field last-success timestamp / a
"refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current.
- **W5-1 / W5-2 (Med)** `lite_wallet_controller.cpp:78,603``liteLog()` the failed save and bubble a
one-shot UI warning (both call sites currently discard the bool).
- **W6-1 (Med)** `wallet_state.h:313` — reset `mining`/`pool_mining` in `clear()` (or comment why not).
- **W6-3 (Low)** `address_book.cpp:46` — per-entry try/catch: skip + count malformed entries instead
of discarding the whole list.
## F — Diagnostics foundation + QoL
Land W7-2 first — it unblocks the rest.
- **W7-2 (Med)** `logger.cpp:31` — call `Logger::instance().init(<config>/dragonx-debug.log)` early in
`main()` on all platforms; add an "Open log folder" action.
- **W7-3 (Med)** `main.cpp:144` — add a `sigaction`-based crash handler writing `dragonx-crash.log` on
POSIX (mirror the Windows SEH path).
- **W7-4 (Low)** `logger.cpp:39` — size-cap/rotate the log on `init()`.
- **QoL** — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner;
refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured
switch/migration audit logging; restore-from-seed entry point (W4-4, effort L).
---
## Progress log
- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
- **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
- **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".
Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch).
- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully):
- **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race).
- **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(<config>/seed-migrate)` unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) when `DRAGONX/wallet.dat` already exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one.
- **W3-4 (Med):** `switchToWallet` only blocked switching while the migration *dialog* was open; closing it via "Later" mid-migration dropped the guard. Now also blocks while `getSeedMigrationPending()`.
Build-clean; `ctest` 1/1. **Remaining P1-A:** W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid).
- **P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail)** — ☑ landed:
- **W2-2:** the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with **no record it was ever requested** — the user believing it was encrypted. Now a persisted `encryption_pending` settings flag is set the moment encryption is requested (**never the passphrase** — only the fact). `refreshWalletEncryptionState()` reconciles it on every connect: wallet observed **encrypted** → clear the flag; wallet **not** encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session **"your wallet is NOT encrypted — open Settings to finish"** warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice.
- **W2-4:** `lockWallet()`'s continuation only handled success — a failed `walletlock` silently left the wallet **unlocked** (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed.
Touches `settings.{h,cpp}`, `app_wizard.cpp`, `app_security.cpp`, `app.h`. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean, `ctest` 1/1.
- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore**`encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open**`unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)*
- **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to <path>". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: <path>", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision.
- **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code:
- **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy.
- **W4-3** `exportAllKeys`/`backupWallet`: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written via `Platform::writeFileAtomically(..., restrictPermissions=true)` (atomic + 0600) instead of a umask-default `ofstream`.
- **W2-3** decrypt-wallet passphrase: `std::move`-captured into the worker lambda (so no plaintext copy is left in the calling frame) and `sodium_memzero`'d right after `unlockWallet` (its only use).
Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean; `ctest` 1/1 (no regression). **Remaining in P0-A:** W5-3 (remove the dead lite `passphrase` field), W4-5 (predictable plaintext seed-backup file).
- **P0-A / W2-1** — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (`obsidiandecryptexport…`) as soon as the `z_importwallet` attempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (`wallet.dat.encrypted.bak`). `app_security.cpp` (after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean, `ctest` 1/1 (no regression).
- **P0-A / W7-1** — ☑ landed: `RedactConsoleCommand`/`ConsoleCommandCarriesSecret` in `console_tab_helpers` redact secret-bearing commands (an allowlist of 13 first-tokens: `walletpassphrase`, `encryptwallet`, `z_importkey`, …) to `> walletpassphrase ****` before they hit the console echo AND the recall history; the real command still executes unredacted. Wired into `submitConsoleCommand` (`console_tab.cpp`). New `testConsoleSecretRedaction` (11 assertions). Clean build; `ctest` 1/1. (Output-secret commands like `z_exportkey` — result redaction — remain a follow-up.)

View File

View File

@@ -734,6 +734,9 @@
"lite_working": "In Arbeit…",
"loading": "Laden...",
"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",
"local_hashrate": "Lokale Hashrate",
"low_spec_mode": "Energiesparmodus",
@@ -1154,6 +1157,8 @@
"sb_connecting_external": "Verbindung zu externem Daemon...",
"sb_connecting_generic": "Verbindung zum Daemon...",
"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_start_failed": "dragonxd konnte nicht gestartet werden",
"sb_dragonxd_running": "dragonxd läuft",
@@ -1169,6 +1174,7 @@
"sb_net_mhs": "Netz: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf nicht gefunden",
"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_pct": "Neuscan %.0f%%",
"sb_restarting_daemon": "Daemon wird neu gestartet...",

View File

@@ -734,6 +734,9 @@
"lite_working": "Trabajando…",
"loading": "Cargando...",
"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",
"local_hashrate": "Tasa Hash Local",
"low_spec_mode": "Modo bajo rendimiento",
@@ -1154,6 +1157,8 @@
"sb_connecting_external": "Conectando a daemon externo...",
"sb_connecting_generic": "Conectando al daemon...",
"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_start_failed": "No se pudo iniciar dragonxd",
"sb_dragonxd_running": "dragonxd ejecutándose",
@@ -1169,6 +1174,7 @@
"sb_net_mhs": "Red: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf no encontrado",
"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_pct": "Reescaneando %.0f%%",
"sb_restarting_daemon": "Reiniciando daemon...",

View File

@@ -734,6 +734,9 @@
"lite_working": "En cours…",
"loading": "Chargement...",
"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",
"local_hashrate": "Hashrate local",
"low_spec_mode": "Mode économie",
@@ -1154,6 +1157,8 @@
"sb_connecting_external": "Connexion au daemon externe...",
"sb_connecting_generic": "Connexion au daemon...",
"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_start_failed": "Impossible de démarrer dragonxd",
"sb_dragonxd_running": "dragonxd en cours",
@@ -1169,6 +1174,7 @@
"sb_net_mhs": "Rés: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf introuvable",
"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_pct": "Rescan %.0f%%",
"sb_restarting_daemon": "Redémarrage du daemon...",

View File

@@ -734,6 +734,9 @@
"lite_working": "処理中…",
"loading": "読み込み中...",
"loading_addresses": "アドレスを読み込み中...",
"loading_stall_body": "デーモンは %.0f 秒間初期化しています。アップデート後や初回起動時(ブロックインデックスの読み込みや再スキャン)は正常な場合があります。準備ができ次第、自動的に接続します。",
"loading_stall_hint": "まだ動かない場合は、設定を開いて「デーモンを再起動」を使うか、コンソールで詳細を確認してください。",
"loading_stall_title": "予想より時間がかかっています",
"loading_transactions": "トランザクションを読み込み中",
"local_hashrate": "ローカルハッシュレート",
"low_spec_mode": "省電力モード",

View File

@@ -734,6 +734,8 @@
"lite_working": "작업 중…",
"loading": "로딩 중...",
"loading_addresses": "주소 로딩 중...",
"loading_stall_body": "데몬이 %.0f초 동안 초기화 중입니다. 업데이트 후나 첫 실행 시(블록 인덱스 로드 또는 재스캔)에는 정상일 수 있습니다. 준비되면 자동으로 연결됩니다.",
"loading_stall_title": "예상보다 오래 걸리고 있습니다",
"loading_transactions": "거래를 불러오는 중",
"local_hashrate": "로컬 해시레이트",
"low_spec_mode": "저사양 모드",
@@ -1154,6 +1156,8 @@
"sb_connecting_external": "외부 데몬에 연결 중...",
"sb_connecting_generic": "데몬에 연결 중...",
"sb_daemon_crashed": "데몬이 %d회 충돌함",
"sb_daemon_extract_failed": "데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
"sb_daemon_files_failed": "%s에 데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
"sb_daemon_not_found": "데몬을 찾을 수 없음",
"sb_daemon_start_failed": "dragonxd를 시작할 수 없습니다",
"sb_dragonxd_running": "dragonxd 실행 중",
@@ -1169,6 +1173,7 @@
"sb_net_mhs": "네트: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
"sb_peers": "피어: %zu",
"sb_plaintext_remote_blocked": "원격 호스트로 RPC 자격 증명을 평문으로 보내는 것을 거부했습니다. 허용하려면 DRAGONX.conf에 rpcallowplaintext=1을 추가하거나 rpctls=1로 TLS를 활성화하세요.",
"sb_rescanning": "재스캔",
"sb_rescanning_pct": "재스캔 %.0f%%",
"sb_restarting_daemon": "데몬 재시작 중...",

View File

@@ -734,6 +734,9 @@
"lite_working": "Processando…",
"loading": "Carregando...",
"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",
"local_hashrate": "Hashrate Local",
"low_spec_mode": "Modo econômico",
@@ -1154,6 +1157,8 @@
"sb_connecting_external": "Conectando ao daemon externo...",
"sb_connecting_generic": "Conectando ao daemon...",
"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_start_failed": "Não foi possível iniciar o dragonxd",
"sb_dragonxd_running": "dragonxd em execução",
@@ -1169,6 +1174,7 @@
"sb_net_mhs": "Rede: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf não encontrado",
"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_pct": "Reescaneando %.0f%%",
"sb_restarting_daemon": "Reiniciando daemon...",

View File

@@ -734,6 +734,9 @@
"lite_working": "Обработка…",
"loading": "Загрузка...",
"loading_addresses": "Загрузка адресов...",
"loading_stall_body": "Демон инициализируется уже %.0f с. Это может быть нормально после обновления или при первом запуске (загрузка индекса блоков или повторное сканирование) — соединение установится автоматически, когда он будет готов.",
"loading_stall_hint": "Всё ещё не отвечает? Откройте Настройки и нажмите «Перезапустить демон» или посмотрите подробности в Консоли.",
"loading_stall_title": "Занимает больше времени, чем ожидалось",
"loading_transactions": "Загрузка транзакций",
"local_hashrate": "Локальный хешрейт",
"low_spec_mode": "Режим экономии",
@@ -1154,6 +1157,8 @@
"sb_connecting_external": "Подключение к внешнему демону...",
"sb_connecting_generic": "Подключение к демону...",
"sb_daemon_crashed": "Демон упал %d раз",
"sb_daemon_extract_failed": "Не удалось записать файлы демона — проверьте свободное место на диске и права доступа.",
"sb_daemon_files_failed": "Не удалось записать файлы демона в %s — проверьте свободное место на диске и права доступа.",
"sb_daemon_not_found": "Демон не найден",
"sb_daemon_start_failed": "Не удалось запустить dragonxd",
"sb_dragonxd_running": "dragonxd запущен",
@@ -1169,6 +1174,7 @@
"sb_net_mhs": "Сеть: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf не найден",
"sb_peers": "Пиры: %zu",
"sb_plaintext_remote_blocked": "Отправка учётных данных RPC открытым текстом на удалённый узел запрещена. Добавьте rpcallowplaintext=1 в DRAGONX.conf, чтобы разрешить, или включите TLS с помощью rpctls=1.",
"sb_rescanning": "Пересканирование",
"sb_rescanning_pct": "Пересканирование %.0f%%",
"sb_restarting_daemon": "Перезапуск демона...",

View File

@@ -734,6 +734,8 @@
"lite_working": "处理中…",
"loading": "加载中...",
"loading_addresses": "正在加载地址...",
"loading_stall_body": "守护进程已初始化 %.0f 秒。更新后或首次启动时(加载区块索引或重新扫描)这可能是正常现象——就绪后会自动连接。",
"loading_stall_title": "耗时超出预期",
"loading_transactions": "正在加载交易",
"local_hashrate": "本地算力",
"low_spec_mode": "低配模式",
@@ -1154,6 +1156,8 @@
"sb_connecting_external": "正在连接外部守护进程...",
"sb_connecting_generic": "正在连接守护进程...",
"sb_daemon_crashed": "守护进程崩溃 %d 次",
"sb_daemon_extract_failed": "无法写入守护进程文件——请检查磁盘剩余空间和权限。",
"sb_daemon_files_failed": "无法将守护进程文件写入 %s——请检查磁盘剩余空间和权限。",
"sb_daemon_not_found": "未找到守护进程",
"sb_daemon_start_failed": "无法启动 dragonxd",
"sb_dragonxd_running": "dragonxd 运行中",

View File

@@ -1503,6 +1503,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
progress-width = { size = 260.0 }
backdrop-alpha = { opacity = 0.80 }
vertical-gap = { size = 8.0 }
stall-timeout-sec = { size = 45.0 }
# ---------------------------------------------------------------------------
# First-Run Wizard Screens

View File

@@ -1,5 +1,17 @@
#!/usr/bin/env bash
# This script uses bash 4+ features (mapfile, safe empty-array expansion under
# `set -u`). macOS ships bash 3.2, so re-exec under a newer bash when one is
# present (Homebrew), and fail with a clear message otherwise.
if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
for _newer_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do
[ -x "$_newer_bash" ] && exec "$_newer_bash" "$0" "$@"
done
echo "ERROR: build-lite-backend-artifact.sh requires bash 4+ (found ${BASH_VERSION:-unknown})." >&2
echo " On macOS: brew install bash" >&2
exit 1
fi
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -312,6 +324,9 @@ prepare_backend_source() {
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src"
[[ -f "$BACKEND_SOURCE_DIR/Cargo.lock" ]] && ln -s "$BACKEND_SOURCE_DIR/Cargo.lock" "$prepared_root/Cargo.lock"
[[ -d "$BACKEND_SOURCE_DIR/.cargo" ]] && ln -s "$BACKEND_SOURCE_DIR/.cargo" "$prepared_root/.cargo"
# Honor the pinned Rust toolchain (rust-toolchain.toml) inside the prepared root too,
# so builds using --silentdragonxlitelib-dir still select rustc 1.63.
[[ -f "$BACKEND_SOURCE_DIR/rust-toolchain.toml" ]] && ln -s "$BACKEND_SOURCE_DIR/rust-toolchain.toml" "$prepared_root/rust-toolchain.toml"
[[ -d "$BACKEND_SOURCE_DIR/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
# "vendor" relative to the build root, so expose it inside the prepared root too.

View File

@@ -256,8 +256,8 @@ HEADER_START
echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"

View File

@@ -1,25 +1,31 @@
#!/usr/bin/env bash
# Sign dragonx full-node release archives for the wallet's in-app daemon updater (ed25519).
# Package the prebuilt dragonx full-node binaries into per-platform release archives and sign them
# for the wallet's in-app daemon updater (ed25519 over the EXACT archive bytes).
#
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset.
# The wallet verifies a detached ed25519 signature over the archive bytes against a public key
# pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is MANDATORY
# (kDaemonRequireSignature = true): an in-app update is refused unless a valid "<archive>.sig" is
# published next to the archive. The wallet also checks each archive's SHA-256 against a markdown
# checksum table in the release body, so `release` prints that table for you to paste in.
#
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl needed. OpenSSL's ed25519 is PureEdDSA (RFC 8032),
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible
# (the same flow the wallet's unit tests verify for the miner updater).
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl. OpenSSL's ed25519 is PureEdDSA (RFC 8032), the
# same primitive libsodium's crypto_sign_verify_detached checks, so the signatures are compatible.
#
# Usage:
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
# scripts/sign-daemon-release.sh sign <secret.key> <file>... # sign existing files -> <file>.sig
# scripts/sign-daemon-release.sh release <secret.key> <version> [--src DIR] [--out DIR]
# # zip prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,macos,
# # win64}.zip, sign each, and print the SHA-256 checksum table. Platforms with no dragonxd
# # binary staged are skipped.
#
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
# Keep the secret key (.ed25519.key) OFFLINE (mode 600). Paste the base64 public key into
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
die() { echo "error: $*" >&2; exit 1; }
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
@@ -27,6 +33,26 @@ command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25
# ed25519 is a fixed 12-byte prefix + the 32-byte key, so the trailing 32 bytes are the raw key.
pubkey_b64() { openssl pkey -in "$1" -pubout -outform DER | tail -c 32 | openssl base64 -A; }
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}';
else shasum -a 256 "$1" | awk '{print $1}'; fi
}
# Detached ed25519 signature over the raw file bytes -> <file>.sig (base64 of the 64-byte sig).
sign_file() {
local key="$1" f="$2" raw
raw="$(mktemp)"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
}
# platform -> (staging dir under prebuilt-binaries, release token, expected daemon binary name)
plat_dir() { case "$1" in linux) echo dragonxd-linux;; mac) echo dragonxd-mac;; win) echo dragonxd-win;; esac; }
plat_token() { case "$1" in linux) echo linux-amd64;; mac) echo macos;; win) echo win64;; esac; }
plat_daemon() { case "$1" in win) echo dragonxd.exe;; *) echo dragonxd;; esac; }
cmd="${1:-}"; shift || true
case "$cmd" in
keygen)
@@ -42,26 +68,90 @@ case "$cmd" in
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
echo " $pub"
;;
pubkey)
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
pubkey_b64 "$1"
;;
sign)
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
key="$1"; shift
[ -f "$key" ] || die "no such key: $key"
for f in "$@"; do
[ -f "$f" ] || die "no such file: $f"
raw="$(mktemp)"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
sign_file "$key" "$f"
echo "signed: $f -> $f.sig"
done
echo "Upload each .sig as a release asset next to its archive."
;;
release)
[ $# -ge 2 ] || die "usage: release <secret.key> <version> [--src DIR] [--out DIR]"
key="$1"; version="$2"; shift 2
src="$PROJECT_ROOT/prebuilt-binaries"
out="$PROJECT_ROOT/release/daemon"
while [ $# -gt 0 ]; do
case "$1" in
--src) [ $# -ge 2 ] || die "--src needs a value"; src="$2"; shift 2 ;;
--out) [ $# -ge 2 ] || die "--out needs a value"; out="$2"; shift 2 ;;
*) die "unknown option: $1" ;;
esac
done
[ -f "$key" ] || die "no such key: $key"
[ -d "$src" ] || die "no such source dir: $src"
command -v zip >/dev/null 2>&1 || die "zip not found (install 'zip')"
mkdir -p "$out"
# Sanity: warn if this key does not match the public key pinned in the wallet (the wallet would
# then reject every signature made with it — only expected when deliberately rotating the key).
pinned="$(grep -oE '"[A-Za-z0-9+/]{43}="' "$PROJECT_ROOT/src/util/daemon_updater.h" 2>/dev/null | head -1 | tr -d '"')"
mine="$(pubkey_b64 "$key")"
if [ -n "$pinned" ] && [ "$pinned" != "$mine" ]; then
echo "WARNING: this key's public key does not match the one pinned in daemon_updater.h:" >&2
echo " signing key -> $mine" >&2
echo " pinned key -> $pinned" >&2
echo " The wallet will REJECT these signatures unless you are rotating the pinned key." >&2
echo >&2
fi
made=0
table=""
for plat in linux mac win; do
d="$src/$(plat_dir "$plat")"
daemon="$d/$(plat_daemon "$plat")"
if [ ! -f "$daemon" ]; then
echo "skip $plat: no $(plat_daemon "$plat") staged in $d" >&2
continue
fi
archive="dragonx-$version-$(plat_token "$plat").zip"
apath="$out/$archive"
rm -f "$apath"
# Zip the staged files at the archive root (binaries + sapling params + asmap), excluding
# the .gitkeep placeholder. The updater flattens paths via baseName(), so a flat zip is fine.
files=()
while IFS= read -r fn; do files+=("$fn"); done < <(cd "$d" && ls -A | grep -vx '.gitkeep')
[ "${#files[@]}" -gt 0 ] || { echo "skip $plat: nothing to package in $d" >&2; continue; }
( cd "$d" && zip -q -X "$apath" "${files[@]}" )
sign_file "$key" "$apath"
sum="$(sha256_of "$apath")"
table+="| $archive | \`$sum\` |"$'\n'
echo "packaged + signed: $apath (+ .sig) sha256=$sum"
made=$((made + 1))
done
[ "$made" -gt 0 ] || die "no platform had a staged daemon binary under $src/dragonxd-{linux,mac,win}/"
echo
echo "Checksum table (paste into the release body so the wallet can verify SHA-256):"
echo "| Archive | SHA-256 |"
echo "|---|---|"
printf '%s' "$table"
echo
echo "Upload each .zip AND its .zip.sig as release assets. Wallet enforces the ed25519 signature"
echo "(kDaemonRequireSignature=true) and the SHA-256 from the table above."
;;
*)
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>... | release <secret.key> <version> [--src DIR] [--out DIR]}"
;;
esac

View File

@@ -133,7 +133,7 @@ pkgs_core_arch="base-devel cmake git pkg-config
libxkbcommon wayland libsodium curl
autoconf automake libtool wget python xxd"
pkgs_core_macos="cmake python xxd"
pkgs_core_macos="bash cmake python xxd"
# Windows cross-compile (from Linux)
pkgs_win_debian="mingw-w64 zip"
@@ -284,18 +284,27 @@ fi
header "Windows Cross-Compile"
if $SETUP_WIN; then
win_pkgs="$(get_pkgs win)"
if [[ -n "$win_pkgs" ]]; then
install_pkgs "$win_pkgs" "Windows cross-compile"
fi
# Only touch apt / update-alternatives (which need sudo) when the toolchain is missing. If it is
# already installed, skip them so `./setup.sh --win` can run WITHOUT sudo — important because the
# daemon cross-compile that follows should run as the invoking user. Running the whole setup under
# sudo leaves root-owned build artifacts under external/dragonx, which then break `make clean` on
# a later non-sudo build (stale objects get relinked -> the mingw link failure recurs).
if has_cmd x86_64-w64-mingw32-g++-posix || has_cmd x86_64-w64-mingw32-g++; then
ok "Windows cross-compile toolchain already present — skipping apt install"
else
win_pkgs="$(get_pkgs win)"
if [[ -n "$win_pkgs" ]]; then
install_pkgs "$win_pkgs" "Windows cross-compile"
fi
# Set posix thread model if available
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
if ! $CHECK_ONLY; then
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
# Set posix thread model if available
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
if ! $CHECK_ONLY; then
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
fi
fi
fi
@@ -699,11 +708,13 @@ if [[ "$STALE_DAEMON" -eq 1 ]]; then
warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
fi
# ── 7. xmrig-hac (mining binary) ────────────────────────────────────────────
header "xmrig-hac Mining Binary"
# ── 7. drg-xmrig (mining binary) ────────────────────────────────────────────
header "drg-xmrig Mining Binary"
XMRIG_SRC="$PROJECT_DIR/external/xmrig-hac"
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/xmrig-hac"
XMRIG_SRC="$PROJECT_DIR/external/drg-xmrig"
# Output dir bundled by build.sh (Linux zip, AppImage, Windows embed, mac .app)
# and scripts/legacy/build-windows.sh — keep this path in sync with those.
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/drg-xmrig"
# Clean previous prebuilt xmrig binaries so we always rebuild
# Only clean the binary for the platform(s) we are actually building,
@@ -715,14 +726,14 @@ if ! $CHECK_ONLY; then
fi
fi
# Helper: clone xmrig-hac if not present
# Helper: clone drg-xmrig if not present
clone_xmrig_if_needed() {
if [[ ! -d "$XMRIG_SRC" ]]; then
info "Cloning xmrig-hac..."
git clone https://git.dragonx.is/dragonx/xmrig-hac.git "$XMRIG_SRC"
info "Cloning drg-xmrig..."
git clone https://git.dragonx.is/DragonX/drg-xmrig.git "$XMRIG_SRC"
else
ok "xmrig-hac source already present"
info "Pulling latest xmrig-hac..."
ok "drg-xmrig source already present"
info "Pulling latest drg-xmrig..."
(cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true)
fi
}
@@ -743,15 +754,15 @@ else
rm -rf "$XMRIG_SRC/build"
# Build dependencies (libuv, hwloc, openssl)
info "Building xmrig-hac dependencies (libuv, hwloc, openssl)..."
info "Building drg-xmrig dependencies (libuv, hwloc, openssl)..."
(
cd "$XMRIG_SRC/scripts"
sh build_deps.sh
)
ok "xmrig-hac dependencies built"
ok "drg-xmrig dependencies built"
# Build xmrig
info "Building xmrig-hac (Linux)..."
info "Building drg-xmrig (Linux)..."
mkdir -p "$XMRIG_SRC/build"
(
cd "$XMRIG_SRC/build"
@@ -768,7 +779,7 @@ else
mkdir -p "$XMRIG_PREBUILT"
if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then
cp "$XMRIG_SRC/build/xmrig" "$XMRIG_LINUX"
ok "xmrig (Linux) built and installed to prebuilt-binaries/xmrig-hac/"
ok "xmrig (Linux) built and installed to prebuilt-binaries/drg-xmrig/"
else
err "xmrig (Linux) build failed — binary not found"
MISSING=$((MISSING + 1))
@@ -792,7 +803,7 @@ else
# Clean previous Windows build
rm -rf "$XMRIG_SRC/build-windows"
info "Building xmrig-hac (Windows cross-compile)..."
info "Building drg-xmrig (Windows cross-compile)..."
(
cd "$XMRIG_SRC/scripts"
bash build_windows.sh
@@ -802,7 +813,7 @@ else
mkdir -p "$XMRIG_PREBUILT"
if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then
cp "$XMRIG_SRC/build-windows/xmrig.exe" "$XMRIG_WIN"
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/xmrig-hac/"
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/drg-xmrig/"
else
err "xmrig.exe (Windows) build failed — binary not found"
MISSING=$((MISSING + 1))
@@ -812,7 +823,7 @@ fi
# ── 8. Binary directories ───────────────────────────────────────────────────
header "Binary Directories"
for platform in dragonxd-linux dragonxd-win dragonxd-mac xmrig; do
for platform in dragonxd-linux dragonxd-win dragonxd-mac drg-xmrig; do
dir="$PROJECT_DIR/prebuilt-binaries/$platform"
if [[ -d "$dir" ]]; then
# Count actual files (not .gitkeep)

View File

@@ -69,6 +69,7 @@
#include "ui/widgets/copy_field.h"
#include "ui/notifications.h"
#include "util/i18n.h"
#include "util/connect_stall.h"
#include "util/platform.h"
#include "util/text_format.h"
#include "util/payment_uri.h"
@@ -4149,7 +4150,11 @@ bool App::startEmbeddedDaemon()
if (resources::hasEmbeddedResources()) {
DEBUG_LOGF("Extracting embedded Sapling params...\n");
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
if (!rpc::Connection::verifySaplingParams()) {
@@ -4168,8 +4173,13 @@ bool App::startEmbeddedDaemon()
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
bool copied = false;
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;
fs::create_directories(daemon_dir, ec);
// On macOS .app bundles, params are in Contents/Resources/
// while the executable is in Contents/MacOS/
@@ -4214,8 +4224,13 @@ bool App::startEmbeddedDaemon()
std::string exe_dir = util::Platform::getExecutableDirectory();
std::string daemon_dir = resources::getDaemonDirectory();
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;
fs::create_directories(daemon_dir, ec);
std::vector<std::string> searchDirs = { exe_dir };
#ifdef __APPLE__
@@ -4226,18 +4241,31 @@ bool App::startEmbeddedDaemon()
}
#endif
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
bool copyFailed = false;
for (const char* name : extraFiles) {
fs::path dst = fs::path(daemon_dir) / name;
if (fs::exists(dst)) continue;
for (const auto& dir : searchDirs) {
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());
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;
}
}
}
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)
// -------------------------------------------------------------------

View File

@@ -1023,6 +1023,9 @@ private:
std::uint64_t clipboard_secret_hash_ = 0;
double clipboard_clear_deadline_ = 0.0;
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)
bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
// Current page (sidebar navigation)
ui::NavPage current_page_ = ui::NavPage::Overview;

View File

@@ -35,6 +35,7 @@
#include "rpc/connection.h"
#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/mining_pool_panel.h" // ui::resolveMiningUserAddress
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
#include <cctype>
#include "config/settings.h"
@@ -196,10 +197,14 @@ static WarmupText translateWarmup(const std::string& raw)
// Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt.
static bool walletOutputLooksCorrupt(const std::string& out)
{
// W1-2: the generic "Error loading wallet" fallback is ALSO printed for DB_TOO_NEW
// ("...requires ... newer version..."), which -salvagewallet cannot fix — so don't misclassify a
// version mismatch as salvageable corruption and offer a repair that can't help.
const bool versionMismatch = out.find("newer version") != std::string::npos;
return out.find("Failed to rename") != std::string::npos
|| out.find("salvage failed") != std::string::npos
|| out.find("wallet.dat corrupt") != std::string::npos
|| out.find("Error loading wallet") != std::string::npos;
|| (out.find("Error loading wallet") != std::string::npos && !versionMismatch);
}
// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
@@ -240,6 +245,16 @@ void App::tryConnect()
// Auto-detect configuration (file I/O — fast, safe on main thread)
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()) {
connection_in_progress_ = false;
@@ -310,11 +325,21 @@ void App::tryConnect()
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());
if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) {
remote_rpc_plaintext_warning_shown_ = true;
ui::Notifications::instance().warning(
"Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.",
10.0f);
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;
ui::Notifications::instance().warning(TR("sb_plaintext_remote_blocked"), 20.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
@@ -385,6 +410,7 @@ void App::tryConnect()
// fail until warmup completes. Set the warmup state so
// the UI shows status instead of a blocking overlay.
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);
state_.warmup_status = wt.title;
state_.warmup_description = wt.description;
@@ -526,6 +552,7 @@ void App::onConnected()
}
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
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_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
connection_status_ = TR("connected");
@@ -606,6 +633,7 @@ void App::onDisconnected(const std::string& reason)
state_.connected = false;
state_.warming_up = false;
state_.warmup_status.clear();
connect_stall_since_ = 0.0; // reset the "taking too long" clock (App member, untouched by state_.clear())
state_.clear();
connection_status_ = reason;
@@ -660,6 +688,7 @@ void App::onDisconnected(const std::string& reason)
std::string App::applyDaemonInitStatus(bool reachableButBusy)
{
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
// what the node is doing (loading the block index, verifying, activating best chain, …).
@@ -1096,7 +1125,9 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes.");
return;
}
if (show_seed_migration_) {
// W3-4: block switching while a migration is PENDING, not only while its dialog is open — closing
// the dialog via "Later" mid-migration leaves the pending state but previously dropped this guard.
if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) {
ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
return;
}
@@ -1108,6 +1139,19 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
return;
}
// W1-1: verify the target wallet file actually exists before switching. dragonxd auto-CREATES a
// fresh empty wallet for a missing -wallet=<name>, so without this a moved/deleted wallet file would
// silently "open" as a brand-new empty wallet with a zero balance — looking exactly like fund loss.
// (Also closes the W1-4 stale-switcher-row race: the check runs no matter how switchToWallet is called.)
{
std::error_code existEc;
const std::string walletPath = util::Platform::getDragonXDataDir() + "/" + walletFile;
if (!std::filesystem::exists(walletPath, existEc)) {
ui::Notifications::instance().warning(
"Wallet file not found (moved or deleted?): " + walletFile + " — it was not opened.", 15.0f);
return;
}
}
// If we're connected to a node this session did NOT spawn (no live process handle — it was left
// running by "keep node running", started by the user, or we just direct-connected to a config-provided
// one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may
@@ -1488,6 +1532,7 @@ void App::refreshCoreData()
state_.warming_up = false;
state_.warmup_status.clear();
state_.warmup_description.clear();
connect_stall_since_ = 0.0; // warmup finished — clear the "taking too long" clock
connection_status_ = TR("connected");
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
@@ -2288,27 +2333,23 @@ void App::startPoolMining(int threads)
cfg.tls = settings_->getPoolTls();
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) {
if (!addr.address.empty()) {
cfg.wallet_address = addr.address;
if (!addr.address.empty()) { firstShielded = addr.address; break; }
}
for (const auto& addr : state_.addresses) {
if (addr.type == "transparent" && !addr.address.empty()) {
firstTransparent = addr.address;
break;
}
}
if (cfg.wallet_address.empty()) {
for (const auto& addr : state_.addresses) {
if (addr.type == "transparent" && !addr.address.empty()) {
cfg.wallet_address = addr.address;
break;
}
}
}
// 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;
}
cfg.wallet_address = ui::resolveMiningUserAddress(cfg.worker_name, firstShielded, firstTransparent);
if (cfg.wallet_address.empty()) {
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
@@ -3761,6 +3802,8 @@ void App::exportAllKeys(std::function<void(const std::string&, int, int)> callba
(*pending)--;
if (*pending == 0 && callback) {
callback(*keys_result, *exported, *total);
// Scrub the concatenated all-keys buffer once the consumer (backup writer) has used it.
if (!keys_result->empty()) sodium_memzero(&(*keys_result)[0], keys_result->size());
}
});
}
@@ -3792,7 +3835,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
== services::WalletSecurityController::KeyKind::Shielded;
// Run on the worker thread — import requests a full rescan (rescan=true), so the
// synchronous curl call can take many seconds; never block the UI thread on it.
worker_->post([this, key, viewing, shielded, startHeight, callback]() -> rpc::RPCWorker::MainCb {
worker_->post([this, key, viewing, shielded, startHeight, callback]() mutable -> rpc::RPCWorker::MainCb {
std::string err, addr;
try {
rpc::RPCClient::TraceScope trace("Settings / Import key");
@@ -3804,6 +3847,11 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
// A start height (shielded RPCs only) rescans from that block instead of genesis.
if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight);
nlohmann::json r = rpc_->call(method, params);
// Scrub the key out of the request params (the json holds its own copy of it).
if (params.is_array() && !params.empty() && params[0].is_string()) {
std::string& pk = params[0].get_ref<std::string&>();
if (!pk.empty()) sodium_memzero(&pk[0], pk.size());
}
// z_import* return {type,address}; importprivkey returns the t-address string.
if (r.is_object() && r.contains("address") && r["address"].is_string())
addr = r["address"].get<std::string>();
@@ -3816,6 +3864,8 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
// below would never run, leaving a stuck "Importing…" spinner.
err = "Import failed (unknown error)";
}
// Scrub the worker's copy of the key now that the request has been sent (all paths).
if (!key.empty()) sodium_memzero(&key[0], key.size());
return [this, err, addr, callback]() {
if (!err.empty()) {
if (callback) callback(false, err, "");
@@ -3826,6 +3876,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
if (callback) callback(true, "", addr);
};
});
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
}
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
@@ -3863,7 +3914,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
const double fee = DRAGONX_DEFAULT_FEE;
worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() -> rpc::RPCWorker::MainCb {
worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() mutable -> rpc::RPCWorker::MainCb {
std::string err, dest, sourceAddr, amountStr;
double amount = 0.0;
try {
@@ -3887,6 +3938,11 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
else { method = "importprivkey"; params = {key, "", true}; }
if (startHeight > 0 && shielded) params.push_back(startHeight);
nlohmann::json r = rpc_->call(method, params);
// Scrub the key out of the request params (the json holds its own copy of it).
if (params.is_array() && !params.empty() && params[0].is_string()) {
std::string& pk = params[0].get_ref<std::string&>();
if (!pk.empty()) sodium_memzero(&pk[0], pk.size());
}
// 2. Determine the swept address. importprivkey returns the t-address string; z_importkey
// returns null, so diff the z-address list to find the one the key just added.
@@ -3945,6 +4001,8 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
} catch (...) {
err = "Sweep failed (unknown error)";
}
// Scrub the worker's copy of the spending key now that the request has been sent (all paths).
if (!key.empty()) sodium_memzero(&key[0], key.size());
return [this, err, sourceAddr, dest, amount, amountStr, fee]() {
invalidateAddressValidationCache();
refreshAddresses();
@@ -3981,6 +4039,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
});
};
});
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
}
void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, const std::string&)> callback)
@@ -4282,7 +4341,13 @@ void App::beginAdoptSeedWallet()
// has its own passphrase; the user can re-enable PIN quick-unlock for it).
if (vault_) vault_->removeVault();
const std::string base = seed_migration_temp_dir_;
async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) {
// W3-1: adopt must swap the ACTIVE wallet file (multi-wallet), not a hardcoded "wallet.dat" —
// otherwise a migration run while e.g. wallet-2.dat is active would install the swept seed wallet
// into an unloaded wallet.dat and leave the daemon loading the (now-emptied) legacy wallet.
// Captured on the main thread; wallet switching is blocked during migration so this can't race.
const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
? settings_->getActiveWalletFile() : std::string("wallet.dat");
async_tasks_.submit("Adopt seed wallet", [this, base, activeWalletName](const util::AsyncTaskManager::Token&) {
namespace fs = std::filesystem;
std::string err; // fatal (swap did not happen; migration incomplete)
std::string warn; // non-fatal (swap done but the daemon did not restart)
@@ -4302,7 +4367,7 @@ void App::beginAdoptSeedWallet()
// 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER
// delete), then copy the new seed wallet in. On any failure, restore the legacy.
const std::string datadir = util::Platform::getDragonXDataDir();
const std::string legacy = datadir + "/wallet.dat";
const std::string legacy = datadir + "/" + activeWalletName;
const std::string newWallet = base + "/DRAGONX/wallet.dat";
std::time_t t = std::time(nullptr);
std::tm tmv{}; // thread-safe local time (the UI thread also uses localtime)
@@ -4456,13 +4521,12 @@ void App::backupWallet(const std::string& destination, std::function<void(bool,
return;
}
std::ofstream file(destination);
if (!file.is_open()) {
if (callback) callback(false, "Could not open file: " + destination);
// Write the key backup atomically and owner-only (0600) — it must never be even briefly
// world-readable, and the previous std::ofstream left it at the umask default.
if (!util::Platform::writeFileAtomically(destination, keys, /*restrictPermissions=*/true)) {
if (callback) callback(false, "Could not write file: " + destination);
return;
}
file << keys;
file.close();
std::string msg = "Wallet backup saved to " + destination + ""
+ std::to_string(exported) + " of " + std::to_string(total) + " keys.";

View File

@@ -33,6 +33,10 @@
#include <ctime>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <vector>
#include <utility>
#include <sodium.h>
#include <functional>
#include <memory>
#include <utility>
@@ -483,7 +487,17 @@ void App::lockWallet() {
state_.locked = true;
state_.unlocked_until = 0;
resetTransactionHistoryCacheSession();
lock_failure_warned_ = false;
DEBUG_LOGF("[App] Wallet locked\n");
} else {
// The walletlock RPC failed — the wallet is still UNLOCKED. Surface it (once) rather
// than silently leaving an auto-lock unfulfilled and the wallet exposed (W2-4).
DEBUG_LOGF("[App] walletlock failed — wallet remains unlocked\n");
if (!lock_failure_warned_) {
lock_failure_warned_ = true;
ui::Notifications::instance().warning(
"Couldn't lock the wallet — it is still unlocked. Check the daemon connection.", 12.0f);
}
}
};
});
@@ -560,6 +574,12 @@ void App::refreshWalletEncryptionState() {
state_.unlocked_until = until;
state_.locked = (until == 0);
state_.encryption_state_known = true;
// Wallet is encrypted — any pending deferred-encryption request has now been
// satisfied (however it completed). Clear the persisted flag (W2-2).
if (settings_ && settings_->getEncryptionPending()) {
settings_->setEncryptionPending(false);
settings_->save();
}
if (state_.locked) {
resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
@@ -572,6 +592,19 @@ void App::refreshWalletEncryptionState() {
state_.locked = false;
state_.unlocked_until = 0;
state_.encryption_state_known = true;
// W2-2: encryption was requested (persisted flag) but the wallet is NOT encrypted,
// and no deferred encryption is pending/in-flight — it was lost to a quit/crash or a
// failed connect before it applied. Warn (once/session) instead of silently leaving
// an unencrypted wallet the user believes is protected. The flag stays set until the
// wallet is actually encrypted, so the warning recurs each launch until resolved.
if (settings_ && settings_->getEncryptionPending() &&
!wallet_security_.hasDeferredEncryption() && !encrypt_in_progress_ &&
!encryption_incomplete_warned_) {
encryption_incomplete_warned_ = true;
ui::Notifications::instance().warning(
"Wallet encryption did not complete — your wallet is NOT encrypted. "
"Open Settings to finish encrypting it.", 30.0f);
}
if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
@@ -1478,12 +1511,14 @@ void App::renderDecryptWalletDialog() {
// Run entire decrypt flow on worker thread
if (worker_) {
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context);
});
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
// Scrub the passphrase — unlock is its only use in this flow.
if (!passphrase.empty()) sodium_memzero(&passphrase[0], passphrase.size());
if (!unlock.ok) {
return [this]() {
wallet_security_workflow_.failEntry("Incorrect passphrase");
@@ -1606,6 +1641,27 @@ void App::renderDecryptWalletDialog() {
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
importAdapter, exportPath);
// The plaintext key export (obsidiandecryptexport…) has served its purpose now
// that the import attempt has resolved — scrub and remove it so a full cleartext
// dump of every private key isn't left on disk forever. Recovery, if ever needed,
// is the encrypted backup (wallet.dat.encrypted.bak), never this file.
{
std::error_code delEc;
const auto sz = std::filesystem::file_size(exportPath, delEc);
if (!delEc && sz > 0) {
std::fstream scrub(exportPath,
std::ios::binary | std::ios::in | std::ios::out);
if (scrub) {
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
scrub.flush();
}
}
std::filesystem::remove(exportPath, delEc);
DEBUG_LOGF("[decrypt] removed plaintext key export after import\n");
}
if (!importResult.ok) {
std::string err = importResult.error;
if (worker_) {

View File

@@ -1338,6 +1338,10 @@ void App::renderFirstRunWizard() {
wallet_security_.beginDeferredEncryption(
std::string(encrypt_pass_buf_),
(pinEntered && pinOk) ? pinStr : std::string());
// Persist that encryption was requested (never the passphrase) so a quit/crash or
// failed daemon connect before it applies isn't silent — reconciled on the next
// connect in refreshWalletEncryptionState (W2-2). Saved with the wizard state below.
settings_->setEncryptionPending(true);
// Clear sensitive buffers
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));

View File

@@ -231,6 +231,7 @@ bool Settings::load(const std::string& path)
}
loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
loadScalar(j, "encryption_pending", encryption_pending_);
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
loadScalar(j, "active_wallet_file", active_wallet_file_);
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
@@ -497,6 +498,7 @@ bool Settings::save(const std::string& path)
}
j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_;
j["encryption_pending"] = encryption_pending_;
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
j["active_wallet_file"] = active_wallet_file_;
j["seed_migration_pending"] = seed_migration_pending_;

View File

@@ -327,6 +327,12 @@ public:
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
// Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is
// observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
// and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
bool getEncryptionPending() const { return encryption_pending_; }
void setEncryptionPending(bool v) { encryption_pending_ = v; }
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
@@ -574,6 +580,7 @@ private:
std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false;
bool seed_backup_reminded_ = false;
bool encryption_pending_ = false;
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
bool seed_migration_pending_ = false;

View File

@@ -488,6 +488,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
return 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...");
@@ -557,8 +585,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
override_extra_args_.clear();
if (!startProcess(daemon_path, args)) {
DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str());
setState(State::Error, "Failed to start dragonxd process");
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
// ... 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;
}
@@ -962,18 +996,38 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
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();
if (pid == -1) {
last_error_ = "Fork failed: " + std::string(strerror(errno));
close(pipefd[0]);
close(pipefd[1]);
close(execpipe[0]);
close(execpipe[1]);
return false;
}
if (pid == 0) {
// 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
// group later (including dragonxd spawned by a wrapper script).
@@ -1040,22 +1094,61 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
execv(binary_path.c_str(), argv.data());
}
// If we get here, exec failed
fprintf(stderr, "execv failed: %s\n", strerror(errno));
// If we get here, execv() failed — the child never became dragonxd.
// 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);
}
// 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];
// Also set process group from parent side (race with child's setpgid)
setpgid(pid, pid);
// Best-effort: the child already calls setpgid(0, 0); this parent-side call
// 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
int flags = fcntl(stdout_fd_, F_GETFL, 0);
fcntl(stdout_fd_, F_SETFL, flags | O_NONBLOCK);
process_pid_ = pid;
return true;
}
@@ -1135,17 +1228,21 @@ double EmbeddedDaemon::getMemoryUsageMB() 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;
int status;
pid_t result = waitpid(process_pid_, &status, WNOHANG);
if (result == 0) {
// Still running
return true;
}
return false;
const State s = state_.load(std::memory_order_relaxed);
// State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
// isRunning() while state_ == Stopping — before the process has actually
// terminated — and must keep seeing "alive" to wait/escalate correctly.
return (s == State::Running || s == State::Stopping);
}
void EmbeddedDaemon::drainOutput()

View File

@@ -235,6 +235,32 @@ public:
*/
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) */
static bool tcpPortInUse(int port);

View File

@@ -54,6 +54,16 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
// RPC port. So the wallet lives in <base>/DRAGONX; `base` is the migration root we clean up.
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
const std::string dataDir = base + "/DRAGONX";
// W3-2: never blindly wipe a pre-existing temp seed wallet. A prior migration that swept funds into
// it but was abandoned or crashed before adopting would otherwise have its (fund-bearing) wallet
// destroyed here. A completed migration removes this dir on adopt, so a leftover means an unfinished
// one — refuse and point the user at it rather than silently destroying it.
if (fs::exists(dataDir + "/wallet.dat")) {
r.error = "A previous seed migration looks unfinished — its temporary wallet is still at\n" + base +
"\nResume or cancel it first. If you are certain its funds are already in your main "
"wallet, delete that folder and try again.";
return r;
}
fs::remove_all(base, ec);
fs::create_directories(dataDir, ec);
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }

View File

@@ -2,7 +2,7 @@
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// xmrig_manager.cpp — Pool mining process management via xmrig-hac.
// xmrig_manager.cpp — Pool mining process management via drg-xmrig.
// Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
#include "xmrig_manager.h"

View File

@@ -726,8 +726,16 @@ int main(int argc, char* argv[])
// Ensure ObsidianDragon config directory exists early (before any file I/O)
{
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
std::error_code ec;
std::filesystem::create_directories(odDir, ec);
std::string odErr;
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

View File

@@ -14,8 +14,12 @@
#include <filesystem>
#include <algorithm>
#include <cctype>
#include <vector>
#include <chrono>
#include "../util/logger.h"
#include "../util/platform.h"
#include "../util/xmrig_updater.h" // util::sha256Hex
#ifdef _WIN32
#include <shlobj.h>
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
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 (params_dir.empty()) {
if (dir.empty()) {
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
return false;
}
#ifdef _WIN32
std::string spend_path = params_dir + "\\sapling-spend.params";
std::string output_path = params_dir + "\\sapling-output.params";
#else
std::string spend_path = params_dir + "/sapling-spend.params";
std::string output_path = params_dir + "/sapling-output.params";
#endif
bool spend_exists = fs::exists(spend_path);
bool output_exists = fs::exists(output_path);
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str());
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING");
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING");
return spend_exists && output_exists;
if (digests.empty()) return false;
// 1) Every param must exist.
std::vector<std::string> paths;
paths.reserve(digests.size());
for (const auto& d : digests) {
std::string p = joinParamPath(dir, d.first);
if (!fs::exists(p)) {
DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
return false;
}
paths.push_back(std::move(p));
}
// 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
// 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;
}
// 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
// (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
// 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;
}
}
// 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)
@@ -195,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
config.proxy = value;
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
config.use_tls = parseBoolValue(value);
} else if (key == "rpcallowplaintext") {
config.allow_plaintext_remote = parseBoolValue(value);
}
}
@@ -209,11 +306,14 @@ ConnectionConfig Connection::autoDetectConfig()
{
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();
if (!fs::exists(data_dir)) {
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str());
fs::create_directories(data_dir);
if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
return config; // data dir unusable — bail early with dir_error set
}
// Try to find DRAGONX.conf
@@ -268,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
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)
{
std::string lowered = lowercase(host);
@@ -277,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
return lowered == "localhost" || lowered == "localhost." ||
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)
@@ -285,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
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)
{
switch (source) {

View File

@@ -5,6 +5,8 @@
#pragma once
#include <string>
#include <vector>
#include <utility>
namespace dragonx {
namespace rpc {
@@ -27,7 +29,11 @@ struct ConnectionConfig {
std::string proxy; // SOCKS5 proxy for Tor
bool use_embedded = true;
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;
// 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();
// 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
*/
@@ -119,6 +133,11 @@ public:
*/
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);
private:

View File

@@ -1416,8 +1416,11 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
{
if (cmd.empty()) return false;
addLine("> " + cmd, ConsoleChannel::Command);
AppendConsoleHistory(command_history_, cmd, 100);
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
// log and the recall history. The real `cmd` below is still executed unredacted.
const std::string display = RedactConsoleCommand(cmd);
addLine("> " + display, ConsoleChannel::Command);
AppendConsoleHistory(command_history_, display, 100);
history_index_ = -1;
// First token, lowercased, for built-in interception.

View File

@@ -1,10 +1,34 @@
#include "console_tab_helpers.h"
#include <algorithm>
#include <cctype>
namespace dragonx {
namespace ui {
namespace {
// First tokens (lowercase) of console/RPC commands that carry a secret argument on the command line.
// Output-secret commands (dumpprivkey / z_exportkey / z_exportmnemonic) are deliberately absent —
// their secret is in the RESULT, which is a separate redaction concern.
const char* const kSecretConsoleCommands[] = {
"walletpassphrase", "walletpassphrasechange", "encryptwallet",
"importprivkey", "importwallet", "importmulti",
"z_importkey", "z_importviewingkey", "z_importwallet",
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
};
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
size_t b = cmd.find_first_not_of(" \t");
if (b == std::string::npos) { tokenEnd = cmd.size(); return {}; }
size_t e = cmd.find_first_of(" \t", b);
tokenEnd = (e == std::string::npos) ? cmd.size() : e;
std::string t = cmd.substr(b, tokenEnd - b);
std::transform(t.begin(), t.end(), t.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return t;
}
} // namespace
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
float itemSpacingY,
float spacingSm,
@@ -27,5 +51,27 @@ float ClampConsoleWrapWidth(float contentWidth, float paddingX)
return std::max(50.0f, contentWidth - paddingX * 2.0f);
}
bool ConsoleCommandCarriesSecret(const std::string& cmd)
{
size_t end = 0;
const std::string name = firstConsoleTokenLower(cmd, end);
if (name.empty()) return false;
for (const char* s : kSecretConsoleCommands) if (name == s) return true;
return false;
}
std::string RedactConsoleCommand(const std::string& cmd)
{
size_t end = 0;
const std::string name = firstConsoleTokenLower(cmd, end);
if (name.empty()) return cmd;
bool secret = false;
for (const char* s : kSecretConsoleCommands) if (name == s) { secret = true; break; }
if (!secret) return cmd;
// Only redact if there are actually arguments after the command name.
if (cmd.find_first_not_of(" \t", end) == std::string::npos) return cmd;
return cmd.substr(0, end) + " ****";
}
} // namespace ui
} // namespace dragonx

View File

@@ -1,5 +1,7 @@
#pragma once
#include <string>
namespace dragonx {
namespace ui {
@@ -14,5 +16,14 @@ float ComputeConsoleOutputHeight(float availableHeight,
float minHeightRatio);
float ClampConsoleWrapWidth(float contentWidth, float paddingX);
// True if `cmd`'s first token names a console/RPC command that carries a SECRET on its command line
// (passphrase, private/spending/viewing key, mnemonic). Output-secret commands (dumpprivkey,
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
bool ConsoleCommandCarriesSecret(const std::string& cmd);
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
std::string RedactConsoleCommand(const std::string& cmd);
} // namespace ui
} // namespace dragonx

View File

@@ -20,6 +20,17 @@ std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses)
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,
const std::string& value)
{

View File

@@ -10,6 +10,14 @@ namespace ui {
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
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,
const std::string& value);
const char* defaultPoolUrl();

View File

@@ -251,11 +251,15 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
}
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 ---
{
char hdr[48];
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);
float btnS = ovFont->LegacySize + 6 * dp;
@@ -278,11 +282,11 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
{
ImDrawList* cdl = ImGui::GetWindowDrawList();
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 listRowH = capFont->LegacySize + 10 * dp;
for (const auto& kp : util::knownPools()) {
for (const auto& kp : effective) {
ImGui::PushID(kp.id.c_str());
const bool isCurrent = current && current->id == 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];
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);
cdl->AddText(capFont, capFont->LegacySize,
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);

View File

@@ -41,6 +41,21 @@ std::string FormatHashrate(double hashrate)
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)
{
(void)difficulty;

View File

@@ -9,6 +9,7 @@ int GetMaxMiningThreads();
int ClampMiningThreads(int requestedThreads, int maxThreads);
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning);
std::string FormatHashrate(double hashrate);
std::string FormatFeePercent(double feePercent);
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty);
std::string FormatEstTime(double estimatedHours);

27
src/util/connect_stall.h Normal file
View 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

View File

@@ -320,7 +320,7 @@ void I18n::loadBuiltinEnglish()
strings_["seed_backup_load_failed"] = "Could not load the seed phrase.";
strings_["seed_backup_copy"] = "Copy";
strings_["seed_backup_save"] = "Save to file…";
strings_["seed_backup_saved"] = "Saved to ";
strings_["seed_backup_saved"] = "Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy: ";
strings_["seed_backup_save_failed"] = "Could not write ";
strings_["seed_backup_close"] = "Close";
strings_["seed_backup_reminder"] = "Your wallet has a 24-word recovery seed phrase. Back it up now in Settings → Node & Security.";
@@ -1316,6 +1316,12 @@ void I18n::loadBuiltinEnglish()
strings_["sb_extracting_sapling"] = "Extracting Sapling parameters...";
strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters.";
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_stopping"] = "Stopping dragonxd...";
strings_["sb_dragonxd_stopped"] = "dragonxd stopped";

View File

@@ -126,6 +126,27 @@ bool Platform::openUrl(const std::string& url)
#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)
{
if (path.empty()) return false;

View File

@@ -128,6 +128,17 @@ public:
*/
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
* @return Total physical RAM in MB, or 0 on failure

View File

@@ -45,16 +45,30 @@ struct PoolHashrate {
std::string id;
double hashrateHs = 0.0;
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
// meaningless to balance against). Stable order.
const std::vector<KnownPool>& knownPools();
// The known pool whose stratum matches `url` (host, and port when both specify one),
// or nullptr. `url` may be a bare host, host:port, or carry a scheme/userinfo/path.
// The pool in `pools` whose stratum matches `url` (host, and port when both specify
// 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);
// 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`.
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,
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
// 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

View File

@@ -28,7 +28,7 @@ const std::vector<KnownPool>& knownPools()
KnownPool{
"dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush",
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
/*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true,
/*miningcorePoolId=*/"", /*feePercent=*/1.0, /*official=*/true,
},
};
return pools;
@@ -83,13 +83,62 @@ bool sameEndpoint(const std::string& a, const std::string& b)
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
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)
{
for (const auto& p : knownPools())
if (sameEndpoint(p.stratum, url)) return &p;
return nullptr;
return findPoolByUrl(knownPools(), url);
}
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)
@@ -159,6 +208,64 @@ double parsePoolHashrate(PoolStatsSchema schema, const std::string& jsonStr,
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,
const std::string& currentId,
std::mt19937& rng)

View File

@@ -94,6 +94,12 @@ void PoolStatsService::run(std::vector<KnownPool> pools)
const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok);
hr.ok = ok;
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;
}

View File

@@ -1172,25 +1172,47 @@ void LiteWalletController::workerLoop()
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
{
auto result = lifecycle_.createWallet(request);
secureWipeLiteSecret(request.passphrase);
onLifecycleResult(result);
// If the user supplied a passphrase, encrypt the brand-new wallet with it now that it's open
// (the backend encrypts + locks + saves). Previously this passphrase was collected but never
// used (W5-3) — a passphrase field that silently did nothing. encryptWallet() takes its own
// copy and wipes it.
if (walletOpen_.load() && !request.passphrase.empty()) {
const auto enc = encryptWallet(request.passphrase);
if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error);
}
secureWipeLiteSecret(request.passphrase);
return result;
}
LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request)
{
auto result = lifecycle_.openWallet(request);
secureWipeLiteSecret(request.passphrase);
onLifecycleResult(result);
// An existing wallet may be encrypted + locked — use the supplied passphrase to unlock it so it
// opens ready to use. Only meaningful when the wallet is actually locked (W5-3).
if (walletOpen_.load() && !request.passphrase.empty()) {
const auto encStatus = encryptionStatus();
if (encStatus.ok && encStatus.encrypted && encStatus.locked) {
if (!unlockWallet(request.passphrase))
liteLog("wallet opened but unlock failed (wrong passphrase?)");
}
}
secureWipeLiteSecret(request.passphrase);
return result;
}
LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request)
{
auto result = lifecycle_.restoreWallet(request);
onLifecycleResult(result);
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
if (walletOpen_.load() && !request.passphrase.empty()) {
const auto enc = encryptWallet(request.passphrase);
if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error);
}
secureWipeLiteSecret(request.seedPhrase);
secureWipeLiteSecret(request.passphrase);
onLifecycleResult(result);
return result;
}

View File

@@ -3,6 +3,8 @@
#include "chat/chat_service.h"
#include "chat/chat_database.h"
#include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
#include "util/connect_stall.h"
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
#include "data/wallet_index.h"
@@ -2477,6 +2479,259 @@ void testDaemonShutdownPolicy()
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 testConsoleSecretRedaction()
{
using dragonx::ui::RedactConsoleCommand;
using dragonx::ui::ConsoleCommandCarriesSecret;
// Secret-bearing commands are recognized (case- and whitespace-insensitive on the name).
EXPECT_TRUE(ConsoleCommandCarriesSecret("walletpassphrase myPass 60"));
EXPECT_TRUE(ConsoleCommandCarriesSecret("z_importkey SK-secret"));
EXPECT_TRUE(ConsoleCommandCarriesSecret(" ENCRYPTWALLET topsecret"));
EXPECT_TRUE(!ConsoleCommandCarriesSecret("getinfo"));
EXPECT_TRUE(!ConsoleCommandCarriesSecret("getwalletinfo")); // not a false-positive substring match
// Redaction replaces the arguments with **** but preserves the (original-case) command name.
EXPECT_EQ(RedactConsoleCommand("walletpassphrase myPass 60"), std::string("walletpassphrase ****"));
EXPECT_EQ(RedactConsoleCommand("z_importkey SK-secret-key"), std::string("z_importkey ****"));
EXPECT_EQ(RedactConsoleCommand("ENCRYPTWALLET topsecret"), std::string("ENCRYPTWALLET ****"));
// A bare secret command with no argument is left unchanged (nothing to hide).
EXPECT_EQ(RedactConsoleCommand("walletpassphrase"), std::string("walletpassphrase"));
// Non-secret commands pass through untouched.
EXPECT_EQ(RedactConsoleCommand("sendtoaddress addr 1.0"), std::string("sendtoaddress addr 1.0"));
EXPECT_EQ(RedactConsoleCommand("getwalletinfo"), std::string("getwalletinfo"));
}
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()
{
using dragonx::daemon::DaemonController;
@@ -3231,6 +3486,19 @@ void testRendererHelpers()
EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
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"));
dragonx::TransactionInfo tx;
@@ -4132,7 +4400,6 @@ void testLiteWalletControllerLifecycle()
EXPECT_FALSE(controller.walletOpen());
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
const auto result = controller.createWallet(req);
EXPECT_TRUE(result.ok);
EXPECT_TRUE(result.walletReady);
@@ -4149,7 +4416,6 @@ void testLiteWalletControllerLifecycle()
dragonx::test::g_liteFakeWalletExists = true;
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletOpenRequest req;
req.passphrase = "hunter2";
const auto result = controller.openWallet(req);
EXPECT_TRUE(result.ok);
EXPECT_TRUE(result.walletReady);
@@ -4224,7 +4490,6 @@ void testLiteWalletControllerM4()
auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req);
return c;
};
@@ -4352,7 +4617,6 @@ void testLiteWalletControllerM5Persistence()
auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req);
return c;
};
@@ -4434,7 +4698,6 @@ void testLiteWalletControllerEncryption()
auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req);
return c;
};
@@ -4665,6 +4928,33 @@ void testLiteWalletControllerConsoleCommand()
// Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also
// fails over: the request runs off the UI thread against the preferred server, then the other
// usable defaults, finalized by pumpLifecycleResult() on the main thread.
// W5-3: a create-time passphrase now actually encrypts (and locks) the new lite wallet, and it
// unlocks with the same passphrase — previously the field was collected but ignored.
void testLiteWalletControllerCreateEncryptsWithPassphrase()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req);
const auto s = c->encryptionStatus();
EXPECT_TRUE(s.ok);
EXPECT_TRUE(s.encrypted); // the create-time passphrase encrypted the new wallet
EXPECT_TRUE(s.locked); // encrypt locks immediately
EXPECT_TRUE(c->unlockWallet("hunter2"));
const auto s2 = c->encryptionStatus();
EXPECT_FALSE(s2.locked);
}
void testLiteWalletControllerAsyncLifecycleFailover()
{
using namespace dragonx::wallet;
@@ -4693,7 +4983,6 @@ void testLiteWalletControllerAsyncLifecycleFailover()
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
EXPECT_TRUE(controller.beginCreateWalletAsync(req));
drain(controller);
EXPECT_TRUE(controller.walletOpen());
@@ -5828,6 +6117,94 @@ void testPoolHashrateParsing()
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.
void testPoolWeightedSelection()
{
@@ -6518,6 +6895,17 @@ int main()
testWalletSecurityWorkflow();
testWalletSecurityWorkflowExecutor();
testDaemonShutdownPolicy();
testDatadirLockGate();
#ifndef _WIN32
testExecFailureReported();
testDaemonCrashDetected();
#endif
testPlatformEnsureDirectory();
testVerifySaplingParams();
testConnectHasStalled();
testIsLocalHost();
testAllowsPlaintextRemote();
testConsoleSecretRedaction();
testDaemonLifecycleExecution();
testDaemonLifecycleAdapters();
testConsoleTextLayout();
@@ -6552,6 +6940,7 @@ int main()
testLiteWalletControllerM4();
testLiteWalletControllerM5Persistence();
testLiteWalletControllerEncryption();
testLiteWalletControllerCreateEncryptsWithPassphrase();
testLiteChainNameMigration();
testLiteRefreshModelAppliesToWalletState();
testLiteSendShowsRecipientFromOutgoing();
@@ -6590,6 +6979,9 @@ int main()
testLiteOfficialServerDetection();
testPoolRegistryLookup();
testPoolHashrateParsing();
testPoolFeeParsing();
testEffectivePools();
testFormatFeePercent();
testPoolWeightedSelection();
testAtomicFileWrite();
testHushChatCrypto();

View File

@@ -0,0 +1,12 @@
# Rust toolchain pin for the vendored SilentDragonXLite (SDXL) backend.
#
# The pinned librustzcash + transitive crates (notably traitobject 0.1.0) rely on
# pre-1.70 trait-coherence rules and fail to compile on newer rustc with
# error[E0119]: conflicting implementations of trait `Trait` for type `(dyn Send + Sync)`
# so the backend must be built with 1.63 (the toolchain scripts/build-lite-backend-artifact.sh
# and CLAUDE.md target). rustup auto-selects this when cargo runs in this tree, so no
# RUSTUP_TOOLCHAIN / `cargo +1.63.0` is needed.
#
# Install it once with: rustup toolchain install 1.63.0
[toolchain]
channel = "1.63.0"