51 Commits

Author SHA1 Message Date
ac49f44f84 fix(node): show recovery actions in the daemon-error overlay (not a separate dialog)
Reported with a screenshot: on a salvage-then-abort, the status correctly read
"Wallet needs recovery — see the prompt" but no prompt appeared — the separate
BeginOverlayDialog is occluded by the full-frame loading/daemon-error overlay
that's drawn every frame while the node is down.

Render the recovery actions directly IN the daemon-error overlay when a salvage is
detected: a concise message + prominent one-click "Rebuild wallet database" /
"Restore original" / "Open data folder" buttons (same handlers as the dialog),
placed right after the title and skipping the verbose daemon-output dump so they
stay on-screen. The verbose diagnostics + crash-count hint still show for
non-recovery errors. Build clean, suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:28:48 -05:00
3216debc7d fix(node): detect a wallet salvage at startup, not only on connect
Reported: loading a BDB-inconsistent wallet silently renamed it and created a new
one — no recovery dialog. Two causes, both fixed:

1) Detection ran only in onConnected(). The salvage happens at STARTUP, and the
   node may never connect (block-index abort, long sync, crash) — or a long sync
   trims the salvage line out of the rolling output buffer before connect. Extract
   detectWalletAutoRecovery() and run it every tryConnect() tick (every ~5s during
   startup), so the salvage is caught the instant it appears, regardless of whether
   the node connects. Also hold the crash-restart loop while a salvage is pending,
   so the wallet can't be re-salvaged/shrunk while the Rebuild/Restore dialog is up.

2) walletAutoRecovered() only matched the SUCCESSFUL-salvage strings. A
   BDB-inconsistent file makes aggressive salvage FAIL ("found no records"), which
   prints different lines. Broaden the detector to the signals that fire in every
   case: "CDBEnv::Salvage", the "Renamed <wallet> to wallet.<ts>.bak" rename, and
   "found no records in wallet" — while still not matching normal startup or a
   block-DB abort.

Adds the exact failed-salvage sequence to the detector test. Build clean, suite
green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:04:07 -05:00
8ffcd9cc8c build(node): bundle dragonx-wallet-rebuild in Linux + Windows releases
Wires the wallet-rebuild recovery helper into the release pipeline so a shipped
build actually carries it (the app locates it next to dragonxd).

- CMakeLists: link the vendored STATIC Berkeley DB for the helper — add
  Threads::Threads + dl (Linux) / ws2_32 (Windows) that the static libdb-6.2 needs
  (the system shared lib pulled those in transitively; the static one doesn't).
- build.sh (Linux + Windows): pass BDB_INCLUDE_DIR/BDB_LIBRARY explicitly at
  configure, pointing at external/dragonx/depends/<triple>/{include,lib/libdb-6.2.a}
  — the same libdb the daemon links, so the helper's output is a v6.2 btree the
  bundled dragonxd reads. Explicit paths bypass find_library (and the mingw
  toolchain's sysroot-only find restriction). Guarded: no depends → helper simply
  not built/bundled. Strip + copy the helper next to dragonxd(.exe) in both bundles.

Verified: Linux links the vendored libdb-6.2.a statically (no dynamic libdb) and
rebuilds the real broken wallet correctly; the helper cross-compiles cleanly with
mingw against the vendored Windows libdb-6.2.a to a PE32+ x64 exe. Full app +
helper build, suite green (1/1), build.sh syntax OK.

Remaining: macOS Berkeley DB (no in-tree depends artifact) + resource-embedding as
an alternative to side-by-side bundling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 11:38:20 -05:00
bc183257b7 feat(node): in-app "Rebuild wallet database" recovery for a BDB-inconsistent wallet
Automates the manual recovery that fixed a wallet.dat with stale Berkeley DB
extent metadata (the "main" subdb metapage records a low last_pgno while its live
data spans thousands of pages beyond it). A tolerant page-walk reads every record,
but the daemon's BDB verify rejects the file and auto-salvages it — finding nothing
and shrinking the wallet to empty on each restart (the salvage cascade that looks
like fund loss). Plain "Restore original" can't fix it (hands the same broken file
back → re-salvage); a rebuild must produce a fresh, consistent DB.

Pieces (Approach A from the design workflow — out-of-process helper keeps AGPL
Berkeley DB out of the GPLv3 GUI):
- util/wallet_file_probe.h: extractWalletBtreeRecords() — sibling to parseWalletBtree
  that collects raw (key,value) bytes (same bounds-checked, subdb-aware walk).
  Records copied verbatim → encrypted key material passes through as opaque
  ciphertext (no passphrase). Overflow-page values (only large tx history) are
  skipped + counted; a rescan rebuilds history — funds unaffected.
- tools/wallet_rebuild/main.cpp: dragonx-wallet-rebuild CLI — reads via the tolerant
  reader, writes the records into a fresh BDB "main" btree via libdb (DB_EXCL, never
  overwrites), prints a JSON summary. New BDB-guarded CMake target.
- App::rebuildWalletDatabase(): picks the largest readable wallet/.bak as source,
  stops the daemon, runs the helper, VERIFIES the output (readable BDB with keys)
  before swapping, moves the current wallet aside (kept, timestamped), installs the
  rebuilt one, clears the stale BDB env, sets -rescan, restarts. Copy/rename only —
  never deletes. Result surfaced via the existing pumpWalletRestore channel.
- Wired as the preferred action on the existing wallet-auto-recovery dialog
  (shown only when the helper is present). Full-node only; lite-safe.

Verified end-to-end against the real broken wallet: helper reads 3,808 t-keys + 1
z-key + HD seed and the daemon LOADS the rebuilt output with no salvage. Adds
extractWalletBtreeRecords coverage. Build clean, suite green (1/1).

Remaining (follow-up): release packaging — build.sh bundling the helper built
against the vendored per-platform static libdb (DRAGONX_BDB_ROOT), and a macOS
Berkeley DB port (no in-tree artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 11:26:55 -05:00
b2e037bb67 chore(gitignore): never track wallet.dat (holds private keys)
A wallet.dat placed in the repo root for recovery was untracked but NOT ignored,
so a stray 'git add .' could commit private keys. *.bak already covered the
salvage backups; add wallet.dat / wallet-*.dat / wallet.dat.* explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 10:20:32 -05:00
975650f11b fix(node): restore the LARGEST salvage backup, not the newest (salvage cascade)
The "Restore original wallet" action picked the newest wallet.<ts>.bak — but the
daemon auto-salvages on every failed BDB verify, and each round SHRINKS the wallet
(salvage keeps only readable records + drops the dead-page bloat). In a cascade the
newest .bak is the most-degraded (seen in the wild as "Salvage found no records")
while the original is the oldest and by far the largest.

Pick by file SIZE instead: add largestWalletSalvageBak((name,size) pairs) — the
largest wallet.<digits>.bak is the least-salvaged, i.e. the pristine original (an
emptied salvage is tiny; a real wallet is large); ties break to the newest ts.
Factor the shared parse into parseWalletSalvageBakTs(). restoreOriginalWallet()
now gathers file sizes and uses it (still verifies the pick is a valid BDB before
swapping). newestWalletSalvageBak kept for reference.

Adds a cascade regression test (a 40KB emptied newest .bak must NOT win over the
194MB original). Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 00:52:41 -05:00
384d64ea5d feat(node): one-click "Restore original wallet" after a daemon auto-recovery
Adds the restore action to the wallet-auto-recovery warning: undo the daemon's
salvage by swapping the untouched original (wallet.<ts>.bak) back over the
salvaged copy and clearing the stale BDB env that triggered the false recovery,
then restarting. Modeled on beginAdoptSeedWallet (stop daemon → file ops →
restart on a worker; result pumped to the main thread for notifications).

Safety (fund-adjacent file ops on a real wallet — copy/rename only, never delete
user data):
- picks the newest wallet.<unixtime>.bak via the pure, unit-tested
  newestWalletSalvageBak(); aborts if none.
- verifies the .bak is a real Berkeley DB (probeWalletFile) before touching
  anything — won't overwrite a working wallet with a bad backup.
- stops the daemon first (stopDaemonForWalletSwitch) so wallet.dat is released.
- moves the salvaged copy aside to wallet.dat.salvaged-<ts>.dat (kept), COPIES
  the .bak into place (the .bak stays), moves database/ aside to
  database.pre-restore-<ts>.bak (kept), and drops only the transient __db.*
  BDB region files. Rolls back the move if the copy fails.
- relaunches the node even on failure so it's never left down.

The warning dialog now offers Restore original wallet / Open data folder /
Keep salvaged copy. Full-node only; lite-safe. Build clean, suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 00:45:47 -05:00
3b7423f3a1 feat(node): warn when the daemon auto-recovers (salvages) wallet.dat
dragonxd auto-recovers a wallet.dat that fails BDB verification on startup — no
flag needed (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)):
it moves the original to wallet.<timestamp>.bak, salvages readable keys into a
fresh wallet.dat, and keeps running. The salvage can be incomplete (or the whole
thing a FALSE POSITIVE from stale/cross-platform BDB env state — __db.* / the
database/ dir carried between machines), so the node silently comes up on a
possibly-empty wallet. To the user that reads as fund loss, with no warning.

Detect it and warn loudly instead:
- daemon/daemon_startup_diagnosis.h: pure walletAutoRecovered() (the salvage /
  "Original wallet.dat saved as wallet.<ts>.bak" markers) + newestWalletSalvageBak()
  (picks the wallet.<unixtime>.bak the recovery just made).
- onConnected() scans the node's captured output once per session; on a match it
  shows a warning dialog + notification: the ORIGINAL is safe in wallet.<ts>.bak,
  the shown balance may be incomplete, and here are the exact steps to restore it
  (rename the .bak back + delete the stale database/ + __db.* env). One-click
  "Open data folder" jumps straight there. Full-node only; lite-safe.

Deliberately does NOT auto-swap the wallet files (untested per-platform file
manipulation on a real wallet is not worth the risk) — it informs + guides.

Adds walletAutoRecovered / newestWalletSalvageBak coverage to
testBlockDbOutputDiagnosis. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 00:30:11 -05:00
d136916e80 feat(node): detect an unreadable block DB on startup and offer a one-click reindex
When a daemon update changes the block-index on-disk format (or the index is
corrupt), dragonxd aborts at startup — "non-canonical optional discriminant" →
"Error loading block database. Aborted." — and the wallet silently shows a zero
balance. Previously the connect loop just crash-restarted into the same abort up
to 3x and then reported a bare "Daemon crashed N times", with no path forward.

Now:
- daemon/daemon_startup_diagnosis.h: pure blockDbOutputLooksBroken() classifies
  the crashed node's captured console output (the fatal block-DB markers).
- The connect loop detects it on the FIRST abort, STOPS crash-restarting into the
  same failure (each retry reloads the whole index — wasteful), and offers a fix.
- A one-shot -reindex flag (EmbeddedDaemon::setReindexOnNextStart → DaemonController
  forwarder → args) rebuilds the block index + chainstate from the intact raw
  blocks; App::reindexBlockDatabase() arms it and un-gates the loop to restart.
- An auto-shown dialog (renderBlockDbReindexDialog) + a notification explain the
  situation ("your coins are safe; the node just can't load the chain") and offer
  a one-click "Rebuild block database". Full-node only (gated), lite-safe.

This is the exact trap behind a real "big wallet shows no funds" report: a
post-format-change daemon over pre-change chaindata. Reindex also fixes a plain
corrupt index.

Adds testBlockDbOutputDiagnosis (the abort sequence + individual markers trip it;
normal startup / wallet-corruption / asmap errors do not). Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 23:49:46 -05:00
f88304fed2 fix(wallets): stop a legacy wallet showing as a seed-phrase wallet when linked
The wallets list badged the active/linked row from the runtime seed status
(activeWalletSeedBadge → wallet_seed_status_, reset only on disconnect) in
preference to the offline on-disk probe. Two issues let a genuinely legacy
wallet render as "seed phrase":

- The offline probe's budget-fallback branch dropped the fMnemonicSeed flag:
  res.mnemonic was set only in the (parsed && complete) branch. The probe shares
  a 768 MB budget across all wallet files, so a large wallet (e.g. a 194 MB one)
  probed after the budget is spent falls into the fallback, loses its seed/legacy
  classification (mnemonic → 0), and the row defers to the runtime badge.
- With mnemonic == 0, the code used the runtime badge, which can still carry a
  HasMnemonic from a previously-active mnemonic wallet — mislabelling the legacy
  wallet.

Fix:
- Carry the definitive positives (fMnemonicSeed/hdSeed/mkey) from a cap-truncated
  btree walk — a found marker is authoritative even when the scan didn't finish.
- Make the on-disk fMnemonicSeed read take precedence: it's the SAME flag the
  daemon's IsMnemonicSeed()/z_exportmnemonic consult, so a definitive read wins;
  the runtime badge is used only when the probe genuinely couldn't decide, and
  never overrides a definitive on-disk classification.

Verified the wallet in question is truly legacy (fMnemonicSeed=false on disk,
matching the daemon's CHDChain serialization + IsMnemonicSeed). The flag reader
(hdChainMnemonicFlag) is already unit-tested; suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 15:29:23 -05:00
5296dd7ae5 fix(parsers): harden RPC/price/updater parsing against valid-but-unhandled input
Audit of the full-node RPC response parsers and updater release-body parsers
(find -> adversarially-verify workflow) surfaced three worth fixing; three
others guard formats the project doesn't emit and are backstopped by signature
verification, so they're documented rather than churned.

- Price (Medium): parseCoinGeckoPriceResponse used .value(key, 0.0), which
  throws type_error on a PRESENT null. CoinGecko emits null for usd_24h_change/
  usd_24h_vol on illiquid tokens (DRGX is one) while still returning a valid
  spot price; the outer catch turned that into no price update at all. Read
  null-tolerantly so the valid usd/btc survives.

- Daemon updater (Medium): parseDaemonChecksums blanked '|'/backtick but not
  markdown emphasis, so a bolded **archive.zip** checksum row was dropped and a
  valid, correctly-signed release would be refused. Also blank '*'/'_' (cannot
  cause a wrong-asset match; the 64-hex + .zip-suffix tests are unchanged).

- Opid poll (Low, severe failure mode): parseOperationStatusPoll read id/status
  via .value() (throws on a present non-string) and the call site parsed OUTSIDE
  its try/catch, so a throw left opid_poll_in_progress_ stuck true and wedged all
  z-operation polling for the session. Type-check the reads and parse inside the
  guard. (dragonxd can't emit non-string id/status; this is defense-in-depth.)

Regression tests: CoinGecko null field; opid non-string id/status; **bold**
checksum row. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 13:38:50 -05:00
a1d3964e34 fix(lite): require exactly 24 words on first-run restore (crash on valid seed)
The lite first-run restore wizard enabled Restore for {12,15,18,21,24}-word
phrases, but the SDXL backend only accepts 24-word / 32-byte-entropy seeds:
LightWallet::new does copy_from_slice(&phrase.entropy()) into a [u8;32]
(lightwallet.rs:231), which panics on 16/20/24/28-byte entropy. Mnemonic::
from_phrase accepts the shorter valid phrase, and the restore FFI
litelib_initialize_new_from_phrase (lib.rs:127) has no catch_unwind (unlike
litelib_execute), so the panic unwinds across extern "C" -> process abort
(UB on the pinned rustc 1.63). A user restoring a legitimate 12-word seed
from another wallet crashed the app.

The Settings restore gate was already tightened to == 24 (6ff1fda) but the
first-run wizard gate (df14533) was never updated — same restore path, two
verdicts, crash only via the more-common first-run path.

Add shared util/seed_phrase.{h,cpp} as the single source of truth:
- normalizeSeedPhrase: fold NBSP/en/em/ideographic/narrow spaces to ASCII,
  strip zero-width marks, collapse+trim (word bytes untouched)
- seedPhraseWordCount
- isCompleteRecoveryPhrase(int) == 24  (the sole SDXL contract)

Both restore gates now count via the normalizer and gate via
isCompleteRecoveryPhrase, and both submit the normalized phrase. This closes
the crash, reconciles the two gates so they can't drift again, and — because
tiny-bip39 splits on literal ASCII space with no NFKD — makes an NBSP-pasted
24-word seed (common from PDFs/note apps) restore correctly instead of being
undercounted and rejected.

Adds testSeedPhraseHelpers. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 01:32:40 -05:00
5b6ba5094b docs(lite): clarify SDXL viewing-key HRP differs from the full node
Audit of the lite import path found no false-rejection defect (no client-side
gate; the two-command fallback in importKey makes the U/5/K/L prefix guess
non-binding; lite send reuses the now-P2SH-fixed send_tab helpers). But the
"zxview" viewing-key comment — which was WRONG in the full node (fixed earlier)
— is genuinely CORRECT here: SDXL's import takes an extended full viewing key
(zxviews…, hrp_sapling_viewing_key), whereas the full node's z_importviewingkey
takes an incoming viewing key (zivks…). The two are not interchangeable.

Add a note so nobody "harmonizes" the two gates and reintroduces the full-node
bug. Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 00:59:29 -05:00
c3e81a5fa6 fix(send): accept P2SH/multisig recipients in the send + URI address gates
Same defect class as the import-key fix: a hardcoded prefix/length pre-filter
layered over the checksum validators rejected valid addresses before the daemon
saw them. The send-screen recipient gate required a[0]=='R', and the payment-URI
parser accepted only 'R'/'t' with rigid length bands — so every valid P2SH /
multisig address (DragonX SCRIPT_ADDRESS=85 → 'b…') was silently refused, leaving
the Send button disabled with no usable recipient.

Centralize recipient recognition in util/address_validation:
- isTransparentAddress: Base58Check with a 21-byte version+hash160 payload —
  covers P2PKH ('R…', v60) AND P2SH ('b…', v85) on every network, rejects WIF
  keys / typos by real checksum.
- isShieldedAddress: Bech32 + a Sapling payment-address HRP (zs / ztestsapling /
  zregtestsapling), distinguishing a payment address from a viewing key.
- isValidRecipientAddress: either of the above.

send_tab's two validity helpers (the single choke point for all 5 call sites) and
the payment-URI format check now route through these. The URI parser now
checksum-validates the recipient (fail-fast on transcription errors) rather than
being prefix/length-only.

Tests use real checksummed vectors (P2PKH/P2SH/shielded, WIF- and typo-rejection);
testPaymentUri updated off its old fake fixed-char addresses. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 00:12:17 -05:00
d603a54618 fix(import): recognize real DragonX key formats in the import gate
The client-side pre-check rejected legitimate keys before the daemon ever
saw them, surfacing "Unrecognized key format" / a cryptic daemon "Invalid"
error. Two concrete defects plus the brittle heuristic behind them:

- Viewing keys: isViewingKey looked for Zcash's "zxview" extended-FVK
  prefix, but DragonX's z_exportviewingkey emits a Sapling *incoming*
  viewing key (HRP "zivks"), which z_importviewingkey is the only form the
  daemon decodes. Every real DragonX viewing key was refused. (F1)
- Uncompressed transparent WIF: the length+first-char heuristic accepted
  {5,K,L,U} only, but a version-188 uncompressed key starts with '7'. (F2)

Replace the heuristic with structural validation using the existing
checksum validators (F3): add util::decodeBase58Check (checksum-stripped
payload) and util::bech32Hrp (HRP of a valid Bech32 string). Transparent
keys are now accepted by decoding Base58Check and checking the payload is a
33/34-byte secret key with a DragonX SECRET_KEY version byte (188 main/
regtest, 128 testnet) — covering compressed and uncompressed, rejecting
addresses/typos by real checksum. Viewing keys are matched by the real
incoming-VK HRPs (zivks / zivktestsapling / zivkregtestsapling).

The Sweep gate and the dialog's live type indicator run off the same
predicates, so they are fixed too (F4). Messaging now names the likely
cause and appends a wrong-coin/network hint to the daemon's raw "Invalid"
error (F5).

Adds testPrivateKeyImportRecognition plus decodeBase58Check/bech32Hrp
coverage; suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:22:29 -05:00
c61c211dfe fix(ui): add vertical padding above and below the recent-alerts popup content
The alert-history popup content sat flush against the popup's top and bottom edges. Add a
padY spacer above the header and below the content (on both the empty and populated paths).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 23:12:20 -05:00
16244d84a0 chore(release): bump version to 2.0.1
Full-node ObsidianDragon 2.0.0 -> 2.0.1 (single source of truth: the project() VERSION in
CMakeLists.txt). Verified the generated header renders "2.0.1 (ObsidianDragon)". The Lite
variant is versioned independently (DRAGONX_LITE_VERSION, unchanged at 1.0.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 22:53:23 -05:00
32be868dbc feat(migrate): persist the sweep opid so a mid-sweep interruption can resume (W3-3)
Migrate-to-seed submits z_mergetoaddress -> an async opid, then only persists the resolved
txid once the op completes. An app-close during Sweeping (opid submitted, txid not yet
resolved) dropped the opid and resumed at the re-sweep gate, silently losing the tx.

Now the opid is persisted and re-tracked on resume. If the daemon forgot it (restart —
its op queue is in-memory only), the existing poller flags it stale and the callback falls
back to the dismissable Sweep gate; it can never hang (a thrown RPC aborts the poll, so a
stale classification only comes from a *successful* poll that omits the opid).

- New seed_migration_sweep_opid setting; adopted atomically with clearing any prior txid in
  the SAME settings.save(), and only once the submit succeeds — so a failed "Sweep remaining"
  re-sweep keeps the already-mined first sweep's Confirming context, and txid/opid are never
  both authoritative (resume checks txid first; torn-write safe).
- Resume routing extracted to a pure, unit-tested helper
  (data/seed_migration_resume.h::decideSeedMigrationResume): txid -> Confirming; opid AND
  connected -> re-track (Sweeping); else -> the dismissable Sweep gate. The connectivity gate
  keeps a disconnected resume out of the buttonless Sweeping spinner.
- Shared makeSweepCompletionCallback(resumed): success -> Confirming; resumed-stale -> Sweep
  gate (re-fetch balance + "may have already completed" copy); fresh-fail -> Error.

Fund safety unchanged: adopt still gated on legacy balance ~0 AND sweep tx mined; legacy
wallet.dat only ever moved to a never-deleted timestamped .bak.

Reviewed in two adversarial rounds (design + implementation) per the migration-code mandate;
both safety facts (no fund loss, no hang) held, and the resume-UX traps they surfaced are
fixed. Build-clean; ctest 1/1 (adds testSeedMigrationResume). See docs/wallet-hardening.md.

*** Still requires a live mainnet interrupted-sweep run before release (human gate). ***

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 22:11:37 -05:00
8bb3198562 fix(diagnostics): address adversarial review of the QoL UI (popup, staleness, DPI, i18n)
Follow-up to the node-banner / staleness-badge / alert-history features — a 5-dimension
finder->verify review surfaced 4 real issues (the ImGui-stack-balance finder found none):

- Alert popup grew off the right edge: pivot (0,1) pinned the panel's LEFT edge at the
  bell, which sits near the window's right edge, so a 320px panel overflowed rightward
  (an explicit SetNextWindowPos pivot skips ImGui's on-screen clamp). Anchor the
  bottom-RIGHT corner at the bell instead (pivot (1,1) at bellMax.x) so it grows left.

- Staleness badge could flash red on reconnect: WalletState::clear() reset everything
  except the four last_*_update stamps, so the pre-outage timestamp survived and the
  badge briefly showed "Updated Nm ago" the same frame the node banner cleared. Zero the
  stamps in clear() (all readers treat 0 as "never"; app_network.cpp:1473 guards != 0).

- Banner min-height floor wasn't DPI-scaled: std::max(minH, baseH*vScale()) now uses
  minH * dpiScale() so both operands are in scaled px.

- New i18n keys weren't in res/lang/: back-filled all 16 diagnostics/QoL keys into the 8
  language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2
  glyphs missing from the CJK subset and hard-asserted tofu-free against the subset font.

Build-clean both variants; ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:14:38 -05:00
4b3f0fa92b feat(diagnostics): persistent alert history with a status-bar bell (Foundation QoL)
Toasts fade in 1-4s, so anything that scrolled past was gone. Notifications now retains
every pushed alert in a capped (100) ring buffer with a wall-clock epoch (AlertRecord) —
separate from the 5-item live-toast deque — plus a monotonic total_pushed_ counter.

A bell in the status-bar right cluster opens an upward popup listing recent alerts
newest-first: severity icon + colour (reusing the toast palette), the message, and a
relative age (formatTimeAgoShort), with a Clear-all action. An unread dot on the bell,
coloured by the most-severe unseen alert, marks alerts that arrived since the panel was
last opened — driven by totalPushed() deltas so it survives capping/clearing.

Thread note: every push is on the UI thread (RPC results run as main-thread MainCb
callbacks), matching this class's existing lock-free model; documented as a
no-raw-worker-thread invariant.

New i18n keys (alerts_*). Build-clean; ctest 1/1 (adds testNotificationHistory: retention,
order, cap, monotonic counter, clear). Closes the QoL bundle and the Foundation tier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:57:02 -05:00
c7c3440a7b feat(diagnostics): refresh-staleness badge on the Total Balance card (W6-2)
When the wallet is connected but the balance has quietly stopped refreshing — a busy
daemon can fail z_gettotalbalance without dropping the whole connection (only *both*
core RPCs failing 3x triggers a disconnect) — the old number sits on screen while the
node-status banner stays hidden. The Total Balance card now shows a small pill on its
status line ("Updated 2m ago", amber, escalating to red past 3 min) so the stale value
isn't silently trusted; hovering explains it and points at the node connection.

No refresh-path changes: WalletState::last_balance_update is already stamped only on a
successful fetch (network_refresh_service.cpp), so the badge reads it and computes age
against the same std::time clock via util::formatTimeAgoShort. The decision is a pure,
unit-tested helper (ui/staleness_badge.h::evaluateStalenessBadge, 45s/180s thresholds)
gated on connected so it never contradicts the banner.

Closes P2 (5/5). Build-clean; ctest 1/1 (adds testStalenessBadge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:48:56 -05:00
e779ded2e8 feat(diagnostics): persistent node/RPC error banner at top of content (Foundation QoL)
A persistent horizontal strip now appears at the top of the content column whenever the
wallet can't reach its node — unlike the transient toasts it stays up for as long as the
fault persists, so an offline wallet is never silently mistaken for a working one.

The show/severity/action decision is a pure, unit-tested function
(ui/node_status_banner.h::evaluateNodeStatusBanner) fed a state snapshot by the new
App::renderNodeStatusBanner(). Three cases:
  - full-node offline        -> amber, "Reconnect"    (App::tryConnect)
  - embedded daemon crashed
    & auto-restart gave up    -> red,   "Restart node" (App::restartDaemon)
  - lite wallet open failed   -> red,   message-only

Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown,
and while an expected startup phase (warmup / init / connect-in-progress) already owns the
screen. Banner height lives in res/themes/ui.toml (banners.node-status); colours come from the
material semantic palette; the detail text is ellipsis-clipped so it can't push the action
button off-screen. Drawn before the content edge-fade vertex capture so it stays fully opaque.

New i18n keys (node_banner_*). Build-clean both variants; ctest 1/1 (adds testNodeStatusBanner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:21:05 -05:00
940dd21464 feat(diagnostics): add "Copy diagnostics" + "Open log folder" actions (Foundation QoL)
Settings (logging section) gains two support-friendly actions, now that the logging
foundation actually produces logs (W7-2):

- Open log folder: opens the config dir (Platform::openFolder) so users can find
  dragonx-debug.log / dragonx-crash.log.

- Copy diagnostics: copies a plaintext support snapshot to the clipboard via the new
  App::buildDiagnosticsReport() — version, build variant, platform, connection status,
  active wallet path + existence + size, encryption/lock state, sync heights, and (full-
  node) daemon status/running/crash-count/lastError, plus the log paths. No secrets.

Build-clean; ctest 1/1. Remaining QoL: persistent alert history, a daemon/RPC error
banner, and the W6-2 refresh-staleness badge. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:37:52 -05:00
207f9074db feat(diagnostics): make the logging + crash infrastructure actually work (W7-2, W7-3, W7-4)
The Foundation tier — answers the original "make it easier to diagnose problems" ask.

- W7-2 (keystone): the app-level Logger file sink was never initialized, so LOG/LOGF/
  VERBOSE_LOGF went nowhere and dragonx-debug.log didn't exist on Linux/macOS at all.
  main() now calls Logger::init(<config>/dragonx-debug.log) on every platform. Fixed a
  latent deadlock this exposed: init() wrote its banner via write(), which re-locks the
  non-recursive mutex_ it already holds — now written directly. On Windows the raw
  stdout/stderr freopen moved to a separate dragonx-stdout.log so the two writers don't
  contend on one file. Added testLoggerFileSink (also a deadlock guard — it would hang if
  the fix regressed).

- W7-3: no crash handler existed on Linux/macOS. Added an async-signal-safe sigaction
  handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes the signal id + a backtrace_symbols_fd
  backtrace to dragonx-crash.log, then re-raises the default disposition for a core dump —
  the POSIX counterpart of the Windows SEH filter.

- W7-4: Logger::init now rotates the log to a single .1 backup past 10 MB, so a long or
  verbose session can't grow it unbounded.

Build-clean; ctest 1/1. Remaining Foundation: the QoL bundle (mostly UI). See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:31:23 -05:00
05b00b158b fix(wallet): surface silent save failures + stale-state cleanups (W5-1, W5-2, W6-1, W6-3)
P2 robustness batch (localized):

- W5-1 (Med, lite): persistAfterBroadcast returned false on a persistent post-send/shield
  save failure, but both callers discarded it and it never logged — completely silent. It
  now liteLogs the failure (the spent note re-derives on the next sync, so it's a
  robustness gap, not fund loss).

- W5-2 (Med, lite): the post-sync and post-rescan save results (in the detached scan
  threads) were ignored; both now liteLog on failure. LiteDiagnostics::log is mutex-guarded,
  so it's safe from those threads.

- W6-1 (Med): WalletState::clear() didn't reset mining/pool_mining, so a wallet switch could
  briefly show the previous wallet's hashrate/blocks. Now reset in clear() (the daemon
  restarts on switch, so mining genuinely stops).

- W6-3 (Low): AddressBook::load() cleared entries_ then threw on the first non-object array
  element — discarding EVERY contact. It now guards is_object() + per-entry try/catch,
  skipping and counting malformed entries.

Build-clean; ctest 1/1. Remaining P2: W6-2 (refresh-staleness badge — needs UI, overlaps
the diagnostics Foundation bundle). See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:22:13 -05:00
f9ddab059e fix(wallet): stamp syncedHere only after identity verified + guard the startup wallet file (W1-3)
- W1-3 (Med): updateWalletIndexForActiveWallet stamped syncedHere in the markOpened block
  at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed
  rescan. syncedHere is now stamped only once the wallet's identity is verified (idHash
  non-empty), so it takes effect at the post-address-refresh index update; lastOpenedEpoch
  still records at open.

- Startup guard (the W1-1 launch counterpart): App::init now exists()-checks the recorded
  active wallet before the daemon is configured. A non-default active wallet moved/deleted
  between sessions falls back to the default wallet.dat with a warning, instead of the
  daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN
  vault init so the vault is scoped to the wallet actually opened.

Completes P1-B. Remaining P1: W3-3 (sweep opid persistence) deferred for careful
adversarially-reviewed work — re-tracking a stale opid could hang the migration if the op
poller doesn't time out; the existing balance/mined gates already prevent fund loss. See
docs/wallet-hardening.md.

Build-clean; ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:18:09 -05:00
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
79 changed files with 5218 additions and 344 deletions

10
.gitignore vendored
View File

@@ -11,8 +11,8 @@ prebuilt-binaries/dragonxd-win/*
!prebuilt-binaries/dragonxd-win/.gitkeep !prebuilt-binaries/dragonxd-win/.gitkeep
prebuilt-binaries/dragonxd-mac/* prebuilt-binaries/dragonxd-mac/*
!prebuilt-binaries/dragonxd-mac/.gitkeep !prebuilt-binaries/dragonxd-mac/.gitkeep
prebuilt-binaries/xmrig-hac/* prebuilt-binaries/drg-xmrig/*
!prebuilt-binaries/xmrig-hac/.gitkeep !prebuilt-binaries/drg-xmrig/.gitkeep
# External sources / toolchains (created by scripts/setup.sh) # External sources / toolchains (created by scripts/setup.sh)
@@ -33,7 +33,11 @@ imgui.ini
*.bak* *.bak*
*.params *.params
asmap.dat asmap.dat
/external/xmrig-hac # Wallet files hold PRIVATE KEYS — never commit them
wallet.dat
wallet-*.dat
wallet.dat.*
/external/drg-xmrig
/memory /memory
/todo.md /todo.md
/.github/ /.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

@@ -15,7 +15,7 @@ if(APPLE)
endif() endif()
project(ObsidianDragon project(ObsidianDragon
VERSION 2.0.0 VERSION 2.0.1
LANGUAGES C CXX LANGUAGES C CXX
DESCRIPTION "DragonX Cryptocurrency Wallet" DESCRIPTION "DragonX Cryptocurrency Wallet"
) )
@@ -136,6 +136,13 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
set_target_properties(dragonx_lite_backend PROPERTIES set_target_properties(dragonx_lite_backend PROPERTIES
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}" 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(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
if(NOT IS_DIRECTORY "${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}") 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_REPOSITORY https://github.com/webmproject/libwebp.git
GIT_TAG v1.4.0 GIT_TAG v1.4.0
GIT_SHALLOW TRUE 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_LINK_STATIC ON CACHE BOOL "" FORCE)
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE) set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
@@ -528,6 +544,7 @@ set(APP_SOURCES
src/util/async_task_manager.cpp src/util/async_task_manager.cpp
src/util/amount_format.cpp src/util/amount_format.cpp
src/util/address_validation.cpp src/util/address_validation.cpp
src/util/seed_phrase.cpp
src/util/base64.cpp src/util/base64.cpp
src/util/single_instance.cpp src/util/single_instance.cpp
src/util/i18n.cpp src/util/i18n.cpp
@@ -1058,6 +1075,32 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
OPTIONAL OPTIONAL
) )
# -----------------------------------------------------------------------------
# dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
# Bundled next to the daemon; the app spawns it out-of-process. It is the ONLY thing that links
# Berkeley DB, so the AGPLv3 BDB never contaminates the GPLv3 GUI (same boundary as the daemon).
# Release builds should point DRAGONX_BDB_ROOT at the vendored static libdb (external/dragonx/depends);
# a dev build falls back to the system Berkeley DB. Skipped (with a note) if no BDB is found.
# -----------------------------------------------------------------------------
find_path(BDB_INCLUDE_DIR db.h HINTS ${DRAGONX_BDB_ROOT}/include /usr/include /usr/local/include)
find_library(BDB_LIBRARY NAMES db-6.2 db-6.0 db-5.3 db libdb
HINTS ${DRAGONX_BDB_ROOT}/lib /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu)
if(BDB_INCLUDE_DIR AND BDB_LIBRARY)
add_executable(dragonx-wallet-rebuild tools/wallet_rebuild/main.cpp)
target_include_directories(dragonx-wallet-rebuild PRIVATE ${CMAKE_SOURCE_DIR}/src ${BDB_INCLUDE_DIR})
target_link_libraries(dragonx-wallet-rebuild PRIVATE ${BDB_LIBRARY})
if(WIN32)
target_link_libraries(dragonx-wallet-rebuild PRIVATE ws2_32) # static libdb-6.2 pulls in winsock
else()
find_package(Threads REQUIRED)
target_link_libraries(dragonx-wallet-rebuild PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) # static libdb needs pthread/dl
endif()
set_target_properties(dragonx-wallet-rebuild PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
message(STATUS "wallet-rebuild helper: ON (Berkeley DB ${BDB_LIBRARY})")
else()
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
endif()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Tests # Tests
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -1106,6 +1149,7 @@ if(BUILD_TESTING)
src/util/payment_uri.cpp src/util/payment_uri.cpp
src/util/amount_format.cpp src/util/amount_format.cpp
src/util/address_validation.cpp src/util/address_validation.cpp
src/util/seed_phrase.cpp
src/util/i18n.cpp src/util/i18n.cpp
src/util/text_format.cpp src/util/text_format.cpp
src/data/wallet_state.cpp src/data/wallet_state.cpp
@@ -1113,6 +1157,7 @@ if(BUILD_TESTING)
src/data/address_book.cpp src/data/address_book.cpp
src/data/wallet_index.cpp src/data/wallet_index.cpp
src/daemon/lifecycle_adapters.cpp src/daemon/lifecycle_adapters.cpp
src/daemon/embedded_daemon.cpp
src/rpc/connection.cpp src/rpc/connection.cpp
src/config/settings.cpp src/config/settings.cpp
src/resources/embedded_resources.cpp src/resources/embedded_resources.cpp

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-win/
- prebuilt-binaries/dragonxd-mac/ - prebuilt-binaries/dragonxd-mac/
**xmrig HAC fork** (https://git.dragonx.is/dragonx/xmrig-hac): **DRG-XMRig fork** (https://git.dragonx.is/DragonX/drg-xmrig):
- prebuilt-binaries/xmrig-hac/ - prebuilt-binaries/drg-xmrig/
## Build Steps ## Build Steps

View File

@@ -131,7 +131,7 @@ fi
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses # truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX. # DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
_cml="$SCRIPT_DIR/CMakeLists.txt" _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) _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_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) _lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
@@ -286,6 +286,14 @@ bundle_linux_daemon() {
# asmap.dat # asmap.dat
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat" find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat)
for p in "$SCRIPT_DIR/build/linux/bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/../dragonx-wallet-rebuild"; do
if [[ -f "$p" ]]; then
cp "$p" "$dest/dragonx-wallet-rebuild"; chmod +x "$dest/dragonx-wallet-rebuild"
info " Bundled dragonx-wallet-rebuild"; break
fi
done
return $found return $found
} }
@@ -336,11 +344,20 @@ build_release_linux() {
mkdir -p "$bd" && cd "$bd" mkdir -p "$bd" && cd "$bd"
# ── Compile ────────────────────────────────────────────────────────────── # ── Compile ──────────────────────────────────────────────────────────────
# Point the wallet-rebuild helper at the vendored static Berkeley DB (same libdb the daemon links)
# so its output is a v6.2 btree the bundled dragonxd reads. Pass the paths EXPLICITLY (bypasses
# find_library + its cache); the helper target is simply not built if the depends tree is absent.
local lin_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-unknown-linux-gnu"
local BDB_ARGS=()
if [[ -f "$lin_bdb/lib/libdb-6.2.a" && -f "$lin_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$lin_bdb/include" -DBDB_LIBRARY="$lin_bdb/lib/libdb-6.2.a" )
fi
info "Configuring ..." info "Configuring ..."
cmake "$SCRIPT_DIR" \ cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \ -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \ -DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=ON \ -DDRAGONX_USE_SYSTEM_SDL3=ON \
"${BDB_ARGS[@]}" \
"${CMAKE_LITE_ARGS[@]}" "${CMAKE_LITE_ARGS[@]}"
info "Building with $JOBS jobs ..." info "Building with $JOBS jobs ..."
@@ -350,6 +367,7 @@ build_release_linux() {
info "Stripping ..." info "Stripping ..."
strip "bin/${APP_BASENAME}" strip "bin/${APP_BASENAME}"
[[ -f "bin/dragonx-wallet-rebuild" ]] && strip "bin/dragonx-wallet-rebuild"
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)" info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
if should_bundle_full_node_assets; then if should_bundle_full_node_assets; then
@@ -386,7 +404,7 @@ build_release_linux() {
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/" [[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
fi fi
# Bundle xmrig for mining support # 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" [[ -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 cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -419,7 +437,7 @@ build_release_linux() {
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/" [[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
fi fi
# Bundle xmrig for mining support # 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"; } [[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
# Desktop entry # Desktop entry
@@ -638,8 +656,8 @@ HDR
info "Lite mode: skipping embedded daemon binaries" info "Lite mode: skipping embedded daemon binaries"
fi fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ──────────────── # ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac" 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 # 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 # 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 # the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
@@ -750,11 +768,20 @@ HDR
fi fi
# ── CMake + build ──────────────────────────────────────────────────────── # ── CMake + build ────────────────────────────────────────────────────────
# The wallet-rebuild helper links the vendored mingw static Berkeley DB (the mingw toolchain's
# find_library is sysroot-only, so pass the depends paths EXPLICITLY to bypass the search). Only
# enabled if the depends tree is present; guarded with -DBDB_* left empty otherwise.
local win_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
local BDB_ARGS=()
if [[ -f "$win_bdb/lib/libdb-6.2.a" && -f "$win_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$win_bdb/include" -DBDB_LIBRARY="$win_bdb/lib/libdb-6.2.a" )
fi
info "Configuring (cross-compile) ..." info "Configuring (cross-compile) ..."
cmake "$SCRIPT_DIR" \ cmake "$SCRIPT_DIR" \
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \ -DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \ -DCMAKE_BUILD_TYPE=Release \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \ -DDRAGONX_USE_SYSTEM_SDL3=OFF \
"${BDB_ARGS[@]}" \
"${FT_CMAKE_ARG[@]}" \ "${FT_CMAKE_ARG[@]}" \
"${CMAKE_LITE_ARGS[@]}" "${CMAKE_LITE_ARGS[@]}"
@@ -779,6 +806,8 @@ HDR
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/" [[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
done done
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat)
[[ -f "bin/dragonx-wallet-rebuild.exe" ]] && { cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/"; info " Bundled dragonx-wallet-rebuild.exe"; }
# Bundle Sapling params + asmap for the zip distribution # Bundle Sapling params + asmap for the zip distribution
# (The single-file exe has these embedded via INCBIN, but the zip # (The single-file exe has these embedded via INCBIN, but the zip
@@ -791,7 +820,7 @@ HDR
fi fi
# Bundle xmrig for mining support # 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" [[ -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 cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -901,8 +930,26 @@ build_release_mac() {
fi fi
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)" info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
else else
# Native macOS: build universal binary (arm64 + x86_64) # Native macOS: build universal (arm64 + x86_64) by default. Override with
MAC_ARCH="universal" # 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" export MACOSX_DEPLOYMENT_TARGET="11.0"
fi fi
@@ -990,7 +1037,7 @@ TOOLCHAIN
need_sodium=true need_sodium=true
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
# Rebuild if existing lib is not universal (single-arch won't link) # 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 ..." info "Existing libsodium is not universal — rebuilding ..."
rm -rf "$SCRIPT_DIR/libs/libsodium" rm -rf "$SCRIPT_DIR/libs/libsodium"
need_sodium=true need_sodium=true
@@ -1001,13 +1048,13 @@ TOOLCHAIN
"$SCRIPT_DIR/scripts/fetch-libsodium.sh" "$SCRIPT_DIR/scripts/fetch-libsodium.sh"
fi fi
info "Configuring (native universal arm64+x86_64) ..." info "Configuring (native macOS, arch: $MAC_ARCHS) ..."
cmake "$SCRIPT_DIR" \ cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \ -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \ -DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \ -DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \ -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ -DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
"${CMAKE_LITE_ARGS[@]}" "${CMAKE_LITE_ARGS[@]}"
fi fi
@@ -1037,8 +1084,12 @@ TOOLCHAIN
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)" info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
# ── Create .app bundle ─────────────────────────────────────────────────── # ── Create .app bundle ───────────────────────────────────────────────────
rm -rf "$out"
mkdir -p "$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 APP="$out/${APP_BASENAME}.app"
local CONTENTS="$APP/Contents" local CONTENTS="$APP/Contents"
@@ -1088,8 +1139,8 @@ TOOLCHAIN
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling" info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
fi fi
# xmrig binary (from prebuilt-binaries/xmrig-hac/) # xmrig binary (from prebuilt-binaries/drg-xmrig/)
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig" local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
if [[ -f "$XMRIG_MAC" ]]; then if [[ -f "$XMRIG_MAC" ]]; then
cp "$XMRIG_MAC" "$MACOS/xmrig" cp "$XMRIG_MAC" "$MACOS/xmrig"
chmod +x "$MACOS/xmrig" chmod +x "$MACOS/xmrig"
@@ -1238,8 +1289,10 @@ PLIST
fi fi
# ── Create DMG ─────────────────────────────────────────────────────────── # ── Create DMG ───────────────────────────────────────────────────────────
local DMG_BASENAME="DragonX_Wallet" # DMG filename matches the app bundle name (ObsidianDragon / ObsidianDragonLite).
$DO_LITE && DMG_BASENAME="DragonX_Wallet_Lite" # 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" local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
if command -v create-dmg &>/dev/null; then 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.

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

@@ -0,0 +1,167 @@
# Wallet Loading & Management — Hardening Plan
Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
diagnosability QoL). Companion to the findings artifact. Line references are against `dev`.
- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the
code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted
(W1-5), 1 raised (W5-3 Low→Med).
- **Severity:** 8 High · 12 Medium · 13 Low.
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
---
## Roadmap (ordered by risk; shared fixes grouped)
| Phase | Findings | Theme | Status |
|-------|----------|-------|--------|
| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
| **P1-A** | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
---
## P0-A — Secret hardening
Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
key/passphrase paths, plus console redaction and deleting the plaintext export.
- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an
allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`,
`encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`,
`signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and
keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for
unit testing. **← implementing first (self-contained + testable).**
- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport<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
- **Adversarial review of the 3 diagnostics UI features** — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found **no** Push/Pop imbalance. Fixes landed:
- **(Med) Alert popup grew off the right edge** — pivot `(0,1)` pinned the panel's *left* edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicit `SetNextWindowPos` pivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-*right* corner at the bell (pivot `(1,1)`, at `bellMax.x`) so it grows left over the canvas.
- **(Low) Staleness badge could flash red on reconnect** — `WalletState::clear()` reset everything *except* the four `last_*_update` stamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps in `clear()` (all readers treat 0 as "never"; verified `app_network.cpp:1473` guards on `!= 0`).
- **(Low) Banner min-height floor wasn't DPI-scaled** — `std::max(minH, baseH*vScale())` compared a raw-px floor against a scaled value; now `minH * dpiScale()`.
- **(Low) New i18n keys weren't in `res/lang/`** — back-filled all 16 diagnostics/QoL keys (this session's node_banner_*/data_stale_*/alerts_*/settings_*/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font.
- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 14s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.**
- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h``evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
- **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(<config>/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed).
- **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter.
- **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded.
Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed:
- **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss).
- **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads).
- **W6-1 (Med):** `WalletState::clear()` didn't reset `mining`/`pool_mining`, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset in `clear()` (the daemon restarts on switch, so mining genuinely stops).
- **W6-3 (Low):** `AddressBook::load()` did `entries_.clear()` then threw on the first non-object element — discarding **every** contact. Now it guards `is_object()` + per-entry try/catch, skipping and counting malformed entries.
Build-clean; `ctest` 1/1. **Remaining P2:** W6-2 (surface refresh staleness — the timestamps exist in `WalletState`; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle).
- **P1-B / W1-3 + startup wallet-existence guard** — ☑ landed:
- **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open.
- **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened.
Build-clean; `ctest` 1/1.
- **P1-A / W3-3 (sweep opid persistence)** — ☑ **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang.
- **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error.
- **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale".
- **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
- **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
- **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
- **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".
Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch).
- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully):
- **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race).
- **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(<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

@@ -48,6 +48,10 @@
"advanced": "ERWEITERT", "advanced": "ERWEITERT",
"advanced_effects": "Erweiterte Effekte...", "advanced_effects": "Erweiterte Effekte...",
"ago": "her", "ago": "her",
"alerts_clear": "Meldungsverlauf löschen",
"alerts_history_tooltip": "Letzte Meldungen",
"alerts_none": "Noch keine Meldungen",
"alerts_recent": "LETZTE MELDUNGEN",
"all_filter": "Alle", "all_filter": "Alle",
"allow_custom_fees": "Benutzerdefinierte Gebühren erlauben", "allow_custom_fees": "Benutzerdefinierte Gebühren erlauben",
"amount": "Betrag", "amount": "Betrag",
@@ -451,6 +455,8 @@
"daemon_update_version": "Version:", "daemon_update_version": "Version:",
"daemon_version": "Daemon", "daemon_version": "Daemon",
"dark": "Dunkel", "dark": "Dunkel",
"data_stale_prefix": "Aktualisiert",
"data_stale_tooltip": "Der Kontostand ist möglicherweise veraltet die Wallet hat kürzlich keine Aktualisierung erhalten. Überprüfe deine Node-Verbindung.",
"date": "Datum", "date": "Datum",
"date_label": "Datum:", "date_label": "Datum:",
"debug_logging": "FEHLERPROTOKOLLIERUNG", "debug_logging": "FEHLERPROTOKOLLIERUNG",
@@ -734,6 +740,9 @@
"lite_working": "In Arbeit…", "lite_working": "In Arbeit…",
"loading": "Laden...", "loading": "Laden...",
"loading_addresses": "Adressen werden geladen...", "loading_addresses": "Adressen werden geladen...",
"loading_stall_body": "Der Daemon initialisiert seit %.0f s. Das kann nach einem Update oder beim ersten Start normal sein (Laden des Blockindex oder erneutes Scannen) die Verbindung wird automatisch hergestellt, sobald er bereit ist.",
"loading_stall_hint": "Hängt es noch? Öffne die Einstellungen und nutze „Daemon neu starten“ oder sieh in der Konsole nach Details.",
"loading_stall_title": "Dauert länger als erwartet",
"loading_transactions": "Transaktionen werden geladen", "loading_transactions": "Transaktionen werden geladen",
"local_hashrate": "Lokale Hashrate", "local_hashrate": "Lokale Hashrate",
"low_spec_mode": "Energiesparmodus", "low_spec_mode": "Energiesparmodus",
@@ -953,6 +962,11 @@
"no_transactions": "Keine Transaktionen gefunden", "no_transactions": "Keine Transaktionen gefunden",
"no_transactions_yet": "Noch keine Transaktionen", "no_transactions_yet": "Noch keine Transaktionen",
"node": "KNOTEN", "node": "KNOTEN",
"node_banner_crashed_title": "Der Node wurde unerwartet beendet",
"node_banner_lite_open_failed": "Wallet konnte nicht geöffnet werden",
"node_banner_offline_title": "Nicht mit dem DragonX-Node verbunden",
"node_banner_reconnect": "Erneut verbinden",
"node_banner_restart": "Node neu starten",
"node_security": "KNOTEN & SICHERHEIT", "node_security": "KNOTEN & SICHERHEIT",
"noise": "Rauschen", "noise": "Rauschen",
"not_connected": "Nicht mit Daemon verbunden...", "not_connected": "Nicht mit Daemon verbunden...",
@@ -1154,6 +1168,8 @@
"sb_connecting_external": "Verbindung zu externem Daemon...", "sb_connecting_external": "Verbindung zu externem Daemon...",
"sb_connecting_generic": "Verbindung zum Daemon...", "sb_connecting_generic": "Verbindung zum Daemon...",
"sb_daemon_crashed": "Daemon ist %d mal abgestürzt", "sb_daemon_crashed": "Daemon ist %d mal abgestürzt",
"sb_daemon_extract_failed": "Daemon-Dateien konnten nicht geschrieben werden prüfe freien Speicherplatz und Berechtigungen.",
"sb_daemon_files_failed": "Daemon-Dateien konnten nicht nach %s geschrieben werden prüfe freien Speicherplatz und Berechtigungen.",
"sb_daemon_not_found": "Daemon nicht gefunden", "sb_daemon_not_found": "Daemon nicht gefunden",
"sb_daemon_start_failed": "dragonxd konnte nicht gestartet werden", "sb_daemon_start_failed": "dragonxd konnte nicht gestartet werden",
"sb_dragonxd_running": "dragonxd läuft", "sb_dragonxd_running": "dragonxd läuft",
@@ -1169,6 +1185,7 @@
"sb_net_mhs": "Netz: %.2f MH/s", "sb_net_mhs": "Netz: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf nicht gefunden", "sb_no_conf": "DRAGONX.conf nicht gefunden",
"sb_peers": "Peers: %zu", "sb_peers": "Peers: %zu",
"sb_plaintext_remote_blocked": "RPC-Anmeldedaten werden nicht im Klartext an einen entfernten Host gesendet. Füge rpcallowplaintext=1 zu DRAGONX.conf hinzu, um dies zu erlauben, oder aktiviere TLS mit rpctls=1.",
"sb_rescanning": "Neuscan", "sb_rescanning": "Neuscan",
"sb_rescanning_pct": "Neuscan %.0f%%", "sb_rescanning_pct": "Neuscan %.0f%%",
"sb_restarting_daemon": "Daemon wird neu gestartet...", "sb_restarting_daemon": "Daemon wird neu gestartet...",
@@ -1285,12 +1302,14 @@
"settings_configure_explorer": "Externe Block-Explorer-Links konfigurieren", "settings_configure_explorer": "Externe Block-Explorer-Links konfigurieren",
"settings_configure_rpc": "Verbindung zum dragonxd-Daemon konfigurieren", "settings_configure_rpc": "Verbindung zum dragonxd-Daemon konfigurieren",
"settings_connection": "Verbindung", "settings_connection": "Verbindung",
"settings_copy_diagnostics": "Diagnose kopieren",
"settings_copyright": "Copyright 2024-2026 DragonX-Entwickler | GPLv3-Lizenz", "settings_copyright": "Copyright 2024-2026 DragonX-Entwickler | GPLv3-Lizenz",
"settings_custom": "Benutzerdefiniert", "settings_custom": "Benutzerdefiniert",
"settings_data_dir": "Datenverzeichnis:", "settings_data_dir": "Datenverzeichnis:",
"settings_debug_changed": "Debug-Kategorien geändert — Daemon neu starten zum Anwenden", "settings_debug_changed": "Debug-Kategorien geändert — Daemon neu starten zum Anwenden",
"settings_debug_restart_note": "Änderungen werden nach einem Neustart des Daemons wirksam.", "settings_debug_restart_note": "Änderungen werden nach einem Neustart des Daemons wirksam.",
"settings_debug_select": "Kategorien auswählen, um Daemon-Fehlerprotokollierung zu aktivieren (-debug= Flags).", "settings_debug_select": "Kategorien auswählen, um Daemon-Fehlerprotokollierung zu aktivieren (-debug= Flags).",
"settings_diagnostics_copied": "Diagnose in die Zwischenablage kopiert",
"settings_encrypt_first_pin": "Verschlüsseln Sie zuerst die Wallet, um PIN zu aktivieren", "settings_encrypt_first_pin": "Verschlüsseln Sie zuerst die Wallet, um PIN zu aktivieren",
"settings_encrypt_wallet": "Wallet verschlüsseln", "settings_encrypt_wallet": "Wallet verschlüsseln",
"settings_explorer_hint": "URLs sollten einen abschließenden Schrägstrich enthalten. Die txid/Adresse wird angehängt.", "settings_explorer_hint": "URLs sollten einen abschließenden Schrägstrich enthalten. Die txid/Adresse wird angehängt.",
@@ -1311,6 +1330,7 @@
"settings_not_found": "Nicht gefunden", "settings_not_found": "Nicht gefunden",
"settings_open_app_dir": "App-Ordner öffnen", "settings_open_app_dir": "App-Ordner öffnen",
"settings_open_data_dir": "Datenordner öffnen", "settings_open_data_dir": "Datenordner öffnen",
"settings_open_log_folder": "Log-Ordner öffnen",
"settings_other": "Sonstiges", "settings_other": "Sonstiges",
"settings_pin_active": "PIN", "settings_pin_active": "PIN",
"settings_privacy": "Datenschutz", "settings_privacy": "Datenschutz",
@@ -1470,6 +1490,7 @@
"tt_chat_timestamp": "Zeitstempelformat nur für diesen Tab: der app-weiten Uhr folgen oder 24-hour bzw. 12-hour erzwingen", "tt_chat_timestamp": "Zeitstempelformat nur für diesen Tab: der app-weiten Uhr folgen oder 24-hour bzw. 12-hour erzwingen",
"tt_clear_ztx": "Lokal zwischengespeicherten Z-Transaktionsverlauf löschen", "tt_clear_ztx": "Lokal zwischengespeicherten Z-Transaktionsverlauf löschen",
"tt_clock_format": "24- oder 12-Stunden-Uhr, app-weit. Der Chat-Tab kann sie überschreiben.", "tt_clock_format": "24- oder 12-Stunden-Uhr, app-weit. Der Chat-Tab kann sie überschreiben.",
"tt_copy_diagnostics": "Kopiert eine Support-Übersicht (Version, Daemon-/Wallet-/Log-Status keine Geheimnisse) in die Zwischenablage",
"tt_custom_fees": "Manuelle Gebühreneingabe beim Senden von Transaktionen aktivieren", "tt_custom_fees": "Manuelle Gebühreneingabe beim Senden von Transaktionen aktivieren",
"tt_custom_theme": "Benutzerdefiniertes Theme aktiv", "tt_custom_theme": "Benutzerdefiniertes Theme aktiv",
"tt_daemon_install_bundled": "Node stoppen, den installierten dragonxd mit der in diesem Wallet-Build enthaltenen Version überschreiben und dann neu starten", "tt_daemon_install_bundled": "Node stoppen, den installierten dragonxd mit der in diesem Wallet-Build enthaltenen Version überschreiben und dann neu starten",
@@ -1523,6 +1544,7 @@
"tt_open_app_dir": "Den ObsidianDragon-Ordner (Einstellungen, Themes, Logs) im Dateimanager öffnen", "tt_open_app_dir": "Den ObsidianDragon-Ordner (Einstellungen, Themes, Logs) im Dateimanager öffnen",
"tt_open_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen", "tt_open_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen",
"tt_open_dir": "Klicken, um im Dateimanager zu öffnen", "tt_open_dir": "Klicken, um im Dateimanager zu öffnen",
"tt_open_log_folder": "Öffnet den Ordner mit den Debug- und Absturzprotokollen",
"tt_reduce_motion": "Animierte Übergänge und Saldo-Lerp für Barrierefreiheit deaktivieren", "tt_reduce_motion": "Animierte Übergänge und Saldo-Lerp für Barrierefreiheit deaktivieren",
"tt_remove_encrypt": "Verschlüsselung entfernen und Wallet ungeschützt speichern", "tt_remove_encrypt": "Verschlüsselung entfernen und Wallet ungeschützt speichern",
"tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern", "tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern",

View File

@@ -48,6 +48,10 @@
"advanced": "AVANZADO", "advanced": "AVANZADO",
"advanced_effects": "Efectos Avanzados...", "advanced_effects": "Efectos Avanzados...",
"ago": "atrás", "ago": "atrás",
"alerts_clear": "Borrar historial de alertas",
"alerts_history_tooltip": "Alertas recientes",
"alerts_none": "Aún no hay alertas",
"alerts_recent": "ALERTAS RECIENTES",
"all_filter": "Todos", "all_filter": "Todos",
"allow_custom_fees": "Permitir comisiones personalizadas", "allow_custom_fees": "Permitir comisiones personalizadas",
"amount": "Cantidad", "amount": "Cantidad",
@@ -451,6 +455,8 @@
"daemon_update_version": "Versión:", "daemon_update_version": "Versión:",
"daemon_version": "Daemon", "daemon_version": "Daemon",
"dark": "Oscuro", "dark": "Oscuro",
"data_stale_prefix": "Actualizado",
"data_stale_tooltip": "El saldo puede estar desactualizado: la cartera no ha recibido una actualización reciente. Comprueba la conexión con tu nodo.",
"date": "Fecha", "date": "Fecha",
"date_label": "Fecha:", "date_label": "Fecha:",
"debug_logging": "REGISTRO DE DEPURACIÓN", "debug_logging": "REGISTRO DE DEPURACIÓN",
@@ -734,6 +740,9 @@
"lite_working": "Trabajando…", "lite_working": "Trabajando…",
"loading": "Cargando...", "loading": "Cargando...",
"loading_addresses": "Cargando direcciones...", "loading_addresses": "Cargando direcciones...",
"loading_stall_body": "El daemon lleva %.0f s inicializándose. Esto puede ser normal tras una actualización o en el primer inicio (cargando el índice de bloques o reescaneando); se conectará automáticamente cuando esté listo.",
"loading_stall_hint": "¿Sigue bloqueado? Abre Ajustes y usa Reiniciar daemon, o revisa la Consola para más detalles.",
"loading_stall_title": "Está tardando más de lo esperado",
"loading_transactions": "Cargando transacciones", "loading_transactions": "Cargando transacciones",
"local_hashrate": "Tasa Hash Local", "local_hashrate": "Tasa Hash Local",
"low_spec_mode": "Modo bajo rendimiento", "low_spec_mode": "Modo bajo rendimiento",
@@ -953,6 +962,11 @@
"no_transactions": "No se encontraron transacciones", "no_transactions": "No se encontraron transacciones",
"no_transactions_yet": "Aún no hay transacciones", "no_transactions_yet": "Aún no hay transacciones",
"node": "NODO", "node": "NODO",
"node_banner_crashed_title": "El nodo se detuvo inesperadamente",
"node_banner_lite_open_failed": "No se pudo abrir tu monedero",
"node_banner_offline_title": "No conectado al nodo DragonX",
"node_banner_reconnect": "Reconectar",
"node_banner_restart": "Reiniciar nodo",
"node_security": "NODO Y SEGURIDAD", "node_security": "NODO Y SEGURIDAD",
"noise": "Ruido", "noise": "Ruido",
"not_connected": "No conectado al daemon...", "not_connected": "No conectado al daemon...",
@@ -1154,6 +1168,8 @@
"sb_connecting_external": "Conectando a daemon externo...", "sb_connecting_external": "Conectando a daemon externo...",
"sb_connecting_generic": "Conectando al daemon...", "sb_connecting_generic": "Conectando al daemon...",
"sb_daemon_crashed": "El daemon se bloqueó %d veces", "sb_daemon_crashed": "El daemon se bloqueó %d veces",
"sb_daemon_extract_failed": "No se pudieron escribir los archivos del daemon: comprueba el espacio libre en disco y los permisos.",
"sb_daemon_files_failed": "No se pudieron escribir los archivos del daemon en %s: comprueba el espacio libre en disco y los permisos.",
"sb_daemon_not_found": "Daemon no encontrado", "sb_daemon_not_found": "Daemon no encontrado",
"sb_daemon_start_failed": "No se pudo iniciar dragonxd", "sb_daemon_start_failed": "No se pudo iniciar dragonxd",
"sb_dragonxd_running": "dragonxd ejecutándose", "sb_dragonxd_running": "dragonxd ejecutándose",
@@ -1169,6 +1185,7 @@
"sb_net_mhs": "Red: %.2f MH/s", "sb_net_mhs": "Red: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf no encontrado", "sb_no_conf": "DRAGONX.conf no encontrado",
"sb_peers": "Pares: %zu", "sb_peers": "Pares: %zu",
"sb_plaintext_remote_blocked": "Se rechaza enviar credenciales RPC en texto plano a un host remoto. Añade rpcallowplaintext=1 a DRAGONX.conf para permitirlo, o habilita TLS con rpctls=1.",
"sb_rescanning": "Reescaneando", "sb_rescanning": "Reescaneando",
"sb_rescanning_pct": "Reescaneando %.0f%%", "sb_rescanning_pct": "Reescaneando %.0f%%",
"sb_restarting_daemon": "Reiniciando daemon...", "sb_restarting_daemon": "Reiniciando daemon...",
@@ -1285,12 +1302,14 @@
"settings_configure_explorer": "Configurar enlaces de explorador de bloques externo", "settings_configure_explorer": "Configurar enlaces de explorador de bloques externo",
"settings_configure_rpc": "Configurar conexión al daemon dragonxd", "settings_configure_rpc": "Configurar conexión al daemon dragonxd",
"settings_connection": "Conexión", "settings_connection": "Conexión",
"settings_copy_diagnostics": "Copiar diagnósticos",
"settings_copyright": "Copyright 2024-2026 Desarrolladores de DragonX | Licencia GPLv3", "settings_copyright": "Copyright 2024-2026 Desarrolladores de DragonX | Licencia GPLv3",
"settings_custom": "Personalizado", "settings_custom": "Personalizado",
"settings_data_dir": "Dir. de datos:", "settings_data_dir": "Dir. de datos:",
"settings_debug_changed": "Categorías de depuración cambiadas — reinicie el daemon para aplicar", "settings_debug_changed": "Categorías de depuración cambiadas — reinicie el daemon para aplicar",
"settings_debug_restart_note": "Los cambios surten efecto después de reiniciar el daemon.", "settings_debug_restart_note": "Los cambios surten efecto después de reiniciar el daemon.",
"settings_debug_select": "Seleccione categorías para habilitar el registro de depuración del daemon (flags -debug=).", "settings_debug_select": "Seleccione categorías para habilitar el registro de depuración del daemon (flags -debug=).",
"settings_diagnostics_copied": "Diagnósticos copiados al portapapeles",
"settings_encrypt_first_pin": "Primero cifre la billetera para habilitar el PIN", "settings_encrypt_first_pin": "Primero cifre la billetera para habilitar el PIN",
"settings_encrypt_wallet": "Cifrar billetera", "settings_encrypt_wallet": "Cifrar billetera",
"settings_explorer_hint": "Las URLs deben incluir una barra final. Se añadirá el txid/dirección.", "settings_explorer_hint": "Las URLs deben incluir una barra final. Se añadirá el txid/dirección.",
@@ -1311,6 +1330,7 @@
"settings_not_found": "No encontrado", "settings_not_found": "No encontrado",
"settings_open_app_dir": "Abrir carpeta de la aplicación", "settings_open_app_dir": "Abrir carpeta de la aplicación",
"settings_open_data_dir": "Abrir carpeta de datos", "settings_open_data_dir": "Abrir carpeta de datos",
"settings_open_log_folder": "Abrir carpeta de registros",
"settings_other": "Otros", "settings_other": "Otros",
"settings_pin_active": "PIN", "settings_pin_active": "PIN",
"settings_privacy": "Privacidad", "settings_privacy": "Privacidad",
@@ -1470,6 +1490,7 @@
"tt_chat_timestamp": "Formato de marca de tiempo solo para esta pestaña: seguir el reloj de toda la app, o forzar 24-hour o 12-hour", "tt_chat_timestamp": "Formato de marca de tiempo solo para esta pestaña: seguir el reloj de toda la app, o forzar 24-hour o 12-hour",
"tt_clear_ztx": "Eliminar historial de z-transacciones en caché local", "tt_clear_ztx": "Eliminar historial de z-transacciones en caché local",
"tt_clock_format": "Reloj de 24 o 12 horas, en toda la app. El chat puede anularlo.", "tt_clock_format": "Reloj de 24 o 12 horas, en toda la app. El chat puede anularlo.",
"tt_copy_diagnostics": "Copia al portapapeles un resumen para soporte (versión, estado de daemon/cartera/registros, sin datos secretos)",
"tt_custom_fees": "Habilitar entrada manual de comisiones al enviar transacciones", "tt_custom_fees": "Habilitar entrada manual de comisiones al enviar transacciones",
"tt_custom_theme": "Tema personalizado activo", "tt_custom_theme": "Tema personalizado activo",
"tt_daemon_install_bundled": "Detiene el nodo, sobrescribe el dragonxd instalado con la versión incluida en esta compilación de la cartera y luego lo reinicia", "tt_daemon_install_bundled": "Detiene el nodo, sobrescribe el dragonxd instalado con la versión incluida en esta compilación de la cartera y luego lo reinicia",
@@ -1523,6 +1544,7 @@
"tt_open_app_dir": "Abrir la carpeta de ObsidianDragon (configuración, temas, registros) en el explorador de archivos", "tt_open_app_dir": "Abrir la carpeta de ObsidianDragon (configuración, temas, registros) en el explorador de archivos",
"tt_open_data_dir": "Abre en el gestor de archivos la carpeta con los datos de tu cartera y de la blockchain", "tt_open_data_dir": "Abre en el gestor de archivos la carpeta con los datos de tu cartera y de la blockchain",
"tt_open_dir": "Clic para abrir en explorador de archivos", "tt_open_dir": "Clic para abrir en explorador de archivos",
"tt_open_log_folder": "Abre la carpeta que contiene los registros de depuración y de fallos",
"tt_reduce_motion": "Desactivar transiciones animadas y lerp de saldo para accesibilidad", "tt_reduce_motion": "Desactivar transiciones animadas y lerp de saldo para accesibilidad",
"tt_remove_encrypt": "Quitar cifrado y almacenar la billetera sin protección", "tt_remove_encrypt": "Quitar cifrado y almacenar la billetera sin protección",
"tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear", "tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear",

View File

@@ -48,6 +48,10 @@
"advanced": "AVANCÉ", "advanced": "AVANCÉ",
"advanced_effects": "Effets avancés...", "advanced_effects": "Effets avancés...",
"ago": "passé", "ago": "passé",
"alerts_clear": "Effacer l'historique des alertes",
"alerts_history_tooltip": "Alertes récentes",
"alerts_none": "Aucune alerte pour l'instant",
"alerts_recent": "ALERTES RÉCENTES",
"all_filter": "Tout", "all_filter": "Tout",
"allow_custom_fees": "Autoriser les frais personnalisés", "allow_custom_fees": "Autoriser les frais personnalisés",
"amount": "Montant", "amount": "Montant",
@@ -451,6 +455,8 @@
"daemon_update_version": "Version :", "daemon_update_version": "Version :",
"daemon_version": "Daemon", "daemon_version": "Daemon",
"dark": "Sombre", "dark": "Sombre",
"data_stale_prefix": "Mis à jour",
"data_stale_tooltip": "Le solde est peut-être obsolète — le portefeuille n'a pas reçu de mise à jour récente. Vérifiez la connexion à votre nœud.",
"date": "Date", "date": "Date",
"date_label": "Date :", "date_label": "Date :",
"debug_logging": "JOURNALISATION DE DÉBOGAGE", "debug_logging": "JOURNALISATION DE DÉBOGAGE",
@@ -734,6 +740,9 @@
"lite_working": "En cours…", "lite_working": "En cours…",
"loading": "Chargement...", "loading": "Chargement...",
"loading_addresses": "Chargement des adresses...", "loading_addresses": "Chargement des adresses...",
"loading_stall_body": "Le démon s'initialise depuis %.0f s. Cela peut être normal après une mise à jour ou au premier lancement (chargement de l'index des blocs ou nouvelle analyse) — la connexion se fera automatiquement une fois prêt.",
"loading_stall_hint": "Toujours bloqué ? Ouvrez les Paramètres et utilisez Redémarrer le démon, ou consultez la Console pour plus de détails.",
"loading_stall_title": "Cela prend plus de temps que prévu",
"loading_transactions": "Chargement des transactions", "loading_transactions": "Chargement des transactions",
"local_hashrate": "Hashrate local", "local_hashrate": "Hashrate local",
"low_spec_mode": "Mode économie", "low_spec_mode": "Mode économie",
@@ -953,6 +962,11 @@
"no_transactions": "Aucune transaction trouvée", "no_transactions": "Aucune transaction trouvée",
"no_transactions_yet": "Aucune transaction pour le moment", "no_transactions_yet": "Aucune transaction pour le moment",
"node": "NŒUD", "node": "NŒUD",
"node_banner_crashed_title": "Le nœud s'est arrêté de façon inattendue",
"node_banner_lite_open_failed": "Impossible d'ouvrir votre portefeuille",
"node_banner_offline_title": "Non connecté au nœud DragonX",
"node_banner_reconnect": "Reconnecter",
"node_banner_restart": "Redémarrer le nœud",
"node_security": "NŒUD & SÉCURITÉ", "node_security": "NŒUD & SÉCURITÉ",
"noise": "Bruit", "noise": "Bruit",
"not_connected": "Non connecté au daemon...", "not_connected": "Non connecté au daemon...",
@@ -1154,6 +1168,8 @@
"sb_connecting_external": "Connexion au daemon externe...", "sb_connecting_external": "Connexion au daemon externe...",
"sb_connecting_generic": "Connexion au daemon...", "sb_connecting_generic": "Connexion au daemon...",
"sb_daemon_crashed": "Le daemon a planté %d fois", "sb_daemon_crashed": "Le daemon a planté %d fois",
"sb_daemon_extract_failed": "Échec de l'écriture des fichiers du démon — vérifiez l'espace disque libre et les permissions.",
"sb_daemon_files_failed": "Échec de l'écriture des fichiers du démon dans %s — vérifiez l'espace disque libre et les permissions.",
"sb_daemon_not_found": "Daemon introuvable", "sb_daemon_not_found": "Daemon introuvable",
"sb_daemon_start_failed": "Impossible de démarrer dragonxd", "sb_daemon_start_failed": "Impossible de démarrer dragonxd",
"sb_dragonxd_running": "dragonxd en cours", "sb_dragonxd_running": "dragonxd en cours",
@@ -1169,6 +1185,7 @@
"sb_net_mhs": "Rés: %.2f MH/s", "sb_net_mhs": "Rés: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf introuvable", "sb_no_conf": "DRAGONX.conf introuvable",
"sb_peers": "Pairs : %zu", "sb_peers": "Pairs : %zu",
"sb_plaintext_remote_blocked": "Refus d'envoyer les identifiants RPC en clair vers un hôte distant. Ajoutez rpcallowplaintext=1 à DRAGONX.conf pour l'autoriser, ou activez TLS avec rpctls=1.",
"sb_rescanning": "Rescan", "sb_rescanning": "Rescan",
"sb_rescanning_pct": "Rescan %.0f%%", "sb_rescanning_pct": "Rescan %.0f%%",
"sb_restarting_daemon": "Redémarrage du daemon...", "sb_restarting_daemon": "Redémarrage du daemon...",
@@ -1285,12 +1302,14 @@
"settings_configure_explorer": "Configurer les liens vers l'explorateur de blocs externe", "settings_configure_explorer": "Configurer les liens vers l'explorateur de blocs externe",
"settings_configure_rpc": "Configurer la connexion au daemon dragonxd", "settings_configure_rpc": "Configurer la connexion au daemon dragonxd",
"settings_connection": "Connexion", "settings_connection": "Connexion",
"settings_copy_diagnostics": "Copier les diagnostics",
"settings_copyright": "Copyright 2024-2026 Développeurs DragonX | Licence GPLv3", "settings_copyright": "Copyright 2024-2026 Développeurs DragonX | Licence GPLv3",
"settings_custom": "Personnalisé", "settings_custom": "Personnalisé",
"settings_data_dir": "Rép. de données :", "settings_data_dir": "Rép. de données :",
"settings_debug_changed": "Catégories de débogage modifiées — redémarrez le daemon pour appliquer", "settings_debug_changed": "Catégories de débogage modifiées — redémarrez le daemon pour appliquer",
"settings_debug_restart_note": "Les modifications prennent effet après le redémarrage du daemon.", "settings_debug_restart_note": "Les modifications prennent effet après le redémarrage du daemon.",
"settings_debug_select": "Sélectionnez les catégories pour activer la journalisation de débogage du daemon (flags -debug=).", "settings_debug_select": "Sélectionnez les catégories pour activer la journalisation de débogage du daemon (flags -debug=).",
"settings_diagnostics_copied": "Diagnostics copiés dans le presse-papiers",
"settings_encrypt_first_pin": "Chiffrez d'abord le portefeuille pour activer le PIN", "settings_encrypt_first_pin": "Chiffrez d'abord le portefeuille pour activer le PIN",
"settings_encrypt_wallet": "Chiffrer le portefeuille", "settings_encrypt_wallet": "Chiffrer le portefeuille",
"settings_explorer_hint": "Les URLs doivent inclure une barre oblique finale. Le txid/adresse sera ajouté.", "settings_explorer_hint": "Les URLs doivent inclure une barre oblique finale. Le txid/adresse sera ajouté.",
@@ -1311,6 +1330,7 @@
"settings_not_found": "Non trouvé", "settings_not_found": "Non trouvé",
"settings_open_app_dir": "Ouvrir le dossier de l'application", "settings_open_app_dir": "Ouvrir le dossier de l'application",
"settings_open_data_dir": "Ouvrir le dossier de données", "settings_open_data_dir": "Ouvrir le dossier de données",
"settings_open_log_folder": "Ouvrir le dossier des journaux",
"settings_other": "Autres", "settings_other": "Autres",
"settings_pin_active": "PIN", "settings_pin_active": "PIN",
"settings_privacy": "Confidentialité", "settings_privacy": "Confidentialité",
@@ -1470,6 +1490,7 @@
"tt_chat_timestamp": "Format d'horodatage pour cet onglet uniquement : suivre l'horloge de l'application, ou forcer 24-hour ou 12-hour", "tt_chat_timestamp": "Format d'horodatage pour cet onglet uniquement : suivre l'horloge de l'application, ou forcer 24-hour ou 12-hour",
"tt_clear_ztx": "Supprimer l'historique des z-transactions mis en cache localement", "tt_clear_ztx": "Supprimer l'historique des z-transactions mis en cache localement",
"tt_clock_format": "Horloge 24 h ou 12 h, dans toute l'app. Le chat peut la remplacer.", "tt_clock_format": "Horloge 24 h ou 12 h, dans toute l'app. Le chat peut la remplacer.",
"tt_copy_diagnostics": "Copie un récapitulatif de support (version, état daemon/portefeuille/journaux — sans données secrètes) dans le presse-papiers",
"tt_custom_fees": "Activer la saisie manuelle des frais lors de l'envoi de transactions", "tt_custom_fees": "Activer la saisie manuelle des frais lors de l'envoi de transactions",
"tt_custom_theme": "Thème personnalisé actif", "tt_custom_theme": "Thème personnalisé actif",
"tt_daemon_install_bundled": "Arrêter le nœud, remplacer le dragonxd installé par la version intégrée dans cette version du portefeuille, puis redémarrer", "tt_daemon_install_bundled": "Arrêter le nœud, remplacer le dragonxd installé par la version intégrée dans cette version du portefeuille, puis redémarrer",
@@ -1523,6 +1544,7 @@
"tt_open_app_dir": "Ouvrir le dossier ObsidianDragon (paramètres, thèmes, journaux) dans le gestionnaire de fichiers", "tt_open_app_dir": "Ouvrir le dossier ObsidianDragon (paramètres, thèmes, journaux) dans le gestionnaire de fichiers",
"tt_open_data_dir": "Ouvrir le dossier contenant les données de votre portefeuille et de la blockchain dans le gestionnaire de fichiers", "tt_open_data_dir": "Ouvrir le dossier contenant les données de votre portefeuille et de la blockchain dans le gestionnaire de fichiers",
"tt_open_dir": "Cliquer pour ouvrir dans l'explorateur de fichiers", "tt_open_dir": "Cliquer pour ouvrir dans l'explorateur de fichiers",
"tt_open_log_folder": "Ouvre le dossier contenant les journaux de débogage et de plantage",
"tt_reduce_motion": "Désactiver les transitions animées et le lerp de solde pour l'accessibilité", "tt_reduce_motion": "Désactiver les transitions animées et le lerp de solde pour l'accessibilité",
"tt_remove_encrypt": "Supprimer le chiffrement et stocker le portefeuille sans protection", "tt_remove_encrypt": "Supprimer le chiffrement et stocker le portefeuille sans protection",
"tt_remove_pin": "Supprimer le PIN et exiger la phrase secrète pour déverrouiller", "tt_remove_pin": "Supprimer le PIN et exiger la phrase secrète pour déverrouiller",

View File

@@ -48,6 +48,10 @@
"advanced": "詳細設定", "advanced": "詳細設定",
"advanced_effects": "高度なエフェクト...", "advanced_effects": "高度なエフェクト...",
"ago": "前", "ago": "前",
"alerts_clear": "通知履歴を消去",
"alerts_history_tooltip": "最近の通知",
"alerts_none": "通知はまだありません",
"alerts_recent": "最近の通知",
"all_filter": "すべて", "all_filter": "すべて",
"allow_custom_fees": "カスタム手数料を許可", "allow_custom_fees": "カスタム手数料を許可",
"amount": "金額", "amount": "金額",
@@ -451,6 +455,8 @@
"daemon_update_version": "バージョン:", "daemon_update_version": "バージョン:",
"daemon_version": "デーモン", "daemon_version": "デーモン",
"dark": "ダーク", "dark": "ダーク",
"data_stale_prefix": "更新",
"data_stale_tooltip": "残高が最新でない可能性があります。ウォレットは最近更新を受信していません。ノード接続を確認してください。",
"date": "日付", "date": "日付",
"date_label": "日付:", "date_label": "日付:",
"debug_logging": "デバッグログ", "debug_logging": "デバッグログ",
@@ -734,6 +740,9 @@
"lite_working": "処理中…", "lite_working": "処理中…",
"loading": "読み込み中...", "loading": "読み込み中...",
"loading_addresses": "アドレスを読み込み中...", "loading_addresses": "アドレスを読み込み中...",
"loading_stall_body": "デーモンは %.0f 秒間初期化しています。アップデート後や初回起動時(ブロックインデックスの読み込みや再スキャン)は正常な場合があります。準備ができ次第、自動的に接続します。",
"loading_stall_hint": "まだ動かない場合は、設定を開いて「デーモンを再起動」を使うか、コンソールで詳細を確認してください。",
"loading_stall_title": "予想より時間がかかっています",
"loading_transactions": "トランザクションを読み込み中", "loading_transactions": "トランザクションを読み込み中",
"local_hashrate": "ローカルハッシュレート", "local_hashrate": "ローカルハッシュレート",
"low_spec_mode": "省電力モード", "low_spec_mode": "省電力モード",
@@ -953,6 +962,11 @@
"no_transactions": "取引が見つかりません", "no_transactions": "取引が見つかりません",
"no_transactions_yet": "まだ取引がありません", "no_transactions_yet": "まだ取引がありません",
"node": "ノード", "node": "ノード",
"node_banner_crashed_title": "ノードが予期せず停止しました",
"node_banner_lite_open_failed": "ウォレットを開けませんでした",
"node_banner_offline_title": "DragonX ノードに接続されていません",
"node_banner_reconnect": "再接続",
"node_banner_restart": "ノードを再起動",
"node_security": "ノードとセキュリティ", "node_security": "ノードとセキュリティ",
"noise": "ノイズ", "noise": "ノイズ",
"not_connected": "デーモンに未接続...", "not_connected": "デーモンに未接続...",
@@ -1285,12 +1299,14 @@
"settings_configure_explorer": "外部ブロックエクスプローラーリンクを設定", "settings_configure_explorer": "外部ブロックエクスプローラーリンクを設定",
"settings_configure_rpc": "dragonxd デーモンへの接続を設定", "settings_configure_rpc": "dragonxd デーモンへの接続を設定",
"settings_connection": "接続", "settings_connection": "接続",
"settings_copy_diagnostics": "診断情報をコピー",
"settings_copyright": "Copyright 2024-2026 DragonX 開発者 | GPLv3 ライセンス", "settings_copyright": "Copyright 2024-2026 DragonX 開発者 | GPLv3 ライセンス",
"settings_custom": "カスタム", "settings_custom": "カスタム",
"settings_data_dir": "データディレクトリ:", "settings_data_dir": "データディレクトリ:",
"settings_debug_changed": "デバッグカテゴリが変更されました — デーモンを再起動して適用", "settings_debug_changed": "デバッグカテゴリが変更されました — デーモンを再起動して適用",
"settings_debug_restart_note": "変更はデーモンの再起動後に有効になります。", "settings_debug_restart_note": "変更はデーモンの再起動後に有効になります。",
"settings_debug_select": "デーモンのデバッグログを有効にするカテゴリを選択(-debug= フラグ)。", "settings_debug_select": "デーモンのデバッグログを有効にするカテゴリを選択(-debug= フラグ)。",
"settings_diagnostics_copied": "診断情報をクリップボードにコピーしました",
"settings_encrypt_first_pin": "PIN を有効にするには、まずウォレットを暗号化してください", "settings_encrypt_first_pin": "PIN を有効にするには、まずウォレットを暗号化してください",
"settings_encrypt_wallet": "ウォレットを暗号化", "settings_encrypt_wallet": "ウォレットを暗号化",
"settings_explorer_hint": "URLには末尾のスラッシュを含めてください。txid/アドレスが追加されます。", "settings_explorer_hint": "URLには末尾のスラッシュを含めてください。txid/アドレスが追加されます。",
@@ -1311,6 +1327,7 @@
"settings_not_found": "見つかりません", "settings_not_found": "見つかりません",
"settings_open_app_dir": "アプリフォルダを開く", "settings_open_app_dir": "アプリフォルダを開く",
"settings_open_data_dir": "データフォルダを開く", "settings_open_data_dir": "データフォルダを開く",
"settings_open_log_folder": "ログフォルダを開く",
"settings_other": "その他", "settings_other": "その他",
"settings_pin_active": "PIN", "settings_pin_active": "PIN",
"settings_privacy": "プライバシー", "settings_privacy": "プライバシー",
@@ -1470,6 +1487,7 @@
"tt_chat_timestamp": "このタブのみのタイムスタンプ形式アプリ全体の時計に従うか、24-hourまたは12-hourを強制します", "tt_chat_timestamp": "このタブのみのタイムスタンプ形式アプリ全体の時計に従うか、24-hourまたは12-hourを強制します",
"tt_clear_ztx": "ローカルにキャッシュされた z-トランザクション履歴を削除", "tt_clear_ztx": "ローカルにキャッシュされた z-トランザクション履歴を削除",
"tt_clock_format": "24時間または12時間表示アプリ全体。チャットで上書きできます。", "tt_clock_format": "24時間または12時間表示アプリ全体。チャットで上書きできます。",
"tt_copy_diagnostics": "サポート用の概要(バージョン、デーモン/ウォレット/ログの状態 — 秘密情報なし)をクリップボードにコピーします",
"tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化", "tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化",
"tt_custom_theme": "カスタムテーマがアクティブ", "tt_custom_theme": "カスタムテーマがアクティブ",
"tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します", "tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します",
@@ -1523,6 +1541,7 @@
"tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く", "tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます", "tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
"tt_open_dir": "クリックしてファイルエクスプローラーで開く", "tt_open_dir": "クリックしてファイルエクスプローラーで開く",
"tt_open_log_folder": "デバッグログとクラッシュログが入ったフォルダを開きます",
"tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする", "tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする",
"tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存", "tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存",
"tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求", "tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求",

View File

@@ -48,6 +48,10 @@
"advanced": "고급 설정", "advanced": "고급 설정",
"advanced_effects": "고급 효과...", "advanced_effects": "고급 효과...",
"ago": "전", "ago": "전",
"alerts_clear": "알림 기록 지우기",
"alerts_history_tooltip": "최근 알림",
"alerts_none": "아직 알림이 없습니다",
"alerts_recent": "최근 알림",
"all_filter": "전체", "all_filter": "전체",
"allow_custom_fees": "사용자 정의 수수료 허용", "allow_custom_fees": "사용자 정의 수수료 허용",
"amount": "금액", "amount": "금액",
@@ -451,6 +455,8 @@
"daemon_update_version": "버전:", "daemon_update_version": "버전:",
"daemon_version": "데몬", "daemon_version": "데몬",
"dark": "다크", "dark": "다크",
"data_stale_prefix": "업데이트",
"data_stale_tooltip": "잔액이 오래되었을 수 있습니다 — 지갑이 최근에 업데이트를 받지 못했습니다. 노드 연결을 확인하세요.",
"date": "날짜", "date": "날짜",
"date_label": "날짜:", "date_label": "날짜:",
"debug_logging": "디버그 로깅", "debug_logging": "디버그 로깅",
@@ -734,6 +740,8 @@
"lite_working": "작업 중…", "lite_working": "작업 중…",
"loading": "로딩 중...", "loading": "로딩 중...",
"loading_addresses": "주소 로딩 중...", "loading_addresses": "주소 로딩 중...",
"loading_stall_body": "데몬이 %.0f초 동안 초기화 중입니다. 업데이트 후나 첫 실행 시(블록 인덱스 로드 또는 재스캔)에는 정상일 수 있습니다. 준비되면 자동으로 연결됩니다.",
"loading_stall_title": "예상보다 오래 걸리고 있습니다",
"loading_transactions": "거래를 불러오는 중", "loading_transactions": "거래를 불러오는 중",
"local_hashrate": "로컬 해시레이트", "local_hashrate": "로컬 해시레이트",
"low_spec_mode": "저사양 모드", "low_spec_mode": "저사양 모드",
@@ -953,6 +961,11 @@
"no_transactions": "거래 내역이 없습니다", "no_transactions": "거래 내역이 없습니다",
"no_transactions_yet": "아직 거래 내역이 없습니다", "no_transactions_yet": "아직 거래 내역이 없습니다",
"node": "노드", "node": "노드",
"node_banner_crashed_title": "노드가 예기치 않게 중지되었습니다",
"node_banner_lite_open_failed": "지갑을 열 수 없습니다",
"node_banner_offline_title": "DragonX 노드에 연결되지 않음",
"node_banner_reconnect": "재연결",
"node_banner_restart": "노드 재시작",
"node_security": "노드 및 보안", "node_security": "노드 및 보안",
"noise": "노이즈", "noise": "노이즈",
"not_connected": "데몬에 연결되지 않음...", "not_connected": "데몬에 연결되지 않음...",
@@ -1154,6 +1167,8 @@
"sb_connecting_external": "외부 데몬에 연결 중...", "sb_connecting_external": "외부 데몬에 연결 중...",
"sb_connecting_generic": "데몬에 연결 중...", "sb_connecting_generic": "데몬에 연결 중...",
"sb_daemon_crashed": "데몬이 %d회 충돌함", "sb_daemon_crashed": "데몬이 %d회 충돌함",
"sb_daemon_extract_failed": "데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
"sb_daemon_files_failed": "%s에 데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
"sb_daemon_not_found": "데몬을 찾을 수 없음", "sb_daemon_not_found": "데몬을 찾을 수 없음",
"sb_daemon_start_failed": "dragonxd를 시작할 수 없습니다", "sb_daemon_start_failed": "dragonxd를 시작할 수 없습니다",
"sb_dragonxd_running": "dragonxd 실행 중", "sb_dragonxd_running": "dragonxd 실행 중",
@@ -1169,6 +1184,7 @@
"sb_net_mhs": "네트: %.2f MH/s", "sb_net_mhs": "네트: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음", "sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
"sb_peers": "피어: %zu", "sb_peers": "피어: %zu",
"sb_plaintext_remote_blocked": "원격 호스트로 RPC 자격 증명을 평문으로 보내는 것을 거부했습니다. 허용하려면 DRAGONX.conf에 rpcallowplaintext=1을 추가하거나 rpctls=1로 TLS를 활성화하세요.",
"sb_rescanning": "재스캔", "sb_rescanning": "재스캔",
"sb_rescanning_pct": "재스캔 %.0f%%", "sb_rescanning_pct": "재스캔 %.0f%%",
"sb_restarting_daemon": "데몬 재시작 중...", "sb_restarting_daemon": "데몬 재시작 중...",
@@ -1285,12 +1301,14 @@
"settings_configure_explorer": "외부 블록 탐색기 링크 구성", "settings_configure_explorer": "외부 블록 탐색기 링크 구성",
"settings_configure_rpc": "dragonxd 데몬 연결 구성", "settings_configure_rpc": "dragonxd 데몬 연결 구성",
"settings_connection": "연결", "settings_connection": "연결",
"settings_copy_diagnostics": "진단 정보 복사",
"settings_copyright": "Copyright 2024-2026 DragonX 개발자 | GPLv3 라이선스", "settings_copyright": "Copyright 2024-2026 DragonX 개발자 | GPLv3 라이선스",
"settings_custom": "사용자 지정", "settings_custom": "사용자 지정",
"settings_data_dir": "데이터 디렉터리:", "settings_data_dir": "데이터 디렉터리:",
"settings_debug_changed": "디버그 카테고리가 변경되었습니다 — 데몬을 재시작하여 적용", "settings_debug_changed": "디버그 카테고리가 변경되었습니다 — 데몬을 재시작하여 적용",
"settings_debug_restart_note": "변경 사항은 데몬을 다시 시작한 후에 적용됩니다.", "settings_debug_restart_note": "변경 사항은 데몬을 다시 시작한 후에 적용됩니다.",
"settings_debug_select": "데몬 디버그 로깅을 활성화할 카테고리를 선택하세요 (-debug= 플래그).", "settings_debug_select": "데몬 디버그 로깅을 활성화할 카테고리를 선택하세요 (-debug= 플래그).",
"settings_diagnostics_copied": "진단 정보를 클립보드에 복사했습니다",
"settings_encrypt_first_pin": "PIN을 활성화하려면 먼저 지갑을 암호화하세요", "settings_encrypt_first_pin": "PIN을 활성화하려면 먼저 지갑을 암호화하세요",
"settings_encrypt_wallet": "지갑 암호화", "settings_encrypt_wallet": "지갑 암호화",
"settings_explorer_hint": "URL에 후행 슬래시를 포함해야 합니다. txid/주소가 추가됩니다.", "settings_explorer_hint": "URL에 후행 슬래시를 포함해야 합니다. txid/주소가 추가됩니다.",
@@ -1311,6 +1329,7 @@
"settings_not_found": "찾을 수 없음", "settings_not_found": "찾을 수 없음",
"settings_open_app_dir": "앱 폴더 열기", "settings_open_app_dir": "앱 폴더 열기",
"settings_open_data_dir": "데이터 폴더 열기", "settings_open_data_dir": "데이터 폴더 열기",
"settings_open_log_folder": "로그 폴더 열기",
"settings_other": "기타", "settings_other": "기타",
"settings_pin_active": "PIN", "settings_pin_active": "PIN",
"settings_privacy": "개인 정보", "settings_privacy": "개인 정보",
@@ -1470,6 +1489,7 @@
"tt_chat_timestamp": "이 탭에만 적용되는 타임스탬프 형식: 앱 전체 시계를 따르거나 24-hour 또는 12-hour로 강제합니다", "tt_chat_timestamp": "이 탭에만 적용되는 타임스탬프 형식: 앱 전체 시계를 따르거나 24-hour 또는 12-hour로 강제합니다",
"tt_clear_ztx": "로컬에 캐시된 z-트랜잭션 기록 삭제", "tt_clear_ztx": "로컬에 캐시된 z-트랜잭션 기록 삭제",
"tt_clock_format": "24시간 또는 12시간 형식(앱 전체). 채팅에서 재정의할 수 있습니다.", "tt_clock_format": "24시간 또는 12시간 형식(앱 전체). 채팅에서 재정의할 수 있습니다.",
"tt_copy_diagnostics": "지원용 요약(버전, 데몬/지갑/로그 상태 — 비밀 정보 없음)을 클립보드에 복사합니다",
"tt_custom_fees": "거래 전송 시 수동 수수료 입력 활성화", "tt_custom_fees": "거래 전송 시 수동 수수료 입력 활성화",
"tt_custom_theme": "사용자 지정 테마 활성화됨", "tt_custom_theme": "사용자 지정 테마 활성화됨",
"tt_daemon_install_bundled": "노드를 중지하고 설치된 dragonxd를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다", "tt_daemon_install_bundled": "노드를 중지하고 설치된 dragonxd를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다",
@@ -1523,6 +1543,7 @@
"tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다", "tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다",
"tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다", "tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다",
"tt_open_dir": "파일 탐색기에서 열려면 클릭", "tt_open_dir": "파일 탐색기에서 열려면 클릭",
"tt_open_log_folder": "디버그 및 충돌 로그가 있는 폴더를 엽니다",
"tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화", "tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화",
"tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장", "tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장",
"tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구", "tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구",

View File

@@ -48,6 +48,10 @@
"advanced": "AVANÇADO", "advanced": "AVANÇADO",
"advanced_effects": "Efeitos Avançados...", "advanced_effects": "Efeitos Avançados...",
"ago": "atrás", "ago": "atrás",
"alerts_clear": "Limpar histórico de alertas",
"alerts_history_tooltip": "Alertas recentes",
"alerts_none": "Ainda não há alertas",
"alerts_recent": "ALERTAS RECENTES",
"all_filter": "Todos", "all_filter": "Todos",
"allow_custom_fees": "Permitir taxas personalizadas", "allow_custom_fees": "Permitir taxas personalizadas",
"amount": "Valor", "amount": "Valor",
@@ -451,6 +455,8 @@
"daemon_update_version": "Versão:", "daemon_update_version": "Versão:",
"daemon_version": "Daemon", "daemon_version": "Daemon",
"dark": "Escuro", "dark": "Escuro",
"data_stale_prefix": "Atualizado",
"data_stale_tooltip": "O saldo pode estar desatualizado — a carteira não recebeu uma atualização recente. Verifique a conexão com o seu nó.",
"date": "Data", "date": "Data",
"date_label": "Data:", "date_label": "Data:",
"debug_logging": "REGISTRO DE DEPURAÇÃO", "debug_logging": "REGISTRO DE DEPURAÇÃO",
@@ -734,6 +740,9 @@
"lite_working": "Processando…", "lite_working": "Processando…",
"loading": "Carregando...", "loading": "Carregando...",
"loading_addresses": "Carregando endereços...", "loading_addresses": "Carregando endereços...",
"loading_stall_body": "O daemon está inicializando há %.0f s. Isso pode ser normal após uma atualização ou no primeiro início (carregando o índice de blocos ou reescaneando) — ele se conectará automaticamente quando estiver pronto.",
"loading_stall_hint": "Ainda travado? Abra as Configurações e use Reiniciar daemon, ou verifique o Console para mais detalhes.",
"loading_stall_title": "Está demorando mais do que o esperado",
"loading_transactions": "Carregando transações", "loading_transactions": "Carregando transações",
"local_hashrate": "Hashrate Local", "local_hashrate": "Hashrate Local",
"low_spec_mode": "Modo econômico", "low_spec_mode": "Modo econômico",
@@ -953,6 +962,11 @@
"no_transactions": "Nenhuma transação encontrada", "no_transactions": "Nenhuma transação encontrada",
"no_transactions_yet": "Nenhuma transação ainda", "no_transactions_yet": "Nenhuma transação ainda",
"node": "NÓ", "node": "NÓ",
"node_banner_crashed_title": "O nó parou inesperadamente",
"node_banner_lite_open_failed": "Não foi possível abrir sua carteira",
"node_banner_offline_title": "Não conectado ao nó DragonX",
"node_banner_reconnect": "Reconectar",
"node_banner_restart": "Reiniciar nó",
"node_security": "NÓ & SEGURANÇA", "node_security": "NÓ & SEGURANÇA",
"noise": "Ruído", "noise": "Ruído",
"not_connected": "Não conectado ao daemon...", "not_connected": "Não conectado ao daemon...",
@@ -1154,6 +1168,8 @@
"sb_connecting_external": "Conectando ao daemon externo...", "sb_connecting_external": "Conectando ao daemon externo...",
"sb_connecting_generic": "Conectando ao daemon...", "sb_connecting_generic": "Conectando ao daemon...",
"sb_daemon_crashed": "O daemon travou %d vezes", "sb_daemon_crashed": "O daemon travou %d vezes",
"sb_daemon_extract_failed": "Falha ao gravar os arquivos do daemon — verifique o espaço livre em disco e as permissões.",
"sb_daemon_files_failed": "Falha ao gravar os arquivos do daemon em %s — verifique o espaço livre em disco e as permissões.",
"sb_daemon_not_found": "Daemon não encontrado", "sb_daemon_not_found": "Daemon não encontrado",
"sb_daemon_start_failed": "Não foi possível iniciar o dragonxd", "sb_daemon_start_failed": "Não foi possível iniciar o dragonxd",
"sb_dragonxd_running": "dragonxd em execução", "sb_dragonxd_running": "dragonxd em execução",
@@ -1169,6 +1185,7 @@
"sb_net_mhs": "Rede: %.2f MH/s", "sb_net_mhs": "Rede: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf não encontrado", "sb_no_conf": "DRAGONX.conf não encontrado",
"sb_peers": "Pares: %zu", "sb_peers": "Pares: %zu",
"sb_plaintext_remote_blocked": "Recusando enviar credenciais RPC em texto simples para um host remoto. Adicione rpcallowplaintext=1 ao DRAGONX.conf para permitir, ou habilite TLS com rpctls=1.",
"sb_rescanning": "Reescaneando", "sb_rescanning": "Reescaneando",
"sb_rescanning_pct": "Reescaneando %.0f%%", "sb_rescanning_pct": "Reescaneando %.0f%%",
"sb_restarting_daemon": "Reiniciando daemon...", "sb_restarting_daemon": "Reiniciando daemon...",
@@ -1285,12 +1302,14 @@
"settings_configure_explorer": "Configurar links do explorador de blocos externo", "settings_configure_explorer": "Configurar links do explorador de blocos externo",
"settings_configure_rpc": "Configurar conexão ao daemon dragonxd", "settings_configure_rpc": "Configurar conexão ao daemon dragonxd",
"settings_connection": "Conexão", "settings_connection": "Conexão",
"settings_copy_diagnostics": "Copiar diagnósticos",
"settings_copyright": "Copyright 2024-2026 Desenvolvedores DragonX | Licença GPLv3", "settings_copyright": "Copyright 2024-2026 Desenvolvedores DragonX | Licença GPLv3",
"settings_custom": "Personalizado", "settings_custom": "Personalizado",
"settings_data_dir": "Dir. de dados:", "settings_data_dir": "Dir. de dados:",
"settings_debug_changed": "Categorias de depuração alteradas — reinicie o daemon para aplicar", "settings_debug_changed": "Categorias de depuração alteradas — reinicie o daemon para aplicar",
"settings_debug_restart_note": "As alterações entram em vigor após reiniciar o daemon.", "settings_debug_restart_note": "As alterações entram em vigor após reiniciar o daemon.",
"settings_debug_select": "Selecione categorias para ativar o registro de depuração do daemon (flags -debug=).", "settings_debug_select": "Selecione categorias para ativar o registro de depuração do daemon (flags -debug=).",
"settings_diagnostics_copied": "Diagnósticos copiados para a área de transferência",
"settings_encrypt_first_pin": "Encripte a carteira primeiro para ativar o PIN", "settings_encrypt_first_pin": "Encripte a carteira primeiro para ativar o PIN",
"settings_encrypt_wallet": "Encriptar carteira", "settings_encrypt_wallet": "Encriptar carteira",
"settings_explorer_hint": "As URLs devem incluir uma barra final. O txid/endereço será adicionado.", "settings_explorer_hint": "As URLs devem incluir uma barra final. O txid/endereço será adicionado.",
@@ -1311,6 +1330,7 @@
"settings_not_found": "Não encontrado", "settings_not_found": "Não encontrado",
"settings_open_app_dir": "Abrir pasta do aplicativo", "settings_open_app_dir": "Abrir pasta do aplicativo",
"settings_open_data_dir": "Abrir pasta de dados", "settings_open_data_dir": "Abrir pasta de dados",
"settings_open_log_folder": "Abrir pasta de logs",
"settings_other": "Outros", "settings_other": "Outros",
"settings_pin_active": "PIN", "settings_pin_active": "PIN",
"settings_privacy": "Privacidade", "settings_privacy": "Privacidade",
@@ -1470,6 +1490,7 @@
"tt_chat_timestamp": "Formato de horário apenas para esta aba: seguir o relógio geral do aplicativo, ou forçar 24-hour ou 12-hour", "tt_chat_timestamp": "Formato de horário apenas para esta aba: seguir o relógio geral do aplicativo, ou forçar 24-hour ou 12-hour",
"tt_clear_ztx": "Excluir histórico de z-transações em cache local", "tt_clear_ztx": "Excluir histórico de z-transações em cache local",
"tt_clock_format": "Relógio de 24 ou 12 horas, em todo o app. O chat pode substituí-lo.", "tt_clock_format": "Relógio de 24 ou 12 horas, em todo o app. O chat pode substituí-lo.",
"tt_copy_diagnostics": "Copia um resumo para suporte (versão, estado do daemon/carteira/logs — sem segredos) para a área de transferência",
"tt_custom_fees": "Ativar entrada manual de taxas ao enviar transações", "tt_custom_fees": "Ativar entrada manual de taxas ao enviar transações",
"tt_custom_theme": "Tema personalizado ativo", "tt_custom_theme": "Tema personalizado ativo",
"tt_daemon_install_bundled": "Parar o nó, sobrescrever o dragonxd instalado com a versão incluída nesta compilação da carteira e reiniciar", "tt_daemon_install_bundled": "Parar o nó, sobrescrever o dragonxd instalado com a versão incluída nesta compilação da carteira e reiniciar",
@@ -1523,6 +1544,7 @@
"tt_open_app_dir": "Abrir a pasta ObsidianDragon (configurações, temas, logs) no gerenciador de arquivos", "tt_open_app_dir": "Abrir a pasta ObsidianDragon (configurações, temas, logs) no gerenciador de arquivos",
"tt_open_data_dir": "Abrir a pasta com os dados da sua carteira e da blockchain no gerenciador de arquivos", "tt_open_data_dir": "Abrir a pasta com os dados da sua carteira e da blockchain no gerenciador de arquivos",
"tt_open_dir": "Clique para abrir no explorador de arquivos", "tt_open_dir": "Clique para abrir no explorador de arquivos",
"tt_open_log_folder": "Abre a pasta que contém os logs de depuração e de falhas",
"tt_reduce_motion": "Desativar transições animadas e lerp de saldo para acessibilidade", "tt_reduce_motion": "Desativar transições animadas e lerp de saldo para acessibilidade",
"tt_remove_encrypt": "Remover encriptação e armazenar a carteira desprotegida", "tt_remove_encrypt": "Remover encriptação e armazenar a carteira desprotegida",
"tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear", "tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear",

View File

@@ -48,6 +48,10 @@
"advanced": "ПРОЧЕЕ", "advanced": "ПРОЧЕЕ",
"advanced_effects": "Расширенные эффекты...", "advanced_effects": "Расширенные эффекты...",
"ago": "назад", "ago": "назад",
"alerts_clear": "Очистить историю оповещений",
"alerts_history_tooltip": "Недавние оповещения",
"alerts_none": "Пока нет оповещений",
"alerts_recent": "НЕДАВНИЕ ОПОВЕЩЕНИЯ",
"all_filter": "Все", "all_filter": "Все",
"allow_custom_fees": "Разрешить пользовательские комиссии", "allow_custom_fees": "Разрешить пользовательские комиссии",
"amount": "Сумма", "amount": "Сумма",
@@ -451,6 +455,8 @@
"daemon_update_version": "Версия:", "daemon_update_version": "Версия:",
"daemon_version": "Демон", "daemon_version": "Демон",
"dark": "Тёмная", "dark": "Тёмная",
"data_stale_prefix": "Обновлено",
"data_stale_tooltip": "Баланс может быть устаревшим — кошелёк давно не получал обновлений. Проверьте подключение к узлу.",
"date": "Дата", "date": "Дата",
"date_label": "Дата:", "date_label": "Дата:",
"debug_logging": "ЖУРНАЛ ОТЛАДКИ", "debug_logging": "ЖУРНАЛ ОТЛАДКИ",
@@ -734,6 +740,9 @@
"lite_working": "Обработка…", "lite_working": "Обработка…",
"loading": "Загрузка...", "loading": "Загрузка...",
"loading_addresses": "Загрузка адресов...", "loading_addresses": "Загрузка адресов...",
"loading_stall_body": "Демон инициализируется уже %.0f с. Это может быть нормально после обновления или при первом запуске (загрузка индекса блоков или повторное сканирование) — соединение установится автоматически, когда он будет готов.",
"loading_stall_hint": "Всё ещё не отвечает? Откройте Настройки и нажмите «Перезапустить демон» или посмотрите подробности в Консоли.",
"loading_stall_title": "Занимает больше времени, чем ожидалось",
"loading_transactions": "Загрузка транзакций", "loading_transactions": "Загрузка транзакций",
"local_hashrate": "Локальный хешрейт", "local_hashrate": "Локальный хешрейт",
"low_spec_mode": "Режим экономии", "low_spec_mode": "Режим экономии",
@@ -953,6 +962,11 @@
"no_transactions": "Транзакции не найдены", "no_transactions": "Транзакции не найдены",
"no_transactions_yet": "Транзакций пока нет", "no_transactions_yet": "Транзакций пока нет",
"node": "УЗЕЛ", "node": "УЗЕЛ",
"node_banner_crashed_title": "Узел неожиданно остановился",
"node_banner_lite_open_failed": "Не удалось открыть кошелёк",
"node_banner_offline_title": "Нет подключения к узлу DragonX",
"node_banner_reconnect": "Переподключить",
"node_banner_restart": "Перезапустить узел",
"node_security": "УЗЕЛ И БЕЗОПАСНОСТЬ", "node_security": "УЗЕЛ И БЕЗОПАСНОСТЬ",
"noise": "Шум", "noise": "Шум",
"not_connected": "Не подключено к daemon...", "not_connected": "Не подключено к daemon...",
@@ -1154,6 +1168,8 @@
"sb_connecting_external": "Подключение к внешнему демону...", "sb_connecting_external": "Подключение к внешнему демону...",
"sb_connecting_generic": "Подключение к демону...", "sb_connecting_generic": "Подключение к демону...",
"sb_daemon_crashed": "Демон упал %d раз", "sb_daemon_crashed": "Демон упал %d раз",
"sb_daemon_extract_failed": "Не удалось записать файлы демона — проверьте свободное место на диске и права доступа.",
"sb_daemon_files_failed": "Не удалось записать файлы демона в %s — проверьте свободное место на диске и права доступа.",
"sb_daemon_not_found": "Демон не найден", "sb_daemon_not_found": "Демон не найден",
"sb_daemon_start_failed": "Не удалось запустить dragonxd", "sb_daemon_start_failed": "Не удалось запустить dragonxd",
"sb_dragonxd_running": "dragonxd запущен", "sb_dragonxd_running": "dragonxd запущен",
@@ -1169,6 +1185,7 @@
"sb_net_mhs": "Сеть: %.2f MH/s", "sb_net_mhs": "Сеть: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf не найден", "sb_no_conf": "DRAGONX.conf не найден",
"sb_peers": "Пиры: %zu", "sb_peers": "Пиры: %zu",
"sb_plaintext_remote_blocked": "Отправка учётных данных RPC открытым текстом на удалённый узел запрещена. Добавьте rpcallowplaintext=1 в DRAGONX.conf, чтобы разрешить, или включите TLS с помощью rpctls=1.",
"sb_rescanning": "Пересканирование", "sb_rescanning": "Пересканирование",
"sb_rescanning_pct": "Пересканирование %.0f%%", "sb_rescanning_pct": "Пересканирование %.0f%%",
"sb_restarting_daemon": "Перезапуск демона...", "sb_restarting_daemon": "Перезапуск демона...",
@@ -1285,12 +1302,14 @@
"settings_configure_explorer": "Настроить ссылки внешнего обозревателя блоков", "settings_configure_explorer": "Настроить ссылки внешнего обозревателя блоков",
"settings_configure_rpc": "Настроить подключение к демону dragonxd", "settings_configure_rpc": "Настроить подключение к демону dragonxd",
"settings_connection": "Подключение", "settings_connection": "Подключение",
"settings_copy_diagnostics": "Копировать диагностику",
"settings_copyright": "Copyright 2024-2026 Разработчики DragonX | Лицензия GPLv3", "settings_copyright": "Copyright 2024-2026 Разработчики DragonX | Лицензия GPLv3",
"settings_custom": "Пользовательские", "settings_custom": "Пользовательские",
"settings_data_dir": "Каталог данных:", "settings_data_dir": "Каталог данных:",
"settings_debug_changed": "Категории отладки изменены — перезапустите демон для применения", "settings_debug_changed": "Категории отладки изменены — перезапустите демон для применения",
"settings_debug_restart_note": "Изменения вступают в силу после перезапуска демона.", "settings_debug_restart_note": "Изменения вступают в силу после перезапуска демона.",
"settings_debug_select": "Выберите категории для включения журнала отладки демона (флаги -debug=).", "settings_debug_select": "Выберите категории для включения журнала отладки демона (флаги -debug=).",
"settings_diagnostics_copied": "Диагностика скопирована в буфер обмена",
"settings_encrypt_first_pin": "Сначала зашифруйте кошелёк, чтобы включить PIN", "settings_encrypt_first_pin": "Сначала зашифруйте кошелёк, чтобы включить PIN",
"settings_encrypt_wallet": "Зашифровать кошелёк", "settings_encrypt_wallet": "Зашифровать кошелёк",
"settings_explorer_hint": "URL-адреса должны заканчиваться косой чертой. Txid/адрес будет добавлен.", "settings_explorer_hint": "URL-адреса должны заканчиваться косой чертой. Txid/адрес будет добавлен.",
@@ -1311,6 +1330,7 @@
"settings_not_found": "Не найден", "settings_not_found": "Не найден",
"settings_open_app_dir": "Открыть папку приложения", "settings_open_app_dir": "Открыть папку приложения",
"settings_open_data_dir": "Открыть папку данных", "settings_open_data_dir": "Открыть папку данных",
"settings_open_log_folder": "Открыть папку журналов",
"settings_other": "Прочее", "settings_other": "Прочее",
"settings_pin_active": "PIN", "settings_pin_active": "PIN",
"settings_privacy": "Конфиденциальность", "settings_privacy": "Конфиденциальность",
@@ -1470,6 +1490,7 @@
"tt_chat_timestamp": "Формат времени только для этой вкладки: следовать общим настройкам часов приложения либо принудительно 24-hour или 12-hour", "tt_chat_timestamp": "Формат времени только для этой вкладки: следовать общим настройкам часов приложения либо принудительно 24-hour или 12-hour",
"tt_clear_ztx": "Удалить локально кешированную историю z-транзакций", "tt_clear_ztx": "Удалить локально кешированную историю z-транзакций",
"tt_clock_format": "24- или 12-часовой формат для всего приложения. Чат может переопределить.", "tt_clock_format": "24- или 12-часовой формат для всего приложения. Чат может переопределить.",
"tt_copy_diagnostics": "Копирует сводку для поддержки (версия, состояние демона/кошелька/журналов — без секретов) в буфер обмена",
"tt_custom_fees": "Включить ручной ввод комиссий при отправке транзакций", "tt_custom_fees": "Включить ручной ввод комиссий при отправке транзакций",
"tt_custom_theme": "Пользовательская тема активна", "tt_custom_theme": "Пользовательская тема активна",
"tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить", "tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить",
@@ -1523,6 +1544,7 @@
"tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере", "tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере",
"tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна", "tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна",
"tt_open_dir": "Нажмите, чтобы открыть в проводнике", "tt_open_dir": "Нажмите, чтобы открыть в проводнике",
"tt_open_log_folder": "Открывает папку с журналами отладки и сбоев",
"tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности", "tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности",
"tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты", "tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты",
"tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки", "tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки",

View File

@@ -48,6 +48,10 @@
"advanced": "高级", "advanced": "高级",
"advanced_effects": "高级特效...", "advanced_effects": "高级特效...",
"ago": "前", "ago": "前",
"alerts_clear": "清除通知历史",
"alerts_history_tooltip": "最近通知",
"alerts_none": "暂无通知",
"alerts_recent": "最近通知",
"all_filter": "全部", "all_filter": "全部",
"allow_custom_fees": "允许自定义手续费", "allow_custom_fees": "允许自定义手续费",
"amount": "金额", "amount": "金额",
@@ -451,6 +455,8 @@
"daemon_update_version": "版本:", "daemon_update_version": "版本:",
"daemon_version": "守护进程", "daemon_version": "守护进程",
"dark": "深色", "dark": "深色",
"data_stale_prefix": "更新于",
"data_stale_tooltip": "余额可能已过时 — 钱包最近未收到更新。请检查您的节点连接。",
"date": "日期", "date": "日期",
"date_label": "日期:", "date_label": "日期:",
"debug_logging": "调试日志", "debug_logging": "调试日志",
@@ -734,6 +740,8 @@
"lite_working": "处理中…", "lite_working": "处理中…",
"loading": "加载中...", "loading": "加载中...",
"loading_addresses": "正在加载地址...", "loading_addresses": "正在加载地址...",
"loading_stall_body": "守护进程已初始化 %.0f 秒。更新后或首次启动时(加载区块索引或重新扫描)这可能是正常现象——就绪后会自动连接。",
"loading_stall_title": "耗时超出预期",
"loading_transactions": "正在加载交易", "loading_transactions": "正在加载交易",
"local_hashrate": "本地算力", "local_hashrate": "本地算力",
"low_spec_mode": "低配模式", "low_spec_mode": "低配模式",
@@ -953,6 +961,11 @@
"no_transactions": "未找到交易", "no_transactions": "未找到交易",
"no_transactions_yet": "尚无交易", "no_transactions_yet": "尚无交易",
"node": "节点", "node": "节点",
"node_banner_crashed_title": "节点意外停止",
"node_banner_lite_open_failed": "无法打开您的钱包",
"node_banner_offline_title": "未连接到 DragonX 节点",
"node_banner_reconnect": "重新连接",
"node_banner_restart": "重启节点",
"node_security": "节点与安全", "node_security": "节点与安全",
"noise": "噪点", "noise": "噪点",
"not_connected": "未连接到守护进程...", "not_connected": "未连接到守护进程...",
@@ -1154,6 +1167,8 @@
"sb_connecting_external": "正在连接外部守护进程...", "sb_connecting_external": "正在连接外部守护进程...",
"sb_connecting_generic": "正在连接守护进程...", "sb_connecting_generic": "正在连接守护进程...",
"sb_daemon_crashed": "守护进程崩溃 %d 次", "sb_daemon_crashed": "守护进程崩溃 %d 次",
"sb_daemon_extract_failed": "无法写入守护进程文件——请检查磁盘剩余空间和权限。",
"sb_daemon_files_failed": "无法将守护进程文件写入 %s——请检查磁盘剩余空间和权限。",
"sb_daemon_not_found": "未找到守护进程", "sb_daemon_not_found": "未找到守护进程",
"sb_daemon_start_failed": "无法启动 dragonxd", "sb_daemon_start_failed": "无法启动 dragonxd",
"sb_dragonxd_running": "dragonxd 运行中", "sb_dragonxd_running": "dragonxd 运行中",
@@ -1285,12 +1300,14 @@
"settings_configure_explorer": "配置外部区块浏览器链接", "settings_configure_explorer": "配置外部区块浏览器链接",
"settings_configure_rpc": "配置 dragonxd 守护进程连接", "settings_configure_rpc": "配置 dragonxd 守护进程连接",
"settings_connection": "连接", "settings_connection": "连接",
"settings_copy_diagnostics": "复制诊断信息",
"settings_copyright": "版权所有 2024-2026 DragonX 开发者 | GPLv3 许可证", "settings_copyright": "版权所有 2024-2026 DragonX 开发者 | GPLv3 许可证",
"settings_custom": "自定义", "settings_custom": "自定义",
"settings_data_dir": "数据目录:", "settings_data_dir": "数据目录:",
"settings_debug_changed": "调试类别已更改——重启守护进程以应用", "settings_debug_changed": "调试类别已更改——重启守护进程以应用",
"settings_debug_restart_note": "更改将在重启守护进程后生效。", "settings_debug_restart_note": "更改将在重启守护进程后生效。",
"settings_debug_select": "选择要启用的守护进程调试日志类别(-debug= 标志)。", "settings_debug_select": "选择要启用的守护进程调试日志类别(-debug= 标志)。",
"settings_diagnostics_copied": "诊断信息已复制到剪贴板",
"settings_encrypt_first_pin": "请先加密钱包以启用 PIN", "settings_encrypt_first_pin": "请先加密钱包以启用 PIN",
"settings_encrypt_wallet": "加密钱包", "settings_encrypt_wallet": "加密钱包",
"settings_explorer_hint": "URL 应包含尾部斜杠。将自动附加 txid/地址。", "settings_explorer_hint": "URL 应包含尾部斜杠。将自动附加 txid/地址。",
@@ -1311,6 +1328,7 @@
"settings_not_found": "未找到", "settings_not_found": "未找到",
"settings_open_app_dir": "打开应用文件夹", "settings_open_app_dir": "打开应用文件夹",
"settings_open_data_dir": "打开数据文件夹", "settings_open_data_dir": "打开数据文件夹",
"settings_open_log_folder": "打开日志文件夹",
"settings_other": "其他", "settings_other": "其他",
"settings_pin_active": "PIN", "settings_pin_active": "PIN",
"settings_privacy": "隐私", "settings_privacy": "隐私",
@@ -1470,6 +1488,7 @@
"tt_chat_timestamp": "仅此标签页的时间戳格式:跟随全应用时钟,或强制使用 24-hour 或 12-hour", "tt_chat_timestamp": "仅此标签页的时间戳格式:跟随全应用时钟,或强制使用 24-hour 或 12-hour",
"tt_clear_ztx": "删除本地缓存的 z-交易历史", "tt_clear_ztx": "删除本地缓存的 z-交易历史",
"tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。", "tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。",
"tt_copy_diagnostics": "将支持诊断摘要(版本、守护进程/钱包/日志状态 — 不含机密)复制到剪贴板",
"tt_custom_fees": "发送交易时启用手动费用输入", "tt_custom_fees": "发送交易时启用手动费用输入",
"tt_custom_theme": "自定义主题已激活", "tt_custom_theme": "自定义主题已激活",
"tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启", "tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启",
@@ -1523,6 +1542,7 @@
"tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)", "tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)",
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹", "tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
"tt_open_dir": "点击在文件管理器中打开", "tt_open_dir": "点击在文件管理器中打开",
"tt_open_log_folder": "打开包含调试和崩溃日志的文件夹",
"tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性", "tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性",
"tt_remove_encrypt": "移除加密并以未受保护状态存储钱包", "tt_remove_encrypt": "移除加密并以未受保护状态存储钱包",
"tt_remove_pin": "移除 PIN 并要求密码解锁", "tt_remove_pin": "移除 PIN 并要求密码解锁",

View File

@@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 }
status-pill-y-offset = { size = 1 } status-pill-y-offset = { size = 1 }
confirmed-threshold = { size = 10 } confirmed-threshold = { size = 10 }
# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner).
# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action.
[banners.node-status]
min-height = { size = 26.0 }
height = { size = 30.0 }
[tabs.transactions] [tabs.transactions]
search-max-width = 300.0 search-max-width = 300.0
search-width-ratio = 0.3 search-width-ratio = 0.3
@@ -1503,6 +1509,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
progress-width = { size = 260.0 } progress-width = { size = 260.0 }
backdrop-alpha = { opacity = 0.80 } backdrop-alpha = { opacity = 0.80 }
vertical-gap = { size = 8.0 } vertical-gap = { size = 8.0 }
stall-timeout-sec = { size = 45.0 }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# First-Run Wizard Screens # First-Run Wizard Screens

View File

@@ -1,5 +1,17 @@
#!/usr/bin/env bash #!/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 set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -312,6 +324,9 @@ prepare_backend_source() {
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src" 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" [[ -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" [[ -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" [[ -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 # 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. # "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}" echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
fi fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ──────────────── # ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac" XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe" cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))" echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"

View File

@@ -1,25 +1,31 @@
#!/usr/bin/env bash #!/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 # The wallet verifies a detached ed25519 signature over the archive bytes against a public key
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is # pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is MANDATORY
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature # (kDaemonRequireSignature = true): an in-app update is refused unless a valid "<archive>.sig" is
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the # published next to the archive. The wallet also checks each archive's SHA-256 against a markdown
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset. # 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), # Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl. OpenSSL's ed25519 is PureEdDSA (RFC 8032), the
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible # same primitive libsodium's crypto_sign_verify_detached checks, so the signatures are compatible.
# (the same flow the wallet's unit tests verify for the miner updater).
# #
# Usage: # Usage:
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64} # 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 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 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. # kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
die() { echo "error: $*" >&2; exit 1; } die() { echo "error: $*" >&2; exit 1; }
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)" 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. # 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; } 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 cmd="${1:-}"; shift || true
case "$cmd" in case "$cmd" in
keygen) keygen)
@@ -42,26 +68,90 @@ case "$cmd" in
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):" echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
echo " $pub" echo " $pub"
;; ;;
pubkey) pubkey)
[ $# -ge 1 ] || die "usage: pubkey <secret.key>" [ $# -ge 1 ] || die "usage: pubkey <secret.key>"
pubkey_b64 "$1" pubkey_b64 "$1"
;; ;;
sign) sign)
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..." [ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
key="$1"; shift key="$1"; shift
[ -f "$key" ] || die "no such key: $key" [ -f "$key" ] || die "no such key: $key"
for f in "$@"; do for f in "$@"; do
[ -f "$f" ] || die "no such file: $f" [ -f "$f" ] || die "no such file: $f"
raw="$(mktemp)" sign_file "$key" "$f"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
echo "signed: $f -> $f.sig" echo "signed: $f -> $f.sig"
done done
echo "Upload each .sig as a release asset next to its archive." 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 esac

View File

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

View File

@@ -68,10 +68,13 @@
#include "ui/material/draw_helpers.h" #include "ui/material/draw_helpers.h"
#include "ui/widgets/copy_field.h" #include "ui/widgets/copy_field.h"
#include "ui/notifications.h" #include "ui/notifications.h"
#include "ui/node_status_banner.h"
#include "util/i18n.h" #include "util/i18n.h"
#include "util/connect_stall.h"
#include "util/platform.h" #include "util/platform.h"
#include "util/text_format.h" #include "util/text_format.h"
#include "util/payment_uri.h" #include "util/payment_uri.h"
#include "util/seed_phrase.h"
#include "util/texture_loader.h" #include "util/texture_loader.h"
#include "util/svg_texture.h" #include "util/svg_texture.h"
#include "ui/material/colors.h" #include "ui/material/colors.h"
@@ -104,6 +107,7 @@
#include <unordered_set> #include <unordered_set>
#include <fstream> #include <fstream>
#include <filesystem> #include <filesystem>
#include <sstream>
#include <thread> #include <thread>
#include <chrono> #include <chrono>
#include <exception> #include <exception>
@@ -340,6 +344,28 @@ bool App::init()
// Ensure ObsidianDragon config directory and template files exist // Ensure ObsidianDragon config directory and template files exist
util::Platform::ensureObsidianDragonSetup(); util::Platform::ensureObsidianDragonSetup();
// W1-1 (startup): if the recorded active wallet file was moved/deleted between sessions, don't hand a
// missing -wallet=<name> to the daemon — it would auto-create a fresh empty wallet under that name,
// silently "opening" as a zero-balance wallet at launch. Fall back to the always-present default and
// warn. (The default "wallet.dat" is legitimately absent on first run, so it is skipped.) Runs before
// the vault init below so the vault is scoped to the wallet actually opened.
if (settings_) {
const std::string active = settings_->getActiveWalletFile();
if (!active.empty() && active != "wallet.dat") {
std::error_code walEc;
const std::string walPath = util::Platform::getDragonXDataDir() + "/" + active;
if (!std::filesystem::exists(walPath, walEc)) {
DEBUG_LOGF("[App] active wallet '%s' not found at startup — falling back to wallet.dat\n",
active.c_str());
settings_->setActiveWalletFile("wallet.dat");
settings_->save();
ui::Notifications::instance().warning(
"Your last-used wallet file (" + active + ") was not found — opened the default wallet "
"instead. If you moved it, restore it and switch back from the wallet list.", 20.0f);
}
}
}
// Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never // Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never
// offered for another (the default wallet keeps the legacy vault.dat). // offered for another (the default wallet keeps the legacy vault.dat).
vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : ""); vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : "");
@@ -804,6 +830,7 @@ void App::update()
// Pick up progress/result from a running seed-wallet migration (create/sweep/adopt). // Pick up progress/result from a running seed-wallet migration (create/sweep/adopt).
pumpSeedMigration(); pumpSeedMigration();
pumpWalletRestore();
// While confirming the sweep, poll the tx confirmations + legacy balance every ~5s. // While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) { if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) {
seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime; seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime;
@@ -1103,6 +1130,7 @@ void App::update()
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
if (!rpc) return [this](){ opid_poll_in_progress_ = false; }; if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
json result; json result;
services::NetworkRefreshService::OperationStatusPollResult parsed;
try { try {
rpc::RPCClient::TraceScope trace("Send tab / Operation status"); rpc::RPCClient::TraceScope trace("Send tab / Operation status");
// No per-opid filter: this daemon rejects z_getoperationstatus(["opid"]) with // No per-opid filter: this daemon rejects z_getoperationstatus(["opid"]) with
@@ -1110,10 +1138,12 @@ void App::update()
// "Waiting for operation". The no-arg form returns ALL operations; // "Waiting for operation". The no-arg form returns ALL operations;
// parseOperationStatusPoll() filters down to the opids we're tracking. // parseOperationStatusPoll() filters down to the opids we're tracking.
result = rpc->call("z_getoperationstatus", json::array()); result = rpc->call("z_getoperationstatus", json::array());
// Parse INSIDE the guard: a malformed/type-anomalous element must never abort the
// poll and leave opid_poll_in_progress_ stuck true for the whole connected session.
parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
} catch (...) { } catch (...) {
return [this](){ opid_poll_in_progress_ = false; }; return [this](){ opid_poll_in_progress_ = false; };
} }
auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
return [this, parsed = std::move(parsed)]() mutable { return [this, parsed = std::move(parsed)]() mutable {
opid_poll_in_progress_ = false; opid_poll_in_progress_ = false;
@@ -1770,6 +1800,11 @@ void App::render()
ImGui::BeginChild("##ContentArea", ImVec2(0, contentH), false, contentFlags); ImGui::BeginChild("##ContentArea", ImVec2(0, contentH), false, contentFlags);
// Persistent node/RPC error banner — drawn first (before the edge-fade vertex capture below,
// so it stays fully opaque) and above every page / overlay in the content column. It renders
// nothing and consumes no space while the node is reachable.
renderNodeStatusBanner();
// Capture vertex start for edge fade mask // Capture vertex start for edge fade mask
ImDrawList* caDL = ImGui::GetWindowDrawList(); ImDrawList* caDL = ImGui::GetWindowDrawList();
int caVtxStart = caDL->VtxBuffer.Size; int caVtxStart = caDL->VtxBuffer.Size;
@@ -2109,6 +2144,8 @@ void App::render()
renderDecryptWalletDialog(); renderDecryptWalletDialog();
renderPinDialogs(); renderPinDialogs();
renderSwitchStopDaemonDialog(); renderSwitchStopDaemonDialog();
renderBlockDbReindexDialog();
renderWalletRecoveredDialog();
// Render notifications (toast messages) // Render notifications (toast messages)
ui::Notifications::instance().render(); ui::Notifications::instance().render();
@@ -2118,6 +2155,234 @@ void App::render()
ui::material::LatchBlurOverlayActive(); ui::material::LatchBlurOverlayActive();
} }
void App::renderNodeStatusBanner()
{
namespace m = ui::material;
// Suppress during flows that legitimately have no connection, so the banner never contradicts
// an overlay the app is already showing: the first-run wizard (no daemon started yet), a
// wallet switch, an in-flight daemon restart, the screenshot sweep (forces demo state), and
// shutdown. tryConnect() sets connection_in_progress_ before the first render on normal
// startup, so the ordinary boot path is covered by the evaluator's own in-progress guard.
if (capture_mode_ || isShuttingDown()) return;
if (getWizardPhase() != WizardPhase::None) return;
if (wallet_switch_phase_.load() != 0) return;
if (daemon_restarting_.load()) return;
ui::NodeBannerInputs in;
in.lite = isLiteBuild();
in.connected = state_.connected;
in.warming_up = state_.warming_up;
in.daemon_initializing = state_.daemon_initializing;
in.connection_in_progress = connection_in_progress_;
in.using_embedded_daemon = isUsingEmbeddedDaemon();
in.has_daemon_controller = (daemon_controller_ != nullptr);
in.daemon_running = isEmbeddedDaemonRunning();
in.daemon_crash_count = daemon_controller_ ? daemon_controller_->crashCount() : 0;
in.connection_status = connection_status_;
in.daemon_last_error = daemon_controller_ ? daemon_controller_->lastError() : std::string();
in.lite_open_error = lite_open_error_;
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
if (!banner.show) return;
const auto& S = ui::schema::UI();
const float minH = S.drawElement("banners.node-status", "min-height").size;
const float baseH = S.drawElement("banners.node-status", "height").size;
// Both operands must be in scaled px: vScale() already folds in dpiScale(), so the raw min-height
// floor needs the same dpiScale() or it under-clamps the banner at HiDPI.
const float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale());
const bool isError = (banner.severity == ui::NodeBannerSeverity::Error);
const ImU32 sevCol = isError ? m::Error() : m::Warning();
const ImU32 bgCol = m::WithAlphaF(sevCol, isError ? 0.20f : 0.15f);
// Translated headline for the reason; `detail` is the live status text (may be empty).
const char* title;
const char* icon;
switch (banner.reason) {
case ui::NodeBannerReason::DaemonCrashed:
title = TR("node_banner_crashed_title"); icon = ICON_MD_ERROR; break;
case ui::NodeBannerReason::LiteOpenFailed:
title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break;
case ui::NodeBannerReason::FullNodeOffline:
default:
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
}
const char* actionLabel = nullptr;
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
const float padX = ui::Layout::spacingLg();
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgCol));
ImGui::BeginChild("##NodeStatusBanner",
ImVec2(ImGui::GetContentRegionAvail().x, bannerH), false,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
const float winW = ImGui::GetWindowSize().x;
ImFont* icoFont = m::Type().iconSmall();
ImFont* txtFont = m::Type().body2();
// Icon — centered on its own metrics.
ImGui::SetCursorPos(ImVec2(padX, (bannerH - icoFont->LegacySize) * 0.5f));
ImGui::PushFont(icoFont);
ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
ImGui::TextUnformatted(icon);
ImGui::PopStyleColor();
ImGui::PopFont();
const float txtCy = (bannerH - txtFont->LegacySize) * 0.5f;
// Right-aligned action button geometry (measured first so the detail text can be clipped to
// never run underneath it).
float btnW = 0.0f, btnH = 0.0f, actionReserve = 0.0f;
if (actionLabel) {
btnH = std::max(0.0f, bannerH - ui::Layout::spacingSm() * 2.0f);
btnW = ImGui::CalcTextSize(actionLabel).x + ui::Layout::spacingLg() * 1.6f;
actionReserve = btnW + padX + ui::Layout::spacingMd();
}
// Title.
ImGui::SameLine(0.0f, ui::Layout::spacingSm());
ImGui::SetCursorPosY(txtCy);
ImGui::PushFont(txtFont);
ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
ImGui::TextUnformatted(title);
ImGui::PopStyleColor();
// Detail (dim) on the same row, clipped with an ellipsis so it can't push the button off-screen.
if (!banner.detail.empty()) {
ImGui::SameLine(0.0f, ui::Layout::spacingSm());
ImGui::SetCursorPosY(txtCy);
const float budget = winW - ImGui::GetCursorPosX() - actionReserve;
if (budget > ImGui::CalcTextSize("W").x) {
const std::string prefix = "\xC2\xB7 "; // "· "
std::string detail = banner.detail;
std::string shown = prefix + detail;
if (ImGui::CalcTextSize(shown.c_str()).x > budget) {
const std::string ell = "\xE2\x80\xA6"; // "…"
while (!detail.empty() &&
ImGui::CalcTextSize((prefix + detail + ell).c_str()).x > budget) {
detail.pop_back();
while (!detail.empty() &&
(static_cast<unsigned char>(detail.back()) & 0xC0) == 0x80)
detail.pop_back(); // drop the whole trailing UTF-8 code point
}
shown = prefix + detail + ell;
}
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceMedium());
ImGui::TextUnformatted(shown.c_str());
ImGui::PopStyleColor();
}
}
ImGui::PopFont();
// Action button.
if (actionLabel) {
ImGui::SetCursorPos(ImVec2(winW - btnW - padX, (bannerH - btnH) * 0.5f));
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
}
}
ImGui::EndChild();
ImGui::PopStyleColor();
}
void App::renderAlertHistoryPanel()
{
namespace m = ui::material;
const float dp = ui::Layout::dpiScale();
auto& notes = ui::Notifications::instance();
const auto& hist = notes.history();
const float innerW = ImGui::GetContentRegionAvail().x;
const float padX = 8.0f * dp;
const float padY = 8.0f * dp;
ImFont* icoF = m::Type().iconSmall();
ImFont* txtF = m::Type().caption();
ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room above the content
// Header: "Recent alerts" on the left, a Clear-all icon button on the right.
ImGui::SetCursorPosX(padX);
ImGui::PushFont(txtF);
ImGui::TextDisabled("%s", TR("alerts_recent"));
ImGui::PopFont();
if (!hist.empty()) {
const float clrW = icoF->LegacySize + 8.0f * dp;
ImGui::SameLine();
ImGui::SetCursorPosX(innerW - clrW);
m::IconButtonStyle cst;
cst.color = m::OnSurfaceMedium();
cst.hoverColor = m::OnSurface();
cst.hoverBg = m::StateHover();
cst.bgRounding = 4.0f * dp;
cst.tooltip = TR("alerts_clear");
if (m::IconButton("##ClearAlerts", ICON_MD_CLEAR_ALL, icoF,
ImVec2(clrW, icoF->LegacySize + 4.0f * dp), cst)) {
notes.clearHistory();
alerts_seen_total_ = notes.totalPushed();
}
}
ImGui::Separator();
if (hist.empty()) {
ImGui::SetCursorPosX(padX);
ImGui::PushFont(txtF);
ImGui::TextDisabled("%s", TR("alerts_none"));
ImGui::PopFont();
ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room below the content
return;
}
// Scrollable list, newest first. Height adapts to the entry count but caps so a busy session
// scrolls inside the panel instead of blowing past the popup's max height.
const float perEntry = txtF->LegacySize * 2.0f + 14.0f * dp; // message line + time line + spacing
const float listH = std::min(300.0f * dp, static_cast<float>(hist.size()) * perEntry);
ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false);
int idx = 0;
for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) {
const ui::AlertRecord& a = *it;
ImU32 col; const char* icon;
switch (a.type) {
case ui::NotificationType::Success: col = m::Success(); icon = ICON_MD_CHECK_CIRCLE; break;
case ui::NotificationType::Warning: col = m::Warning(); icon = ICON_MD_WARNING; break;
case ui::NotificationType::Error: col = m::Error(); icon = ICON_MD_ERROR; break;
case ui::NotificationType::Info:
default: col = m::Primary(); icon = ICON_MD_INFO; break;
}
ImGui::PushID(idx);
// Icon + message (message wraps in the remaining width).
ImGui::SetCursorPosX(padX);
ImGui::PushFont(icoF);
ImGui::PushStyleColor(ImGuiCol_Text, col);
ImGui::TextUnformatted(icon);
ImGui::PopStyleColor();
ImGui::PopFont();
ImGui::SameLine(0.0f, 6.0f * dp);
ImGui::PushFont(txtF);
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurface());
ImGui::PushTextWrapPos(innerW - padX);
ImGui::TextWrapped("%s", a.message.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
// Relative age, dim, indented under the message.
ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled());
ImGui::TextUnformatted(util::formatTimeAgoShort(a.epoch).c_str());
ImGui::PopStyleColor();
ImGui::PopFont();
ImGui::PopID();
ImGui::Spacing();
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room below the content
}
void App::renderStatusBar() void App::renderStatusBar()
{ {
// Status bar layout from unified UI schema // Status bar layout from unified UI schema
@@ -2394,9 +2659,80 @@ void App::renderStatusBar()
float cbX = occupiedX - cbW - gap; float cbX = occupiedX - cbW - gap;
ImGui::SameLine(cbX); ImGui::SameLine(cbX);
ImGui::TextUnformatted(cb.c_str()); ImGui::TextUnformatted(cb.c_str());
occupiedX = cbX;
} }
} }
// Alert-history bell — leftmost item of the right cluster. Opens a panel of recent alerts,
// including ones whose toast already faded; an unread dot marks alerts that arrived since
// the panel was last opened.
{
const float dp = ui::Layout::dpiScale();
auto& notes = ui::Notifications::instance();
ImFont* bellFont = ui::material::Type().iconSmall();
const bool anyHist = notes.hasHistory();
const char* bellGlyph = anyHist ? ICON_MD_NOTIFICATIONS : ICON_MD_NOTIFICATIONS_NONE;
ImGui::PushFont(bellFont);
const float glyphW = ImGui::CalcTextSize(bellGlyph).x;
ImGui::PopFont();
const float bellW = glyphW + 10.0f * dp;
const float bellH = bellFont->LegacySize + 4.0f * dp;
const float bellX = occupiedX - bellW - gap;
ImGui::SameLine(bellX);
ui::material::IconButtonStyle st;
st.color = ui::material::OnSurfaceMedium();
st.hoverColor = ui::material::OnSurface();
st.hoverBg = ui::material::StateHover();
st.bgRounding = 4.0f * dp;
st.tooltip = TR("alerts_history_tooltip");
const bool clicked = ui::material::IconButton("##AlertBell", bellGlyph, bellFont,
ImVec2(bellW, bellH), st);
const ImVec2 bellMin = ImGui::GetItemRectMin();
const ImVec2 bellMax = ImGui::GetItemRectMax();
// Unread dot: alerts pushed since the panel was last opened, coloured by the most
// severe unseen alert. totalPushed() is monotonic, so this survives capping/clearing.
const std::uint64_t unseen = notes.totalPushed() - alerts_seen_total_;
if (unseen > 0 && anyHist) {
const auto& h = notes.history();
size_t scan = (unseen < h.size()) ? static_cast<size_t>(unseen) : h.size();
bool anyErr = false, anyWarn = false;
for (size_t i = 0; i < scan; ++i) {
auto t = h[h.size() - 1 - i].type;
if (t == ui::NotificationType::Error) { anyErr = true; break; }
if (t == ui::NotificationType::Warning) anyWarn = true;
}
ImU32 dotCol = anyErr ? ui::material::Error()
: anyWarn ? ui::material::Warning()
: ui::material::Primary();
const float r = 3.0f * dp;
ImGui::GetWindowDrawList()->AddCircleFilled(
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
}
if (clicked) {
alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
ImGui::OpenPopup("##AlertHistoryPopup");
}
// The bell sits near the window's bottom-right, so anchor the popup's bottom-RIGHT
// corner at the bell's right edge (pivot (1,1)) — it then grows LEFT over the canvas and
// UP from the status bar. A left pivot would push a 320px panel off the right edge (and
// an explicit SetNextWindowPos pivot skips ImGui's on-screen clamp, so it would overflow).
ImGui::SetNextWindowPos(ImVec2(bellMax.x, bellMin.y - 4.0f * dp),
ImGuiCond_Always, ImVec2(1.0f, 1.0f));
const float panelW = 320.0f * dp;
ImGui::SetNextWindowSizeConstraints(ImVec2(panelW, 0), ImVec2(panelW, 360.0f * dp));
if (ImGui::BeginPopup("##AlertHistoryPopup")) {
renderAlertHistoryPanel();
ImGui::EndPopup();
}
occupiedX = bellX;
}
// Version always at far right // Version always at far right
ImGui::SameLine(versionX); ImGui::SameLine(versionX);
ImGui::Text("%s", versionBuf); ImGui::Text("%s", versionBuf);
@@ -2745,22 +3081,16 @@ void App::renderLiteFirstRunPrompt()
} }
ImGui::Spacing(); ImGui::Spacing(); ImGui::Spacing(); ImGui::Spacing();
// Trim surrounding whitespace from the entered seed. // Normalize the entered seed (trim, fold exotic Unicode whitespace like NBSP to plain
std::string seedTrim(restoreSeed); // spaces) so the word count and the phrase we submit agree regardless of paste source.
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin()); std::string seedTrim = util::normalizeSeedPhrase(restoreSeed);
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back();
// Require a valid BIP39 word count before enabling Restore — otherwise a truncated or // Require a COMPLETE 24-word phrase before enabling Restore. The SDXL backend only
// garbage phrase (previously any non-empty text passed) is submitted and fails opaquely. // accepts 24-word / 32-byte-entropy seeds; a shorter valid-BIP39 phrase (12/15/18/21)
int seedWords = 0; // panics it uncaught across the restore FFI, so it must be refused here (matches the
{ bool inWord = false; // Settings restore gate — both go through util::isCompleteRecoveryPhrase).
for (char c : seedTrim) { int seedWords = util::seedPhraseWordCount(seedTrim);
bool sp = (c == ' ' || c == '\t' || c == '\n' || c == '\r'); bool seedLenOk = util::isCompleteRecoveryPhrase(seedWords);
if (!sp && !inWord) { seedWords++; inWord = true; }
else if (sp) inWord = false;
} }
bool seedLenOk = (seedWords == 12 || seedWords == 15 || seedWords == 18 ||
seedWords == 21 || seedWords == 24);
if (!seedTrim.empty() && !seedLenOk) { if (!seedTrim.empty() && !seedLenOk) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning())); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning()));
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
@@ -2774,7 +3104,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::BeginDisabled(!seedLenOk); ImGui::BeginDisabled(!seedLenOk);
if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) { if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) {
wallet::LiteWalletRestoreRequest req; wallet::LiteWalletRestoreRequest req;
req.seedPhrase = seedTrim; req.seedPhrase = seedTrim; // normalized: NBSP-glued pastes restore correctly
req.birthday = static_cast<unsigned long long>(std::max(0, restoreBirthday)); req.birthday = static_cast<unsigned long long>(std::max(0, restoreBirthday));
req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file
if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) { if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) {
@@ -3953,6 +4283,82 @@ void App::renderAntivirusHelpDialog()
#endif #endif
} }
// Auto-shown when the node auto-recovered (salvaged) wallet.dat: the real wallet is safe in a
// wallet.<timestamp>.bak, but a possibly-incomplete salvaged copy is now loaded — warn loudly and point
// the user at the datadir so they can restore the original instead of mistaking it for fund loss.
void App::renderWalletRecoveredDialog()
{
if (!show_wallet_recovered_dialog_) return;
ui::material::OverlayDialogSpec ov;
ov.title = TR("wallet_recovered_title");
ov.p_open = &show_wallet_recovered_dialog_;
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 560.0f;
ov.idSuffix = "walletrecovered";
if (!ui::material::BeginOverlayDialog(ov)) return;
const float dp = ui::Layout::dpiScale();
ui::material::DialogWarningHeader(TR("wallet_recovered_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("wallet_recovered_body"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// Preferred fix when available: REBUILD the wallet database. Plain "Restore original" hands the same
// BDB-inconsistent file back and the daemon just re-salvages it (the cascade); the rebuild produces a
// fresh, consistent copy of every key that the daemon loads cleanly. Copy/rename-only — never deletes.
if (walletRebuildAvailable()) {
if (ui::material::TactileButton(TR("wallet_recovered_rebuild"), ImVec2(280.0f * dp, 0))) {
rebuildWalletDatabase(); // clears show_wallet_recovered_dialog_
}
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
}
// Restore of the untouched original (stops the node, swaps the .bak back over the salvaged copy,
// clears the stale BDB env, restarts).
if (ui::material::TactileButton(TR("wallet_recovered_restore"), ImVec2(260.0f * dp, 0))) {
restoreOriginalWallet(); // clears show_wallet_recovered_dialog_
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("wallet_recovered_open_folder"), ImVec2(200.0f * dp, 0))) {
util::Platform::openFolder(util::Platform::getDragonXDataDir()); // manual restore instead
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("wallet_recovered_dismiss"), ImVec2(150.0f * dp, 0))) {
show_wallet_recovered_dialog_ = false; // acknowledged; keeps the salvaged wallet loaded
}
ui::material::EndOverlayDialog();
}
// Auto-shown when the embedded node aborts on an unreadable block database — offers the one-click
// -reindex rebuild instead of leaving the wallet stuck on a silent zero balance.
void App::renderBlockDbReindexDialog()
{
if (!show_block_db_reindex_confirm_) return;
ui::material::OverlayDialogSpec ov;
ov.title = TR("block_db_reindex_title");
ov.p_open = &show_block_db_reindex_confirm_; // X / backdrop dismisses (offer remains; loop stays held)
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 540.0f;
ov.idSuffix = "blockdbreindex";
if (!ui::material::BeginOverlayDialog(ov)) return;
const float dp = ui::Layout::dpiScale();
ui::material::DialogWarningHeader(TR("block_db_reindex_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("block_db_reindex_body"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("block_db_reindex_confirm"), ImVec2(260.0f * dp, 0))) {
reindexBlockDatabase(); // clears show_block_db_reindex_confirm_ + block_db_reindex_available_
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
show_block_db_reindex_confirm_ = false;
// Leave block_db_reindex_available_ set: the connect loop keeps HOLDING (no crash-restart storm)
// rather than looping into the same abort; the user can rebuild later from Settings.
}
ui::material::EndOverlayDialog();
}
void App::renderSwitchStopDaemonDialog() void App::renderSwitchStopDaemonDialog()
{ {
const bool confirm = show_switch_stop_daemon_confirm_; const bool confirm = show_switch_stop_daemon_confirm_;
@@ -4149,7 +4555,11 @@ bool App::startEmbeddedDaemon()
if (resources::hasEmbeddedResources()) { if (resources::hasEmbeddedResources()) {
DEBUG_LOGF("Extracting embedded Sapling params...\n"); DEBUG_LOGF("Extracting embedded Sapling params...\n");
daemon_status_ = TR("sb_extracting_sapling"); daemon_status_ = TR("sb_extracting_sapling");
resources::extractEmbeddedResources(); if (!resources::extractEmbeddedResources()) {
daemon_status_ = TR("sb_daemon_extract_failed");
DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n");
return false;
}
// Check again after extraction // Check again after extraction
if (!rpc::Connection::verifySaplingParams()) { if (!rpc::Connection::verifySaplingParams()) {
@@ -4168,8 +4578,13 @@ bool App::startEmbeddedDaemon()
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" }; const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
bool copied = false; bool copied = false;
if (!exe_dir.empty()) { if (!exe_dir.empty()) {
std::string dirErr;
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
daemon_status_ = dirErr;
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
return false;
}
std::error_code ec; std::error_code ec;
fs::create_directories(daemon_dir, ec);
// On macOS .app bundles, params are in Contents/Resources/ // On macOS .app bundles, params are in Contents/Resources/
// while the executable is in Contents/MacOS/ // while the executable is in Contents/MacOS/
@@ -4214,8 +4629,13 @@ bool App::startEmbeddedDaemon()
std::string exe_dir = util::Platform::getExecutableDirectory(); std::string exe_dir = util::Platform::getExecutableDirectory();
std::string daemon_dir = resources::getDaemonDirectory(); std::string daemon_dir = resources::getDaemonDirectory();
if (!exe_dir.empty()) { if (!exe_dir.empty()) {
std::string dirErr;
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
daemon_status_ = dirErr;
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
return false;
}
std::error_code ec; std::error_code ec;
fs::create_directories(daemon_dir, ec);
std::vector<std::string> searchDirs = { exe_dir }; std::vector<std::string> searchDirs = { exe_dir };
#ifdef __APPLE__ #ifdef __APPLE__
@@ -4226,18 +4646,31 @@ bool App::startEmbeddedDaemon()
} }
#endif #endif
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }; const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
bool copyFailed = false;
for (const char* name : extraFiles) { for (const char* name : extraFiles) {
fs::path dst = fs::path(daemon_dir) / name; fs::path dst = fs::path(daemon_dir) / name;
if (fs::exists(dst)) continue; if (fs::exists(dst)) continue;
for (const auto& dir : searchDirs) { for (const auto& dir : searchDirs) {
fs::path src = fs::path(dir) / name; fs::path src = fs::path(dir) / name;
if (fs::exists(src)) { if (fs::exists(src)) { // an absent source is optional; only a real copy error counts
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str()); DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
fs::copy_file(src, dst, ec); fs::copy_file(src, dst, ec);
if (ec) {
DEBUG_LOGF("[ERROR] Failed to copy %s: %s\n", name, ec.message().c_str());
copyFailed = true;
ec.clear();
}
break; break;
} }
} }
} }
if (copyFailed) {
char buf[512];
snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
daemon_status_ = buf;
DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str());
return false;
}
} }
} }
@@ -5246,26 +5679,99 @@ void App::renderLoadingOverlay(float contentH)
IM_COL32(255, 90, 90, 255), errTitle); IM_COL32(255, 90, 90, 255), errTitle);
curY += ts.y + gap * 0.5f; curY += ts.y + gap * 0.5f;
// Error details (wrapped) — show full diagnostic info // Wallet auto-recovery/salvage takes over the error card: a concise message + prominent one-click
const std::string& errDetail = daemon_controller_->lastError(); // actions, RIGHT HERE in the overlay the user is looking at (the separate dialog can be occluded
if (!errDetail.empty()) { // by this full-frame overlay while the node is down). Skip the verbose daemon dump so the buttons
float wrapW = ws.x * 0.8f; // stay on-screen. Same handlers as the dialog.
if (wrapW > 700.0f) wrapW = 700.0f; if (wallet_auto_recovered_) {
ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str()); const char* msg = TR("wallet_recovered_warn");
dl->AddText(capFont, capFont->LegacySize, float wrapW = ws.x * 0.8f; if (wrapW > 640.0f) wrapW = 640.0f;
ImVec2(wp.x + cx - wrapW * 0.5f, curY), ImVec2 ms = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, msg);
IM_COL32(255, 180, 180, 220), errDetail.c_str(), nullptr, wrapW); dl->AddText(capFont, capFont->LegacySize, ImVec2(wp.x + cx - wrapW * 0.5f, curY),
curY += es.y + gap; IM_COL32(230, 210, 210, 235), msg, nullptr, wrapW);
curY += ms.y + gap;
const float dpi = ui::Layout::dpiScale();
const float bw = 340.0f * dpi;
auto placeBtn = [&](const char* label) -> bool {
ImGui::SetCursorScreenPos(ImVec2(wp.x + cx - bw * 0.5f, curY));
const bool clicked = ui::material::TactileButton(label, ImVec2(bw, 0));
curY = ImGui::GetItemRectMax().y + gap * 0.4f;
return clicked;
};
if (walletRebuildAvailable() && placeBtn(TR("wallet_recovered_rebuild"))) rebuildWalletDatabase();
if (placeBtn(TR("wallet_recovered_restore"))) restoreOriginalWallet();
if (placeBtn(TR("wallet_recovered_open_folder")))
util::Platform::openFolder(util::Platform::getDragonXDataDir());
} else {
// Error details (wrapped) — full diagnostic info.
const std::string& errDetail = daemon_controller_->lastError();
if (!errDetail.empty()) {
float wrapW = ws.x * 0.8f;
if (wrapW > 700.0f) wrapW = 700.0f;
ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
IM_COL32(255, 180, 180, 220), errDetail.c_str(), nullptr, wrapW);
curY += es.y + gap;
}
// Crash count hint
if (daemon_controller_->crashCount() >= 3) {
const char* hint = "Use Settings > Restart Daemon to try again";
ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(wp.x + cx - hs2.x * 0.5f, curY),
IM_COL32(200, 200, 200, 180), hint);
curY += hs2.y + gap;
}
} }
}
// Crash count hint // -------------------------------------------------------------------
if (daemon_controller_->crashCount() >= 3) { // 3d. "Taking longer than expected" notice — the daemon is reachable/launching but
const char* hint = "Use Settings > Restart Daemon to try again"; // hasn't become ready within the stall threshold. The connect loop keeps retrying
ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint); // 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, dl->AddText(capFont, capFont->LegacySize,
ImVec2(wp.x + cx - hs2.x * 0.5f, curY), ImVec2(wp.x + cx - hs.x * 0.5f, curY),
IM_COL32(200, 200, 200, 180), hint); IM_COL32(180, 180, 180, 190), hint);
curY += hs2.y + gap; curY += hs.y + gap;
} }
} }
@@ -5559,6 +6065,62 @@ void App::maybeFinishTransactionSendProgress()
if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) return; if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) return;
send_progress_active_ = false; send_progress_active_ = false;
} }
std::string App::buildDiagnosticsReport()
{
std::ostringstream os;
os << "=== ObsidianDragon diagnostics ===\n";
os << "version: " << DRAGONX_VERSION << "\n";
#if DRAGONX_LITE_BUILD
os << "variant: Lite\n";
#else
os << "variant: Full-node\n";
#endif
#if defined(_WIN32)
os << "platform: windows\n";
#elif defined(__APPLE__)
os << "platform: macos\n";
#else
os << "platform: linux\n";
#endif
os << "connected: " << (state_.connected ? "yes" : "no") << "\n";
os << "status: " << connection_status_ << "\n";
const std::string activeWallet = settings_ ? settings_->getActiveWalletFile() : std::string("(none)");
os << "active wallet: " << activeWallet << "\n";
{
std::error_code ec;
const std::string wp = util::Platform::getDragonXDataDir() + "/" + activeWallet;
const bool present = std::filesystem::exists(wp, ec);
os << " path: " << wp << (present ? " [present" : " [MISSING");
if (present) { auto sz = std::filesystem::file_size(wp, ec); if (!ec) os << ", " << sz << " bytes"; }
os << "]\n";
}
os << "encryption: "
<< (state_.encryption_state_known
? (state_.encrypted ? (state_.locked ? "encrypted, locked" : "encrypted, unlocked") : "unencrypted")
: "unknown")
<< "\n";
os << "sync: block " << state_.sync.blocks << " / " << state_.sync.headers
<< (state_.sync.syncing ? " (syncing)" : "")
<< (state_.warming_up ? " (warming up)" : "") << "\n";
#if !DRAGONX_LITE_BUILD
os << "daemon status: " << daemon_status_ << "\n";
if (daemon_controller_) {
os << "daemon running: " << (daemon_controller_->isRunning() ? "yes" : "no")
<< ", crashes: " << daemon_controller_->crashCount() << "\n";
const std::string derr = daemon_controller_->lastError();
if (!derr.empty()) os << "daemon lastError: " << derr << "\n";
}
#endif
const std::string cfg = util::Platform::getObsidianDragonDir();
os << "log folder: " << cfg << "\n";
os << " " << cfg << "/dragonx-debug.log\n";
os << " " << cfg << "/dragonx-crash.log\n";
return os.str();
}
void App::restartDaemon() void App::restartDaemon()
{ {
if (!supportsFullNodeLifecycleActions()) { if (!supportsFullNodeLifecycleActions()) {
@@ -5595,4 +6157,24 @@ void App::restartDaemon()
}); });
} }
// One-click recovery for an unreadable block database: arm the one-shot -reindex flag, then un-gate the
// connect loop (which detected the abort and stopped restarting). Its next attempt calls
// startEmbeddedDaemon(), which consumes the flag → the node rebuilds its block index + chainstate from
// the raw blocks. The daemon is not running here (it aborted), so no explicit stop/restart is needed.
void App::reindexBlockDatabase()
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
if (!daemon_controller_) return;
daemon_controller_->setReindexOnNextStart(true);
daemon_controller_->resetCrashCount(); // the abort no longer counts against the restart budget
show_block_db_reindex_confirm_ = false;
block_db_reindex_available_ = false; // un-gate → the connect loop restarts the node with -reindex
connection_status_ = TR("sb_starting_daemon");
ui::Notifications::instance().info(TR("block_db_reindex_started"), 12.0f);
DEBUG_LOGF("[App] Block-database reindex requested — restarting node with -reindex\n");
}
} // namespace dragonx } // namespace dragonx

View File

@@ -147,6 +147,10 @@ public:
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); } bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); } bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); } bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
// W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state)
// for the "Copy diagnostics" action. Contains no secrets.
std::string buildDiagnosticsReport();
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); } bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); } bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); } bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
@@ -800,6 +804,10 @@ private:
void pumpSeedMigration(); // main thread: pick up background progress/result each frame void pumpSeedMigration(); // main thread: pick up background progress/result each frame
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet. // Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step) void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
// W3-3: the terminal callback for the sweep opid, shared by the initial submit and a resume
// re-track. `resumed` selects the failure behaviour: a fresh sweep that fails -> Error; a resumed
// opid the daemon no longer knows (stale) -> back to the dismissable Sweep gate (re-check balance).
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
@@ -887,6 +895,23 @@ private:
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true). // sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
bool show_switch_stop_daemon_confirm_ = false; bool show_switch_stop_daemon_confirm_ = false;
std::string pending_switch_wallet_file_; std::string pending_switch_wallet_file_;
// Block-database recovery: set when the embedded node aborts because its block DB is unreadable
// (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the
// connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead.
bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop)
bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog
// Wallet auto-recovery: the daemon moved wallet.dat to wallet.<ts>.bak and loaded a salvaged copy
// (BDB-verify failure — often a false positive from stale/cross-platform env state). We warn loudly
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
bool wallet_auto_recovered_ = false; // a salvage happened this session
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
// "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore()
// (main thread) shows the result. 0 = success, 1 = warning, 2 = error.
std::mutex wallet_restore_mutex_;
bool wallet_restore_done_ = false;
int wallet_restore_severity_ = 0;
std::string wallet_restore_msg_;
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start → // Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated; // reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread. // dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
@@ -1023,6 +1048,10 @@ private:
std::uint64_t clipboard_secret_hash_ = 0; std::uint64_t clipboard_secret_hash_ = 0;
double clipboard_clear_deadline_ = 0.0; double clipboard_clear_deadline_ = 0.0;
float loading_timer_ = 0.0f; // spinner animation for loading overlay float loading_timer_ = 0.0f; // spinner animation for loading overlay
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
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
std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot
// Current page (sidebar navigation) // Current page (sidebar navigation)
ui::NavPage current_page_ = ui::NavPage::Overview; ui::NavPage current_page_ = ui::NavPage::Overview;
@@ -1288,6 +1317,13 @@ private:
// Private methods - rendering // Private methods - rendering
void renderStatusBar(); void renderStatusBar();
// Persistent node/RPC error strip at the top of the content column when the wallet can't
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
void renderNodeStatusBanner();
// Body of the status-bar alert-history popup: recent alerts (incl. ones whose toast faded),
// newest first, with severity icon + relative age + a Clear action. See src/ui/notifications.h.
void renderAlertHistoryPanel();
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
void renderImportKeyDialog(); void renderImportKeyDialog();
@@ -1305,6 +1341,14 @@ private:
void renderPinDialogs(); void renderPinDialogs();
void renderAntivirusHelpDialog(); void renderAntivirusHelpDialog();
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result
void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper)
bool walletRebuildAvailable() const; // the dragonx-wallet-rebuild helper is present
void processDeferredEncryption(); void processDeferredEncryption();
// Private methods - connection // Private methods - connection

View File

@@ -35,6 +35,7 @@
#include "rpc/connection.h" #include "rpc/connection.h"
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning #include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch #include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic #include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
#include <cctype> #include <cctype>
#include "config/settings.h" #include "config/settings.h"
@@ -43,6 +44,7 @@
#include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics #include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics
#include "config/version.h" #include "config/version.h"
#include "daemon/daemon_controller.h" #include "daemon/daemon_controller.h"
#include "daemon/daemon_startup_diagnosis.h"
#include "daemon/embedded_daemon.h" #include "daemon/embedded_daemon.h"
#include "daemon/seed_wallet_creator.h" #include "daemon/seed_wallet_creator.h"
#include "daemon/xmrig_manager.h" #include "daemon/xmrig_manager.h"
@@ -54,7 +56,11 @@
#include "util/http_download.h" #include "util/http_download.h"
#include "data/exchange_info.h" #include "data/exchange_info.h"
#include "data/exchange_candles.h" #include "data/exchange_candles.h"
#include "data/seed_migration_resume.h"
#include "util/platform.h" #include "util/platform.h"
#include "util/wallet_file_probe.h" // verify a salvage-backup is a real BDB before restoring it
#include "resources/embedded_resources.h" // getDaemonDirectory() — locate the wallet-rebuild helper
#include <cstdio> // popen the rebuild helper
#include "util/perf_log.h" #include "util/perf_log.h"
#include "util/i18n.h" #include "util/i18n.h"
#include "util/secure_vault.h" #include "util/secure_vault.h"
@@ -196,10 +202,14 @@ static WarmupText translateWarmup(const std::string& raw)
// Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt. // Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt.
static bool walletOutputLooksCorrupt(const std::string& out) 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 return out.find("Failed to rename") != std::string::npos
|| out.find("salvage failed") != std::string::npos || out.find("salvage failed") != std::string::npos
|| out.find("wallet.dat corrupt") != 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() // Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
@@ -219,6 +229,24 @@ static constexpr int kDaemonWaitWarnAttempts = 4;
// Connection Management // Connection Management
// ============================================================================ // ============================================================================
// dragonxd moves wallet.dat to wallet.<ts>.bak and loads a salvaged copy whenever BDB verify fails —
// no flag, and often a false positive (stale/cross-platform env) or an inconsistent-but-readable file.
// The salvage prints to the node's captured output at STARTUP, but the node may then fail to connect
// (block-index abort, long sync, crash) so we must NOT wait for onConnected — scan the output on every
// tryConnect tick, early enough that the line hasn't been trimmed from the rolling buffer. Fires once
// per session; the dialog offers Rebuild (fix the DB) / Restore (swap the .bak back).
void App::detectWalletAutoRecovery()
{
if (wallet_auto_recovered_warned_) return;
if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return;
if (!daemon::walletAutoRecovered(daemon_controller_->daemon()->getOutput())) return;
wallet_auto_recovered_ = true;
wallet_auto_recovered_warned_ = true;
show_wallet_recovered_dialog_ = true;
ui::Notifications::instance().error(TR("wallet_recovered_notify"), 30.0f);
VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n");
}
void App::tryConnect() void App::tryConnect()
{ {
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine // Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
@@ -226,6 +254,10 @@ void App::tryConnect()
// derived from it each frame in App::update(), which also gates the wallet UI (isConnected()). // derived from it each frame in App::update(), which also gates the wallet UI (isConnected()).
if (isLiteBuild()) return; if (isLiteBuild()) return;
// Catch a startup wallet salvage as soon as it appears in the node's output — independent of whether
// the node ever finishes starting or connects (skip only while an orchestrated swap is mid-flight).
if (!daemon_restarting_) detectWalletAutoRecovery();
if (connection_in_progress_) return; if (connection_in_progress_) return;
// Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps // Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps
@@ -241,6 +273,16 @@ void App::tryConnect()
// Auto-detect configuration (file I/O — fast, safe on main thread) // Auto-detect configuration (file I/O — fast, safe on main thread)
auto config = rpc::Connection::autoDetectConfig(); auto config = rpc::Connection::autoDetectConfig();
if (!config.dir_error.empty()) {
// The data directory could not be created (read-only home, permission denied,
// disk full). Retrying won't fix it, so surface it in the status line instead of
// mislabelling it as "waiting for config" below.
connection_in_progress_ = false;
connection_status_ = config.dir_error;
VERBOSE_LOGF("[connect #%d] data dir error: %s\n", connect_attempt, config.dir_error.c_str());
return;
}
if (config.rpcuser.empty() || config.rpcpassword.empty()) { if (config.rpcuser.empty() || config.rpcpassword.empty()) {
connection_in_progress_ = false; connection_in_progress_ = false;
std::string confPath = rpc::Connection::getDefaultConfPath(); std::string confPath = rpc::Connection::getDefaultConfPath();
@@ -310,11 +352,21 @@ void App::tryConnect()
VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n", VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str()); connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());
if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) { if (rpc::Connection::usesPlaintextRemote(config) &&
remote_rpc_plaintext_warning_shown_ = true; !rpc::Connection::allowsPlaintextRemote(config)) {
ui::Notifications::instance().warning( // Refuse to send Basic-auth credentials in cleartext to a remote host — a local-network
"Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.", // MITM would otherwise capture rpcuser:rpcpassword. This is a deliberate behaviour change
10.0f); // 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 // Run the blocking rpc_->connect() on the worker thread so the UI
@@ -385,6 +437,7 @@ void App::tryConnect()
// fail until warmup completes. Set the warmup state so // fail until warmup completes. Set the warmup state so
// the UI shows status instead of a blocking overlay. // the UI shows status instead of a blocking overlay.
state_.warming_up = true; state_.warming_up = true;
if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock
auto wt = translateWarmup(warmupStatus); auto wt = translateWarmup(warmupStatus);
state_.warmup_status = wt.title; state_.warmup_status = wt.title;
state_.warmup_description = wt.description; state_.warmup_description = wt.description;
@@ -474,8 +527,25 @@ void App::tryConnect()
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt); VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) { if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
// If the node aborted because its BLOCK DATABASE is unreadable (a daemon-vs-chaindata
// format mismatch after an update, or a corrupt index), crash-restarting just repeats
// the same abort — and each attempt reloads the whole index (wasteful). Detect it once
// and offer a one-click reindex instead of silently looping into a zero-balance node.
if (!block_db_reindex_available_ && daemon_controller_ && daemon_controller_->daemon() &&
daemon::blockDbOutputLooksBroken(daemon_controller_->daemon()->getOutput())) {
block_db_reindex_available_ = true;
show_block_db_reindex_confirm_ = true;
ui::Notifications::instance().error(TR("block_db_reindex_notify"), 20.0f);
VERBOSE_LOGF("[connect #%d] Block database unreadable — offering a one-click reindex\n", attempt);
}
// Prevent infinite crash-restart loop // Prevent infinite crash-restart loop
if (daemon_controller_ && daemon_controller_->crashCount() >= 3) { if (block_db_reindex_available_) {
connection_status_ = TR("sb_block_db_unreadable"); // hold; awaiting the rebuild choice
} else if (wallet_auto_recovered_) {
// A salvage is happening — DON'T restart into another one (each round can shrink
// the wallet further). Hold while the recovery dialog (Rebuild/Restore) is up.
connection_status_ = TR("sb_wallet_needs_recovery");
} else if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
if (wallet_switch_pending_confirm_.load()) { if (wallet_switch_pending_confirm_.load()) {
// The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that // The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that
// fails LATE in init, past the fast start grace) — revert to the previous // fails LATE in init, past the fast start grace) — revert to the previous
@@ -526,10 +596,13 @@ void App::onConnected()
} }
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock
daemon_start_error_shown_ = false; daemon_start_error_shown_ = false;
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too) daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
connection_status_ = TR("connected"); connection_status_ = TR("connected");
detectWalletAutoRecovery(); // also runs every tryConnect tick — catches a salvage even if we never connect
// Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance + // Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance +
// address count fill in on the first address refresh (addresses aren't loaded yet here). // address count fill in on the first address refresh (addresses aren't loaded yet here).
updateWalletIndexForActiveWallet(/*markOpened=*/true); updateWalletIndexForActiveWallet(/*markOpened=*/true);
@@ -606,6 +679,7 @@ void App::onDisconnected(const std::string& reason)
state_.connected = false; state_.connected = false;
state_.warming_up = false; state_.warming_up = false;
state_.warmup_status.clear(); state_.warmup_status.clear();
connect_stall_since_ = 0.0; // reset the "taking too long" clock (App member, untouched by state_.clear())
state_.clear(); state_.clear();
connection_status_ = reason; connection_status_ = reason;
@@ -660,6 +734,7 @@ void App::onDisconnected(const std::string& reason)
std::string App::applyDaemonInitStatus(bool reachableButBusy) std::string App::applyDaemonInitStatus(bool reachableButBusy)
{ {
state_.daemon_initializing = true; state_.daemon_initializing = true;
if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock
// Find the most recent console line that names an init phase, so we can tell the user exactly // Find the most recent console line that names an init phase, so we can tell the user exactly
// what the node is doing (loading the block index, verifying, activating best chain, …). // what the node is doing (loading the block index, verifying, activating best chain, …).
@@ -1067,9 +1142,16 @@ void App::updateWalletIndexForActiveWallet(bool markOpened)
if (!ec) e.sizeBytesAtLastOpen = static_cast<long long>(sz); if (!ec) e.sizeBytesAtLastOpen = static_cast<long long>(sz);
} }
// W1-3: record "synced here" only once the wallet's identity is actually verified (its addresses are
// known -> idHash non-empty). Stamping it on the bare connect (before any address readback) would let
// a freshly-restored wallet skip its needed rescan. It's idempotent, so the post-refresh update
// (updateWalletIndexForActiveWallet after addresses load) sets it once; lastOpenedEpoch is still
// recorded at open time here.
if (!idHash.empty()) {
e.syncedHere = true; // loaded + identity-verified in this datadir -> catch-up (no full rescan)
}
if (markOpened) { if (markOpened) {
e.lastOpenedEpoch = static_cast<long long>(std::time(nullptr)); e.lastOpenedEpoch = static_cast<long long>(std::time(nullptr));
e.syncedHere = true; // we've loaded it in this datadir -> catch-up (no full rescan) on switch
} }
if (wallet_index_.upsert(e)) wallet_index_.save(); if (wallet_index_.upsert(e)) wallet_index_.save();
@@ -1096,7 +1178,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."); ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes.");
return; 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."); ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
return; return;
} }
@@ -1108,6 +1192,19 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets."); ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
return; 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 // 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 // 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 // one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may
@@ -1488,6 +1585,7 @@ void App::refreshCoreData()
state_.warming_up = false; state_.warming_up = false;
state_.warmup_status.clear(); state_.warmup_status.clear();
state_.warmup_description.clear(); state_.warmup_description.clear();
connect_stall_since_ = 0.0; // warmup finished — clear the "taking too long" clock
connection_status_ = TR("connected"); connection_status_ = TR("connected");
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n"); VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
@@ -2288,27 +2386,23 @@ void App::startPoolMining(int threads)
cfg.tls = settings_->getPoolTls(); cfg.tls = settings_->getPoolTls();
cfg.hugepages = settings_->getPoolHugepages(); cfg.hugepages = settings_->getPoolHugepages();
// Use first shielded address as the mining wallet address, fall back to transparent // xmrig "user" is the pool login the block rewards are credited to. The user's
// "Payout Address" field (cfg.worker_name = getPoolWorker) is exactly that, so it
// takes priority — otherwise a payout address that differs from the wallet's own
// first z-address is silently ignored and rewards go to the wrong address. Only when
// no payout address is set do we fall back to the wallet's own first shielded, then
// transparent, address (available even before the daemon is connected/synced).
std::string firstShielded, firstTransparent;
for (const auto& addr : state_.z_addresses) { for (const auto& addr : state_.z_addresses) {
if (!addr.address.empty()) { if (!addr.address.empty()) { firstShielded = addr.address; break; }
cfg.wallet_address = addr.address; }
for (const auto& addr : state_.addresses) {
if (addr.type == "transparent" && !addr.address.empty()) {
firstTransparent = addr.address;
break; break;
} }
} }
if (cfg.wallet_address.empty()) { cfg.wallet_address = ui::resolveMiningUserAddress(cfg.worker_name, firstShielded, firstTransparent);
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;
}
if (cfg.wallet_address.empty()) { if (cfg.wallet_address.empty()) {
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n"); DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
@@ -3761,6 +3855,8 @@ void App::exportAllKeys(std::function<void(const std::string&, int, int)> callba
(*pending)--; (*pending)--;
if (*pending == 0 && callback) { if (*pending == 0 && callback) {
callback(*keys_result, *exported, *total); 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());
} }
}); });
} }
@@ -3783,7 +3879,9 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
// Reject anything that isn't a recognized Z/T private key or shielded viewing key before handing // Reject anything that isn't a recognized Z/T private key or shielded viewing key before handing
// it to the daemon (the dialog's indicator and this guard share isRecognizedImportKey). // it to the daemon (the dialog's indicator and this guard share isRecognizedImportKey).
if (!services::WalletSecurityController::isRecognizedImportKey(key)) { if (!services::WalletSecurityController::isRecognizedImportKey(key)) {
if (callback) callback(false, "Unrecognized key format.", ""); if (callback) callback(false,
"Not a recognized DragonX private key or viewing key. Check for missing or "
"mistyped characters, and that this is a DragonX key (not another coin).", "");
return; return;
} }
@@ -3792,7 +3890,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
== services::WalletSecurityController::KeyKind::Shielded; == services::WalletSecurityController::KeyKind::Shielded;
// Run on the worker thread — import requests a full rescan (rescan=true), so the // 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. // 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; std::string err, addr;
try { try {
rpc::RPCClient::TraceScope trace("Settings / Import key"); rpc::RPCClient::TraceScope trace("Settings / Import key");
@@ -3804,6 +3902,11 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
// A start height (shielded RPCs only) rescans from that block instead of genesis. // A start height (shielded RPCs only) rescans from that block instead of genesis.
if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight); if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight);
nlohmann::json r = rpc_->call(method, params); 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. // z_import* return {type,address}; importprivkey returns the t-address string.
if (r.is_object() && r.contains("address") && r["address"].is_string()) if (r.is_object() && r.contains("address") && r["address"].is_string())
addr = r["address"].get<std::string>(); addr = r["address"].get<std::string>();
@@ -3816,6 +3919,16 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
// below would never run, leaving a stuck "Importing…" spinner. // below would never run, leaving a stuck "Importing…" spinner.
err = "Import failed (unknown error)"; 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());
// A checksum-valid key the daemon still rejects is almost always the right *format* but the
// wrong network/coin (Komodo-family chains share version bytes) or a corrupted paste — say so,
// since the bare "Invalid …" text reads like a wallet bug (F5).
if (!err.empty() && err.find("Invalid") != std::string::npos &&
err.find("DragonX") == std::string::npos) {
err += " — check the key is for DragonX (not another coin or network) and has no missing "
"or altered characters.";
}
return [this, err, addr, callback]() { return [this, err, addr, callback]() {
if (!err.empty()) { if (!err.empty()) {
if (callback) callback(false, err, ""); if (callback) callback(false, err, "");
@@ -3826,6 +3939,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
if (callback) callback(true, "", addr); 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 // Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
@@ -3863,7 +3977,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key) const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded; == services::WalletSecurityController::KeyKind::Shielded;
const double fee = DRAGONX_DEFAULT_FEE; 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; std::string err, dest, sourceAddr, amountStr;
double amount = 0.0; double amount = 0.0;
try { try {
@@ -3887,6 +4001,11 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
else { method = "importprivkey"; params = {key, "", true}; } else { method = "importprivkey"; params = {key, "", true}; }
if (startHeight > 0 && shielded) params.push_back(startHeight); if (startHeight > 0 && shielded) params.push_back(startHeight);
nlohmann::json r = rpc_->call(method, params); 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 // 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. // returns null, so diff the z-address list to find the one the key just added.
@@ -3945,6 +4064,8 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
} catch (...) { } catch (...) {
err = "Sweep failed (unknown error)"; 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]() { return [this, err, sourceAddr, dest, amount, amountStr, fee]() {
invalidateAddressValidationCache(); invalidateAddressValidationCache();
refreshAddresses(); refreshAddresses();
@@ -3981,6 +4102,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) void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, const std::string&)> callback)
@@ -4111,27 +4233,56 @@ void App::showSeedMigrationDialog()
// Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the // Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the
// confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start // confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start
// at the Sweep step. With no pending migration, start fresh at the intro. // at the Sweep step. With no pending migration, start fresh at the intro.
if (settings_ && settings_->getSeedMigrationPending() && !settings_->getSeedMigrationDest().empty()) { const bool pending = settings_ && settings_->getSeedMigrationPending();
seed_migration_dest_ = settings_->getSeedMigrationDest(); const bool haveDest = settings_ && !settings_->getSeedMigrationDest().empty();
const std::string sweepTxid = settings_ ? settings_->getSeedMigrationSweepTxid() : std::string();
const std::string sweepOpid = settings_ ? settings_->getSeedMigrationSweepOpid() : std::string();
const bool connected = state_.connected && rpc_ && worker_;
switch (decideSeedMigrationResume(pending, haveDest, sweepTxid, sweepOpid, connected)) {
case MigrationResume::Confirming:
seed_migration_dest_ = settings_->getSeedMigrationDest();
seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
seed_migration_sweep_txid_ = sweepTxid;
seed_migration_sweep_confs_ = 0;
seed_migration_legacy_remaining_ = -1.0;
seed_migration_poll_timer_ = 0.0f; // poll immediately
seed_migration_step_ = SeedMigrationStep::Confirming;
break;
case MigrationResume::RetrackOpid:
// W3-3: a sweep opid was submitted but its txid was never persisted (app closed mid-Sweeping).
// Re-track it to recover the txid. If the daemon forgot it (restart), the opid poller flags it
// stale and makeSweepCompletionCallback(resumed) falls back to the Sweep gate — never a hang.
// Only reached when connected (decideSeedMigrationResume), so the poller can actually run and
// the buttonless "Sweeping" spinner is guaranteed an exit.
seed_migration_dest_ = settings_->getSeedMigrationDest();
seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
seed_migration_sweep_txid_.clear();
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
trackOperation(sweepOpid);
seed_migration_step_ = SeedMigrationStep::Sweeping;
seed_migration_status_ = "Checking on the previous sweep…";
break;
case MigrationResume::SweepGate:
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in
// place so a later reconnect+reopen can re-track it). The Sweep step is dismissable and
// reloads the balance, so the user is never trapped while offline.
seed_migration_dest_ = settings_->getSeedMigrationDest();
seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir(); seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
seed_migration_sweep_txid_ = settings_->getSeedMigrationSweepTxid(); seed_migration_sweep_txid_.clear();
if (!seed_migration_sweep_txid_.empty()) { seed_migration_step_ = SeedMigrationStep::Sweep;
seed_migration_sweep_confs_ = 0; seed_migration_balance_loaded_ = false;
seed_migration_legacy_remaining_ = -1.0; seed_migration_nofunds_confirmed_ = false;
seed_migration_poll_timer_ = 0.0f; // poll immediately refreshSeedMigrationBalance();
seed_migration_step_ = SeedMigrationStep::Confirming; break;
} else { case MigrationResume::Intro:
seed_migration_step_ = SeedMigrationStep::Sweep; default:
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
refreshSeedMigrationBalance();
}
} else {
seed_migration_step_ = SeedMigrationStep::Intro; seed_migration_step_ = SeedMigrationStep::Intro;
// Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old // Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old
// daemon) before offering to create anything. // daemon) before offering to create anything.
seed_migration_precheck_ = SeedMigrationPrecheck::Pending; seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
seed_migration_precheck_started_ = false; seed_migration_precheck_started_ = false;
break;
} }
} }
@@ -4206,29 +4357,71 @@ void App::beginSweepToSeedWallet()
seed_migration_step_ = SeedMigrationStep::Error; seed_migration_step_ = SeedMigrationStep::Error;
return; return;
} }
pending_send_callbacks_[opid] = [this](bool ok, const std::string& result) { // W3-3: adopt this new opid atomically — persist it AND clear any prior sweep txid in the
if (ok) { // SAME settings write. Persisting the opid lets an app-close during Sweeping (opid
seed_migration_sweep_txid_ = result; // submitted, not yet resolved to a txid) re-poll it on resume instead of dropping it. Doing
// Persist the txid so a restart resumes at the confirm/adopt stage and never // the swap HERE — only once the new submit has succeeded — rather than speculatively at
// re-sweeps from scratch. The Confirming step gates adopt on this tx being mined // function entry means a FAILED "Sweep remaining" remainder re-sweep leaves the
// (>= 1 confirmation) AND the legacy balance dropping to ~0. // already-mined first sweep's txid intact and resumable to Confirming; and the txid and
if (settings_) { settings_->setSeedMigrationSweepTxid(result); settings_->save(); } // opid are never both authoritative at once (torn-write safe; resume checks txid first).
seed_migration_sweep_confs_ = 0; seed_migration_sweep_txid_.clear();
seed_migration_legacy_remaining_ = -1.0; if (settings_) {
seed_migration_poll_timer_ = 0.0f; settings_->setSeedMigrationSweepTxid("");
seed_migration_status_.clear(); settings_->setSeedMigrationSweepOpid(opid);
seed_migration_step_ = SeedMigrationStep::Confirming; settings_->save();
} else { }
seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result; pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
seed_migration_step_ = SeedMigrationStep::Error;
}
};
trackOperation(opid); trackOperation(opid);
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…"; seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
}; };
}); });
} }
// W3-3: terminal handling for the sweep operation, shared by the initial submit (resumed=false) and
// a resume re-track (resumed=true). On success it persists the txid and clears the opid in the SAME
// settings write, so the txid always outranks the opid on a later resume (torn-write safe).
std::function<void(bool, const std::string&)> App::makeSweepCompletionCallback(bool resumed)
{
return [this, resumed](bool ok, const std::string& result) {
if (ok) {
seed_migration_sweep_txid_ = result;
// Persist the txid (and drop the now-redundant opid) so a restart resumes at the
// confirm/adopt stage and never re-sweeps from scratch. The Confirming step gates adopt on
// this tx being mined (>= 1 confirmation) AND the legacy balance dropping to ~0.
if (settings_) {
settings_->setSeedMigrationSweepTxid(result);
settings_->setSeedMigrationSweepOpid("");
settings_->save();
}
seed_migration_sweep_confs_ = 0;
seed_migration_legacy_remaining_ = -1.0;
seed_migration_poll_timer_ = 0.0f;
seed_migration_status_.clear();
seed_migration_step_ = SeedMigrationStep::Confirming;
} else if (resumed) {
// A resumed opid the daemon no longer knows (it restarted — the op queue is in-memory
// only). "Stale" can't be told apart from "failed", and the earlier sweep may in fact have
// already broadcast/mined, so DON'T dead-end at Error: drop the stale opid and return to
// the Sweep gate, re-fetching the legacy balance. If that sweep did complete, the balance
// reads ~0 and the Sweep step short-circuits to adopt; otherwise the user can sweep again.
if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); }
seed_migration_sweep_txid_.clear();
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
seed_migration_status_ =
"Couldn't confirm the earlier sweep — it may have already completed. "
"Check your balance below before sweeping again.";
seed_migration_step_ = SeedMigrationStep::Sweep;
refreshSeedMigrationBalance(); // else the Sweep step sits on a permanent "Checking balance…"
} else {
// A fresh sweep that genuinely failed. Clear the persisted opid so it can't mis-resume.
if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); }
seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result;
seed_migration_step_ = SeedMigrationStep::Error;
}
};
}
// Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The // Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The
// adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we // adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we
// never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a // never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a
@@ -4282,7 +4475,13 @@ void App::beginAdoptSeedWallet()
// has its own passphrase; the user can re-enable PIN quick-unlock for it). // has its own passphrase; the user can re-enable PIN quick-unlock for it).
if (vault_) vault_->removeVault(); if (vault_) vault_->removeVault();
const std::string base = seed_migration_temp_dir_; 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; namespace fs = std::filesystem;
std::string err; // fatal (swap did not happen; migration incomplete) std::string err; // fatal (swap did not happen; migration incomplete)
std::string warn; // non-fatal (swap done but the daemon did not restart) std::string warn; // non-fatal (swap done but the daemon did not restart)
@@ -4302,7 +4501,7 @@ void App::beginAdoptSeedWallet()
// 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER // 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. // delete), then copy the new seed wallet in. On any failure, restore the legacy.
const std::string datadir = util::Platform::getDragonXDataDir(); 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"; const std::string newWallet = base + "/DRAGONX/wallet.dat";
std::time_t t = std::time(nullptr); std::time_t t = std::time(nullptr);
std::tm tmv{}; // thread-safe local time (the UI thread also uses localtime) std::tm tmv{}; // thread-safe local time (the UI thread also uses localtime)
@@ -4363,6 +4562,285 @@ void App::beginAdoptSeedWallet()
}); });
} }
// Undo a daemon wallet auto-recovery: swap the untouched original (wallet.<ts>.bak) back over the
// salvaged copy and clear the stale BDB env that triggered the false recovery, then restart. Modeled on
// beginAdoptSeedWallet — stop daemon → file ops (copy/rename only, NEVER delete user data) → restart.
void App::restoreOriginalWallet()
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
if (daemon_restarting_) {
ui::Notifications::instance().warning(TR("wallet_restore_busy"));
return;
}
show_wallet_recovered_dialog_ = false;
{ std::lock_guard<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
daemon_restarting_ = true; // gate the reconnect loop while we swap files
connection_status_ = TR("sb_restarting_daemon");
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
onDisconnected("Restoring original wallet");
ui::Notifications::instance().info(TR("wallet_restore_started"), 12.0f);
const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
? settings_->getActiveWalletFile() : std::string("wallet.dat");
async_tasks_.submit("Restore original wallet", [this, activeWalletName](const util::AsyncTaskManager::Token&) {
namespace fs = std::filesystem;
std::string err, warn;
try {
const std::string datadir = util::Platform::getDragonXDataDir();
// 1. Find the LARGEST salvage backup (offline — no daemon needed). Largest = least-salvaged =
// the original: a salvage cascade shrinks the wallet each round, so the newest .bak can be
// empty ("Salvage found no records") while the original is untouched and huge.
std::vector<std::pair<std::string, unsigned long long>> files;
{
std::error_code lec;
for (const auto& e : fs::directory_iterator(datadir, lec)) {
if (lec) break;
std::error_code se;
const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0;
files.emplace_back(e.path().filename().string(),
se ? 0ull : static_cast<unsigned long long>(sz));
}
}
const std::string bak = daemon::largestWalletSalvageBak(files);
if (bak.empty()) {
err = TR("wallet_restore_no_backup");
} else if (!util::probeWalletFile(datadir + "/" + bak).isBerkeleyDB) {
err = TR("wallet_restore_bad_backup"); // don't overwrite a working wallet with a bad .bak
} else if (!stopDaemonForWalletSwitch()) { // 2. Release wallet.dat + the RPC port first.
err = TR("wallet_restore_stop_failed");
} else {
std::error_code ec;
std::time_t t = std::time(nullptr);
std::tm tmv{};
#ifdef _WIN32
localtime_s(&tmv, &t);
#else
localtime_r(&t, &tmv);
#endif
char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
const std::string active = datadir + "/" + activeWalletName;
const std::string salvagedAside = active + ".salvaged-" + ts + ".dat";
// 3. Move the salvaged copy aside (NEVER delete), then copy the original .bak into place
// (copy, so the .bak itself stays as a backup). Roll back the move if the copy fails.
bool movedSalvaged = false;
if (fs::exists(active)) {
fs::rename(active, salvagedAside, ec);
if (ec) err = TR("wallet_restore_move_failed");
else movedSalvaged = true;
}
if (err.empty()) {
fs::copy_file(datadir + "/" + bak, active, fs::copy_options::overwrite_existing, ec);
if (ec) {
if (movedSalvaged) { std::error_code e2; fs::rename(salvagedAside, active, e2); }
err = TR("wallet_restore_copy_failed");
}
}
// 4. Clear the stale BDB environment that triggered the false recovery — otherwise the
// daemon would just re-salvage the restored wallet on the next start. Move database/
// aside (keeps its logs) and drop the transient __db.* region files.
if (err.empty()) {
std::error_code e2;
if (fs::exists(datadir + "/database"))
fs::rename(datadir + "/database", datadir + "/database.pre-restore-" + ts + ".bak", e2);
for (const auto& e : fs::directory_iterator(datadir, e2)) {
if (e.path().filename().string().rfind("__db.", 0) == 0) {
std::error_code e3; fs::remove(e.path(), e3);
}
}
}
}
// 5. Bring the daemon back up (unless quitting). Even on a restore failure we relaunch so the
// node isn't left down; the connect loop reconnects and onConnected clears the gate.
if (!shutting_down_) {
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
if (!startEmbeddedDaemon() && err.empty())
warn = TR("wallet_restore_no_restart");
}
} catch (const std::exception& e) {
err = std::string("Restore failed: ") + e.what();
} catch (...) {
err = "Restore failed due to an unexpected error.";
}
daemon_restarting_ = false; // ALWAYS re-arm the reconnect gate
std::lock_guard<std::mutex> lk(wallet_restore_mutex_);
wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0);
wallet_restore_msg_ = !err.empty() ? err : warn;
wallet_restore_done_ = true;
});
}
void App::pumpWalletRestore()
{
if (capture_mode_) return;
bool done = false; int sev = 0; std::string msg;
{
std::lock_guard<std::mutex> lk(wallet_restore_mutex_);
if (wallet_restore_done_) { done = true; sev = wallet_restore_severity_; msg = wallet_restore_msg_; wallet_restore_done_ = false; }
}
if (!done) return;
if (sev == 2) ui::Notifications::instance().error(msg, 25.0f);
else if (sev == 1) ui::Notifications::instance().warning(msg, 20.0f);
else ui::Notifications::instance().success(msg.empty() ? TR("wallet_restore_ok") : msg, 12.0f);
}
// Locate the bundled dragonx-wallet-rebuild helper (exe dir → daemon dir). "" if not present.
static std::string findWalletRebuildHelper()
{
namespace fs = std::filesystem;
#ifdef _WIN32
const char* exe = "dragonx-wallet-rebuild.exe";
#else
const char* exe = "dragonx-wallet-rebuild";
#endif
for (const std::string& d : { util::Platform::getExecutableDirectory(),
dragonx::resources::getDaemonDirectory() }) {
if (d.empty()) continue;
std::error_code ec;
const std::string p = d + "/" + exe;
if (fs::exists(p, ec)) return p;
}
return {};
}
bool App::walletRebuildAvailable() const { return !findWalletRebuildHelper().empty(); }
// Rebuild a BDB-inconsistent wallet into a fresh, daemon-loadable one via the offline helper (see
// tools/wallet_rebuild). This is the real fix for the salvage cascade: plain "Restore original" just
// hands the same broken file back and the daemon re-salvages it. Modeled on restoreOriginalWallet:
// stop daemon → run helper → verify → safe swap (copy/rename only, never delete) → rescan → restart.
void App::rebuildWalletDatabase()
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; }
const std::string helper = findWalletRebuildHelper();
if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; }
show_wallet_recovered_dialog_ = false;
{ std::lock_guard<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
daemon_restarting_ = true;
connection_status_ = TR("sb_restarting_daemon");
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
onDisconnected("Rebuilding wallet database");
ui::Notifications::instance().info(TR("wallet_rebuild_started"), 15.0f);
const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
? settings_->getActiveWalletFile() : std::string("wallet.dat");
async_tasks_.submit("Rebuild wallet database", [this, helper, activeWalletName](const util::AsyncTaskManager::Token&) {
namespace fs = std::filesystem;
std::string err, warn;
try {
const std::string datadir = util::Platform::getDragonXDataDir();
const std::string active = datadir + "/" + activeWalletName;
// 1. Rebuild SOURCE = the largest readable wallet file (the active wallet or any salvage .bak).
// Largest = most records = the original / least-salvaged (a salvaged copy is tiny).
std::string src; unsigned long long best = 0;
{
std::error_code ec;
for (const auto& e : fs::directory_iterator(datadir, ec)) {
if (ec) break;
const std::string n = e.path().filename().string();
if (n != activeWalletName && daemon::parseWalletSalvageBakTs(n) < 0) continue;
std::error_code se;
const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0;
const auto usz = se ? 0ull : static_cast<unsigned long long>(sz);
if (usz > best && util::probeWalletFile(e.path().string()).isBerkeleyDB) {
best = usz; src = e.path().string();
}
}
}
if (src.empty()) {
err = TR("wallet_rebuild_no_source");
} else if (!stopDaemonForWalletSwitch()) { // 2. release wallet.dat + the port
err = TR("wallet_restore_stop_failed");
} else {
std::time_t t = std::time(nullptr);
std::tm tmv{};
#ifdef _WIN32
localtime_s(&tmv, &t);
#else
localtime_r(&t, &tmv);
#endif
char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
const std::string tmpOut = datadir + "/wallet.rebuilt-" + std::string(ts) + ".tmp";
{ std::error_code ec; fs::remove(tmpOut, ec); } // helper uses DB_EXCL — path must be fresh
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line.
std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
#ifdef _WIN32
cmd = "\"" + cmd + "\""; // cmd.exe strips the outermost quotes
FILE* fp = _popen(cmd.c_str(), "r");
#else
FILE* fp = popen(cmd.c_str(), "r");
#endif
std::string jout;
if (fp) { char b[512]; while (std::fgets(b, sizeof b, fp)) jout += b; }
#ifdef _WIN32
const int rc = fp ? _pclose(fp) : -1;
#else
const int rc = fp ? pclose(fp) : -1;
#endif
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
const auto probe = util::parseWalletBtree(tmpOut);
if (rc != 0 || !probe.parsed || probe.addresses() == 0) {
std::error_code ec; fs::remove(tmpOut, ec);
err = TR("wallet_rebuild_failed");
} else {
// 5. Swap: set the current wallet aside (kept), install the rebuilt one, clear stale env.
std::error_code ec;
const std::string aside = active + ".prerebuild-" + std::string(ts) + ".dat";
bool moved = false;
if (fs::exists(active)) {
fs::rename(active, aside, ec);
if (ec) err = TR("wallet_restore_move_failed"); else moved = true;
}
if (err.empty()) {
fs::rename(tmpOut, active, ec);
if (ec) {
if (moved) { std::error_code e2; fs::rename(aside, active, e2); }
err = TR("wallet_rebuild_install_failed");
}
}
if (err.empty()) {
std::error_code e2;
if (fs::exists(datadir + "/database"))
fs::rename(datadir + "/database", datadir + "/database.prerebuild-" + std::string(ts) + ".bak", e2);
for (const auto& e : fs::directory_iterator(datadir, e2))
if (e.path().filename().string().rfind("__db.", 0) == 0) { std::error_code e3; fs::remove(e.path(), e3); }
if (daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
}
}
}
if (!shutting_down_) {
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
if (!startEmbeddedDaemon() && err.empty()) warn = TR("wallet_restore_no_restart");
}
} catch (const std::exception& e) {
err = std::string("Rebuild failed: ") + e.what();
} catch (...) {
err = "Rebuild failed due to an unexpected error.";
}
daemon_restarting_ = false;
std::lock_guard<std::mutex> lk(wallet_restore_mutex_);
wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0);
wallet_restore_msg_ = !err.empty() ? err : (!warn.empty() ? warn : std::string(TR("wallet_rebuild_ok")));
wallet_restore_done_ = true;
});
}
void App::pumpSeedMigration() void App::pumpSeedMigration()
{ {
if (capture_mode_) return; // no live ops during a UI sweep (steps are set directly) if (capture_mode_) return; // no live ops during a UI sweep (steps are set directly)
@@ -4386,6 +4864,7 @@ void App::pumpSeedMigration()
settings_->setSeedMigrationDest(""); settings_->setSeedMigrationDest("");
settings_->setSeedMigrationTempDir(""); settings_->setSeedMigrationTempDir("");
settings_->setSeedMigrationSweepTxid(""); settings_->setSeedMigrationSweepTxid("");
settings_->setSeedMigrationSweepOpid(""); // W3-3
settings_->save(); settings_->save();
} }
seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done
@@ -4428,6 +4907,11 @@ void App::pumpSeedMigration()
settings_->setSeedMigrationPending(true); settings_->setSeedMigrationPending(true);
settings_->setSeedMigrationDest(seed_migration_dest_); settings_->setSeedMigrationDest(seed_migration_dest_);
settings_->setSeedMigrationTempDir(seed_migration_temp_dir_); settings_->setSeedMigrationTempDir(seed_migration_temp_dir_);
// W3-3: a brand-new migration has done no sweep yet — clear any sweep artifacts left over
// from a prior aborted run so reopening this fresh migration can't mis-resume on a stale
// txid/opid (the resume block reads these whenever the migration is pending).
settings_->setSeedMigrationSweepTxid("");
settings_->setSeedMigrationSweepOpid("");
settings_->save(); settings_->save();
} }
} else { } else {
@@ -4456,13 +4940,12 @@ void App::backupWallet(const std::string& destination, std::function<void(bool,
return; return;
} }
std::ofstream file(destination); // Write the key backup atomically and owner-only (0600) — it must never be even briefly
if (!file.is_open()) { // world-readable, and the previous std::ofstream left it at the umask default.
if (callback) callback(false, "Could not open file: " + destination); if (!util::Platform::writeFileAtomically(destination, keys, /*restrictPermissions=*/true)) {
if (callback) callback(false, "Could not write file: " + destination);
return; return;
} }
file << keys;
file.close();
std::string msg = "Wallet backup saved to " + destination + "" std::string msg = "Wallet backup saved to " + destination + ""
+ std::to_string(exported) + " of " + std::to_string(total) + " keys."; + std::to_string(exported) + " of " + std::to_string(total) + " keys.";

View File

@@ -33,6 +33,10 @@
#include <ctime> #include <ctime>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <fstream>
#include <vector>
#include <utility>
#include <sodium.h>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <utility> #include <utility>
@@ -483,7 +487,17 @@ void App::lockWallet() {
state_.locked = true; state_.locked = true;
state_.unlocked_until = 0; state_.unlocked_until = 0;
resetTransactionHistoryCacheSession(); resetTransactionHistoryCacheSession();
lock_failure_warned_ = false;
DEBUG_LOGF("[App] Wallet locked\n"); 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_.unlocked_until = until;
state_.locked = (until == 0); state_.locked = (until == 0);
state_.encryption_state_known = true; 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) { if (state_.locked) {
resetTransactionHistoryCacheSession(); resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) { } else if (state_.transactions.empty()) {
@@ -572,6 +592,19 @@ void App::refreshWalletEncryptionState() {
state_.locked = false; state_.locked = false;
state_.unlocked_until = 0; state_.unlocked_until = 0;
state_.encryption_state_known = true; 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()) { if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable(); loadTransactionHistoryCacheIfAvailable();
} else { } else {
@@ -1478,12 +1511,14 @@ void App::renderDecryptWalletDialog() {
// Run entire decrypt flow on worker thread // Run entire decrypt flow on worker thread
if (worker_) { if (worker_) {
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb { worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(), WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) { [this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context); return sendStopCommandSafely(client, context);
}); });
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc); 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) { if (!unlock.ok) {
return [this]() { return [this]() {
wallet_security_workflow_.failEntry("Incorrect passphrase"); wallet_security_workflow_.failEntry("Incorrect passphrase");
@@ -1606,6 +1641,27 @@ void App::renderDecryptWalletDialog() {
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_); WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet( auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
importAdapter, exportPath); 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) { if (!importResult.ok) {
std::string err = importResult.error; std::string err = importResult.error;
if (worker_) { if (worker_) {

View File

@@ -1338,6 +1338,10 @@ void App::renderFirstRunWizard() {
wallet_security_.beginDeferredEncryption( wallet_security_.beginDeferredEncryption(
std::string(encrypt_pass_buf_), std::string(encrypt_pass_buf_),
(pinEntered && pinOk) ? pinStr : std::string()); (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 // Clear sensitive buffers
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_)); memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));

View File

@@ -231,12 +231,14 @@ bool Settings::load(const std::string& path)
} }
loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); 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, "daemon_update_prompted_size", daemon_update_prompted_size_);
loadScalar(j, "active_wallet_file", active_wallet_file_); loadScalar(j, "active_wallet_file", active_wallet_file_);
loadScalar(j, "seed_migration_pending", seed_migration_pending_); loadScalar(j, "seed_migration_pending", seed_migration_pending_);
loadScalar(j, "seed_migration_dest", seed_migration_dest_); loadScalar(j, "seed_migration_dest", seed_migration_dest_);
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_); loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_); loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_); loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
loadScalar(j, "unlock_duration", unlock_duration_); loadScalar(j, "unlock_duration", unlock_duration_);
loadScalar(j, "pin_enabled", pin_enabled_); loadScalar(j, "pin_enabled", pin_enabled_);
@@ -497,12 +499,14 @@ bool Settings::save(const std::string& path)
} }
j["wizard_completed"] = wizard_completed_; j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_; j["seed_backup_reminded"] = seed_backup_reminded_;
j["encryption_pending"] = encryption_pending_;
j["daemon_update_prompted_size"] = daemon_update_prompted_size_; j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
j["active_wallet_file"] = active_wallet_file_; j["active_wallet_file"] = active_wallet_file_;
j["seed_migration_pending"] = seed_migration_pending_; j["seed_migration_pending"] = seed_migration_pending_;
j["seed_migration_dest"] = seed_migration_dest_; j["seed_migration_dest"] = seed_migration_dest_;
j["seed_migration_temp_dir"] = seed_migration_temp_dir_; j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_; j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
j["auto_lock_timeout"] = auto_lock_timeout_; j["auto_lock_timeout"] = auto_lock_timeout_;
j["unlock_duration"] = unlock_duration_; j["unlock_duration"] = unlock_duration_;
j["pin_enabled"] = pin_enabled_; j["pin_enabled"] = pin_enabled_;

View File

@@ -327,6 +327,12 @@ public:
bool getSeedBackupReminded() const { return seed_backup_reminded_; } bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } 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 // 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. // "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; } long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
@@ -350,6 +356,11 @@ public:
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again). // migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; } std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; } void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
// W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
// to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
// Security — auto-lock timeout (seconds; 0 = disabled) // Security — auto-lock timeout (seconds; 0 = disabled)
int getAutoLockTimeout() const { return auto_lock_timeout_; } int getAutoLockTimeout() const { return auto_lock_timeout_; }
@@ -574,12 +585,14 @@ private:
std::map<std::string, AddressMeta> address_meta_; std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false; bool wizard_completed_ = false;
bool seed_backup_reminded_ = 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 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) std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
bool seed_migration_pending_ = false; bool seed_migration_pending_ = false;
std::string seed_migration_dest_; std::string seed_migration_dest_;
std::string seed_migration_temp_dir_; std::string seed_migration_temp_dir_;
std::string seed_migration_sweep_txid_; std::string seed_migration_sweep_txid_;
std::string seed_migration_sweep_opid_;
int auto_lock_timeout_ = 900; // 15 minutes int auto_lock_timeout_ = 900; // 15 minutes
int unlock_duration_ = 600; // 10 minutes int unlock_duration_ = 600; // 10 minutes
bool pin_enabled_ = false; bool pin_enabled_ = false;

View File

@@ -126,6 +126,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled)
daemon_->setSalvageOnNextStart(enabled); daemon_->setSalvageOnNextStart(enabled);
} }
void DaemonController::setReindexOnNextStart(bool enabled)
{
daemon_->setReindexOnNextStart(enabled);
}
bool DaemonController::zapOnNextStart() const bool DaemonController::zapOnNextStart() const
{ {
return daemon_->zapOnNextStart(); return daemon_->zapOnNextStart();

View File

@@ -108,6 +108,7 @@ public:
void setZapOnNextStart(bool enabled); void setZapOnNextStart(bool enabled);
bool zapOnNextStart() const; bool zapOnNextStart() const;
void setSalvageOnNextStart(bool enabled); void setSalvageOnNextStart(bool enabled);
void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon, static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
bool externalDaemonDetected, bool externalDaemonDetected,

View File

@@ -0,0 +1,97 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// daemon_startup_diagnosis.h — pure classifiers over a crashed daemon's captured console output,
// so the app can offer a targeted one-click fix instead of a bare "daemon crashed" / silent no-funds.
#pragma once
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
namespace daemon {
// True when dragonxd aborted because its BLOCK DATABASE could not be loaded — either a
// daemon-vs-chaindata serialization-format mismatch after a daemon update (the deterministic
// "non-canonical optional discriminant" → "Error loading block database" → "Aborted block database
// rebuild. Exiting." sequence) or a genuinely corrupt/incomplete block index. In BOTH cases the fix
// is the same: `-reindex` rebuilds the index + chainstate from the intact raw blocks (blk*.dat).
// This is what otherwise silently presents as a wallet with zero balance — the node never starts.
inline bool blockDbOutputLooksBroken(const std::string& out)
{
return out.find("Error loading block database") != std::string::npos
|| out.find("non-canonical optional discriminant") != std::string::npos
|| out.find("Aborted block database rebuild") != std::string::npos
|| out.find("LoadBlockIndex()") != std::string::npos; // "... : failed to read value"
}
// True when dragonxd AUTO-RECOVERED the wallet on startup: on any BDB-verify failure it moves the
// original wallet.dat to "wallet.{timestamp}.bak", salvages readable keys into a fresh wallet.dat, and
// keeps running — no flag required (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)).
// The salvage can be incomplete (or a false positive from stale/cross-platform BDB env state), so the
// node silently comes up on a possibly-empty wallet — which reads as fund loss unless we surface it.
inline bool walletAutoRecovered(const std::string& out)
{
// Cover BOTH salvage outcomes. A successful salvage prints the "data salvaged"/"saved as wallet.<ts>.bak"
// warning; a FAILED one (e.g. an inconsistent-but-readable file where aggressive salvage finds no
// records) prints "salvage failed"/"found no records". In every case CWalletDB::Recover first logs
// "Renamed <wallet> to wallet.<ts>.bak" and CDBEnv::Salvage logs its own banner — those two fire the
// instant a salvage begins, before the daemon may abort, so they're the earliest reliable signal.
return out.find("CDBEnv::Salvage") != std::string::npos // salvage is running
|| out.find("wallet.dat corrupt, data salvaged") != std::string::npos // RECOVER_OK
|| out.find("Original wallet.dat saved as wallet.") != std::string::npos
|| out.find("wallet.dat corrupt, salvage failed") != std::string::npos // RECOVER_FAIL
|| out.find("found no records in wallet") != std::string::npos // aggressive salvage empty
|| (out.find("Renamed ") != std::string::npos && out.find(" to wallet.") != std::string::npos
&& out.find(".bak") != std::string::npos); // Recover moved wallet.dat aside
}
// If `name` is a daemon salvage backup "wallet.<unixtime>.bak", return its timestamp; else -1.
inline long long parseWalletSalvageBakTs(const std::string& name)
{
if (name.rfind("wallet.", 0) != 0) return -1; // must start "wallet."
if (name.size() < 12 || name.compare(name.size() - 4, 4, ".bak") != 0) return -1; // ...and end ".bak"
const std::string mid = name.substr(7, name.size() - 7 - 4); // digits between the dots
if (mid.empty() || mid.size() > 18) return -1;
for (char c : mid) if (c < '0' || c > '9') return -1;
long long ts = 0;
for (char c : mid) ts = ts * 10 + (c - '0');
return ts;
}
// Most RECENT salvage backup (highest timestamp). Pure, testable.
inline std::string newestWalletSalvageBak(const std::vector<std::string>& filenames)
{
long long best = -1;
std::string bestName;
for (const auto& f : filenames) {
const long long ts = parseWalletSalvageBakTs(f);
if (ts > best) { best = ts; bestName = f; }
}
return bestName;
}
// LARGEST salvage backup, from (filename, fileSize) pairs — the least-salvaged one, i.e. the original.
// This is what "Restore original wallet" should use: a salvage CASCADE shrinks the wallet each round, so
// the newest .bak is the WORST and the largest is the pristine pre-salvage original (an emptied salvage
// is tiny; a real wallet is large). Ties break toward the newest timestamp. Returns "" if none present.
inline std::string largestWalletSalvageBak(const std::vector<std::pair<std::string, unsigned long long>>& files)
{
std::string bestName;
unsigned long long bestSize = 0;
long long bestTs = -1;
for (const auto& fp : files) {
const long long ts = parseWalletSalvageBakTs(fp.first);
if (ts < 0) continue;
if (fp.second > bestSize || (fp.second == bestSize && ts > bestTs)) {
bestSize = fp.second; bestTs = ts; bestName = fp.first;
}
}
return bestName;
}
} // namespace daemon
} // namespace dragonx

View File

@@ -489,6 +489,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
} }
external_daemon_detected_ = false; external_daemon_detected_ = false;
// A previous dragonxd can release the RPC port well before it releases the datadir
// .lock — a graceful shutdown can take up to ~90s (see isDaemonProcessRunning). Starting
// into a still-held lock spawns a process that dies instantly with "Cannot obtain a lock
// on data directory"; the crash monitor reports that generically and, three times in
// ~12s, that is enough to trip the 3-strike restart cap before the lock's ~90s life
// elapses. Gate on the process actually still being alive, with a SHORT bounded wait
// (not the full ~90s — start() runs on the UI thread). Isolated starts (migrate-to-seed:
// skip_port_check_ / -datadir override) are exempt; they run their own datadir+port.
{
constexpr int kDatadirLockWaitPollMs = 100;
constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
bool stillRunning = false;
if (!skip_port_check_ && override_datadir_.empty()) {
stillRunning = isDaemonProcessRunning();
for (int i = 0; stillRunning && i < kDatadirLockWaitMaxPolls; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
stillRunning = isDaemonProcessRunning();
}
}
const StartLockGateDecision gate =
evaluateDatadirLockGate(skip_port_check_, !override_datadir_.empty(), stillRunning);
if (!gate.proceed) {
VERBOSE_LOGF("[INFO] %s\n", gate.errorMessage);
setState(State::Error, gate.errorMessage);
return false;
}
}
setState(State::Starting, "Looking for dragonxd binary..."); setState(State::Starting, "Looking for dragonxd binary...");
std::string daemon_path = binary_path; std::string daemon_path = binary_path;
@@ -543,6 +571,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-rescan"); args.push_back("-rescan");
} }
// -reindex rebuilds the block index + chainstate from the raw blocks (fixes an unreadable/format-
// mismatched block DB). It's about the CHAIN, not the wallet, so it's independent of the wallet-repair
// chain above (and implies its own wallet rescan). One-shot, consumed here.
if (reindex_on_next_start_.exchange(false)) {
DEBUG_LOGF("[INFO] Adding -reindex flag to rebuild the block database from raw blocks\n");
args.push_back("-reindex");
}
// One-shot isolated-datadir override (migrate-to-seed flow): run this start against a // One-shot isolated-datadir override (migrate-to-seed flow): run this start against a
// throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts // throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts
// revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX) // revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX)
@@ -557,8 +593,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
override_extra_args_.clear(); override_extra_args_.clear();
if (!startProcess(daemon_path, args)) { if (!startProcess(daemon_path, args)) {
DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str()); // startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
setState(State::Error, "Failed to start dragonxd process"); // ... not executable or wrong architecture"). Surface THAT via setState — which also
// stores the Error message into last_error_ — instead of clobbering it with a generic
// string that would then be all getLastError()/the UI ever sees.
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
: last_error_;
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
setState(State::Error, detail);
return false; return false;
} }
@@ -963,17 +1005,37 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
return false; return false;
} }
// Self-pipe used purely as an exec-success/failure handshake, separate from
// the stdout pipe above. Both ends are close-on-exec, so a successful execv()
// closes the write end for free (parent reads EOF); on execv() failure the
// child writes errno here, so the parent learns synchronously instead of
// reporting State::Running for a child that never became dragonxd. We use
// pipe()+FD_CLOEXEC (not pipe2) because this POSIX branch is shared with
// macOS, which has no pipe2().
int execpipe[2];
if (pipe(execpipe) == -1) {
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
close(pipefd[0]);
close(pipefd[1]);
return false;
}
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
pid_t pid = fork(); pid_t pid = fork();
if (pid == -1) { if (pid == -1) {
last_error_ = "Fork failed: " + std::string(strerror(errno)); last_error_ = "Fork failed: " + std::string(strerror(errno));
close(pipefd[0]); close(pipefd[0]);
close(pipefd[1]); close(pipefd[1]);
close(execpipe[0]);
close(execpipe[1]);
return false; return false;
} }
if (pid == 0) { if (pid == 0) {
// Child process // Child process
close(pipefd[0]); // Close read end close(pipefd[0]); // Close read end of the stdout pipe
close(execpipe[0]); // Child only writes the exec-status pipe
// Put child in its own process group so we can kill the entire // Put child in its own process group so we can kill the entire
// group later (including dragonxd spawned by a wrapper script). // group later (including dragonxd spawned by a wrapper script).
@@ -1040,17 +1102,56 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
execv(binary_path.c_str(), argv.data()); execv(binary_path.c_str(), argv.data());
} }
// If we get here, exec failed // If we get here, execv() failed — the child never became dragonxd.
fprintf(stderr, "execv failed: %s\n", strerror(errno)); // Capture errno before fprintf/strerror can clobber it, report it to
// the parent over the exec-status pipe (EINTR-safe), then exit.
int exec_errno = errno;
fprintf(stderr, "execv failed: %s\n", strerror(exec_errno));
ssize_t w;
do {
w = write(execpipe[1], &exec_errno, sizeof(exec_errno));
} while (w < 0 && errno == EINTR);
_exit(127); _exit(127);
} }
// Parent process // Parent process
close(pipefd[1]); // Close write end close(pipefd[1]); // Close our copy of the stdout write end
close(execpipe[1]); // Must close our copy, or the read() below never sees EOF
// Exec-status handshake: EOF => execv() succeeded (its write end was closed
// on exec); a full sizeof(int) => execv() failed and the child sent errno.
int child_errno = 0;
size_t got = 0;
char* ep = reinterpret_cast<char*>(&child_errno);
for (;;) {
ssize_t n = read(execpipe[0], ep + got, sizeof(child_errno) - got);
if (n == 0) break; // EOF: exec succeeded
if (n < 0) { if (errno == EINTR) continue; break; } // other error: assume success
got += static_cast<size_t>(n);
if (got >= sizeof(child_errno)) break; // full errno: exec failed
}
close(execpipe[0]);
if (got >= sizeof(child_errno)) {
// execv() never replaced the child; it fprintf'd and _exit(127)'d. Reap
// the already-dead zombie here — monitorProcess() is only started after
// this function returns true, so there is no competing reaper.
close(pipefd[0]);
int status;
waitpid(pid, &status, 0);
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
" — not executable or wrong architecture";
return false;
}
stdout_fd_ = pipefd[0]; stdout_fd_ = pipefd[0];
// Also set process group from parent side (race with child's setpgid) // Best-effort: the child already calls setpgid(0, 0); this parent-side call
setpgid(pid, pid); // just closes the fork/exec race window. A failure here is not fatal to
// startup, so we log rather than abort.
if (setpgid(pid, pid) != 0) {
DEBUG_LOGF("[WARN] setpgid(%d) from parent failed: %s\n", (int)pid, strerror(errno));
}
// Set non-blocking // Set non-blocking
int flags = fcntl(stdout_fd_, F_GETFL, 0); int flags = fcntl(stdout_fd_, F_GETFL, 0);
@@ -1135,17 +1236,21 @@ double EmbeddedDaemon::getMemoryUsageMB() const
bool EmbeddedDaemon::isRunning() const bool EmbeddedDaemon::isRunning() const
{ {
// Read the atomic state_ instead of calling waitpid() here. monitorProcess()
// is the sole thread allowed to waitpid() process_pid_ during normal operation.
// Calling waitpid() from this method too (as it used to, and this is invoked
// from the UI thread nearly every frame) meant whichever thread reaped the
// child's exit first consumed the status; if isRunning() won that race,
// monitorProcess() never saw the exit, so crash_count_ / the decoded exit
// code / the State::Error transition were all silently lost. Mirrors the
// fix already in XmrigManager::isRunning().
if (process_pid_ <= 0) return false; if (process_pid_ <= 0) return false;
int status; const State s = state_.load(std::memory_order_relaxed);
pid_t result = waitpid(process_pid_, &status, WNOHANG); // State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
// isRunning() while state_ == Stopping — before the process has actually
if (result == 0) { // terminated — and must keep seeing "alive" to wait/escalate correctly.
// Still running return (s == State::Running || s == State::Stopping);
return true;
}
return false;
} }
void EmbeddedDaemon::drainOutput() void EmbeddedDaemon::drainOutput()

View File

@@ -206,6 +206,13 @@ public:
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; } void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); } bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
// -reindex: rebuild the block index + chainstate from the raw blocks (blk*.dat) on startup. One-shot,
// consumed on the next start. Offered when the node aborts on an unreadable block database (a
// daemon-vs-chaindata format mismatch after an update, or a corrupt index). It implies a wallet
// rescan, so it's the block-DB analogue of -salvagewallet and coexists with the wallet-repair flags.
void setReindexOnNextStart(bool v) { reindex_on_next_start_ = v; }
bool reindexOnNextStart() const { return reindex_on_next_start_.load(); }
/** /**
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a * @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the * different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
@@ -235,6 +242,32 @@ public:
*/ */
static bool isDaemonProcessRunning(); static bool isDaemonProcessRunning();
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
struct StartLockGateDecision {
bool proceed = true; // false => bail before spawning
const char* errorMessage = ""; // set (a string literal) when proceed == false
};
/**
* @brief Pure decision for start(): bail because a previous dragonxd still holds the
* shared datadir lock? Isolated instances (skip_port_check_ / an active -datadir
* override) are exempt — they run their own throwaway datadir+port and can coexist
* with the main daemon. Does no process/fs I/O itself (the caller does the probing),
* so it is directly unit-testable; defined inline so tests need only this header.
*/
static StartLockGateDecision evaluateDatadirLockGate(bool skipPortCheck,
bool isolatedOverride,
bool stillRunningAfterWait)
{
if (skipPortCheck || isolatedOverride) return {true, ""};
if (stillRunningAfterWait) {
return {false,
"A previous dragonxd is still shutting down and holding the data "
"directory lock. Retrying shortly…"};
}
return {true, ""};
}
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */ /** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
static bool tcpPortInUse(int port); static bool tcpPortInUse(int port);
@@ -280,6 +313,7 @@ private:
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
std::atomic<bool> reindex_on_next_start_{false}; // -reindex flag for next start (rebuild block DB)
std::string override_datadir_; // one-shot: -datadir for the next start std::string override_datadir_; // one-shot: -datadir for the next start
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
bool skip_port_check_ = false; // isolated instance on a non-default port bool skip_port_check_ = false; // isolated instance on a non-default 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. // 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 base = util::Platform::getConfigDir() + "/seed-migrate";
const std::string dataDir = base + "/DRAGONX"; 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::remove_all(base, ec);
fs::create_directories(dataDir, ec); fs::create_directories(dataDir, ec);
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; } 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 // Copyright 2024-2026 The Hush Developers
// Released under the GPLv3 // 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. // Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
#include "xmrig_manager.h" #include "xmrig_manager.h"

View File

@@ -46,20 +46,25 @@ bool AddressBook::load()
entries_.clear(); entries_.clear();
if (j.contains("entries") && j["entries"].is_array()) { if (j.contains("entries") && j["entries"].is_array()) {
size_t skipped = 0;
for (const auto& entry : j["entries"]) { for (const auto& entry : j["entries"]) {
AddressBookEntry e; // W6-3: skip (and count) a malformed element rather than letting one bad entry throw and
e.label = entry.value("label", ""); // abort the whole load — which would discard EVERY contact (entries_ was already cleared).
e.address = entry.value("address", ""); if (!entry.is_object()) { ++skipped; continue; }
e.notes = entry.value("notes", ""); try {
// Legacy entries (no "scope") migrate to "global" so nothing disappears when AddressBookEntry e;
// multi-wallet scoping lands — a contact you already had stays visible everywhere. e.label = entry.value("label", "");
e.scope = entry.value("scope", "global"); e.address = entry.value("address", "");
e.avatar = entry.value("avatar", ""); e.notes = entry.value("notes", "");
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
if (!e.address.empty()) { // multi-wallet scoping lands — a contact you already had stays visible everywhere.
entries_.push_back(e); e.scope = entry.value("scope", "global");
} e.avatar = entry.value("avatar", "");
if (!e.address.empty()) entries_.push_back(e);
} catch (const std::exception&) { ++skipped; }
} }
if (skipped > 0)
DEBUG_LOGF("Address book: skipped %zu malformed entr%s\n", skipped, skipped == 1 ? "y" : "ies");
} }
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size()); DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());

View File

@@ -0,0 +1,37 @@
#pragma once
#include <string>
// Pure routing decision for resuming a pending migrate-to-seed flow (finding W3-3). Kept free of
// App/UI/RPC state so the highest-risk branch — where a reopened migration lands — is unit-testable
// and reviewable in isolation. App::showSeedMigrationDialog feeds it the persisted state + live
// connectivity and switches on the result. See src/app_network.cpp.
namespace dragonx {
enum class MigrationResume {
Intro, // no pending migration → start fresh at the intro
Confirming, // a sweep txid is persisted → resume at the confirm/adopt gate (re-derived from chain)
RetrackOpid, // a sweep opid (but no txid yet) is persisted AND we're connected → re-poll it
SweepGate, // otherwise → the dismissable Sweep step (reload balance, offer re-sweep)
};
// Decide where reopening the migration dialog lands.
//
// Invariant: the txid outranks the opid — once a sweep resolves to a txid the opid is cleared in the
// same settings write, so a persisted txid always means "past the sweep". A persisted opid is only
// re-tracked when connected, because the buttonless "Sweeping" spinner relies on the opid poller
// (which needs an RPC connection) to ever exit; disconnected, we fall back to the dismissable Sweep
// gate (which reloads the balance and, if the earlier sweep already drained it, short-circuits to
// adopt) — never trapping the user.
inline MigrationResume decideSeedMigrationResume(bool pending,
bool haveDest,
const std::string& sweepTxid,
const std::string& sweepOpid,
bool connected) {
if (!pending || !haveDest) return MigrationResume::Intro;
if (!sweepTxid.empty()) return MigrationResume::Confirming;
if (!sweepOpid.empty() && connected) return MigrationResume::RetrackOpid;
return MigrationResume::SweepGate;
}
} // namespace dragonx

View File

@@ -335,6 +335,15 @@ struct WalletState {
transactions.clear(); transactions.clear();
peers.clear(); peers.clear();
bannedPeers.clear(); bannedPeers.clear();
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
// stops), so leaving the previous wallet's hashrate/blocks would show stale mining stats.
mining = MiningInfo{};
pool_mining = PoolMiningState{};
// After a disconnect / wallet switch nothing is freshly known, so drop the "last successful
// refresh" stamps. Otherwise the pre-teardown time survives and, on reconnect, the staleness
// badge (and any "updated X ago" reader) briefly reports it as current until the first refresh
// re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return "").
last_balance_update = last_tx_update = last_peer_update = last_mining_update = 0;
} }
// Rebuild combined addresses list from z/t lists // Rebuild combined addresses list from z/t lists

View File

@@ -721,20 +721,105 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale,
} }
} }
#if !defined(_WIN32)
#include <csignal>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>
#if defined(__has_include)
# if __has_include(<execinfo.h>)
# include <execinfo.h>
# define DRAGONX_HAVE_BACKTRACE 1
# endif
#endif
// Absolute path to the crash log, filled at install time so the async-signal handler needs no
// allocation. (POSIX counterpart of the Windows SEH CrashHandler above — W7-3.)
static char g_crashLogPath[1024] = {0};
// Async-signal-safe crash handler: only open()/write()/backtrace_symbols_fd()/raise() are used —
// no stdio, std::filesystem or malloc (all unsafe inside a signal handler).
static void PosixCrashHandler(int sig)
{
int fd = g_crashLogPath[0] ? open(g_crashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0600) : -1;
if (fd >= 0) {
auto put = [fd](const char* s) { ssize_t n = write(fd, s, std::strlen(s)); (void)n; };
put("\n=== CRASH: signal ");
char num[16]; int i = 0, v = sig; // signal number -> decimal, no stdio
if (v == 0) { num[i++] = '0'; }
else { char tmp[16]; int t = 0; while (v > 0) { tmp[t++] = char('0' + v % 10); v /= 10; }
while (t > 0) num[i++] = tmp[--t]; }
num[i] = '\n';
ssize_t nn = write(fd, num, i + 1); (void)nn;
#ifdef DRAGONX_HAVE_BACKTRACE
void* frames[64];
int nframes = backtrace(frames, 64);
backtrace_symbols_fd(frames, nframes, fd); // async-signal-safe
#endif
put("=== END CRASH ===\n");
close(fd);
}
// Restore the default disposition and re-raise so we still get a core dump / normal termination.
signal(sig, SIG_DFL);
raise(sig);
}
static void installPosixCrashHandler(const std::string& crashLogPath)
{
std::snprintf(g_crashLogPath, sizeof(g_crashLogPath), "%s", crashLogPath.c_str());
struct sigaction sa;
std::memset(&sa, 0, sizeof(sa));
sa.sa_handler = PosixCrashHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) {
sigaction(sig, &sa, nullptr);
}
}
#endif // !_WIN32
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
// Ensure ObsidianDragon config directory exists early (before any file I/O) // Ensure ObsidianDragon config directory exists early (before any file I/O)
{ {
std::string odDir = dragonx::util::Platform::getObsidianDragonDir(); std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
std::error_code ec; std::string odErr;
std::filesystem::create_directories(odDir, ec); if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) {
// Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the
// Windows log redirect below isn't set up yet — report loudly before any setup.
std::fprintf(stderr, "%s\n", odErr.c_str());
#ifdef _WIN32
MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR);
#endif
return 1;
}
} }
#ifdef _WIN32 // W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are
// Redirect stdout/stderr to a log file so diagnostic output is visible // actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the
// even when built as a GUI app (WIN32_EXECUTABLE hides the console). // file never existed at all (the Windows-only stdout freopen below is a separate mechanism).
{ {
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string(); const std::string logPath =
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
dragonx::util::Logger::instance().init(logPath);
}
#if !defined(_WIN32)
// W7-3: install the POSIX crash handler (the Windows SEH filter is installed below). A segfault or
// abort now leaves a backtrace in dragonx-crash.log instead of vanishing silently on Linux/macOS.
{
const std::string crashPath =
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-crash.log").string();
installPosixCrashHandler(crashPath);
}
#endif
#ifdef _WIN32
// Redirect raw stdout/stderr (library / daemon-pipe writes) to a log file so it's visible even when
// built as a GUI app (WIN32_EXECUTABLE hides the console). Separate file from the structured Logger
// above so the two writers don't interleave/contend on one file.
{
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
freopen(logPath.c_str(), "w", stdout); freopen(logPath.c_str(), "w", stdout);
freopen(logPath.c_str(), "a", stderr); freopen(logPath.c_str(), "a", stderr);
} }

View File

@@ -14,8 +14,12 @@
#include <filesystem> #include <filesystem>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <vector>
#include <chrono>
#include "../util/logger.h" #include "../util/logger.h"
#include "../util/platform.h"
#include "../util/xmrig_updater.h" // util::sha256Hex
#ifdef _WIN32 #ifdef _WIN32
#include <shlobj.h> #include <shlobj.h>
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
return resources::getDaemonDirectory(); return resources::getDaemonDirectory();
} }
bool Connection::verifySaplingParams() namespace {
std::string joinParamPath(const std::string& dir, const std::string& file) {
#ifdef _WIN32
return dir + "\\" + file;
#else
return dir + "/" + file;
#endif
}
// "<size>:<mtime>" fingerprint used to skip re-hashing an unchanged file. Empty on error.
std::string paramStatLine(const std::string& path) {
std::error_code ec;
auto sz = fs::file_size(path, ec);
if (ec) return {};
auto mtime = fs::last_write_time(path, ec);
long long ticks = ec ? 0 :
std::chrono::duration_cast<std::chrono::seconds>(mtime.time_since_epoch()).count();
return std::to_string(static_cast<unsigned long long>(sz)) + ":" + std::to_string(ticks);
}
bool paramHashMatches(const std::string& path, const std::string& expectedHex) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) return false;
std::streamsize sz = f.tellg();
if (sz <= 0) return false;
f.seekg(0, std::ios::beg);
std::vector<char> buf(static_cast<size_t>(sz));
if (!f.read(buf.data(), sz)) return false;
std::string got = util::sha256Hex(buf.data(), buf.size());
return !got.empty() && got == expectedHex;
}
// The verification cache: <params_dir>/.sapling_verified holds one paramStatLine per param,
// in list order, from the last successful hash check.
bool saplingMarkerMatches(const std::string& markerPath, const std::vector<std::string>& expected) {
for (const auto& s : expected) if (s.empty()) return false; // couldn't stat -> don't trust
std::ifstream f(markerPath);
if (!f) return false;
std::vector<std::string> lines;
std::string l;
while (std::getline(f, l)) lines.push_back(l);
return lines == expected;
}
void writeSaplingMarker(const std::string& markerPath, const std::vector<std::string>& lines) {
std::ofstream f(markerPath, std::ios::trunc);
if (!f) return;
for (const auto& l : lines) f << l << "\n";
}
// Canonical Zcash-family Sapling trusted-setup param digests — identical bytes across every
// fork/platform. Source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params().
// Keep in sync if the params are ever rotated.
const std::pair<std::string, std::string> kSaplingParamDigests[] = {
{ "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" },
{ "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" },
};
} // namespace
bool Connection::verifySaplingParamsIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests)
{ {
std::string params_dir = getSaplingParamsDir(); if (dir.empty()) {
if (params_dir.empty()) {
DEBUG_LOGF("verifySaplingParams: params dir is empty\n"); DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
return false; return false;
} }
if (digests.empty()) return false;
#ifdef _WIN32 // 1) Every param must exist.
std::string spend_path = params_dir + "\\sapling-spend.params"; std::vector<std::string> paths;
std::string output_path = params_dir + "\\sapling-output.params"; paths.reserve(digests.size());
#else for (const auto& d : digests) {
std::string spend_path = params_dir + "/sapling-spend.params"; std::string p = joinParamPath(dir, d.first);
std::string output_path = params_dir + "/sapling-output.params"; if (!fs::exists(p)) {
#endif DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
return false;
}
paths.push_back(std::move(p));
}
bool spend_exists = fs::exists(spend_path); // 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
bool output_exists = fs::exists(output_path); // the previous successful hash instead of re-hashing ~48MB on every startup.
const std::string markerPath = joinParamPath(dir, ".sapling_verified");
std::vector<std::string> current;
current.reserve(paths.size());
for (const auto& p : paths) current.push_back(paramStatLine(p));
if (saplingMarkerMatches(markerPath, current)) {
return true;
}
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str()); // 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING"); // (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING"); // here instead of being handed to the daemon and failing later on a shielded operation.
for (size_t i = 0; i < paths.size(); ++i) {
if (!paramHashMatches(paths[i], digests[i].second)) {
DEBUG_LOGF("verifySaplingParams: %s FAILED integrity check (truncated or corrupt)\n",
paths[i].c_str());
return false;
}
}
return spend_exists && output_exists; // 4) Record the verified state so later startups take the fast path.
writeSaplingMarker(markerPath, current);
DEBUG_LOGF("verifySaplingParams: %zu params verified (sha256)\n", paths.size());
return true;
}
bool Connection::verifySaplingParams()
{
std::vector<std::pair<std::string, std::string>> digests;
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
} }
ConnectionConfig Connection::parseConfFile(const std::string& path) ConnectionConfig Connection::parseConfFile(const std::string& path)
@@ -195,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
config.proxy = value; config.proxy = value;
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") { } else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
config.use_tls = parseBoolValue(value); config.use_tls = parseBoolValue(value);
} else if (key == "rpcallowplaintext") {
config.allow_plaintext_remote = parseBoolValue(value);
} }
} }
@@ -209,11 +306,14 @@ ConnectionConfig Connection::autoDetectConfig()
{ {
ConnectionConfig config; ConnectionConfig config;
// Ensure data directory exists // Ensure the data directory exists. Use the non-throwing helper and report any failure
// via config.dir_error so callers can surface it — the old throwing create_directories()
// overload could raise an uncaught filesystem_error straight through autoDetectConfig()'s
// callers (read-only home, permission denied, etc.).
std::string data_dir = getDefaultDataDir(); std::string data_dir = getDefaultDataDir();
if (!fs::exists(data_dir)) { if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str()); DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
fs::create_directories(data_dir); return config; // data dir unusable — bail early with dir_error set
} }
// Try to find DRAGONX.conf // Try to find DRAGONX.conf
@@ -268,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
return true; return true;
} }
// True only for a well-formed IPv4 loopback literal (127.0.0.0/8): exactly four dot-separated
// 0-255 octets with the first == 127. Rejects "127.evil.com", "127.0.0.1.attacker",
// "127.300.0.1", "1270.0.0.1", etc. — the old rfind("127.",0)==0 prefix matched all of those.
static bool isExactIPv4Loopback(const std::string& host)
{
int octets = 0, value = 0, digits = 0;
bool firstIs127 = false;
for (size_t i = 0; i <= host.size(); ++i) {
const char c = (i < host.size()) ? host[i] : '.'; // trailing sentinel flushes the last octet
if (c == '.') {
if (digits == 0 || digits > 3 || value > 255) return false;
if (octets == 0) firstIs127 = (value == 127);
++octets;
value = 0;
digits = 0;
} else if (c >= '0' && c <= '9') {
value = value * 10 + (c - '0');
++digits;
} else {
return false;
}
}
return octets == 4 && firstIs127;
}
bool Connection::isLocalHost(const std::string& host) bool Connection::isLocalHost(const std::string& host)
{ {
std::string lowered = lowercase(host); std::string lowered = lowercase(host);
@@ -277,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
return lowered == "localhost" || lowered == "localhost." || return lowered == "localhost" || lowered == "localhost." ||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" || lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0; isExactIPv4Loopback(lowered);
} }
bool Connection::usesPlaintextRemote(const ConnectionConfig& config) bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
@@ -285,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
return !config.use_tls && !isLocalHost(config.host); return !config.use_tls && !isLocalHost(config.host);
} }
bool Connection::allowsPlaintextRemote(const ConnectionConfig& config)
{
// Explicit opt-in (DRAGONX.conf: rpcallowplaintext=1) to send credentials over a plaintext
// link to a remote host. Off by default — see usesPlaintextRemote().
return config.allow_plaintext_remote;
}
const char* Connection::authSourceName(AuthSource source) const char* Connection::authSourceName(AuthSource source)
{ {
switch (source) { switch (source) {

View File

@@ -5,6 +5,8 @@
#pragma once #pragma once
#include <string> #include <string>
#include <vector>
#include <utility>
namespace dragonx { namespace dragonx {
namespace rpc { namespace rpc {
@@ -27,7 +29,11 @@ struct ConnectionConfig {
std::string proxy; // SOCKS5 proxy for Tor std::string proxy; // SOCKS5 proxy for Tor
bool use_embedded = true; bool use_embedded = true;
bool use_tls = false; bool use_tls = false;
bool allow_plaintext_remote = false; // rpcallowplaintext=1 — opt in to plaintext creds to a remote host
AuthSource auth_source = AuthSource::Missing; AuthSource auth_source = AuthSource::Missing;
// Non-empty when autoDetectConfig() could not create the data directory; callers
// should surface it and abort the connect rather than proceeding blindly.
std::string dir_error;
}; };
/** /**
@@ -69,6 +75,14 @@ public:
*/ */
static bool verifySaplingParams(); static bool verifySaplingParams();
// Verify the Sapling params in `dir` against a { filename, expected-sha256-hex } list.
// Exposed with an injectable dir + digest list so the integrity + marker-cache logic is
// unit-testable without the real ~48MB params; verifySaplingParams() calls it with the
// pinned production digests and getSaplingParamsDir().
static bool verifySaplingParamsIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests);
/** /**
* @brief Get the Sapling params directory * @brief Get the Sapling params directory
*/ */
@@ -119,6 +133,11 @@ public:
*/ */
static bool usesPlaintextRemote(const ConnectionConfig& config); static bool usesPlaintextRemote(const ConnectionConfig& config);
// Whether plaintext credentials to a remote host are explicitly allowed (opt-in via the
// DRAGONX.conf rpcallowplaintext key). Off by default: usesPlaintextRemote() && !this
// means the connect is refused.
static bool allowsPlaintextRemote(const ConnectionConfig& config);
static const char* authSourceName(AuthSource source); static const char* authSourceName(AuthSource source);
private: private:

View File

@@ -426,12 +426,21 @@ std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::
if (!parsed.contains("dragonx-2")) return std::nullopt; if (!parsed.contains("dragonx-2")) return std::nullopt;
const auto& data = parsed["dragonx-2"]; const auto& data = parsed["dragonx-2"];
// CoinGecko emits JSON null (not an omitted key) for fields it can't currently compute —
// commonly usd_24h_change on illiquid/newly-listed tokens — while still returning a valid
// spot price in the same object. .value(key, default) throws type_error on a PRESENT null,
// which the outer catch turns into "no price update at all", so read null-tolerantly and
// keep the valid usd/btc rather than discarding the whole refresh.
auto num = [&data](const char* key, double def) {
auto it = data.find(key);
return (it != data.end() && it->is_number()) ? it->get<double>() : def;
};
PriceRefreshResult result; PriceRefreshResult result;
result.market.price_usd = data.value("usd", 0.0); result.market.price_usd = num("usd", 0.0);
result.market.price_btc = data.value("btc", 0.0); result.market.price_btc = num("btc", 0.0);
result.market.change_24h = data.value("usd_24h_change", 0.0); result.market.change_24h = num("usd_24h_change", 0.0);
result.market.volume_24h = data.value("usd_24h_vol", 0.0); result.market.volume_24h = num("usd_24h_vol", 0.0);
result.market.market_cap = data.value("usd_market_cap", 0.0); result.market.market_cap = num("usd_market_cap", 0.0);
char buf[64]; char buf[64];
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the // Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
@@ -1101,12 +1110,16 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
std::set<std::string> reported; std::set<std::string> reported;
for (const auto& op : result) { for (const auto& op : result) {
if (!op.is_object()) continue; if (!op.is_object()) continue;
std::string opid = op.value("id", std::string()); // Type-checked reads: .value(key, default) throws if the key is PRESENT with a non-string
// type, which would abort the whole poll (and wedge it for the session — see the call site).
if (!op.contains("id") || !op["id"].is_string()) continue;
std::string opid = op["id"].get<std::string>();
if (opid.empty()) continue; if (opid.empty()) continue;
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
reported.insert(opid); reported.insert(opid);
std::string status = op.value("status", std::string()); std::string status = (op.contains("status") && op["status"].is_string())
? op["status"].get<std::string>() : std::string();
if (status == "success") { if (status == "success") {
parsed.doneOpids.push_back(opid); parsed.doneOpids.push_back(opid);
parsed.anySuccess = true; parsed.anySuccess = true;

View File

@@ -1,9 +1,12 @@
#include "wallet_security_controller.h" #include "wallet_security_controller.h"
#include "../util/secure_vault.h" #include "../util/secure_vault.h"
#include "../util/address_validation.h"
#include <cctype> #include <cctype>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <utility> #include <utility>
#include <vector>
namespace dragonx { namespace dragonx {
namespace services { namespace services {
@@ -108,18 +111,35 @@ WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(c
bool WalletSecurityController::isViewingKey(const std::string& key) bool WalletSecurityController::isViewingKey(const std::string& key)
{ {
// Sapling extended full viewing key (mainnet HRP "zxviews"; "zxview" also matches the prefix the // DragonX's z_exportviewingkey returns a Sapling *incoming* viewing key (mainnet HRP "zivks");
// lite backend recognizes). Watch-only: reveals the address's funds but cannot spend them. // z_importviewingkey only decodes that form. Recognize it structurally — a valid Bech32 checksum
return key.rfind("zxview", 0) == 0; // plus a known HRP — instead of a bare prefix, and cover testnet/regtest too. (The old check
// looked for Zcash's "zxview" extended-FVK HRP, which DragonX never emits, so every real viewing
// key was rejected client-side.) Watch-only: reveals the address's funds but cannot spend them.
const std::string hrp = util::bech32Hrp(key);
return hrp == "zivks" // mainnet
|| hrp == "zivktestsapling" // testnet
|| hrp == "zivkregtestsapling"; // regtest
} }
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key) bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
{ {
// Sapling z spending key (HRP "secret-extended-key-{main,test,regtest}"). These run ~300 chars,
// past the Bech32 length cap, so match by HRP prefix and let the daemon vet the payload.
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
// Transparent WIF: base58, ~51-52 chars, common version prefixes. // Transparent WIF: decode Base58Check and confirm it is actually a secret key — version byte plus
if (key.size() >= 51 && key.size() <= 52 && // a 32-byte key, optionally a compression flag (payload 33 or 34 bytes). This accepts BOTH the
(key[0] == '5' || key[0] == 'K' || key[0] == 'L' || key[0] == 'U')) return true; // compressed ("U…") and uncompressed ("7…") mainnet forms and the testnet form, and rejects
// addresses / typos via the real checksum — the old length+first-char heuristic dropped the
// uncompressed mainnet key (which starts with '7', not one of 5/K/L/U).
std::vector<std::uint8_t> payload;
if (util::decodeBase58Check(key, payload) &&
(payload.size() == 33 || payload.size() == 34) &&
(payload[0] == 188 /* DragonX main/regtest SECRET_KEY */ ||
payload[0] == 128 /* DragonX testnet SECRET_KEY */)) {
return true;
}
return false; return false;
} }

View File

@@ -74,7 +74,7 @@ public:
std::size_t minLength = 4); std::size_t minLength = 4);
static KeyKind classifyAddress(const std::string& address); static KeyKind classifyAddress(const std::string& address);
static KeyKind classifyPrivateKey(const std::string& key); static KeyKind classifyPrivateKey(const std::string& key);
// True if `key` is a shielded viewing key (extended full viewing key, "zxview…" — watch-only). // True if `key` is a shielded viewing key (Sapling incoming viewing key, "zivks…" — watch-only).
static bool isViewingKey(const std::string& key); static bool isViewingKey(const std::string& key);
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key. // True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
static bool isRecognizedPrivateKey(const std::string& key); static bool isRecognizedPrivateKey(const std::string& key);

111
src/ui/node_status_banner.h Normal file
View File

@@ -0,0 +1,111 @@
#pragma once
#include <string>
// Persistent node-connectivity banner shown at the top of the content column when the wallet
// cannot reach its node. Distinct from the transient toast notifications: it stays visible for
// as long as the fault persists, so an offline wallet is never silently mistaken for a working
// one. The decision (whether to show, how severe, which action) is a pure function of a state
// snapshot so it can be unit-tested; App::renderNodeStatusBanner() feeds it the live state and
// draws the strip. See src/app.cpp.
namespace dragonx::ui {
// Visual weight. Warning (amber) = recoverable / a reconnect is offered; Error (red) = a hard
// fault the user must act on (the daemon gave up crashing, or a lite wallet failed to open).
enum class NodeBannerSeverity {
Warning,
Error,
};
// What the banner's action button does. App maps this to the concrete call.
enum class NodeBannerAction {
None, // no button — nothing the user can usefully do from here
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
};
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
// already-human-readable status text (connection_status_ / daemon lastError / lite open error).
enum class NodeBannerReason {
None,
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
LiteOpenFailed, // lite build: the wallet failed to open
};
struct NodeBannerState {
bool show = false;
NodeBannerSeverity severity = NodeBannerSeverity::Warning;
NodeBannerReason reason = NodeBannerReason::None;
NodeBannerAction action = NodeBannerAction::None;
std::string detail; // passthrough status/error text (may be empty)
};
// Snapshot of the connection state the banner reads. Plain values so the decision is testable
// without an App instance.
struct NodeBannerInputs {
bool lite = false; // lite build (no embedded daemon / RPC)
bool connected = false; // state_.connected — the master "online" flag
bool warming_up = false; // daemon reachable, RPC warmup (code -28)
bool daemon_initializing = false; // daemon launching / block index loading
bool connection_in_progress = false; // a connect attempt is actively running
// Full-node embedded-daemon crash signal.
bool using_embedded_daemon = false;
bool has_daemon_controller = false;
bool daemon_running = false;
int daemon_crash_count = 0;
std::string connection_status; // human-readable status line (already translated)
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
std::string lite_open_error; // lite: last wallet-open failure reason
};
// Auto-restart give-up threshold — mirrors the crash cap in app_network.cpp's connect loop.
inline constexpr int kNodeBannerCrashGiveUpCount = 3;
inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
NodeBannerState s;
if (in.lite) {
// Lite has no daemon/RPC; "online" == wallet open. Only a genuine open failure is a
// fault worth a persistent banner (a not-yet-created wallet is handled by the normal
// "No wallet open" prompt, and leaves lite_open_error empty).
if (!in.connected && !in.lite_open_error.empty()) {
s.show = true;
s.severity = NodeBannerSeverity::Error;
s.reason = NodeBannerReason::LiteOpenFailed;
s.action = NodeBannerAction::None;
s.detail = in.lite_open_error;
}
return s;
}
// Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns
// the screen, so no banner. An active connect attempt likewise shows progress, not an
// error — don't flicker a banner over it.
if (in.connected) return s;
if (in.warming_up || in.daemon_initializing) return s;
if (in.connection_in_progress) return s;
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
in.daemon_crash_count >= kNodeBannerCrashGiveUpCount) {
s.show = true;
s.severity = NodeBannerSeverity::Error;
s.reason = NodeBannerReason::DaemonCrashed;
s.action = NodeBannerAction::RestartNode;
s.detail = !in.daemon_last_error.empty() ? in.daemon_last_error : in.connection_status;
return s;
}
s.show = true;
s.severity = NodeBannerSeverity::Warning;
s.reason = NodeBannerReason::FullNodeOffline;
s.action = NodeBannerAction::Reconnect;
s.detail = in.connection_status;
return s;
}
} // namespace dragonx::ui

View File

@@ -9,6 +9,8 @@
#include <chrono> #include <chrono>
#include <functional> #include <functional>
#include <cstdio> #include <cstdio>
#include <cstdint>
#include <ctime>
#include "../util/logger.h" #include "../util/logger.h"
#include "schema/ui_schema.h" #include "schema/ui_schema.h"
@@ -22,6 +24,15 @@ enum class NotificationType {
Error Error
}; };
// A retained alert for the persistent history panel. Unlike a live Notification (which fades and is
// erased within seconds), this keeps a wall-clock epoch so its age can be shown as "3m ago" long
// after the toast is gone. See App::renderAlertHistoryPanel.
struct AlertRecord {
std::string message;
NotificationType type;
std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display
};
struct Notification { struct Notification {
std::string message; std::string message;
NotificationType type; NotificationType type;
@@ -85,6 +96,16 @@ public:
void push(const std::string& message, NotificationType type, float duration = 5.0f) { void push(const std::string& message, NotificationType type, float duration = 5.0f) {
notifications_.emplace_back(message, type, duration); notifications_.emplace_back(message, type, duration);
// Retain a copy in the persistent history (the toast above will fade in seconds; this
// survives so the user can review what happened). Thread note: every push is on the UI
// thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock,
// consistent with the rest of this class. Do NOT push from a raw worker thread.
history_.push_back(AlertRecord{message, type, static_cast<std::int64_t>(std::time(nullptr))});
++total_pushed_;
while (history_.size() > kMaxHistory) {
history_.pop_front();
}
// Log errors and warnings (debug-only output) // Log errors and warnings (debug-only output)
if (type == NotificationType::Error) { if (type == NotificationType::Error) {
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str()); DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
@@ -127,6 +148,15 @@ public:
max_notifications_ = max; max_notifications_ = max;
} }
// ── Persistent alert history (for the status-bar bell panel) ──
/// Retained alerts, oldest first (capped at kMaxHistory; the toast deque is separate).
const std::deque<AlertRecord>& history() const { return history_; }
bool hasHistory() const { return !history_.empty(); }
void clearHistory() { history_.clear(); }
/// Monotonic count of every alert ever pushed this session — survives capping/clearing, so it is
/// the correct basis for an "unseen since last opened" count (deque size is not).
std::uint64_t totalPushed() const { return total_pushed_; }
private: private:
Notifications() = default; Notifications() = default;
~Notifications() = default; ~Notifications() = default;
@@ -137,6 +167,10 @@ private:
size_t max_notifications_ = 5; size_t max_notifications_ = 5;
std::function<void(const std::string&, bool)> console_callback_; std::function<void(const std::string&, bool)> console_callback_;
std::deque<AlertRecord> history_;
std::uint64_t total_pushed_ = 0;
static constexpr size_t kMaxHistory = 100;
static float schemaDuration(const char* key, float fallback) { static float schemaDuration(const char* key, float fallback) {
float v = schema::UI().drawElement("components.notifications", key).size; float v = schema::UI().drawElement("components.notifications", key).size;
return v > 0.0f ? v : fallback; return v > 0.0f ? v : fallback;

View File

@@ -16,6 +16,7 @@
#include "../windows/console_tab.h" #include "../windows/console_tab.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../../util/platform.h" #include "../../util/platform.h"
#include "../../util/seed_phrase.h"
#include "../../resources/embedded_resources.h" #include "../../resources/embedded_resources.h"
#include <ctime> #include <ctime>
#include "../../rpc/rpc_client.h" #include "../../rpc/rpc_client.h"
@@ -231,16 +232,12 @@ static void exitLowSpec(bool applyEffects) {
s_settingsState.low_spec_snapshot.valid = false; s_settingsState.low_spec_snapshot.valid = false;
} }
// Count whitespace-separated words in a (seed) buffer — used to validate/guide restore input. // Count words in a (seed) buffer — used to validate/guide restore input. Normalizes exotic Unicode
// whitespace (NBSP etc.) first so the count matches the phrase actually submitted (shared with the
// first-run restore gate via util::seed_phrase).
static int liteSeedWordCount(const char* s) { static int liteSeedWordCount(const char* s) {
int words = 0; return dragonx::util::seedPhraseWordCount(
bool inWord = false; dragonx::util::normalizeSeedPhrase(s ? std::string(s) : std::string()));
for (; s && *s; ++s) {
const bool space = std::isspace(static_cast<unsigned char>(*s)) != 0;
if (space) inWord = false;
else if (!inWord) { inWord = true; ++words; }
}
return words;
} }
static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() { static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() {
@@ -277,7 +274,9 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
break; break;
case wallet::LiteWalletLifecycleOperation::RestoreFromSeed: case wallet::LiteWalletLifecycleOperation::RestoreFromSeed:
input.request.restoreRequest.walletPath = s_settingsState.lite_wallet_path; input.request.restoreRequest.walletPath = s_settingsState.lite_wallet_path;
input.request.restoreRequest.seedPhrase = s_settingsState.lite_restore_seed; // Normalize (fold NBSP/exotic whitespace to plain spaces) so an NBSP-pasted phrase the
// gate counted as 24 words also restores correctly at the backend.
input.request.restoreRequest.seedPhrase = dragonx::util::normalizeSeedPhrase(s_settingsState.lite_restore_seed);
input.request.restoreRequest.passphrase = s_settingsState.lite_lifecycle_passphrase; input.request.restoreRequest.passphrase = s_settingsState.lite_lifecycle_passphrase;
input.request.restoreRequest.birthday = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_birthday)); input.request.restoreRequest.birthday = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_birthday));
input.request.restoreRequest.account = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_account)); input.request.restoreRequest.account = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_account));
@@ -320,7 +319,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
// entered secret on this return path). // entered secret on this return path).
if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) { if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) {
const int words = liteSeedWordCount(s_settingsState.lite_restore_seed); const int words = liteSeedWordCount(s_settingsState.lite_restore_seed);
if (words != 24) { if (!dragonx::util::isCompleteRecoveryPhrase(words)) {
s_settingsState.lite_lifecycle_status = s_settingsState.lite_lifecycle_status =
"Enter all 24 seed words to restore (got " + std::to_string(words) + ")"; "Enter all 24 seed words to restore (got " + std::to_string(words) + ")";
s_settingsState.lite_lifecycle_summary.clear(); s_settingsState.lite_lifecycle_summary.clear();
@@ -1351,6 +1350,21 @@ void RenderSettingsPage(App* app) {
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_verbose")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_verbose"));
} }
// W7 QoL: quick diagnostics actions — open the log folder, and copy a plaintext support bundle
// (version, variant, daemon/RPC/wallet/log state) to the clipboard.
{
const float diagBtnW = (contentW - Layout::spacingMd()) * 0.5f;
if (TactileButton(TR("settings_open_log_folder"), ImVec2(diagBtnW, 0), S.resolveFont("button")))
dragonx::util::Platform::openFolder(dragonx::util::Platform::getObsidianDragonDir());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_log_folder"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("settings_copy_diagnostics"), ImVec2(diagBtnW, 0), S.resolveFont("button"))) {
ImGui::SetClipboardText(app->buildDiagnosticsReport().c_str());
ui::Notifications::instance().info(TR("settings_diagnostics_copied"), 4.0f);
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_copy_diagnostics"));
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Collapsible: Tools & Actions... --- // --- Collapsible: Tools & Actions... ---

48
src/ui/staleness_badge.h Normal file
View File

@@ -0,0 +1,48 @@
#pragma once
#include <cstdint>
// Refresh-staleness badge (finding W6-2). The wallet stamps WalletState::last_balance_update only on
// a *successful* balance fetch (see services/network_refresh_service.cpp), so a busy daemon that fails
// z_gettotalbalance without dropping the whole connection leaves the old balance on screen with a
// frozen timestamp — and the node-status banner (which only fires on a full disconnect) stays hidden.
// This badge is the surface that reflects that "connected but the number may be out of date" state.
//
// The decision is a pure function of (last-success timestamp, now, connected) so it is unit-testable;
// balance_tab.cpp draws the pill. Both use the same std::time(nullptr) wall-clock the refresh path
// stamps with, so age = now - last_update is consistent.
namespace dragonx::ui {
enum class StalenessSeverity {
Warning, // amber — noticeably behind
Error, // red — very stale, something is likely wrong
};
struct StalenessBadge {
bool show = false;
StalenessSeverity severity = StalenessSeverity::Warning;
std::int64_t seconds_old = 0;
};
// Balance refreshes every ~2s on the Overview profile (and ~10s while syncing), so tens of seconds
// with no successful update means refreshes are failing, not merely slow.
inline constexpr std::int64_t kStaleAfterSeconds = 45;
inline constexpr std::int64_t kVeryStaleAfterSeconds = 180;
inline StalenessBadge evaluateStalenessBadge(std::int64_t last_update, std::int64_t now, bool connected) {
StalenessBadge b;
// Offline is the node-status banner's job; don't double up. A zero stamp means "never updated
// this session" (fresh start) or "reset on disconnect" — nothing to be stale about yet.
if (!connected || last_update <= 0) return b;
std::int64_t age = now - last_update;
if (age < 0) age = 0; // clock skew guard
if (age < kStaleAfterSeconds) return b;
b.show = true;
b.seconds_old = age;
b.severity = (age >= kVeryStaleAfterSeconds) ? StalenessSeverity::Error : StalenessSeverity::Warning;
return b;
}
} // namespace dragonx::ui

View File

@@ -26,6 +26,7 @@
#include "../effects/imgui_acrylic.h" #include "../effects/imgui_acrylic.h"
#include "../sidebar.h" #include "../sidebar.h"
#include "../notifications.h" #include "../notifications.h"
#include "../staleness_badge.h"
#include "../../embedded/IconsMaterialDesign.h" #include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h" #include "imgui.h"
#include <toml++/toml.hpp> #include <toml++/toml.hpp>
@@ -421,6 +422,25 @@ static void RenderBalanceClassic(App* app)
dl->AddText(capFont, capFont->LegacySize, dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + 12 * dp, cy), ImVec2(cx + 12 * dp, cy),
WithAlpha(Success(), 200), buf); WithAlpha(Success(), 200), buf);
} else {
// Refresh-staleness badge (W6-2): connected, not syncing/mining, but the balance
// hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without
// dropping the whole connection). The node banner only covers full disconnects, so
// this pill is the sole signal that the shown number may be out of date.
StalenessBadge badge = evaluateStalenessBadge(
state.last_balance_update, std::time(nullptr), state.connected);
if (badge.show) {
const bool err = (badge.severity == StalenessSeverity::Error);
ImU32 fg = err ? Error() : Warning();
ImU32 bg = WithAlpha(fg, 38);
ImU32 bd = WithAlpha(fg, 90);
snprintf(buf, sizeof(buf), "%s %s",
TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str());
ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd);
// Hover → explain what stale means and how old the data actually is.
if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + pillSz.x, cy + pillSz.y)))
Tooltip("%s", TR("data_stale_tooltip"));
}
} }
// Hover glow // Hover glow

View File

@@ -1416,8 +1416,11 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
{ {
if (cmd.empty()) return false; if (cmd.empty()) return false;
addLine("> " + cmd, ConsoleChannel::Command); // Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
AppendConsoleHistory(command_history_, cmd, 100); // 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; history_index_ = -1;
// First token, lowercased, for built-in interception. // First token, lowercased, for built-in interception.

View File

@@ -1,10 +1,34 @@
#include "console_tab_helpers.h" #include "console_tab_helpers.h"
#include <algorithm> #include <algorithm>
#include <cctype>
namespace dragonx { namespace dragonx {
namespace ui { 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 ComputeConsoleInputHeight(float frameHeightWithSpacing,
float itemSpacingY, float itemSpacingY,
float spacingSm, float spacingSm,
@@ -27,5 +51,27 @@ float ClampConsoleWrapWidth(float contentWidth, float paddingX)
return std::max(50.0f, contentWidth - paddingX * 2.0f); 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 ui
} // namespace dragonx } // namespace dragonx

View File

@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <string>
namespace dragonx { namespace dragonx {
namespace ui { namespace ui {
@@ -14,5 +16,14 @@ float ComputeConsoleOutputHeight(float availableHeight,
float minHeightRatio); float minHeightRatio);
float ClampConsoleWrapWidth(float contentWidth, float paddingX); 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 ui
} // namespace dragonx } // namespace dragonx

View File

@@ -20,6 +20,17 @@ std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses)
return {}; return {};
} }
std::string resolveMiningUserAddress(const std::string& payoutAddress,
const std::string& firstShieldedAddress,
const std::string& firstTransparentAddress)
{
// The configured payout address is the pool login rewards go to, so it wins over
// the wallet's own addresses. "x" is the placeholder for an unset field.
if (!payoutAddress.empty() && payoutAddress != "x") return payoutAddress;
if (!firstShieldedAddress.empty()) return firstShieldedAddress;
return firstTransparentAddress; // may be empty -> caller reports "no address"
}
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues, bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
const std::string& value) const std::string& value)
{ {

View File

@@ -10,6 +10,14 @@ namespace ui {
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted); bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses); std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses);
// The xmrig "user" — the pool login block rewards are credited to. The user-entered
// payout address wins; otherwise fall back to the wallet's own first shielded, then
// transparent, address. "x" is the empty-field placeholder and counts as unset. The
// result may be empty (no address anywhere), which the caller treats as an error.
std::string resolveMiningUserAddress(const std::string& payoutAddress,
const std::string& firstShieldedAddress,
const std::string& firstTransparentAddress);
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues, bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
const std::string& value); const std::string& value);
const char* defaultPoolUrl(); const char* defaultPoolUrl();

View File

@@ -251,11 +251,15 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
} }
y += gap * 0.5f; y += gap * 0.5f;
// The pool list = official pools user-saved favorites the current custom pool.
const auto effective = util::effectivePools(app->settings()->getPoolUrl(),
app->settings()->getSavedPoolUrls());
// --- POOLS (N) header + Refresh --- // --- POOLS (N) header + Refresh ---
{ {
char hdr[48]; char hdr[48];
snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"), snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"),
(int)util::knownPools().size()); (int)effective.size());
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr); dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr);
float btnS = ovFont->LegacySize + 6 * dp; float btnS = ovFont->LegacySize + 6 * dp;
@@ -278,11 +282,11 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
{ {
ImDrawList* cdl = ImGui::GetWindowDrawList(); ImDrawList* cdl = ImGui::GetWindowDrawList();
const auto snap = app->poolStatsSnapshot(); const auto snap = app->poolStatsSnapshot();
const util::KnownPool* current = util::findKnownPoolByUrl(app->settings()->getPoolUrl()); const util::KnownPool* current = util::findPoolByUrl(effective, app->settings()->getPoolUrl());
const float childW = ImGui::GetContentRegionAvail().x; const float childW = ImGui::GetContentRegionAvail().x;
const float listRowH = capFont->LegacySize + 10 * dp; const float listRowH = capFont->LegacySize + 10 * dp;
for (const auto& kp : util::knownPools()) { for (const auto& kp : effective) {
ImGui::PushID(kp.id.c_str()); ImGui::PushID(kp.id.c_str());
const bool isCurrent = current && current->id == kp.id; const bool isCurrent = current && current->id == kp.id;
const auto it = snap.byId.find(kp.id); const auto it = snap.byId.find(kp.id);
@@ -315,7 +319,17 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
char right[64]; char right[64];
std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string(""); std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("");
snprintf(right, sizeof(right), "%s %.0f%% fee", hrStr.c_str(), kp.feePercent); // Prefer the live fee the pool reports; fall back to the compile-time
// KnownPool.feePercent. A synthetic user pool has an unknown (<0) fee, so
// we show just its hashrate placeholder for it.
double feePct = (it != snap.byId.end() && it->second.feePercent >= 0.0)
? it->second.feePercent
: kp.feePercent;
if (feePct >= 0.0)
snprintf(right, sizeof(right), "%s %s%% fee", hrStr.c_str(),
FormatFeePercent(feePct).c_str());
else
snprintf(right, sizeof(right), "%s", hrStr.c_str());
ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right); ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right);
cdl->AddText(capFont, capFont->LegacySize, cdl->AddText(capFont, capFont->LegacySize,
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right); ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);

View File

@@ -41,6 +41,21 @@ std::string FormatHashrate(double hashrate)
return std::string(buffer); return std::string(buffer);
} }
std::string FormatFeePercent(double feePercent)
{
// Whole fees read "1"; fractional ones keep only their significant decimals
// ("1.5", "0.9", "1.25") with no trailing zeros. Capped at 2 dp — finer than
// any pool advertises, and the caller appends the "%".
char buffer[32];
snprintf(buffer, sizeof(buffer), "%.2f", feePercent);
std::string s(buffer);
if (s.find('.') != std::string::npos) {
s.erase(s.find_last_not_of('0') + 1);
if (!s.empty() && s.back() == '.') s.pop_back();
}
return s;
}
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty) double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
{ {
(void)difficulty; (void)difficulty;

View File

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

View File

@@ -148,14 +148,14 @@ static double GetAvailableBalance(App* app) {
return 0.0; return 0.0;
} }
// Recipient validity = prefix/length pre-filter AND a real encoding-checksum check, so a // Recipient validity via the shared, structure-based recognizers: a real encoding-checksum check
// transcription error that still matches the prefix/length is no longer labelled "Valid". // plus the actual DragonX address types — so a transcription error is never "Valid", and a valid
// The checksum verifiers are version-agnostic, so they never reject a genuine address. // P2SH/multisig ('b…') recipient is no longer dropped by a hardcoded 'R'-only prefix filter.
static bool IsValidShieldedAddr(const char* a) { static bool IsValidShieldedAddr(const char* a) {
return a[0] == 'z' && a[1] == 's' && strlen(a) > 60 && dragonx::util::isValidBech32(a); return a && dragonx::util::isShieldedAddress(a);
} }
static bool IsValidTransparentAddr(const char* a) { static bool IsValidTransparentAddr(const char* a) {
return a[0] == 'R' && strlen(a) >= 34 && dragonx::util::isValidBase58Check(a); return a && dragonx::util::isTransparentAddress(a);
} }
static std::string timeAgo(int64_t timestamp) { static std::string timeAgo(int64_t timestamp) {
@@ -1318,8 +1318,7 @@ void RenderSendTab(App* app)
trimmed.erase(trimmed.begin()); trimmed.erase(trimmed.begin());
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\n' || trimmed.back() == '\r' || trimmed.back() == '\t')) while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\n' || trimmed.back() == '\r' || trimmed.back() == '\t'))
trimmed.pop_back(); trimmed.pop_back();
bool looksValid = (trimmed.size() > 30 && bool looksValid = dragonx::util::isValidRecipientAddress(trimmed);
((trimmed[0] == 'z' && trimmed[1] == 's') || trimmed[0] == 'R'));
if (looksValid && s_to_address[0] == '\0') { if (looksValid && s_to_address[0] == '\0') {
s_preview_text = trimmed; s_preview_text = trimmed;
s_paste_previewing = true; s_paste_previewing = true;

View File

@@ -288,17 +288,21 @@ public:
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet. // encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
const bool bLock = pres.probed && pres.encrypted; const bool bLock = pres.probed && pres.encrypted;
// Seed-phrase vs legacy. Runtime status (z_exportmnemonic → activeWalletSeedBadge) is // Seed-phrase vs legacy. The offline probe reads the hdchain record's fMnemonicSeed flag
// authoritative for the ACTIVE wallet; otherwise the offline probe reads the hdchain // straight off disk (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy with no phrase,
// record's fMnemonicSeed flag directly (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy // 0 = couldn't tell). That is the SAME flag the daemon's IsMnemonicSeed()/z_exportmnemonic
// with no phrase, 0 = couldn't tell) — which, unlike bare HD-record presence, actually // consult, so a definitive read is authoritative and takes precedence. The runtime badge
// distinguishes the two. seed uses the same 1/2/0 encoding. // (activeWalletSeedBadge, reset only on disconnect) is used ONLY when the offline probe
const int activeBadge = rowActive[i] ? app->activeWalletSeedBadge() : 0; // couldn't decide — it must NEVER override a definitive on-disk read, or a stale
int seed = activeBadge; // HasMnemonic carried from a previously-active mnemonic wallet mislabels a legacy wallet as
if (seed == 0 && pres.probed) { // a seed-phrase wallet. seed uses the same 1/2/0 encoding.
if (pres.mnemonic != 0) seed = pres.mnemonic; // read the flag off disk int seed = 0;
else if (pres.complete && !pres.hdSeed) seed = 2; // no HD records at all → no phrase if (pres.probed && pres.mnemonic != 0)
} seed = pres.mnemonic; // definitive on-disk flag wins
else if (rowActive[i] && app->activeWalletSeedBadge() != 0)
seed = app->activeWalletSeedBadge(); // runtime fallback (active row only)
else if (pres.probed && pres.complete && !pres.hdSeed)
seed = 2; // no HD records at all → no phrase
const bool bSeed = (seed == 1); const bool bSeed = (seed == 1);
const bool bLegacy = (seed == 2); const bool bLegacy = (seed == 2);
// seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD // seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD
@@ -792,6 +796,14 @@ private:
} else { } else {
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile)); const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed }; res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
// A cap-truncated btree walk still yields DEFINITIVE positives (a found marker is
// authoritative even when the scan didn't finish), so carry what it read — notably
// the fMnemonicSeed flag. Otherwise a large wallet probed after the shared budget is
// spent loses its seed/legacy classification and the row falls back to the (possibly
// stale) runtime badge, mislabelling a legacy wallet as a seed-phrase wallet.
if (bt.mnemonicSeed != 0) res.mnemonic = bt.mnemonicSeed;
if (bt.hdSeed) res.hdSeed = true;
if (bt.encrypted) res.encrypted = true;
budget -= std::min(budget, std::max(bt.bytesRead, pr.bytesRead)); budget -= std::min(budget, std::max(bt.bytesRead, pr.bytesRead));
} }
} }

View File

@@ -76,7 +76,7 @@ std::vector<int> bech32HrpExpand(const std::string& hrp)
} // namespace } // namespace
bool isValidBase58Check(const std::string& s) bool decodeBase58Check(const std::string& s, std::vector<std::uint8_t>& payloadOut)
{ {
if (s.size() < 5 || s.size() > 256) return false; if (s.size() < 5 || s.size() > 256) return false;
std::vector<std::uint8_t> data; std::vector<std::uint8_t> data;
@@ -88,7 +88,15 @@ bool isValidBase58Check(const std::string& s)
unsigned char h2[crypto_hash_sha256_BYTES]; unsigned char h2[crypto_hash_sha256_BYTES];
crypto_hash_sha256(h1, data.data(), payloadLen); crypto_hash_sha256(h1, data.data(), payloadLen);
crypto_hash_sha256(h2, h1, sizeof(h1)); crypto_hash_sha256(h2, h1, sizeof(h1));
return std::memcmp(h2, data.data() + payloadLen, 4) == 0; if (std::memcmp(h2, data.data() + payloadLen, 4) != 0) return false;
payloadOut.assign(data.begin(), data.begin() + payloadLen);
return true;
}
bool isValidBase58Check(const std::string& s)
{
std::vector<std::uint8_t> payload;
return decodeBase58Check(s, payload);
} }
bool isValidBech32(const std::string& s) bool isValidBech32(const std::string& s)
@@ -127,5 +135,39 @@ bool isValidBech32(const std::string& s)
return bech32Polymod(combined) == 1; // original Bech32 constant (Sapling, not Bech32m) return bech32Polymod(combined) == 1; // original Bech32 constant (Sapling, not Bech32m)
} }
std::string bech32Hrp(const std::string& s)
{
if (!isValidBech32(s)) return {};
// isValidBech32 already rejected mixed case and guaranteed a non-empty HRP before the
// final '1' separator, so lower-casing and splitting there recovers the HRP verbatim.
std::string lower(s);
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
const std::size_t sep = lower.rfind('1');
if (sep == std::string::npos) return {};
return lower.substr(0, sep);
}
bool isTransparentAddress(const std::string& s)
{
std::vector<std::uint8_t> payload;
// version byte (1) + hash160 (20) = 21 bytes, checksum stripped. Covers P2PKH ('R', v60) and
// P2SH/multisig ('b', v85); the daemon vets the exact version byte for the active network.
return decodeBase58Check(s, payload) && payload.size() == 21;
}
bool isShieldedAddress(const std::string& s)
{
const std::string hrp = bech32Hrp(s);
return hrp == "zs" // mainnet Sapling payment address
|| hrp == "ztestsapling" // testnet
|| hrp == "zregtestsapling"; // regtest
}
bool isValidRecipientAddress(const std::string& s)
{
return isTransparentAddress(s) || isShieldedAddress(s);
}
} // namespace util } // namespace util
} // namespace dragonx } // namespace dragonx

View File

@@ -11,7 +11,9 @@
#pragma once #pragma once
#include <cstdint>
#include <string> #include <string>
#include <vector>
namespace dragonx { namespace dragonx {
namespace util { namespace util {
@@ -20,9 +22,31 @@ namespace util {
// (transparent R-addresses). Version-byte agnostic by design. // (transparent R-addresses). Version-byte agnostic by design.
bool isValidBase58Check(const std::string& s); bool isValidBase58Check(const std::string& s);
// Decodes `s` as Base58Check; on success returns true and fills `payloadOut` with the
// decoded bytes EXCLUDING the trailing 4-byte checksum (i.e. version byte + data). Lets
// callers inspect the version byte / payload length (e.g. to tell a WIF from an address).
bool decodeBase58Check(const std::string& s, std::vector<std::uint8_t>& payloadOut);
// True if `s` is a valid Bech32 string (Sapling zs-addresses). The HRP is taken // True if `s` is a valid Bech32 string (Sapling zs-addresses). The HRP is taken
// from the string itself and folded into the checksum, so no HRP is hardcoded. // from the string itself and folded into the checksum, so no HRP is hardcoded.
bool isValidBech32(const std::string& s); bool isValidBech32(const std::string& s);
// Returns the (lower-cased) human-readable prefix of a valid Bech32 string, or "" if
// `s` is not valid Bech32. The HRP identifies the key/address type (e.g. "zivks").
std::string bech32Hrp(const std::string& s);
// True if `s` is a transparent (Base58Check) address — P2PKH *or* P2SH/multisig. Accepts any
// address whose payload is a 21-byte version+hash160, so it covers both the 'R…' (v60) and 'b…'
// (v85 script) forms on every DragonX network and rejects WIF keys / typos by checksum. Version-byte
// agnostic by design — a bare prefix check ('R' only) silently drops valid P2SH recipients.
bool isTransparentAddress(const std::string& s);
// True if `s` is a shielded Sapling payment address (HRP "zs" / "ztestsapling" / "zregtestsapling"),
// with a valid Bech32 checksum. Distinguishes a payment address from a viewing key (e.g. "zivks…").
bool isShieldedAddress(const std::string& s);
// True if `s` is any address a payment can be sent to (transparent or shielded).
bool isValidRecipientAddress(const std::string& s);
} // namespace util } // namespace util
} // namespace dragonx } // namespace dragonx

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

@@ -140,15 +140,16 @@ std::map<std::string, std::string> parseDaemonChecksums(const std::string& body)
// | File | SHA-256 | // | File | SHA-256 |
// |------|---------| // |------|---------|
// | dragonx-1.0.2-linux-amd64.zip | `85f1dd…16` | // | dragonx-1.0.2-linux-amd64.zip | `85f1dd…16` |
// Per line: blank out the table/code delimiters ('|' and '`'), then find the 64-hex token (the // Per line: blank out the table/code/emphasis delimiters ('|', '`', and markdown '*'/'_' so a
// hash) and a token ending in ".zip" (the archive name). Header/separator/prose rows lack one // bolded **archive.zip** still tokenizes), then find the 64-hex token (the hash) and a token
// or the other and are skipped, so this is robust to surrounding text and column order. // ending in ".zip" (the archive name). Header/separator/prose rows lack one or the other and are
// skipped, so this is robust to surrounding text and column order.
std::map<std::string, std::string> out; std::map<std::string, std::string> out;
std::istringstream in(body); std::istringstream in(body);
std::string line; std::string line;
while (std::getline(in, line)) { while (std::getline(in, line)) {
for (char& c : line) for (char& c : line)
if (c == '|' || c == '`') c = ' '; if (c == '|' || c == '`' || c == '*' || c == '_') c = ' ';
std::istringstream ls(line); std::istringstream ls(line);
std::string tok, hash, name; std::string tok, hash, name;
while (ls >> tok) { while (ls >> tok) {

View File

@@ -320,7 +320,7 @@ void I18n::loadBuiltinEnglish()
strings_["seed_backup_load_failed"] = "Could not load the seed phrase."; strings_["seed_backup_load_failed"] = "Could not load the seed phrase.";
strings_["seed_backup_copy"] = "Copy"; strings_["seed_backup_copy"] = "Copy";
strings_["seed_backup_save"] = "Save to file…"; 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_save_failed"] = "Could not write ";
strings_["seed_backup_close"] = "Close"; 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."; strings_["seed_backup_reminder"] = "Your wallet has a 24-word recovery seed phrase. Back it up now in Settings → Node & Security.";
@@ -1185,6 +1185,41 @@ void I18n::loadBuiltinEnglish()
strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it."; strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it.";
strings_["switch_corrupt_repair"] = "Try to repair (salvage)"; strings_["switch_corrupt_repair"] = "Try to repair (salvage)";
// Block-database recovery (offered when the node aborts on an unreadable/format-mismatched block DB).
strings_["block_db_reindex_title"] = "Rebuild block database?";
strings_["block_db_reindex_warn"] = "The node can't read its block database.";
strings_["block_db_reindex_body"] = "This usually happens after a daemon update changes the on-disk format, or if the block index is damaged. Your wallet and coins are safe — the node just can't load the chain, so balances show as zero.\n\nRebuilding re-reads your existing block files and can take a while (it also rescans your wallet). Nothing is downloaded.";
strings_["block_db_reindex_confirm"] = "Rebuild block database";
strings_["block_db_reindex_notify"] = "The node can't read its block database (often after a daemon update). Rebuild it to restore your balance — see the prompt, or Settings Node.";
strings_["block_db_reindex_started"] = "Rebuilding the block database from your blocks — this can take a while.";
// Wallet auto-recovery warning (the node moved wallet.dat aside and loaded a salvaged copy).
strings_["wallet_recovered_title"] = "Your wallet was auto-recovered";
strings_["wallet_recovered_warn"] = "The node moved your wallet aside and loaded a salvaged copy.";
strings_["wallet_recovered_body"] = "On startup the node decided your wallet.dat looked damaged and recovered it automatically. Your ORIGINAL wallet was NOT deleted — it was renamed to \"wallet.<numbers>.bak\" in your data folder, and a salvaged copy is loaded now.\n\nThe salvaged copy may be incomplete, so the balance shown here could be wrong — don't treat it as final.\n\nThis is often a false alarm caused by leftover database files (e.g. after moving the wallet between machines). To restore your original: quit the wallet, then in the data folder rename the current wallet.dat aside, rename \"wallet.<numbers>.bak\" back to \"wallet.dat\", delete the \"database\" folder and any \"__db.*\" files, and reopen.";
strings_["wallet_recovered_open_folder"] = "Open data folder";
strings_["wallet_recovered_dismiss"] = "Keep salvaged copy";
strings_["wallet_recovered_restore"] = "Restore original wallet";
strings_["wallet_recovered_notify"] = "The node recovered your wallet and moved the original to a .bak — your shown balance may be incomplete. See the prompt to restore it.";
// One-click "Restore original wallet" flow.
strings_["wallet_restore_started"] = "Restoring your original wallet and restarting the node…";
strings_["wallet_restore_busy"] = "The node is busy restarting — try again in a moment.";
strings_["wallet_restore_ok"] = "Original wallet restored. The node is loading it now.";
strings_["wallet_restore_no_backup"] = "Couldn't find a wallet.<timestamp>.bak to restore. Nothing was changed.";
strings_["wallet_restore_bad_backup"] = "The backup wallet file looks unreadable, so it was NOT restored — your current wallet is unchanged. Restore from your own backup instead.";
strings_["wallet_restore_stop_failed"] = "The node didn't stop in time, so nothing was changed. Try again.";
strings_["wallet_restore_move_failed"] = "Couldn't set the current wallet aside — nothing was changed.";
strings_["wallet_restore_copy_failed"] = "Couldn't install the backup wallet; your current wallet was left in place.";
strings_["wallet_restore_no_restart"] = "Your original wallet was restored, but the node didn't restart — start it from Settings.";
// One-click "Rebuild wallet database" flow (fixes a BDB-inconsistent wallet that keeps getting salvaged).
strings_["wallet_recovered_rebuild"] = "Rebuild wallet database (recommended)";
strings_["wallet_rebuild_started"] = "Rebuilding your wallet database and restarting the node…";
strings_["wallet_rebuild_ok"] = "Wallet database rebuilt — the node is loading it and rescanning for your balance.";
strings_["wallet_rebuild_no_helper"] = "The wallet-rebuild helper isn't available in this build. Use Restore, or rebuild manually.";
strings_["wallet_rebuild_no_source"] = "Couldn't find a readable wallet to rebuild. Nothing was changed.";
strings_["wallet_rebuild_failed"] = "The rebuild didn't produce a valid wallet, so nothing was changed. Your wallet is untouched.";
strings_["wallet_rebuild_install_failed"] = "Couldn't install the rebuilt wallet; your current wallet was left in place.";
// Receive Tab // Receive Tab
strings_["receiving_addresses"] = "Your Receiving Addresses"; strings_["receiving_addresses"] = "Your Receiving Addresses";
strings_["new_z_shielded"] = "New z-Address (Shielded)"; strings_["new_z_shielded"] = "New z-Address (Shielded)";
@@ -1309,6 +1344,24 @@ void I18n::loadBuiltinEnglish()
strings_["sb_connecting_err"] = "Connecting to daemon — %s"; strings_["sb_connecting_err"] = "Connecting to daemon — %s";
strings_["sb_daemon_crashed"] = "Daemon crashed %d times"; strings_["sb_daemon_crashed"] = "Daemon crashed %d times";
strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd"; strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd";
strings_["sb_block_db_unreadable"] = "Block database unreadable — rebuild required";
strings_["sb_wallet_needs_recovery"] = "Wallet needs recovery — see the prompt";
// Persistent node-status banner (App::renderNodeStatusBanner).
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
strings_["node_banner_reconnect"] = "Reconnect";
strings_["node_banner_restart"] = "Restart node";
// Refresh-staleness badge (W6-2) on the Total Balance card.
strings_["data_stale_prefix"] = "Updated";
strings_["data_stale_tooltip"] =
"Balance may be out of date — the wallet hasn't received a fresh update recently. "
"Check your node connection.";
// Persistent alert-history panel (status-bar bell).
strings_["alerts_history_tooltip"] = "Recent alerts";
strings_["alerts_recent"] = "RECENT ALERTS";
strings_["alerts_none"] = "No alerts yet";
strings_["alerts_clear"] = "Clear alert history";
strings_["daemon_port_busy_warn"] = strings_["daemon_port_busy_warn"] =
"Port " DRAGONX_DEFAULT_RPC_PORT " is in use but isn't responding as a DragonX node. " "Port " DRAGONX_DEFAULT_RPC_PORT " is in use but isn't responding as a DragonX node. "
"Close the program using it (or free the port), then restart — the wallet can't start " "Close the program using it (or free the port), then restart — the wallet can't start "
@@ -1316,6 +1369,17 @@ void I18n::loadBuiltinEnglish()
strings_["sb_extracting_sapling"] = "Extracting Sapling parameters..."; strings_["sb_extracting_sapling"] = "Extracting Sapling parameters...";
strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters."; strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters.";
strings_["sb_sapling_not_found"] = "Sapling parameters not found."; strings_["sb_sapling_not_found"] = "Sapling parameters not found.";
strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions.";
strings_["sb_daemon_files_failed"] = "Failed to write daemon files to %s — check free disk space and permissions.";
strings_["loading_stall_title"] = "Taking longer than expected";
strings_["loading_stall_body"] = "The daemon has been initializing for %.0fs. This can be normal after an update or on first launch (loading the block index or rescanning) — it will connect automatically once ready.";
strings_["loading_stall_hint"] = "Still stuck? Open Settings and use Restart Daemon, or check the Console for details.";
strings_["sb_plaintext_remote_blocked"] = "Refusing to send RPC credentials over plaintext to a remote host. Add rpcallowplaintext=1 to DRAGONX.conf to allow it, or enable TLS with rpctls=1.";
strings_["settings_open_log_folder"] = "Open log folder";
strings_["settings_copy_diagnostics"] = "Copy diagnostics";
strings_["settings_diagnostics_copied"] = "Diagnostics copied to clipboard";
strings_["tt_open_log_folder"] = "Open the folder containing the debug and crash logs";
strings_["tt_copy_diagnostics"] = "Copy a support snapshot (version, daemon/wallet/log state — no secrets) to the clipboard";
strings_["sb_dragonxd_running"] = "dragonxd running"; strings_["sb_dragonxd_running"] = "dragonxd running";
strings_["sb_dragonxd_stopping"] = "Stopping dragonxd..."; strings_["sb_dragonxd_stopping"] = "Stopping dragonxd...";
strings_["sb_dragonxd_stopped"] = "dragonxd stopped"; strings_["sb_dragonxd_stopped"] = "dragonxd stopped";

View File

@@ -5,10 +5,12 @@
#include "logger.h" #include "logger.h"
#include <cstdarg> #include <cstdarg>
#include <cstdint>
#include <ctime> #include <ctime>
#include <chrono> #include <chrono>
#include <iomanip> #include <iomanip>
#include <sstream> #include <sstream>
#include <filesystem>
namespace dragonx { namespace dragonx {
namespace util { namespace util {
@@ -36,11 +38,27 @@ bool Logger::init(const std::string& path)
file_.close(); file_.close();
} }
// W7-4: cap the log's growth — if the existing file is already large, rotate it to a single .1
// backup before reopening in append mode, so a long-lived or verbose session can't grow it
// without bound.
{
std::error_code ec;
const auto sz = std::filesystem::file_size(path, ec);
constexpr std::uintmax_t kMaxLogBytes = 10ull * 1024ull * 1024ull; // 10 MB
if (!ec && sz > kMaxLogBytes) {
std::filesystem::rename(path, path + ".1", ec); // replaces any previous .1 backup
if (ec) std::filesystem::remove(path, ec); // fall back to truncation if rename fails
}
}
file_.open(path, std::ios::out | std::ios::app); file_.open(path, std::ios::out | std::ios::app);
initialized_ = file_.is_open(); initialized_ = file_.is_open();
if (initialized_) { if (initialized_) {
write("=== Logger initialized ==="); // Write the banner directly, NOT via write(): write() re-locks the non-recursive mutex_ we
// already hold here, which would deadlock (latent — init() was previously never called, W7-2).
file_ << "=== Logger initialized ===" << std::endl;
file_.flush();
} }
return initialized_; return initialized_;

View File

@@ -3,6 +3,7 @@
// Released under the GPLv3 // Released under the GPLv3
#include "payment_uri.h" #include "payment_uri.h"
#include "address_validation.h"
#include <sstream> #include <sstream>
#include <iomanip> #include <iomanip>
@@ -161,20 +162,11 @@ PaymentURI parsePaymentURI(const std::string& uri)
return result; return result;
} }
// Basic address format check. NOTE: this is format-only by design — the send flow // Address format check via the shared, structure-based recognizers (checksum + real DragonX
// checksum-validates the recipient (isValidBase58Check / shielded check) before broadcasting, // address types). This accepts shielded ("zs…"), P2PKH ("R…") and P2SH/multisig ("b…") forms —
// so an invalid-checksum address parsed here can never actually be sent to. // the old prefix/length heuristic rejected P2SH and hardcoded a 't' prefix DragonX never emits.
bool validFormat = false; const bool validFormat = isShieldedAddress(result.address) ||
isTransparentAddress(result.address);
// z-address: starts with 'zs' and is 78+ chars
if (result.address[0] == 'z' && result.address.size() >= 78) {
validFormat = true;
}
// t-address: starts with 'R' (DragonX) or 't' (HUSH) and is ~34 chars
else if ((result.address[0] == 'R' || result.address[0] == 't') &&
result.address.size() >= 26 && result.address.size() <= 36) {
validFormat = true;
}
if (!validFormat) { if (!validFormat) {
result.error = "Invalid address format"; result.error = "Invalid address format";

View File

@@ -126,6 +126,27 @@ bool Platform::openUrl(const std::string& url)
#endif #endif
} }
bool Platform::ensureDirectory(const std::string& dir, std::string* outError)
{
if (dir.empty()) {
if (outError) *outError = "Cannot create directory: empty path.";
return false;
}
std::error_code ec;
if (std::filesystem::is_directory(dir, ec)) return true;
ec.clear();
std::filesystem::create_directories(dir, ec);
if (ec) {
if (outError) {
*outError = "Cannot create " + dir + ": " + ec.message() +
". Check permissions / free space.";
}
DEBUG_LOGF("[ERROR] ensureDirectory failed for %s: %s\n", dir.c_str(), ec.message().c_str());
return false;
}
return true;
}
bool Platform::openFolder(const std::string& path, bool createIfMissing) bool Platform::openFolder(const std::string& path, bool createIfMissing)
{ {
if (path.empty()) return false; if (path.empty()) return false;

View File

@@ -128,6 +128,17 @@ public:
*/ */
static void ensureObsidianDragonSetup(); static void ensureObsidianDragonSetup();
/**
* @brief Create a directory (and parents) if missing, with a clear error on failure.
*
* Uses the non-throwing std::error_code overload internally. On failure sets *outError
* (when non-null) to one consistent, user-facing message:
* "Cannot create <dir>: <reason>. Check permissions / free space."
*
* @return true if the directory exists (already did, or was just created).
*/
static bool ensureDirectory(const std::string& dir, std::string* outError = nullptr);
/** /**
* @brief Get total system RAM in megabytes * @brief Get total system RAM in megabytes
* @return Total physical RAM in MB, or 0 on failure * @return Total physical RAM in MB, or 0 on failure

View File

@@ -45,16 +45,30 @@ struct PoolHashrate {
std::string id; std::string id;
double hashrateHs = 0.0; double hashrateHs = 0.0;
bool ok = false; bool ok = false;
// Live pool fee (%) read from the same stats JSON. <0 means "not available" —
// callers fall back to the compile-time KnownPool.feePercent.
double feePercent = -1.0;
}; };
// The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is // The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is
// meaningless to balance against). Stable order. // meaningless to balance against). Stable order.
const std::vector<KnownPool>& knownPools(); const std::vector<KnownPool>& knownPools();
// The known pool whose stratum matches `url` (host, and port when both specify one), // The pool in `pools` whose stratum matches `url` (host, and port when both specify
// or nullptr. `url` may be a bare host, host:port, or carry a scheme/userinfo/path. // one), or nullptr. `url` may be a bare host, host:port, or carry a scheme/path.
const KnownPool* findPoolByUrl(const std::vector<KnownPool>& pools, const std::string& url);
// Same, over the built-in official pools only.
const KnownPool* findKnownPoolByUrl(const std::string& url); const KnownPool* findKnownPoolByUrl(const std::string& url);
// The full list the UI should show: the official knownPools(), plus a row for every
// user-saved pool URL and for `currentPoolUrl` when it isn't one of those — so a
// custom/bookmarked pool is a first-class, selectable row. Synthetic (user) rows are
// official=false and carry no statsUrl (feePercent<0, no live hashrate), and endpoints
// are de-duplicated so a saved URL that equals an official pool isn't listed twice.
std::vector<KnownPool> effectivePools(const std::string& currentPoolUrl,
const std::vector<std::string>& savedPoolUrls);
// The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`. // The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`.
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback); std::string resolvePoolAlgo(const std::string& url, const std::string& fallback);
@@ -64,6 +78,13 @@ std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
double parsePoolHashrate(PoolStatsSchema schema, const std::string& json, double parsePoolHashrate(PoolStatsSchema schema, const std::string& json,
const std::string& miningcorePoolId, bool& ok); const std::string& miningcorePoolId, bool& ok);
// Parse a pool's advertised fee (%) out of the same stats JSON (DragonXIs:
// pools.<name>.poolFee; Miningcore: pools[id].poolFeePercent). Selects the same
// pool entry as parsePoolHashrate. Sets ok=false and returns 0 when the field is
// absent / malformed, so the caller keeps the compile-time fallback.
double parsePoolFee(PoolStatsSchema schema, const std::string& json,
const std::string& miningcorePoolId, bool& ok);
// Weighted-random pick among the usable (ok==true) pools: probability is inversely // Weighted-random pick among the usable (ok==true) pools: probability is inversely
// proportional to hashrate (smaller pools favored), so miners spread out instead of // proportional to hashrate (smaller pools favored), so miners spread out instead of
// all stampeding to the single lowest pool. The current pool (`currentId`, may be // all stampeding to the single lowest pool. The current pool (`currentId`, may be

View File

@@ -28,7 +28,7 @@ const std::vector<KnownPool>& knownPools()
KnownPool{ KnownPool{
"dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush", "dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush",
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs, "https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
/*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true, /*miningcorePoolId=*/"", /*feePercent=*/1.0, /*official=*/true,
}, },
}; };
return pools; return pools;
@@ -83,13 +83,62 @@ bool sameEndpoint(const std::string& a, const std::string& b)
return pa == pb; return pa == pb;
} }
// Build a synthetic, selectable pool row for a user-supplied URL (a saved favorite
// or the current custom pool). We don't know its stats API, so it carries no
// statsUrl / live hashrate and an unknown (<0) fee — the UI falls back to "—".
KnownPool makeUserPool(const std::string& url)
{
KnownPool p;
const std::string hp = hostPortOf(url);
std::string host, port;
splitHostPort(hp, host, port);
p.id = "user:" + trimmed(url); // stable + unique (used as the ImGui id)
p.label = host.empty() ? hp : host;
p.stratum = trimmed(url); // what the miner connects to / a row-click restores
p.algo = ""; // unknown; xmrig resolves via resolvePoolAlgo's fallback
p.statsUrl = ""; // no known stats endpoint -> no live hashrate/fee
p.schema = PoolStatsSchema::DragonXIs;
p.miningcorePoolId = "";
p.feePercent = -1.0; // unknown fee
p.official = false;
return p;
}
} // namespace } // namespace
const KnownPool* findPoolByUrl(const std::vector<KnownPool>& pools, const std::string& url)
{
for (const auto& p : pools)
if (sameEndpoint(p.stratum, url)) return &p;
return nullptr;
}
const KnownPool* findKnownPoolByUrl(const std::string& url) const KnownPool* findKnownPoolByUrl(const std::string& url)
{ {
for (const auto& p : knownPools()) return findPoolByUrl(knownPools(), url);
if (sameEndpoint(p.stratum, url)) return &p; }
return nullptr;
std::vector<KnownPool> effectivePools(const std::string& currentPoolUrl,
const std::vector<std::string>& savedPoolUrls)
{
std::vector<KnownPool> pools = knownPools();
// Skip anything whose endpoint already appears (official or an earlier user row).
auto listed = [&](const std::string& url) {
return findPoolByUrl(pools, url) != nullptr;
};
for (const auto& url : savedPoolUrls) {
if (trimmed(url).empty() || listed(url)) continue;
pools.push_back(makeUserPool(url));
}
// The pool currently being mined, if not already shown, so the active pool is
// always visible even before it's bookmarked.
if (!trimmed(currentPoolUrl).empty() && !listed(currentPoolUrl))
pools.push_back(makeUserPool(currentPoolUrl));
return pools;
} }
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback) std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
@@ -159,6 +208,64 @@ double parsePoolHashrate(PoolStatsSchema schema, const std::string& jsonStr,
return 0.0; return 0.0;
} }
double parsePoolFee(PoolStatsSchema schema, const std::string& jsonStr,
const std::string& miningcorePoolId, bool& ok)
{
ok = false;
try {
const json j = json::parse(jsonStr);
if (schema == PoolStatsSchema::DragonXIs) {
// { "pools": { "dragonx": { "poolFee": <num>, ... }, ... } }
if (j.contains("pools") && j["pools"].is_object()) {
const auto& pools = j["pools"];
auto readFee = [&](const json& pool, double& out) -> bool {
if (pool.is_object() && pool.contains("poolFee") &&
pool["poolFee"].is_number()) {
out = pool["poolFee"].get<double>();
return true;
}
return false;
};
double fee = 0.0;
if (pools.contains("dragonx") && readFee(pools["dragonx"], fee)) {
ok = true;
return fee;
}
for (auto it = pools.begin(); it != pools.end(); ++it) {
if (readFee(it.value(), fee)) {
ok = true;
return fee;
}
}
}
} else { // Miningcore: pools[id].poolFeePercent
if (j.contains("pools") && j["pools"].is_array()) {
const json* chosen = nullptr;
for (const auto& pool : j["pools"]) {
if (!pool.is_object()) continue;
if (!miningcorePoolId.empty()) {
if (pool.value("id", std::string{}) == miningcorePoolId) {
chosen = &pool;
break;
}
} else if (!chosen) {
chosen = &pool; // first pool when no id requested
}
}
if (chosen && chosen->contains("poolFeePercent") &&
(*chosen)["poolFeePercent"].is_number()) {
ok = true;
return (*chosen)["poolFeePercent"].get<double>();
}
}
}
} catch (...) {
// fall through — ok stays false
}
return 0.0;
}
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools, std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
const std::string& currentId, const std::string& currentId,
std::mt19937& rng) std::mt19937& rng)

View File

@@ -94,6 +94,12 @@ void PoolStatsService::run(std::vector<KnownPool> pools)
const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok); const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok);
hr.ok = ok; hr.ok = ok;
hr.hashrateHs = ok ? v : 0.0; hr.hashrateHs = ok ? v : 0.0;
bool feeOk = false;
const double fee = parsePoolFee(p.schema, body, p.miningcorePoolId, feeOk);
// Only trust a sane fee; anything else leaves feePercent < 0 so the UI
// falls back to the compile-time KnownPool.feePercent.
if (feeOk && fee >= 0.0 && fee <= 100.0) hr.feePercent = fee;
} }
results[p.id] = hr; results[p.id] = hr;
} }

77
src/util/seed_phrase.cpp Normal file
View File

@@ -0,0 +1,77 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "seed_phrase.h"
#include <cctype>
namespace dragonx {
namespace util {
std::string normalizeSeedPhrase(const std::string& raw)
{
// Unicode whitespace encoded as UTF-8, each mapped to a single ASCII space; and zero-width marks
// to strip. We substitute only these EXACT byte sequences, so ordinary (ASCII) word bytes are
// never touched — the common all-ASCII phrase just gets its spacing collapsed and trimmed.
static const char* const kSpaces[] = {
"\xC2\xA0", // U+00A0 NBSP
"\xC2\x85", // U+0085 NEL
"\xE1\x9A\x80", // U+1680 ogham space
"\xE2\x80\x80", "\xE2\x80\x81", "\xE2\x80\x82", "\xE2\x80\x83", // U+20002003
"\xE2\x80\x84", "\xE2\x80\x85", "\xE2\x80\x86", "\xE2\x80\x87", // U+20042007
"\xE2\x80\x88", "\xE2\x80\x89", "\xE2\x80\x8A", // U+2008200A
"\xE2\x80\xAF", // U+202F narrow NBSP
"\xE2\x81\x9F", // U+205F math space
"\xE3\x80\x80", // U+3000 ideographic
};
static const char* const kZeroWidth[] = {
"\xE2\x80\x8B", "\xE2\x80\x8C", "\xE2\x80\x8D", // U+200B/C/D
"\xEF\xBB\xBF", // U+FEFF BOM / ZWNBSP
};
std::string s = raw;
auto replaceAll = [&s](const std::string& from, const std::string& to) {
if (from.empty()) return;
std::size_t pos = 0;
while ((pos = s.find(from, pos)) != std::string::npos) {
s.replace(pos, from.size(), to);
pos += to.size();
}
};
for (const char* zw : kZeroWidth) replaceAll(zw, "");
for (const char* sp : kSpaces) replaceAll(sp, " ");
// Collapse ASCII whitespace runs to a single space and trim ends.
std::string out;
out.reserve(s.size());
bool pendingSpace = false;
bool sawWord = false;
for (unsigned char c : s) {
if (std::isspace(c)) { pendingSpace = sawWord; continue; }
if (pendingSpace) { out.push_back(' '); pendingSpace = false; }
out.push_back(static_cast<char>(c));
sawWord = true;
}
return out;
}
int seedPhraseWordCount(const std::string& phrase)
{
int words = 0;
bool inWord = false;
for (unsigned char c : phrase) {
const bool space = std::isspace(c) != 0;
if (space) inWord = false;
else if (!inWord) { inWord = true; ++words; }
}
return words;
}
bool isCompleteRecoveryPhrase(int words)
{
return words == 24;
}
} // namespace util
} // namespace dragonx

38
src/util/seed_phrase.h Normal file
View File

@@ -0,0 +1,38 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// seed_phrase.h — shared, pure helpers for validating a pasted BIP39 recovery phrase.
// One source of truth for the seed-length contract so the lite-restore gates (first-run
// wizard + Settings) cannot drift apart. No I/O, no secrets retained — safe for both variants.
#pragma once
#include <string>
namespace dragonx {
namespace util {
// Normalize a pasted recovery phrase for consistent word counting AND backend submission:
// - every run of Unicode/ASCII whitespace (incl. NBSP U+00A0, en/em spaces U+2000200A,
// U+202F, U+205F, ideographic U+3000, NEL, tab/newline) collapses to a single ASCII space,
// - zero-width marks (U+200B/C/D, U+FEFF BOM) are stripped,
// - leading/trailing space is trimmed.
// Word bytes are copied verbatim — only these exact whitespace byte-sequences are substituted.
// The lite backend (tiny-bip39) splits on the literal ASCII space and does NO Unicode folding, so
// a phrase pasted with NBSPs (common from PDFs/note apps) is otherwise unrestorable; normalizing
// before submit makes the words space-separated and recoverable.
std::string normalizeSeedPhrase(const std::string& raw);
// Count ASCII-whitespace-separated words. Pair with normalizeSeedPhrase so exotic spacing counts right.
int seedPhraseWordCount(const std::string& phrase);
// True if `words` is a complete recovery phrase the DragonX backends accept. DragonX seeds are
// 24-word / 256-bit / 32-byte-entropy ONLY: the SDXL lite backend's LightWallet::new copies the
// phrase entropy into a fixed [u8;32] (a shorter valid-BIP39 phrase — 12/15/18/21 words — makes it
// panic, uncaught, across the restore FFI), and the full-node daemon likewise generates 24 words.
// Both lite-restore gates MUST use this so a crash-inducing length is refused client-side.
bool isCompleteRecoveryPhrase(int words);
} // namespace util
} // namespace dragonx

View File

@@ -339,5 +339,155 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path,
return st; return st;
} }
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// Tier 3: collect the raw (key,value) record BYTES — the read half of the offline wallet REBUILD that
// recovers a BDB-inconsistent wallet.dat (stale extent metadata: our tolerant walk reads records the
// daemon's Berkeley DB verify rejects and auto-salvages). A helper then writes these verbatim into a
// fresh, consistent BDB so the daemon loads it cleanly. Records are copied byte-for-byte — encrypted
// key material (ckey/csapzkey/mkey) passes through as opaque ciphertext, so no passphrase is needed.
// Values that live in BDB OVERFLOW pages (only large `tx` history records) are NOT captured (skipped +
// counted); they are irrelevant to funds — a rescan rebuilds transaction history. Same bounds-checked,
// subdb-aware, visited-set-capped walk as parseWalletBtree.
struct WalletRawRecords {
bool parsed = false; ///< the btree walked cleanly
bool complete = false; ///< the whole file was read (not cap-truncated)
std::vector<std::pair<std::string, std::string>> records; ///< inline (key,value) bytes, verbatim
int keyRecords = 0; ///< fund-critical key-type records captured (key/wkey/ckey/z*/sap*/hdseed)
int skippedOverflow = 0; ///< records whose value spilled to overflow pages (tx history) — not captured
std::size_t bytesRead = 0;
};
inline WalletRawRecords extractWalletBtreeRecords(const std::string& path,
std::size_t maxBytes = 512u * 1024u * 1024u) {
WalletRawRecords out;
std::ifstream f(path, std::ios::binary);
if (!f) return out;
std::string buf;
{
f.seekg(0, std::ios::end);
std::streamoff sz = f.tellg();
if (sz < 512) return out;
const std::size_t want = std::min<std::size_t>(static_cast<std::size_t>(sz), maxBytes);
f.seekg(0, std::ios::beg);
buf.resize(want);
f.read(&buf[0], static_cast<std::streamsize>(want));
buf.resize(static_cast<std::size_t>(std::max<std::streamsize>(0, f.gcount())));
if (buf.size() < 512) return out;
out.bytesRead = buf.size();
out.complete = (buf.size() == static_cast<std::size_t>(sz));
}
const unsigned char* B = reinterpret_cast<const unsigned char*>(buf.data());
const std::size_t N = buf.size();
auto rd32at = [&](std::size_t o, bool le) -> uint32_t {
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) | ((uint32_t)B[o+2]<<16) | ((uint32_t)B[o+3]<<24)
: (uint32_t)B[o+3] | ((uint32_t)B[o+2]<<8) | ((uint32_t)B[o+1]<<16) | ((uint32_t)B[o]<<24);
};
constexpr uint32_t kBtreeMagic = 0x00053162u;
bool le;
if (rd32at(12, true) == kBtreeMagic) le = true;
else if (rd32at(12, false) == kBtreeMagic) le = false;
else return out;
auto r32 = [&](std::size_t o) { return o + 4 <= N ? rd32at(o, le) : 0u; };
auto r16 = [&](std::size_t o) -> uint32_t {
if (o + 2 > N) return 0;
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) : (uint32_t)B[o+1] | ((uint32_t)B[o]<<8);
};
const uint32_t pagesize = r32(20);
if (pagesize < 512 || pagesize > 65536 || (pagesize & (pagesize - 1)) != 0) return out;
const uint32_t npages = static_cast<uint32_t>(N / pagesize);
const uint32_t root = r32(88);
if (npages == 0 || root == 0 || root >= npages) return out;
if (B[24] != 0 || (B[26] & 0x01)) return out; // page checksum/encryption — offsets shift; bail
constexpr uint8_t P_IBTREE = 3, P_LBTREE = 5, P_BTREEMETA = 9, B_KEYDATA = 1;
constexpr std::size_t kMaxPagesVisited = 600000;
constexpr int kMaxKeys = 4000000;
std::vector<bool> visited(npages, false);
std::size_t pagesVisited = 0;
int keys = 0;
bool aborted = false;
auto rdpgno = [&](const unsigned char* p) -> uint32_t {
return le ? (uint32_t)p[0] | ((uint32_t)p[1]<<8) | ((uint32_t)p[2]<<16) | ((uint32_t)p[3]<<24)
: (uint32_t)p[3] | ((uint32_t)p[2]<<8) | ((uint32_t)p[1]<<16) | ((uint32_t)p[0]<<24);
};
auto traverse = [&](uint32_t rootPg, auto&& fn) {
std::fill(visited.begin(), visited.end(), false);
std::vector<uint32_t> stack;
if (rootPg < npages && !visited[rootPg]) { visited[rootPg] = true; stack.push_back(rootPg); }
while (!stack.empty()) {
const uint32_t pg = stack.back(); stack.pop_back();
if (pg >= npages) continue;
if (++pagesVisited > kMaxPagesVisited) { aborted = true; return; }
const std::size_t base = static_cast<std::size_t>(pg) * pagesize;
if (base + 26 > N) continue;
const uint8_t type = B[base + 25];
const uint32_t entries = r16(base + 20);
if (26 + static_cast<std::size_t>(entries) * 2 > pagesize) continue;
if (type == P_IBTREE) {
for (uint32_t i = 0; i < entries; ++i) {
const uint32_t off = r16(base + 26 + i * 2);
if (off + 8 > pagesize) continue;
const uint32_t child = r32(base + off + 4);
if (child > 0 && child < npages && !visited[child]) { visited[child] = true; stack.push_back(child); }
}
} else if (type == P_LBTREE) {
for (uint32_t i = 0; i + 1 < entries; i += 2) {
if (++keys > kMaxKeys) { aborted = true; return; }
const uint32_t ko = r16(base + 26 + i * 2);
const uint32_t dO = r16(base + 26 + (i + 1) * 2);
if (ko + 3 > pagesize || dO + 3 > pagesize) continue;
if (B[base + ko + 2] != B_KEYDATA) continue; // overflow/dup key — never a record name
const uint32_t kl = r16(base + ko);
if (kl < 1 || ko + 3 + kl > pagesize) continue;
const uint8_t dtype = B[base + dO + 2];
const uint32_t dl = r16(base + dO);
const unsigned char* dp = (dO + 3 + dl <= pagesize) ? B + base + dO + 3 : nullptr;
fn(B + base + ko + 3, kl, dp, dl, dtype);
}
}
}
};
// Master DB: subdb-name → subdb meta/root pgno (big-endian value; native fallback), same as parseWalletBtree.
std::vector<uint32_t> subRoots;
traverse(root, [&](const unsigned char*, uint32_t, const unsigned char* dp, uint32_t dl, uint8_t dtype) {
if (dtype != B_KEYDATA || dl != 4 || !dp) return;
const uint32_t cand[2] = {
(uint32_t)dp[3] | ((uint32_t)dp[2]<<8) | ((uint32_t)dp[1]<<16) | ((uint32_t)dp[0]<<24),
rdpgno(dp),
};
for (const uint32_t pgno : cand) {
if (pgno == 0 || pgno >= npages) continue;
const uint8_t pt = B[static_cast<std::size_t>(pgno) * pagesize + 25];
if (pt == P_BTREEMETA) {
const uint32_t sr = r32(static_cast<std::size_t>(pgno) * pagesize + 88);
if (sr > 0 && sr < npages) { subRoots.push_back(sr); break; }
} else if (pt == P_LBTREE || pt == P_IBTREE) { subRoots.push_back(pgno); break; }
}
});
if (aborted) return out;
if (subRoots.empty()) subRoots.push_back(root);
auto isKeyType = [](const char* nm, uint32_t nl) {
auto is = [&](const char* s) { return std::strlen(s) == nl && std::memcmp(nm, s, nl) == 0; };
return is("key") || is("wkey") || is("ckey") || is("zkey") || is("czkey")
|| is("sapzkey") || is("csapzkey") || is("hdseed") || is("chdseed");
};
for (const uint32_t sr : subRoots) {
traverse(sr, [&](const unsigned char* kp, uint32_t kl, const unsigned char* dp, uint32_t dl, uint8_t dt) {
if (dt != B_KEYDATA || !dp) { out.skippedOverflow++; return; } // overflow value (tx history) — skip
out.records.emplace_back(std::string(reinterpret_cast<const char*>(kp), kl),
std::string(reinterpret_cast<const char*>(dp), dl));
const uint32_t nlen = kp[0];
if (nlen >= 2 && nlen <= 20 && 1u + nlen <= kl && isKeyType(reinterpret_cast<const char*>(kp + 1), nlen))
out.keyRecords++;
});
if (aborted) return out;
}
out.parsed = true;
return out;
}
} // namespace util } // namespace util
} // namespace dragonx } // namespace dragonx

View File

@@ -80,8 +80,10 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
for (int attempt = 0; attempt < 2; ++attempt) { for (int attempt = 0; attempt < 2; ++attempt) {
if (bridge.execute("save", "").ok) return true; if (bridge.execute("save", "").ok) return true;
} }
// Persistent failure: the spent note will be re-derived from the chain on the next sync, // Persistent failure: the spent note will be re-derived from the chain on the next sync, so this
// so this is a robustness gap, not fund loss. (Retry handles the common transient case.) // is a robustness gap, not fund loss. Log it (W5-1) — both callers discard this return, so the
// failure was previously completely silent.
liteLog("save failed after send/shield — the wallet will re-derive it on the next sync");
return false; return false;
} }
@@ -600,7 +602,8 @@ void LiteWalletController::startSync()
// The backend does NOT auto-save after a sync, so persist the freshly-scanned wallet; // The backend does NOT auto-save after a sync, so persist the freshly-scanned wallet;
// otherwise the next launch re-scans from the checkpoint (~30 min). Set `done` only // otherwise the next launch re-scans from the checkpoint (~30 min). Set `done` only
// after the save so a syncComplete() observer sees a fully-persisted wallet. // after the save so a syncComplete() observer sees a fully-persisted wallet.
bridge->execute("save", ""); if (!bridge->execute("save", "").ok) // W5-2: don't leave a failed post-sync save silent
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
} }
done->store(true); done->store(true);
}); });
@@ -631,7 +634,8 @@ bool LiteWalletController::startRescan()
// `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the // `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the
// birthday height — a blocking, uninterruptible full scan, same as `sync`. // birthday height — a blocking, uninterruptible full scan, same as `sync`.
bridge->execute("rescan", ""); bridge->execute("rescan", "");
bridge->execute("save", ""); // backend doesn't auto-save after a rescan if (!bridge->execute("save", "").ok) // W5-2: don't leave a failed post-rescan save silent
liteLog("save failed after rescan — the next launch will re-scan from the checkpoint");
} }
done->store(true); done->store(true);
}); });
@@ -883,6 +887,11 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe
} }
// Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with // Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with
// "secret-..." / viewing keys "zxview...", so this prefix check usually won't collide. // "secret-..." / viewing keys "zxview...", so this prefix check usually won't collide.
// NB: the lite/SDXL backend's viewing key is an *extended full* viewing key ("zxviews…",
// hrp_sapling_viewing_key), which is genuinely correct here — do NOT "harmonize" this with the
// full node, whose z_importviewingkey takes an *incoming* viewing key ("zivks…") instead. The
// two variants accept different viewing-key forms; a VK is not portable between them. Regardless,
// the two-command fallback below means a mis-guessed prefix never rejects an otherwise-valid key.
const char first = spendingOrViewingKey[0]; const char first = spendingOrViewingKey[0];
const bool transparentFirst = (first == 'U' || first == '5' || first == 'K' || first == 'L'); const bool transparentFirst = (first == 'U' || first == '5' || first == 'K' || first == 'L');
@@ -1172,25 +1181,47 @@ void LiteWalletController::workerLoop()
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request) LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
{ {
auto result = lifecycle_.createWallet(request); auto result = lifecycle_.createWallet(request);
secureWipeLiteSecret(request.passphrase);
onLifecycleResult(result); 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; return result;
} }
LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request) LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request)
{ {
auto result = lifecycle_.openWallet(request); auto result = lifecycle_.openWallet(request);
secureWipeLiteSecret(request.passphrase);
onLifecycleResult(result); 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; return result;
} }
LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request) LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request)
{ {
auto result = lifecycle_.restoreWallet(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.seedPhrase);
secureWipeLiteSecret(request.passphrase); secureWipeLiteSecret(request.passphrase);
onLifecycleResult(result);
return result; return result;
} }

View File

@@ -3,6 +3,9 @@
#include "chat/chat_service.h" #include "chat/chat_service.h"
#include "chat/chat_database.h" #include "chat/chat_database.h"
#include "daemon/daemon_controller.h" #include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
#include "util/connect_stall.h"
#include "util/logger.h"
#include "data/transaction_history_cache.h" #include "data/transaction_history_cache.h"
#include "data/address_book.h" #include "data/address_book.h"
#include "data/wallet_index.h" #include "data/wallet_index.h"
@@ -29,7 +32,13 @@
#include "ui/windows/mining_benchmark.h" #include "ui/windows/mining_benchmark.h"
#include "ui/windows/mining_pool_panel.h" #include "ui/windows/mining_pool_panel.h"
#include "ui/windows/mining_tab_helpers.h" #include "ui/windows/mining_tab_helpers.h"
#include "ui/node_status_banner.h"
#include "ui/staleness_badge.h"
#include "ui/notifications.h"
#include "data/seed_migration_resume.h"
#include "util/address_validation.h" #include "util/address_validation.h"
#include "util/seed_phrase.h"
#include "daemon/daemon_startup_diagnosis.h"
#include "util/amount_format.h" #include "util/amount_format.h"
#include "util/payment_uri.h" #include "util/payment_uri.h"
#include "util/platform.h" #include "util/platform.h"
@@ -719,7 +728,9 @@ void testConnectionConfig()
void testPaymentUri() void testPaymentUri()
{ {
std::string taddr = "R" + std::string(33, 'a'); // Real checksummed addresses — the parser now checksum-validates the recipient (not a bare
// prefix/length filter), so it accepts P2PKH / P2SH / shielded and rejects transcription errors.
std::string taddr = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"; // P2PKH (v60)
auto parsed = dragonx::util::parsePaymentURI( auto parsed = dragonx::util::parsePaymentURI(
"drgx:" + taddr + "?amount=1.25000000&label=Main+Wallet&memo=hello%20there&message=thanks"); "drgx:" + taddr + "?amount=1.25000000&label=Main+Wallet&memo=hello%20there&message=thanks");
@@ -730,11 +741,19 @@ void testPaymentUri()
EXPECT_EQ(parsed.memo, std::string("hello there")); EXPECT_EQ(parsed.memo, std::string("hello there"));
EXPECT_EQ(parsed.message, std::string("thanks")); EXPECT_EQ(parsed.message, std::string("thanks"));
std::string zaddr = "zs" + std::string(76, 'b'); std::string zaddr = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5";
auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5"); auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5");
EXPECT_TRUE(zparsed.valid); EXPECT_TRUE(zparsed.valid);
EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001); EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001);
// Regression: a P2SH/multisig recipient ("b…", v85) must parse — the old 'R'/'t'-only filter dropped it.
auto p2sh = dragonx::util::parsePaymentURI("drgx:bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C?amount=1");
EXPECT_TRUE(p2sh.valid);
// A transcription error (flipped checksum char) is now rejected at parse time.
auto typo = dragonx::util::parsePaymentURI("drgx:R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX?amount=1");
EXPECT_FALSE(typo.valid);
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1"); auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
EXPECT_FALSE(invalid.valid); EXPECT_FALSE(invalid.valid);
EXPECT_EQ(invalid.error, std::string("Invalid negative amount")); EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
@@ -826,6 +845,18 @@ void testWalletFileProbe()
EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1); EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1);
EXPECT_EQ(s.createdEpoch, kCreated); EXPECT_EQ(s.createdEpoch, kCreated);
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed); EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
// Byte-collecting reader (the wallet-REBUILD read half): same walk, but collects (key,value) bytes.
auto ex = dragonx::util::extractWalletBtreeRecords((dir / "btree.dat").string());
EXPECT_TRUE(ex.parsed);
EXPECT_TRUE(ex.records.size() >= static_cast<size_t>(3)); // tx + key + keymeta captured verbatim
EXPECT_EQ(ex.keyRecords, 1); // the "key" record is fund-critical
bool foundKeyRec = false; // value copied byte-for-byte, name intact
for (const auto& kv : ex.records)
if (kv.first.size() >= 4 && (unsigned char)kv.first[0] == 3 && kv.first.compare(1, 3, "key") == 0
&& !kv.second.empty()) foundKeyRec = true;
EXPECT_TRUE(foundKeyRec);
EXPECT_FALSE(dragonx::util::extractWalletBtreeRecords((dir / "junk.dat").string()).parsed);
} }
// 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the // 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the
@@ -2014,6 +2045,20 @@ void testNetworkRefreshResultModels()
EXPECT_EQ(state.market.price_history.size(), static_cast<size_t>(1)); EXPECT_EQ(state.market.price_history.size(), static_cast<size_t>(1));
} }
// Regression: CoinGecko emits JSON null (not an omitted key) for fields it can't compute —
// commonly usd_24h_change on illiquid tokens like DRGX — while still returning a valid spot
// price. The parser must keep the valid usd/btc, not discard the whole update on the null.
auto priceNull = Refresh::parseCoinGeckoPriceResponse(
R"({"dragonx-2":{"usd":0.42,"btc":0.000009,"usd_24h_change":null,"usd_24h_vol":null,"usd_market_cap":50000}})",
0);
EXPECT_TRUE(priceNull.has_value());
if (priceNull) {
EXPECT_NEAR(priceNull->market.price_usd, 0.42, 0.00000001);
EXPECT_NEAR(priceNull->market.price_btc, 0.000009, 0.00000001);
EXPECT_NEAR(priceNull->market.change_24h, 0.0, 0.0001); // null -> default, not a throw
EXPECT_NEAR(priceNull->market.market_cap, 50000.0, 0.0001);
}
Refresh::markPriceRefreshStarted(state); Refresh::markPriceRefreshStarted(state);
Refresh::applyPriceRefreshFailure(state, "timeout"); Refresh::applyPriceRefreshFailure(state, "timeout");
EXPECT_FALSE(state.market.price_loading); EXPECT_FALSE(state.market.price_loading);
@@ -2195,6 +2240,20 @@ void testOperationStatusPollParsing()
EXPECT_FALSE(malformed.anySuccess); EXPECT_FALSE(malformed.anySuccess);
EXPECT_TRUE(malformed.doneOpids.empty()); EXPECT_TRUE(malformed.doneOpids.empty());
EXPECT_TRUE(malformed.staleOpids.empty()); EXPECT_TRUE(malformed.staleOpids.empty());
// Regression: a type-anomalous element (non-string "id"/"status") must be skipped, not throw —
// a throw here would escape the parser and permanently wedge opid polling for the session. A
// valid tracked opid alongside the anomaly must still be processed.
auto typeSafe = Refresh::parseOperationStatusPoll(json::array({
json{{"id", 12345}, {"status", "failed"}}, // non-string id -> skipped, no throw
json{{"id", "op-ok"}, {"status", 7}}, // non-string status -> "" (not done)
json{{"id", "op-good"}, {"status", "success"}, {"result", json{{"txid", "tx-good"}}}}
}), {"op-ok", "op-good", "op-x"});
EXPECT_TRUE(typeSafe.anySuccess);
EXPECT_EQ(typeSafe.successTxidsByOpid.at("op-good"), std::string("tx-good"));
EXPECT_TRUE(typeSafe.failureMessages.empty()); // the non-string-id "failed" was skipped
EXPECT_EQ(typeSafe.staleOpids.size(), static_cast<size_t>(1)); // op-x absent; op-ok was seen (not stale)
EXPECT_EQ(typeSafe.staleOpids[0], std::string("op-x"));
} }
void testSecureVaultScope() void testSecureVaultScope()
@@ -2477,6 +2536,451 @@ void testDaemonShutdownPolicy()
EXPECT_TRUE(bootstrap.disconnectRpc); EXPECT_TRUE(bootstrap.disconnectRpc);
} }
void testIsLocalHost()
{
using dragonx::rpc::Connection;
// Genuine loopback / local hosts.
EXPECT_TRUE(Connection::isLocalHost("127.0.0.1"));
EXPECT_TRUE(Connection::isLocalHost("127.1.2.3"));
EXPECT_TRUE(Connection::isLocalHost("localhost"));
EXPECT_TRUE(Connection::isLocalHost("LocalHost"));
EXPECT_TRUE(Connection::isLocalHost("::1"));
EXPECT_TRUE(Connection::isLocalHost("[::1]"));
// The regression this fix targets: a hostname merely starting "127." is NOT loopback.
EXPECT_TRUE(!Connection::isLocalHost("127.evil.com"));
EXPECT_TRUE(!Connection::isLocalHost("127.0.0.1.attacker.example"));
EXPECT_TRUE(!Connection::isLocalHost("127.300.0.1"));
EXPECT_TRUE(!Connection::isLocalHost("1270.0.0.1"));
EXPECT_TRUE(!Connection::isLocalHost("10.0.0.5"));
EXPECT_TRUE(!Connection::isLocalHost("example.com"));
}
void testAllowsPlaintextRemote()
{
using dragonx::rpc::Connection;
using dragonx::rpc::ConnectionConfig;
ConnectionConfig local;
local.host = "127.0.0.1";
local.use_tls = false;
EXPECT_TRUE(!Connection::usesPlaintextRemote(local)); // local is never "plaintext remote"
ConnectionConfig remote;
remote.host = "10.0.0.5";
remote.use_tls = false;
EXPECT_TRUE(Connection::usesPlaintextRemote(remote)); // remote + no TLS
EXPECT_TRUE(!Connection::allowsPlaintextRemote(remote)); // blocked by default → connect refused
remote.allow_plaintext_remote = true;
EXPECT_TRUE(Connection::allowsPlaintextRemote(remote)); // explicit opt-in
ConnectionConfig remoteTls;
remoteTls.host = "10.0.0.5";
remoteTls.use_tls = true;
EXPECT_TRUE(!Connection::usesPlaintextRemote(remoteTls)); // TLS → not plaintext, never refused
}
void 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 testNodeStatusBanner()
{
using namespace dragonx::ui;
// Connected full node → no banner.
{
NodeBannerInputs in; in.connected = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
}
// Expected startup phases own the screen (loading/warmup overlay) → no banner.
{
NodeBannerInputs in; in.warming_up = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
NodeBannerInputs in2; in2.daemon_initializing = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(in2).show);
NodeBannerInputs in3; in3.connection_in_progress = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(in3).show);
}
// Genuinely offline full node → amber, reconnect offered, detail passed through.
{
NodeBannerInputs in;
in.connection_status = "Lost connection to daemon";
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.show);
EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline);
EXPECT_TRUE(s.action == NodeBannerAction::Reconnect);
EXPECT_EQ(s.detail, std::string("Lost connection to daemon"));
}
// Embedded daemon crashed and auto-restart gave up → red, restart offered, lastError preferred.
{
NodeBannerInputs in;
in.using_embedded_daemon = true;
in.has_daemon_controller = true;
in.daemon_running = false;
in.daemon_crash_count = kNodeBannerCrashGiveUpCount;
in.daemon_last_error = "exit code 134";
in.connection_status = "Daemon crashed 3 times";
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.show);
EXPECT_TRUE(s.severity == NodeBannerSeverity::Error);
EXPECT_TRUE(s.reason == NodeBannerReason::DaemonCrashed);
EXPECT_TRUE(s.action == NodeBannerAction::RestartNode);
EXPECT_EQ(s.detail, std::string("exit code 134"));
}
// Below the give-up threshold it's still just an offline/reconnect banner, not the crash one.
{
NodeBannerInputs in;
in.using_embedded_daemon = true;
in.has_daemon_controller = true;
in.daemon_running = false;
in.daemon_crash_count = kNodeBannerCrashGiveUpCount - 1;
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.show);
EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline);
EXPECT_TRUE(s.action == NodeBannerAction::Reconnect);
}
// Lite: an open failure shows a red, action-less banner; no failure → nothing.
{
NodeBannerInputs in; in.lite = true; in.connected = false;
in.lite_open_error = "wallet.dat is corrupt";
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.show);
EXPECT_TRUE(s.severity == NodeBannerSeverity::Error);
EXPECT_TRUE(s.reason == NodeBannerReason::LiteOpenFailed);
EXPECT_TRUE(s.action == NodeBannerAction::None);
EXPECT_EQ(s.detail, std::string("wallet.dat is corrupt"));
NodeBannerInputs clean; clean.lite = true; clean.connected = false; // no error yet
EXPECT_TRUE(!evaluateNodeStatusBanner(clean).show);
NodeBannerInputs open; open.lite = true; open.connected = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(open).show);
}
}
void testStalenessBadge()
{
using namespace dragonx::ui;
const int64_t now = 1'000'000;
// Disconnected → banner's job, never a badge.
EXPECT_TRUE(!evaluateStalenessBadge(now - 999, now, /*connected=*/false).show);
// Never updated this session (0 stamp, e.g. fresh start / reset on disconnect) → nothing.
EXPECT_TRUE(!evaluateStalenessBadge(0, now, true).show);
// Fresh (just under the threshold) → no badge.
EXPECT_TRUE(!evaluateStalenessBadge(now - (kStaleAfterSeconds - 1), now, true).show);
// At the threshold → amber badge, age reported.
{
StalenessBadge b = evaluateStalenessBadge(now - kStaleAfterSeconds, now, true);
EXPECT_TRUE(b.show);
EXPECT_TRUE(b.severity == StalenessSeverity::Warning);
EXPECT_EQ(b.seconds_old, (int64_t)kStaleAfterSeconds);
}
// Past the very-stale threshold → red.
{
StalenessBadge b = evaluateStalenessBadge(now - kVeryStaleAfterSeconds, now, true);
EXPECT_TRUE(b.show);
EXPECT_TRUE(b.severity == StalenessSeverity::Error);
}
// Clock skew (future timestamp) is clamped to age 0 → no badge, no negative age.
{
StalenessBadge b = evaluateStalenessBadge(now + 100, now, true);
EXPECT_TRUE(!b.show);
}
}
void testNotificationHistory()
{
using dragonx::ui::Notifications;
using dragonx::ui::NotificationType;
auto& n = Notifications::instance();
n.clearHistory();
auto base = n.totalPushed(); // monotonic counter is NOT reset by clearHistory()
n.push("first", NotificationType::Info, 5.0f);
n.push("second", NotificationType::Error, 5.0f);
EXPECT_EQ((int)n.history().size(), 2);
EXPECT_TRUE(n.hasHistory());
// Oldest first, newest last; type + wall-clock stamp retained.
EXPECT_EQ(n.history().front().message, std::string("first"));
EXPECT_EQ(n.history().back().message, std::string("second"));
EXPECT_TRUE(n.history().back().type == NotificationType::Error);
EXPECT_TRUE(n.history().back().epoch > 0);
EXPECT_EQ((int)(n.totalPushed() - base), 2);
// Cap at 100: push past it → size caps, oldest entries drop, totalPushed keeps counting.
for (int i = 0; i < 150; ++i) n.push("bulk", NotificationType::Info, 5.0f);
EXPECT_EQ((int)n.history().size(), 100);
EXPECT_EQ((int)(n.totalPushed() - base), 152);
EXPECT_EQ(n.history().front().message, std::string("bulk")); // the two originals fell off
n.clearHistory();
EXPECT_TRUE(!n.hasHistory());
EXPECT_EQ((int)n.history().size(), 0);
EXPECT_EQ((int)(n.totalPushed() - base), 152); // clearing doesn't rewind the counter
}
void testSeedMigrationResume()
{
using dragonx::decideSeedMigrationResume;
using dragonx::MigrationResume;
// No pending migration (or missing dest) → start fresh at the intro.
EXPECT_TRUE(decideSeedMigrationResume(false, false, "", "", true) == MigrationResume::Intro);
EXPECT_TRUE(decideSeedMigrationResume(false, true, "tx", "op", true) == MigrationResume::Intro);
EXPECT_TRUE(decideSeedMigrationResume(true, false, "tx", "op", true) == MigrationResume::Intro);
// A persisted txid outranks everything → resume at the confirm/adopt gate (txid-first invariant).
EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "", true) == MigrationResume::Confirming);
EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", true) == MigrationResume::Confirming);
EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", false) == MigrationResume::Confirming);
// Opid but no txid, AND connected → re-track the opid (recover the txid / detect stale).
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", true) == MigrationResume::RetrackOpid);
// W3-3 connectivity gate: opid but NOT connected → the dismissable Sweep gate, NOT the buttonless
// Sweeping spinner (whose only exit is the opid poller, which needs a connection). This is the
// trap the review caught.
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", false) == MigrationResume::SweepGate);
// No txid and no opid → the Sweep gate (whether or not connected).
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", true) == MigrationResume::SweepGate);
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", false) == MigrationResume::SweepGate);
}
void testLoggerFileSink()
{
using dragonx::util::Logger;
namespace fsn = std::filesystem;
fsn::path logPath = fsn::temp_directory_path() / "od_logger_test.log";
std::error_code ec;
fsn::remove(logPath, ec);
fsn::remove(logPath.string() + ".1", ec);
// W7-2: init() opens the file sink and must NOT deadlock — it writes the banner under the same
// non-recursive lock it holds (this test would hang if that regressed).
Logger& lg = Logger::instance();
EXPECT_TRUE(lg.init(logPath.string()));
lg.write("hello-w7-2-sink");
EXPECT_TRUE(fsn::exists(logPath));
std::ifstream f(logPath.string());
std::string all, line;
while (std::getline(f, line)) all += line + "\n";
f.close();
EXPECT_TRUE(all.find("hello-w7-2-sink") != std::string::npos);
EXPECT_TRUE(all.find("Logger initialized") != std::string::npos);
fsn::remove(logPath, ec);
fsn::remove(logPath.string() + ".1", ec);
}
void testConnectHasStalled()
{
using dragonx::util::connectHasStalled;
EXPECT_TRUE(connectHasStalled(100.0, 145.0, 45.0f)); // exactly at threshold
EXPECT_TRUE(connectHasStalled(100.0, 300.0, 45.0f)); // well over
EXPECT_TRUE(!connectHasStalled(100.0, 144.0, 45.0f)); // just under
EXPECT_TRUE(!connectHasStalled(0.0, 1000.0, 45.0f)); // sentinel: not stalling
EXPECT_TRUE(!connectHasStalled(-1.0, 1000.0, 45.0f)); // sentinel: not stalling
EXPECT_TRUE(!connectHasStalled(10.0, 20.0, 0.0f)); // disabled: threshold 0
EXPECT_TRUE(!connectHasStalled(10.0, 20.0, -5.0f)); // disabled: negative threshold
}
void testVerifySaplingParams()
{
using dragonx::rpc::Connection;
namespace fsn = std::filesystem;
fsn::path dir = fsn::temp_directory_path() / "od_sapling_test";
std::error_code rmec;
fsn::remove_all(dir, rmec);
fsn::create_directories(dir);
auto writeFile = [](const fsn::path& p, const std::string& content) {
std::ofstream(p.string(), std::ios::binary) << content;
};
const std::string spendContent = "fake-spend-params-contents";
const std::string outputContent = "fake-output-params-contents";
writeFile(dir / "sapling-spend.params", spendContent);
writeFile(dir / "sapling-output.params", outputContent);
const std::string spendHash = dragonx::util::sha256Hex(spendContent.data(), spendContent.size());
const std::string outputHash = dragonx::util::sha256Hex(outputContent.data(), outputContent.size());
const std::vector<std::pair<std::string, std::string>> good = {
{ "sapling-spend.params", spendHash },
{ "sapling-output.params", outputHash },
};
// Valid params → pass, and a verification marker is written.
EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));
EXPECT_TRUE(fsn::exists(dir / ".sapling_verified"));
// Second call → marker fast-path, still true (round-trips the cache).
EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));
// Wrong expected hash → integrity failure (fresh dir so no marker can short-circuit it).
fsn::path dir2 = fsn::temp_directory_path() / "od_sapling_test2";
fsn::remove_all(dir2, rmec);
fsn::create_directories(dir2);
writeFile(dir2 / "sapling-spend.params", spendContent);
writeFile(dir2 / "sapling-output.params", outputContent);
const std::vector<std::pair<std::string, std::string>> wrong = {
{ "sapling-spend.params", std::string(64, 'a') },
{ "sapling-output.params", outputHash },
};
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir2.string(), wrong));
// Truncated content (size change) invalidates the marker AND fails the hash.
writeFile(dir / "sapling-spend.params", std::string("x"));
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));
// A missing param → fail.
fsn::remove(dir / "sapling-output.params", rmec);
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));
fsn::remove_all(dir, rmec);
fsn::remove_all(dir2, rmec);
}
void testPlatformEnsureDirectory()
{
using dragonx::util::Platform;
// An existing directory → true (temp_directory_path always exists).
{
std::string err = "sentinel";
EXPECT_TRUE(Platform::ensureDirectory(std::filesystem::temp_directory_path().string(), &err));
}
// A fresh nested path → created, no error.
{
std::filesystem::path base = std::filesystem::temp_directory_path() / "od_ensuredir_test";
std::error_code rmec; std::filesystem::remove_all(base, rmec);
std::filesystem::path nested = base / "a" / "b" / "c";
std::string err;
EXPECT_TRUE(Platform::ensureDirectory(nested.string(), &err));
EXPECT_TRUE(std::filesystem::is_directory(nested));
EXPECT_TRUE(err.empty());
std::filesystem::remove_all(base, rmec);
}
// Empty path → false with a message.
{
std::string err;
EXPECT_TRUE(!Platform::ensureDirectory("", &err));
EXPECT_TRUE(!err.empty());
}
// A path whose parent component is a regular file cannot be created. This fails the
// same way for root and non-root, so it's a stable negative case across environments.
{
std::filesystem::path f = std::filesystem::temp_directory_path() / "od_ensuredir_file";
std::error_code rmec; std::filesystem::remove_all(f, rmec);
{ std::ofstream(f.string()) << "x"; }
std::string err;
bool ok = Platform::ensureDirectory((f / "child").string(), &err);
std::filesystem::remove_all(f, rmec);
EXPECT_TRUE(!ok);
EXPECT_TRUE(err.find("Cannot create") != std::string::npos);
}
}
#ifndef _WIN32
// Integration tests that drive the REAL EmbeddedDaemon fork/exec/waitpid paths (POSIX only).
void testExecFailureReported()
{
using dragonx::daemon::EmbeddedDaemon;
namespace fsn = std::filesystem;
// A present-but-non-executable file: execv() must fail, and the F2 self-pipe handshake
// must report it as a start FAILURE with a precise reason — not a transient "Running".
fsn::path bin = fsn::temp_directory_path() / "od_fake_daemon_bin";
{ std::ofstream(bin.string(), std::ios::binary) << "this is not an executable"; }
fsn::permissions(bin, fsn::perms::owner_read, fsn::perm_options::replace); // 0400, no +x
EmbeddedDaemon d;
d.setSkipPortCheck(true); // bypass the port + datadir-lock gates so we reach startProcess()
EXPECT_TRUE(!d.start(bin.string()));
EXPECT_TRUE(d.getLastError().find("not executable or wrong architecture") != std::string::npos);
EXPECT_TRUE(!d.isRunning());
std::error_code ec; fsn::remove(bin, ec);
}
void testDaemonCrashDetected()
{
using dragonx::daemon::EmbeddedDaemon;
namespace fsn = std::filesystem;
// A tiny script that ignores the injected daemon args, lives briefly, then exits abnormally
// — standing in for a daemon that crashes. is_script detection runs it via /bin/bash.
fsn::path script = fsn::temp_directory_path() / "od_fake_daemon.sh";
{ std::ofstream(script.string()) << "#!/bin/bash\nsleep 0.2\nexit 7\n"; }
fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace); // +x
EmbeddedDaemon d;
d.setSkipPortCheck(true);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(d.isRunning()); // reads the atomic state_, not a racy waitpid()
// Hammer isRunning() the way the UI thread does while the child exits and monitorProcess()
// reaps it. Pre-fix (F1), isRunning()'s own waitpid() could steal the reap and hide the
// crash; with the fix the monitor is the sole reaper and always sees it.
for (int i = 0; i < 400 && d.getCrashCount() == 0; ++i) {
(void)d.isRunning();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
EXPECT_TRUE(d.getCrashCount() >= 1); // the unexpected exit was detected and counted
EXPECT_TRUE(!d.isRunning()); // state_ flipped to Error
d.stop(); // join the monitor thread cleanly
std::error_code ec; fsn::remove(script, ec);
}
#endif // !_WIN32
void testDatadirLockGate()
{
using dragonx::daemon::EmbeddedDaemon;
// Normal start, no lingering daemon after the bounded wait → proceed.
auto clear = EmbeddedDaemon::evaluateDatadirLockGate(false, false, false);
EXPECT_TRUE(clear.proceed);
// A previous dragonxd still alive after the wait → bail with a distinct, non-crash msg.
auto locked = EmbeddedDaemon::evaluateDatadirLockGate(false, false, true);
EXPECT_TRUE(!locked.proceed);
EXPECT_TRUE(std::string(locked.errorMessage).find("data directory lock") != std::string::npos);
// Isolated instance via skip_port_check_ is exempt even if a sibling dragonxd is running.
auto skipPort = EmbeddedDaemon::evaluateDatadirLockGate(true, false, true);
EXPECT_TRUE(skipPort.proceed);
// Isolated instance via -datadir override is exempt even if a sibling is running.
auto isolated = EmbeddedDaemon::evaluateDatadirLockGate(false, true, true);
EXPECT_TRUE(isolated.proceed);
}
void testDaemonLifecycleExecution() void testDaemonLifecycleExecution()
{ {
using dragonx::daemon::DaemonController; using dragonx::daemon::DaemonController;
@@ -3231,6 +3735,19 @@ void testRendererHelpers()
EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker")); EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b")); EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, "")); EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, ""));
// resolveMiningUserAddress: the configured payout address is the xmrig "user"
// (where rewards go) and must win over the wallet's own addresses.
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("zs-payout", "zs-own", "R-own"),
std::string("zs-payout")); // explicit payout wins
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "zs-own", "R-own"),
std::string("zs-own")); // unset -> own shielded
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "zs-own", "R-own"),
std::string("zs-own")); // "x" placeholder counts as unset
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "", "R-own"),
std::string("R-own")); // no shielded -> transparent
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "", ""),
std::string("")); // nothing anywhere -> caller errors
EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433")); EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433"));
dragonx::TransactionInfo tx; dragonx::TransactionInfo tx;
@@ -4132,7 +4649,6 @@ void testLiteWalletControllerLifecycle()
EXPECT_FALSE(controller.walletOpen()); EXPECT_FALSE(controller.walletOpen());
LiteWalletCreateRequest req; LiteWalletCreateRequest req;
req.passphrase = "hunter2";
const auto result = controller.createWallet(req); const auto result = controller.createWallet(req);
EXPECT_TRUE(result.ok); EXPECT_TRUE(result.ok);
EXPECT_TRUE(result.walletReady); EXPECT_TRUE(result.walletReady);
@@ -4149,7 +4665,6 @@ void testLiteWalletControllerLifecycle()
dragonx::test::g_liteFakeWalletExists = true; dragonx::test::g_liteFakeWalletExists = true;
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletOpenRequest req; LiteWalletOpenRequest req;
req.passphrase = "hunter2";
const auto result = controller.openWallet(req); const auto result = controller.openWallet(req);
EXPECT_TRUE(result.ok); EXPECT_TRUE(result.ok);
EXPECT_TRUE(result.walletReady); EXPECT_TRUE(result.walletReady);
@@ -4224,7 +4739,6 @@ void testLiteWalletControllerM4()
auto c = std::make_unique<LiteWalletController>( auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req; LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req); (void)c->createWallet(req);
return c; return c;
}; };
@@ -4352,7 +4866,6 @@ void testLiteWalletControllerM5Persistence()
auto c = std::make_unique<LiteWalletController>( auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req; LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req); (void)c->createWallet(req);
return c; return c;
}; };
@@ -4434,7 +4947,6 @@ void testLiteWalletControllerEncryption()
auto c = std::make_unique<LiteWalletController>( auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req; LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req); (void)c->createWallet(req);
return c; return c;
}; };
@@ -4665,6 +5177,33 @@ void testLiteWalletControllerConsoleCommand()
// Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also // 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 // 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. // 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() void testLiteWalletControllerAsyncLifecycleFailover()
{ {
using namespace dragonx::wallet; using namespace dragonx::wallet;
@@ -4693,7 +5232,6 @@ void testLiteWalletControllerAsyncLifecycleFailover()
LiteWalletController controller(liteCaps, conn, LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req; LiteWalletCreateRequest req;
req.passphrase = "hunter2";
EXPECT_TRUE(controller.beginCreateWalletAsync(req)); EXPECT_TRUE(controller.beginCreateWalletAsync(req));
drain(controller); drain(controller);
EXPECT_TRUE(controller.walletOpen()); EXPECT_TRUE(controller.walletOpen());
@@ -5466,6 +6004,188 @@ void testAddressChecksumValidation()
EXPECT_FALSE(isValidBech32("abc1rzg")); // too short / bad checksum EXPECT_FALSE(isValidBech32("abc1rzg")); // too short / bad checksum
EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); // mixed case EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); // mixed case
EXPECT_FALSE(isValidBech32("nosalt")); // no separator EXPECT_FALSE(isValidBech32("nosalt")); // no separator
// decodeBase58Check exposes the checksum-stripped payload so callers can inspect version/length.
using dragonx::util::decodeBase58Check;
std::vector<std::uint8_t> payload;
EXPECT_TRUE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", payload));
EXPECT_EQ(payload.size(), (size_t)21); // version(1) + 20-byte hash160, checksum stripped
EXPECT_EQ((int)payload[0], 0); // mainnet P2PKH version byte
EXPECT_FALSE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna", payload)); // bad checksum
// bech32Hrp returns the (lower-cased) HRP of a valid string, or "" when invalid.
using dragonx::util::bech32Hrp;
EXPECT_EQ(bech32Hrp("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"), std::string("abcdef"));
EXPECT_EQ(bech32Hrp("A12UEL5L"), std::string("a")); // lower-cased
EXPECT_EQ(bech32Hrp("A12UEL5M"), std::string("")); // invalid → empty
// Address type recognizers: accept every real DragonX recipient form, reject non-addresses.
using dragonx::util::isTransparentAddress;
using dragonx::util::isShieldedAddress;
using dragonx::util::isValidRecipientAddress;
const std::string p2pkh = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"; // v60
const std::string p2sh = "bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C"; // v85 multisig — the regression
const std::string zaddr = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5";
EXPECT_TRUE(isTransparentAddress(p2pkh));
EXPECT_TRUE(isTransparentAddress(p2sh)); // was silently dropped by the old 'R'-only filter
EXPECT_FALSE(isShieldedAddress(p2pkh));
EXPECT_TRUE(isShieldedAddress(zaddr));
EXPECT_FALSE(isTransparentAddress(zaddr));
EXPECT_TRUE(isValidRecipientAddress(p2pkh));
EXPECT_TRUE(isValidRecipientAddress(p2sh));
EXPECT_TRUE(isValidRecipientAddress(zaddr));
// A WIF spending key is NOT a recipient (33/34-byte payload, not 21); nor is a typo'd address.
EXPECT_FALSE(isTransparentAddress("Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C"));
EXPECT_FALSE(isValidRecipientAddress("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX")); // flipped checksum char
EXPECT_FALSE(isValidRecipientAddress(""));
}
// Import-key recognition: the client gate must accept every real DragonX key form and reject
// non-keys, so it never blocks a valid import with "Unrecognized key format" (audit F1/F2/F3).
void testPrivateKeyImportRecognition()
{
using dragonx::services::WalletSecurityController;
using KeyKind = WalletSecurityController::KeyKind;
// --- Transparent WIF (DragonX SECRET_KEY version 188; testnet 128) ---
const std::string wifCompressed = "Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C"; // v188 compressed 'U'
const std::string wifUncompressed = "7JTPumX2kofLQdHKANy8MMLRkmXCmuJcosiv9f4RFqW9oCJXBHD"; // v188 uncompressed '7'
const std::string wifTestnet = "KwFfpDsaF7yxCELuyrH9gP5XL7TAt5b9HPWC1xCQbmrxvhJgMQHb"; // v128 compressed
EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifCompressed));
EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifUncompressed)); // F2 regression: '7' was rejected
EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifTestnet));
EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(wifUncompressed));
EXPECT_EQ(WalletSecurityController::classifyPrivateKey(wifUncompressed), KeyKind::Transparent);
EXPECT_FALSE(WalletSecurityController::isViewingKey(wifCompressed));
// A corrupted WIF (flipped last char) fails the checksum → caught locally, not at the daemon.
std::string wifBad = wifCompressed;
wifBad.back() = (wifBad.back() == 'C' ? 'D' : 'C');
EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(wifBad));
// A transparent R-address is Base58Check-valid but NOT a key (21-byte payload, not 33/34).
EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"));
// --- Sapling incoming viewing key (mainnet HRP "zivks") — the F1 regression ---
const std::string ivk = "zivks1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7rusq45amw7";
EXPECT_TRUE(WalletSecurityController::isViewingKey(ivk));
EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(ivk));
EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(ivk)); // a viewing key can't spend
// The stale Zcash "zxview…" prefix is NOT a DragonX viewing key (and not valid Bech32 here).
EXPECT_FALSE(WalletSecurityController::isViewingKey("zxviews1abcdef"));
// --- Sapling z spending key (recognized by HRP prefix; daemon vets the long payload) ---
const std::string zspend = "secret-extended-key-main1qxxxxxxxxxxxxxxxxxxxx";
EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(zspend));
EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(zspend));
EXPECT_EQ(WalletSecurityController::classifyPrivateKey(zspend), KeyKind::Shielded);
// --- Garbage / empty ---
EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey(""));
EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey("hello world"));
}
// Seed-phrase normalization + word count + the 24-word completeness gate. Guards the lite-restore
// crash fix (only 24-word/32-byte-entropy seeds are safe for the SDXL backend) and the NBSP-paste
// recovery fix. Both restore gates (first-run wizard + Settings) route through these.
void testSeedPhraseHelpers()
{
using dragonx::util::normalizeSeedPhrase;
using dragonx::util::seedPhraseWordCount;
using dragonx::util::isCompleteRecoveryPhrase;
// --- completeness gate: 24 words only (12/15/18/21 valid-BIP39 lengths crash the backend) ---
EXPECT_TRUE(isCompleteRecoveryPhrase(24));
EXPECT_FALSE(isCompleteRecoveryPhrase(12));
EXPECT_FALSE(isCompleteRecoveryPhrase(15));
EXPECT_FALSE(isCompleteRecoveryPhrase(21));
EXPECT_FALSE(isCompleteRecoveryPhrase(23));
EXPECT_FALSE(isCompleteRecoveryPhrase(25));
EXPECT_FALSE(isCompleteRecoveryPhrase(0));
// --- plain ASCII: trim, collapse runs, count exactly; the common case must be untouched otherwise ---
EXPECT_EQ(normalizeSeedPhrase(" alpha beta\tgamma\ndelta "), std::string("alpha beta gamma delta"));
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha beta gamma")), 3);
EXPECT_EQ(seedPhraseWordCount(""), 0);
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(" \t \n ")), 0); // whitespace-only
// --- NBSP (U+00A0 = 0xC2 0xA0) between words must fold to a real space, not glue the words ---
EXPECT_EQ(normalizeSeedPhrase("alpha\xC2\xA0" "beta"), std::string("alpha beta"));
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha\xC2\xA0" "beta")), 2);
// Other Unicode spaces: en space U+2002, ideographic U+3000, narrow NBSP U+202F.
EXPECT_EQ(normalizeSeedPhrase("a\xE2\x80\x82" "b\xE3\x80\x80" "c\xE2\x80\xAF" "d"), std::string("a b c d"));
// Zero-width chars (U+200B, U+FEFF BOM) are stripped, not treated as separators.
EXPECT_EQ(normalizeSeedPhrase("\xEF\xBB\xBF" "alpha\xE2\x80\x8B beta"), std::string("alpha beta"));
// --- a full 24-word phrase pasted with NBSP separators counts as 24 (regression for the fix) ---
std::string words24;
for (int i = 0; i < 24; ++i) { if (i) words24 += "\xC2\xA0"; words24 += "word"; }
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(words24)), 24);
EXPECT_TRUE(isCompleteRecoveryPhrase(seedPhraseWordCount(normalizeSeedPhrase(words24))));
// The normalized form is plain single-space separated (what the backend's split(" ") needs).
EXPECT_EQ(normalizeSeedPhrase(words24).find("\xC2\xA0"), std::string::npos);
}
// Block-DB abort detection: classify a crashed daemon's console output so the app can offer a
// one-click reindex instead of silently showing a zero balance (a daemon-vs-chaindata format break).
void testBlockDbOutputDiagnosis()
{
using dragonx::daemon::blockDbOutputLooksBroken;
// The exact abort sequence we observed on a format-mismatched datadir.
EXPECT_TRUE(blockDbOutputLooksBroken(
"Opened LevelDB successfully\n"
"GetValue: CDataStream error - non-canonical optional discriminant: iostream error\n"
"ERROR: LoadBlockIndex() : failed to read value\n"
": Error loading block database.\n"
"Aborted block database rebuild. Exiting.\n"));
// Each individual fatal marker also trips it (partial capture / different phrasing).
EXPECT_TRUE(blockDbOutputLooksBroken("... : Error loading block database."));
EXPECT_TRUE(blockDbOutputLooksBroken("Aborted block database rebuild. Exiting."));
EXPECT_TRUE(blockDbOutputLooksBroken("ERROR: LoadBlockIndex() : failed to read value"));
// Normal startup / other failures must NOT be misread as a block-DB problem (no false reindex offer).
EXPECT_FALSE(blockDbOutputLooksBroken(
"Loading block index...\nVerifying blocks...\nLoading wallet...\nRescanning...\nDone loading\n"));
EXPECT_FALSE(blockDbOutputLooksBroken("Error loading wallet")); // wallet corruption → salvage, not reindex
EXPECT_FALSE(blockDbOutputLooksBroken("Error: Could not find any asmap file!"));
EXPECT_FALSE(blockDbOutputLooksBroken(""));
// Wallet auto-recovery detection: the node salvaged wallet.dat (moved the original to a .bak).
using dragonx::daemon::walletAutoRecovered;
EXPECT_TRUE(walletAutoRecovered(
"Warning: wallet.dat corrupt, data salvaged! Original wallet.dat saved as wallet.1786300000.bak in ..."));
EXPECT_TRUE(walletAutoRecovered("wallet.dat corrupt, salvage failed"));
// The FAILED-salvage sequence a BDB-inconsistent wallet actually produces (must also be detected —
// this is the case that previously slipped through and silently emptied the wallet).
EXPECT_TRUE(walletAutoRecovered(
"Renamed wallet.dat to wallet.1786375505.bak\n"
"CDBEnv::Salvage: Database salvage found errors, all data may not be recoverable.\n"
"Salvage(aggressive) found no records in wallet.1786375505.bak.\n"));
EXPECT_FALSE(walletAutoRecovered("Loading wallet...\nWallet completed loading\n")); // normal load
EXPECT_FALSE(walletAutoRecovered(": Error loading block database.")); // block-DB abort != salvage
EXPECT_FALSE(walletAutoRecovered(""));
// Newest salvage backup picker (the "wallet.<unixtime>.bak" the recovery just made).
using dragonx::daemon::newestWalletSalvageBak;
EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.1786200000.bak", "wallet.1786300000.bak", "peers.dat"}),
std::string("wallet.1786300000.bak")); // highest timestamp wins
EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.dat.encrypted.bak", "notes.txt"}),
std::string("")); // no wallet.<digits>.bak present
EXPECT_EQ(newestWalletSalvageBak({}), std::string(""));
// Restore must pick the LARGEST (least-salvaged) backup, NOT the newest — a salvage cascade shrinks
// the wallet each round, so the newest .bak can be an emptied 40KB copy while the original is huge.
using dragonx::daemon::largestWalletSalvageBak;
EXPECT_EQ(largestWalletSalvageBak({
{"wallet.dat", 40000ull}, // current (salvaged, tiny) — not a .bak
{"wallet.1786200000.bak", 194174976ull}, // ORIGINAL — oldest ts, biggest
{"wallet.1786340620.bak", 40960ull}, // latest salvage — newest ts, empty
{"peers.dat", 999ull}}),
std::string("wallet.1786200000.bak")); // largest wins over newest
EXPECT_EQ(largestWalletSalvageBak({{"wallet.dat", 100ull}, {"notes.txt", 5ull}}), std::string(""));
EXPECT_EQ(largestWalletSalvageBak({}), std::string(""));
} }
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture. // Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
@@ -5642,6 +6362,12 @@ void testDaemonChecksumParsing()
"| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |"); "| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"), EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"),
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a")); std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
// Regression: a markdown-bolded filename (**archive.zip**) must still parse — otherwise a valid,
// correctly-signed release whose body bolds the name would fail checksum lookup and be refused.
const auto bold = parseDaemonChecksums(
"| **dragonx-1.0.2-win64.zip** | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
EXPECT_EQ(bold.at("dragonx-1.0.2-win64.zip"),
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
} }
void testDaemonBasenamesAndVersionCore() void testDaemonBasenamesAndVersionCore()
@@ -5828,6 +6554,94 @@ void testPoolHashrateParsing()
EXPECT_FALSE(ok); EXPECT_FALSE(ok);
} }
// Schema-aware pool fee parsing (fed to the mining-tab "N% fee" display).
void testPoolFeeParsing()
{
using namespace dragonx::util;
bool ok = false;
// pool.dragonx.is custom schema: pools.dragonx.poolFee (a whole-percent number).
const std::string isJson =
R"({"pools":{"dragonx":{"hashrate":27670.14,"poolFee":1,"soloFee":3}}})";
double fee = parsePoolFee(PoolStatsSchema::DragonXIs, isJson, "", ok);
EXPECT_TRUE(ok);
EXPECT_NEAR(fee, 1.0, 0.001);
// Fractional fees survive (display rounds, but the parse must not).
const std::string isFrac = R"({"pools":{"dragonx":{"poolFee":1.5}}})";
fee = parsePoolFee(PoolStatsSchema::DragonXIs, isFrac, "", ok);
EXPECT_TRUE(ok);
EXPECT_NEAR(fee, 1.5, 0.001);
// Miningcore schema: the requested pool id's poolFeePercent.
const std::string ccJson =
R"({"pools":[)"
R"({"id":"dragonx-solo","poolFeePercent":2.0,"poolStats":{"poolHashrate":88780.0}},)"
R"({"id":"dragonx-pplns","poolFeePercent":0.9,"poolStats":{"poolHashrate":1585.9}}]})";
fee = parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok);
EXPECT_TRUE(ok);
EXPECT_NEAR(fee, 0.9, 0.001);
// Missing field / malformed / wrong-schema input all fail closed (caller keeps
// the compile-time fallback rather than showing a bogus 0%).
parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"hashrate":1.0}}})", "", ok);
EXPECT_FALSE(ok); // no poolFee key
parsePoolFee(PoolStatsSchema::DragonXIs, "not json", "", ok);
EXPECT_FALSE(ok);
parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok);
EXPECT_FALSE(ok);
parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"poolFee":"1"}}})", "", ok);
EXPECT_FALSE(ok); // string, not number
}
// The effective pool list = official pools saved favorites current custom pool,
// endpoint-deduped, with synthetic user rows flagged official=false.
void testEffectivePools()
{
using namespace dragonx::util;
const int base = (int)knownPools().size();
// Current pool is the official one, nothing saved -> just the official pools.
auto a = effectivePools("pool.dragonx.is:3433", {});
EXPECT_EQ((int)a.size(), base);
// A custom current pool (neither official nor saved) appears as an extra row.
auto b = effectivePools("my.pool.example:3333", {});
EXPECT_EQ((int)b.size(), base + 1);
const KnownPool* custom = findPoolByUrl(b, "my.pool.example:3333");
EXPECT_TRUE(custom != nullptr);
EXPECT_FALSE(custom->official);
EXPECT_TRUE(custom->feePercent < 0.0); // unknown fee
// Saved pools are appended; an official one among them and a duplicate collapse.
auto c = effectivePools("pool.dragonx.is:3433",
{"pool.dragonx.is:3433", "alt.pool:1", "alt.pool:1"});
EXPECT_EQ((int)c.size(), base + 1);
EXPECT_TRUE(findPoolByUrl(c, "alt.pool:1") != nullptr);
// Current pool equal to a saved one is not listed twice.
auto d = effectivePools("alt.pool:1", {"alt.pool:1"});
EXPECT_EQ((int)d.size(), base + 1);
// Blank/whitespace URLs are ignored (no phantom rows).
auto e = effectivePools(" ", {"", " "});
EXPECT_EQ((int)e.size(), base);
}
// Fee formatting: whole numbers stay clean, fractional fees keep their decimals.
void testFormatFeePercent()
{
using dragonx::ui::FormatFeePercent;
EXPECT_TRUE(FormatFeePercent(1.0) == "1");
EXPECT_TRUE(FormatFeePercent(0.0) == "0");
EXPECT_TRUE(FormatFeePercent(3.0) == "3");
EXPECT_TRUE(FormatFeePercent(1.5) == "1.5");
EXPECT_TRUE(FormatFeePercent(0.9) == "0.9");
EXPECT_TRUE(FormatFeePercent(1.25) == "1.25");
EXPECT_TRUE(FormatFeePercent(2.50) == "2.5"); // trailing zero trimmed
EXPECT_TRUE(FormatFeePercent(100.0) == "100");
}
// Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe. // Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe.
void testPoolWeightedSelection() void testPoolWeightedSelection()
{ {
@@ -6518,6 +7332,22 @@ int main()
testWalletSecurityWorkflow(); testWalletSecurityWorkflow();
testWalletSecurityWorkflowExecutor(); testWalletSecurityWorkflowExecutor();
testDaemonShutdownPolicy(); testDaemonShutdownPolicy();
testDatadirLockGate();
#ifndef _WIN32
testExecFailureReported();
testDaemonCrashDetected();
#endif
testPlatformEnsureDirectory();
testVerifySaplingParams();
testConnectHasStalled();
testIsLocalHost();
testAllowsPlaintextRemote();
testConsoleSecretRedaction();
testNodeStatusBanner();
testStalenessBadge();
testNotificationHistory();
testSeedMigrationResume();
testLoggerFileSink();
testDaemonLifecycleExecution(); testDaemonLifecycleExecution();
testDaemonLifecycleAdapters(); testDaemonLifecycleAdapters();
testConsoleTextLayout(); testConsoleTextLayout();
@@ -6552,6 +7382,7 @@ int main()
testLiteWalletControllerM4(); testLiteWalletControllerM4();
testLiteWalletControllerM5Persistence(); testLiteWalletControllerM5Persistence();
testLiteWalletControllerEncryption(); testLiteWalletControllerEncryption();
testLiteWalletControllerCreateEncryptsWithPassphrase();
testLiteChainNameMigration(); testLiteChainNameMigration();
testLiteRefreshModelAppliesToWalletState(); testLiteRefreshModelAppliesToWalletState();
testLiteSendShowsRecipientFromOutgoing(); testLiteSendShowsRecipientFromOutgoing();
@@ -6590,6 +7421,9 @@ int main()
testLiteOfficialServerDetection(); testLiteOfficialServerDetection();
testPoolRegistryLookup(); testPoolRegistryLookup();
testPoolHashrateParsing(); testPoolHashrateParsing();
testPoolFeeParsing();
testEffectivePools();
testFormatFeePercent();
testPoolWeightedSelection(); testPoolWeightedSelection();
testAtomicFileWrite(); testAtomicFileWrite();
testHushChatCrypto(); testHushChatCrypto();
@@ -6601,6 +7435,9 @@ int main()
testHushChatShuffledReceive(); testHushChatShuffledReceive();
testWalletFileProbe(); testWalletFileProbe();
testAddressChecksumValidation(); testAddressChecksumValidation();
testPrivateKeyImportRecognition();
testSeedPhraseHelpers();
testBlockDbOutputDiagnosis();
testLiteServerProbeLive(); testLiteServerProbeLive();
testXmrigLiveInstall(); testXmrigLiveInstall();
testGeneratedResourceBehavior(); testGeneratedResourceBehavior();

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"

View File

@@ -0,0 +1,90 @@
// dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
//
// Some wallet.dat files are valid Berkeley DB btrees whose EXTENT metadata is stale (the "main"
// subdatabase metapage records a low last_pgno while its live data spans thousands of pages further
// in the file). A tolerant page-walk reads every record, but the daemon's Berkeley DB `verify`
// rejects the file and auto-salvages it — which finds nothing and, on each restart, shrinks the
// wallet to empty (the "salvage cascade" that looks like fund loss). The keys are intact; only the
// DB envelope is broken.
//
// This tool fixes it the way it must be fixed — offline, on the file, before any daemon touches it:
// 1) read all (key,value) records with the tolerant walker (util/wallet_file_probe.h), then
// 2) write them VERBATIM into a fresh, consistent Berkeley DB "main" btree via real libdb put()s,
// so libdb computes correct extent/metapage bookkeeping itself (sidestepping the whole defect).
// Records are copied byte-for-byte: encrypted key material (ckey/csapzkey/mkey) passes through as
// opaque ciphertext — no passphrase, no decryption, no key material ever interpreted. Only large `tx`
// history values (which live in BDB overflow pages) are skipped; a wallet rescan rebuilds those.
//
// Usage: dragonx-wallet-rebuild <source-wallet.dat> <output-wallet.dat>
// Output: a single JSON line on stdout; exit 0 on success, non-zero on failure. Never touches the
// source (opens it read-only); refuses to overwrite an existing output (DB_EXCL).
#include "util/wallet_file_probe.h"
#include <db.h>
#include <cstdio>
#include <cstring>
#include <string>
int main(int argc, char** argv)
{
if (argc < 3) {
std::fprintf(stderr, "usage: dragonx-wallet-rebuild <source-wallet.dat> <output-wallet.dat>\n");
return 2;
}
const char* src = argv[1];
const char* dst = argv[2];
// --- 1) tolerant read (no libdb; reads records the daemon's BDB can't) ---
const auto rec = dragonx::util::extractWalletBtreeRecords(src);
if (!rec.parsed) {
std::printf("{\"ok\":false,\"error\":\"source is not a readable Berkeley DB btree wallet\"}\n");
return 3;
}
if (rec.keyRecords == 0) {
// Refuse to produce a keyless wallet — nothing to recover, and installing it would look like loss.
std::printf("{\"ok\":false,\"error\":\"no key records found in source\",\"read\":%zu}\n",
rec.records.size());
return 4;
}
// --- 2) write a fresh, consistent BDB "main" btree (what CWalletDB expects) ---
DB* db = nullptr;
int r = db_create(&db, nullptr, 0);
if (r != 0) {
std::printf("{\"ok\":false,\"error\":\"db_create: %s\"}\n", db_strerror(r));
return 5;
}
// DB_EXCL: never clobber an existing file — the caller passes a fresh path.
r = db->open(db, nullptr, dst, "main", DB_BTREE, DB_CREATE | DB_EXCL, 0600);
if (r != 0) {
std::printf("{\"ok\":false,\"error\":\"open output: %s\"}\n", db_strerror(r));
db->close(db, 0);
return 6;
}
long wrote = 0;
for (const auto& kv : rec.records) {
DBT k, v;
std::memset(&k, 0, sizeof k);
std::memset(&v, 0, sizeof v);
k.data = const_cast<char*>(kv.first.data()); k.size = static_cast<u_int32_t>(kv.first.size());
v.data = const_cast<char*>(kv.second.data()); v.size = static_cast<u_int32_t>(kv.second.size());
r = db->put(db, nullptr, &k, &v, 0);
if (r != 0) {
std::printf("{\"ok\":false,\"error\":\"put failed: %s\",\"wrote\":%ld}\n", db_strerror(r), wrote);
db->close(db, 0);
return 7;
}
++wrote;
}
r = db->close(db, 0); // close flushes correct metadata
if (r != 0) {
std::printf("{\"ok\":false,\"error\":\"close: %s\",\"wrote\":%ld}\n", db_strerror(r), wrote);
return 8;
}
std::printf("{\"ok\":true,\"read\":%zu,\"keyRecords\":%d,\"skippedOverflow\":%d,\"wrote\":%ld,\"complete\":%s}\n",
rec.records.size(), rec.keyRecords, rec.skippedOverflow, wrote, rec.complete ? "true" : "false");
return 0;
}