21 Commits

Author SHA1 Message Date
24e8fb4942 chore: bump full-node to 2.0.0; stop tracking the generated manifest
Set project(ObsidianDragon VERSION 2.0.0) (was 1.3.0); the lite variant keeps
its own independent DRAGONX_LITE_VERSION (1.0.0). res/ObsidianDragon.manifest is
generated per-variant by configure_file from res/ObsidianDragon.manifest.in, so
it was wrongly tracked (it kept showing dirty, stamped with whichever variant
built last) - untrack it and gitignore it; the .in template remains the source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:20:56 -05:00
1393d9ae1a feat(lite): vendor SDXL backend source; source deps from git.dragonx.is
Vendor the two local Rust crates that build the lite backend artifact into
third_party/silentdragonxlite/ (the qtlib C-ABI wrapper + the silentdragonxlitelib
core, with proto/res and all the lite-send fixes), and point
build-lite-backend-artifact.sh's default --backend-dir there, so the lite wallet
builds without the upstream SilentDragonXLite repo.

External build inputs are now only the Rust toolchain + git.dragonx.is + crates.io:
- the 6 librustzcash git deps point at the git.dragonx.is/DragonX/librustzcash
  mirror (pinned rev acff1444), not git.hush.is;
- the Sapling params are gitignored (not committed, no Git LFS) - the build fetches
  them from the git.dragonx.is/DragonX/zcash-params 'sapling-v1' release and verifies
  their SHA-256 before rust-embed bakes them in (ensure_sapling_params).

For fully offline builds, cargo vendor into lib/vendor/ and add a vendored-sources
redirect (vendor/ is gitignored; the script symlinks it into the prepared dir).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:06:42 -05:00
3ce62326f9 chore(lite): send-smoke harness + checkpoint generator
tools/lite_send_smoke drives the real SDXL backend's exact GUI send path
(newaddr/status/list/tree/send/rescan) for diagnosing shielded sends.
scripts/gen-lite-checkpoints.sh generates verified mainnet checkpoints from a
synced dragonxd (getblockhash + getblockmerkletree), self-checking against a
known checkpoint before emitting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:27:35 -05:00
b20e7efb16 feat(lite): network-tab polish, reworked key-export/QR dialogs
Network tab: glow only the active node, drop the left accent bar. Key-export
dialog: fix the lite-wallet "Not connected" failure by exporting the key
locally via the SDXL backend when there's no daemon; rework the layout to
wrapping click-to-copy fields with a side QR (empty placeholder when hidden),
85% modal width, HRP-preserving key chunking, and a centered, emphasized
warning. QR popup matched to the same sizing and click-to-copy address. Shared
field rendering extracted to widgets/copy_field.h so both dialogs stay in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:27:25 -05:00
4473e7e00a feat(updater): in-app dragonxd updater + browse-all-releases
Add a full-node daemon updater (util/DaemonUpdater + daemon_download_dialog)
reachable from Settings -> NODE & SECURITY: downloads/verifies (SHA-256 +
enforced ed25519 signature) and atomically installs the latest dragonxd from
the project Gitea, with a "Restart daemon now" step. Add a shared "Browse all
releases..." picker (release_list_view) to both the miner and daemon updaters
so users can pin older/pre-release builds. Pure no-I/O cores
(daemon_updater_core / xmrig_updater_core) are unit-tested; sign-daemon-release.sh
signs release archives offline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:27:13 -05:00
2e8e214689 refactor(ui): app-wide compact, translucent tooltips
Add material::Tooltip / BeginTooltip / EndTooltip wrappers (tooltip_style.h)
that scope a small window padding (8x4, dpi-scaled) and ~85% opacity to
tooltips only, then route the tooltip call sites through them. Menus and combo
dropdowns are untouched (they keep the global opaque PopupBg).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:26:52 -05:00
69a6fb3e64 fix(fullnode): smooth witness-cache progress from the new daemon's done/total
Use the new daemon's "Reading blocks for witness rebuild: <done> / <total>" as an
exact fraction: the reported total is the denominator directly, so the bar sweeps
0..1 smoothly instead of being held near the top by the peak-anchored remaining
heuristic (kept only as a fallback for older daemons that log bare "<n> remaining").
Also snap to 100% on the parallel rebuild's completion line ("rebuilt <n> note
witness cache(s) … using <t> thread(s)"), which otherwise logs no progress, so the
bar visibly finishes before the rescan-complete signal clears it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:48:33 -05:00
ed6120eef9 feat(fullnode): parse the new multi-threaded daemon's witness-rebuild progress
The updated dragonxd (parallel witness rebuild) replaced the per-block
"Building Witnesses for block <h> <frac> complete, <n> remaining" log line with a
clean serial read counter "Reading blocks for witness rebuild: <done> / <total>".
Parse the new line and map it onto the existing phase-2 path (remaining = total -
done), so the witness progress bar shows done/total against this daemon. The old
"Building Witnesses" matcher is kept for backward compatibility with older daemons;
"Setting Initial Sapling Witness for tx … i of N" (phase 1) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:41:36 -05:00
a5bd2dadd7 fix(fullnode): make witness sub-phase upgrade-only to stop progress thrash
The initial-witness pass ("Setting Initial Sapling Witness") and the cache walk
("Building Witnesses for block … remaining") interleave during a rescan — the daemon
does both per block. The phase selector picked phase 1 whenever a batch had only
initial-pass lines, so once the cache walk started an interleaved initial line would
flip the phase back to 1 and reset the bar to 0 every batch. Make the phase
upgrade-only (once the cache walk is seen it never drops back), so the reset happens
at most twice (→1, →2) and the cache-walk percentage advances monotonically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:09:29 -05:00
25ee1496b4 fix(fullnode): make witness/rescan progress work on the real daemon
Verified by running the app against a live node and watching a real rescan. Three
issues that only surfaced at runtime:

- Wrong RPC name: this daemon (hush/komodo) exposes the runtime rescan as
  "rescan <height>", not bitcoin's "rescanblockchain". runtimeRescan() and
  RPCClient::rescanBlockchain() used the bitcoin name and failed with "Method not
  found" on every node. Corrected to "rescan".

- Witness/rescan progress never surfaced during a rescan: the daemon-output parser
  that drives it was gated behind rpcConnected, but a heavy rescan holds cs_main so
  getinfo times out and the RPC reads disconnected — silencing the parser exactly
  when it's needed. The parser reads the daemon's stdout pipe (no RPC), so it now
  runs whenever the daemon process is alive. It also now parses INLINE on the main
  thread instead of via fast_worker_, so it can't be starved when the worker is
  blocked on a getrescaninfo call (which waits on cs_main during a witness rebuild).

- Witness rebuild has TWO sub-phases with different scales — the initial-witness
  pass ("Setting Initial Sapling Witness for tx <hash>, <i> of <N>") and the cache
  walk ("Building Witnesses for block <h> <frac> complete, <n> remaining"). Tracking
  them with one monotonic value pinned the bar at the initial pass's ~100% through
  the whole cache walk. They're now tracked as distinct phases (witness_phase) with
  their own monotonic progress and labels ("Setting witnesses" vs "Rebuilding
  witnesses"), so neither resets/bounces and the long phase shows real movement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:00:36 -05:00
e2bc3623b6 fix(fullnode): stable overall progress for Sapling witness rebuild
The witness-rebuild bar reset repeatedly because the daemon's "Building Witnesses
for block <h> <frac> complete" line reports per-call progress: BuildWitnessCache is
re-invoked for each connected block and each call walks from its own start height to
the tip, so the fraction restarts every time. The earlier "Setting Initial Sapling
Witness for tx <i> of <m>" counter resets per call too, so neither is a usable
overall metric.

Derive a stable, monotonic percentage from the "<n> remaining" count instead: track
the largest "remaining" seen during the phase as the full span and show how far
remaining has fallen below it. The longest pass defines 0→100%; the short per-block
follow-up passes only nudge the bar near the end rather than resetting it. The
"Setting Initial" line now only marks the phase active. Per-phase tracking resets at
phase start and every rescan-completion site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:44:01 -05:00
b32fe07cb1 feat(fullnode): show Sapling note witness-rebuild progress
The daemon's post-rescan witness rebuild ("Building Witnesses for block ...")
is a distinct, often-long phase that previously showed only as an indeterminate
"Rescanning..." with no progress. Parse the daemon's witness-build log lines and
surface a dedicated progress indicator.

- Parse "Building Witnesses for block <h> <frac> complete, <n> remaining" (and the
  earlier "Setting Initial Sapling Witness for tx ..., <i> of <m>") from daemon
  output, extracting a 0..1 fraction and remaining-block count.
- New SyncInfo fields building_witnesses / witness_progress / witness_remaining,
  cleared at every rescan-completion site (warmup-end, getrescaninfo poll, runtime
  rescan callback, daemon-log "finished").
- Status bar shows "Rebuilding witnesses NN%" (priority over the generic rescan
  text); the loading overlay (shown during -rescan warmup) gets a labelled witness
  progress bar with the remaining-block count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:23:52 -05:00
a0532275dd feat(fullnode): auto-reconcile wallet after bootstrap; runtime rescan for pruned nodes
A wallet bootstrapped from a snapshot keeps its wallet.dat but never rescans, so
its spent-state is stale and the first send tries to spend already-spent notes and
is rejected. The startup -rescan flag can't fix it either: the snapshot lacks the
pre-snapshot block history -rescan needs, so it errors. The working fix is a runtime
rescanblockchain RPC from a height the snapshot actually has.

- Add App::runtimeRescan(startHeight): runs rescanblockchain via the worker, drives
  the rescanning UI state, and owns completion via the RPC callback (getrescaninfo
  is unavailable on this daemon). Suppresses the per-second mining/rescan pollers
  and the Core/balance/tx refreshes while the daemon holds cs_main for the scan.
- Add App::detectLowestAvailableBlockHeight(): async binary search via getblock for
  the lowest height whose block data is on disk → the snapshot base, and whether the
  node still has full history.
- Auto-reconcile after bootstrap: both completion sites (wizard + Settings download
  dialog) mark a pending rescan; once the daemon is back up and the tip is known,
  detect the base and runtimeRescan() from it (or -rescan restart on a full node).
- Settings "Rescan Blockchain" now probes first: full-history nodes get the existing
  -rescan restart; bootstrapped/pruned nodes get a prompt pre-filled with the
  detected base height that runs the runtime rescan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:48:30 -05:00
7df00b0909 fix(ui): move Daemon binary into its own full-width row below Node & Security
The Daemon binary panel (info + the all-actions toolbar) lived inside the 60%-wide
NODE column, so the six-button toolbar clipped. Pull it out into a dedicated full-width
row rendered after the NODE + SECURITY columns reconcile, so it spans the whole card:
Installed | Bundled info side by side, status line, and the Install bundled | Refresh |
Test connection | Rescan | Delete blockchain | Repair wallet toolbar now have the full
container width and no longer clip. The NODE column keeps only the node/RPC info.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 02:41:33 -05:00
090e288c44 fix(ui): put all node action buttons on one toolbar row under Daemon binary
Move Test Connection / Rescan Blockchain / Delete Blockchain / Repair Wallet onto the
same line as and right after the Daemon binary buttons (Install bundled | Refresh), so
all node actions form a single toolbar row beneath the Daemon binary panel. Buttons are
auto-sized to pack onto one line; each disabled-state group (connection vs embedded
daemon vs bundle present) keeps its own guard. Removes the former two stacked button rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:18:57 -05:00
6544c10ac1 fix(ui): tidy Node maintenance layout + widen the Daemon binary panel
- Delete Blockchain and Repair Wallet now sit on one line, paired with the same
  uniform button width as Test Connection / Rescan Blockchain (a clean 2-column grid;
  all maintenance buttons share one rowBtnW sized across the four labels).
- The Daemon binary panel lays Installed and Bundled side by side across the node
  column (version + size·date | version + size) instead of stacked narrow lines, with
  the status line spanning underneath and Install bundled / Refresh paired below.
- Shorten the install button label to "Install bundled" so it fits the shared width;
  the tooltip still explains the full action. Date shown as YYYY-MM-DD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:21:06 -05:00
b2e104358d feat(fullnode): manage the daemon binary in Settings; stop auto-overwriting it
Previously the wallet re-extracted the bundled dragonxd on startup whenever the
installed binary's size differed from the bundle ("stale" overwrite), which could
replace a node a user had deliberately placed in dragonx/.

Now dragonx binaries (dragonxd/cli/tx) are auto-placed ONLY when missing — never
auto-overwritten on a size mismatch (needsParamsExtraction + extractEmbeddedResources).
Params/asmap keep their size-based refresh; a daemon dropped next to the wallet exe
still takes priority and is never touched.

Replacing the daemon is now an explicit action: Settings → "Daemon binary" reports the
installed binary's version (scanned from the file), size and modified date, compares it
to the version bundled in this build, and offers an "Install bundled daemon" button.
That stops the node, overwrites dragonxd/cli/tx with the bundled copies (waiting for the
process to release the file lock), and restarts — wallet/keys/chain data untouched.

Adds resources::{getInstalledDaemonInfo,getBundledDaemonInfo,reextractBundledDaemon}
(+ a version-string scanner) and App::reinstallBundledDaemon().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:06:59 -05:00
de70e68472 fix(fullnode): work around daemon note-selection fee-gap on shielded sends
dragonxd's z_sendmany picks notes to cover the recipient total (nTotalOut) but not
the miner fee, then rejects the build unless the selected notes cover amount+fee
(rpcwallet.cpp:5312 vs asyncrpcoperation_sendmany.cpp:278). So a shielded send whose
largest notes sum exactly to the amount fails with "Insufficient shielded funds,
have H, need H+fee" despite ample balance — e.g. sending exactly 2.0 from an address
whose biggest note is 2.0.

Since the failure is async (reported via the opid poll), detect it there: when a
shielded send fails with that message and the selected total H >= the requested
amount (selection covered the amount but stopped one note short of the fee — vs a
genuine shortfall where H < amount), re-issue the send once with a tiny self-output
(= fee) back to the from-address. That lifts the daemon's selection target past the
boundary so it grabs another note and can cover the fee; the recipient still receives
the exact amount. Retries are tracked so a second failure surfaces normally (no loop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:27:52 -05:00
0fe12d65df fix(ui): drop duplicate daemon log on the startup overlay
renderLoadingOverlay() rendered the daemon's recent output twice during startup:
a bare 4-line centered tail (section 2c, init/warmup only) and the styled
terminal-style box (section 4, always shown when the embedded daemon exists).
The bare tail was a strict subset of the box, so the same dragonxd output showed
stacked twice. Remove the redundant bare tail; keep the terminal box (which also
matches the shutdown screen's panel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:48:53 -05:00
37c8287a12 feat(fullnode): add "Repair Wallet" (-zapwallettxes=2) to Settings
When a note's stored record is corrupt or its tx isn't in the canonical chain,
z_sendmany fails to build a valid sapling spend proof even after a full -rescan,
because a plain rescan replays witnesses but keeps the existing tx/note records.
The zcashd repair for this is -zapwallettxes=2, which deletes all wallet tx/note
records and rebuilds them from the chain (keys/addresses preserved).

Adds a RepairWallet lifecycle operation that mirrors the existing -rescan plumbing
(one-shot zapOnNextStart flag on the embedded daemon; -zapwallettxes=2 implies and
supersedes -rescan), an App::repairWallet() that reuses the rescan status UI (so the
status bar + warmup-end completion detection apply), and a confirmed "Repair Wallet"
button + dialog in Settings → node maintenance (embedded daemon only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:42:10 -05:00
6ff80354df fix(fullnode): reliable rescan completion + self-explaining shielded send errors
Two related fixes for the post-bootstrap "send fails / rescan stuck at 99%" trap:

1) Rescan completion now keys off warmup-end. A -rescan runs entirely inside daemon
   warmup (every RPC returns -28 until it finishes), so warmup completing IS the rescan
   completing. The old detectors relied on getrescaninfo (which some daemons answer with
   "Method not found") or a "Done rescanning"/bench log line the daemon may never print,
   leaving the status bar stuck at 99% — so users killed the rescan before it finished.
   When warmup ends and a rescan was confirmed active, clear the rescan state, flip to
   100%, refresh history/balance, and toast completion.

2) z_sendmany failures that mean stale shielded note data (shielded-requirements-not-met,
   missing sapling anchor, invalid sapling spend proof, bad-txns-sapling-*) now append a
   plain-language hint telling the user to run a full rescan, instead of surfacing only the
   raw daemon string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:43:24 -05:00
88 changed files with 17972 additions and 495 deletions

7
.gitignore vendored
View File

@@ -47,3 +47,10 @@ docs/_archive/
# ed25519 release-signing keys — the secret key must NEVER be committed
*.ed25519.key
*.ed25519.pub.b64
# Lite-backend deps are fetched (or `cargo vendor`-ed locally for offline); not committed.
third_party/silentdragonxlite/lib/vendor/
# Generated by configure_file from res/ObsidianDragon.manifest.in (do not track)
res/ObsidianDragon.manifest

View File

@@ -61,7 +61,7 @@ There is no per-test filtering — it is one binary that runs every assertion. T
Variants are selected with CMake options (set by `build.sh` flags), surfaced to C++ as compile definitions:
- `DRAGONX_BUILD_LITE` (`--lite`) → `DRAGONX_LITE_BUILD` define; renames the app to `ObsidianDragonLite` and excludes embedded-daemon / full-node assets (Sapling params, asmap, dragonxd).
- `DRAGONX_ENABLE_LITE_BACKEND` → links a real external lite backend. Requires `--lite`, link mode `imported`, ABI `sdxl-c-v1`, and a symbols inventory file (built by `scripts/build-lite-backend-artifact.sh`); CMake hard-fails if any required `litelib_*` symbol is missing.
- `DRAGONX_ENABLE_LITE_BACKEND` → links a real external lite backend. Requires `--lite`, link mode `imported`, ABI `sdxl-c-v1`, and a symbols inventory file (built by `scripts/build-lite-backend-artifact.sh`); CMake hard-fails if any required `litelib_*` symbol is missing. The backend **source is vendored in-tree** at `third_party/silentdragonxlite/` — the `qtlib` C-ABI wrapper (`lib/`, produces `libsilentdragonxlite.a`) and the `silentdragonxlitelib` core (`silentdragonxlite-cli/lib/`, with `proto/` + `res/`). `build-lite-backend-artifact.sh` defaults `--backend-dir` there, so the lite wallet builds **without** the upstream SilentDragonXLite repo. External build inputs are limited to the **Rust toolchain (rustc/cargo 1.63)** plus two project-controlled sources on `git.dragonx.is`: the librustzcash crates come from the mirror `git.dragonx.is/DragonX/librustzcash` (the 6 `git =` deps in the core `Cargo.toml`, pinned to rev `acff1444…`), and the **Sapling params are not committed** (gitignored) — the build fetches them from the `git.dragonx.is/DragonX/zcash-params` release `sapling-v1` and verifies their SHA-256 before rust-embed bakes them in (`ensure_sapling_params`; override the URL with `SAPLING_PARAMS_BASE_URL`). Other crate deps come from crates.io. For a fully offline build, `cargo vendor` into `third_party/silentdragonxlite/lib/vendor/` and add a `vendored-sources` redirect to `lib/.cargo/config.toml` (the build script symlinks `vendor/` into its prepared dir if present); `vendor/` is gitignored.
- `DRAGONX_ENABLE_CHAT``DRAGONX_ENABLE_CHAT` define gating the chat module.
Guard full-node-only code paths with `#if DRAGONX_LITE_BUILD` / chat code with `DRAGONX_ENABLE_CHAT`.
@@ -79,10 +79,16 @@ The detailed milestone plan and design history (the v2 plan, backend artifact/AB
## Miner updater (xmrig)
The mining tab's pool section has an **"Update miner…"** button that downloads/verifies/installs the latest DRG-XMRig from the project Gitea (`util/XmrigUpdater` + `ui/windows/xmrig_download_dialog.h`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/latest` → pick the asset for this platform (`linux-x64` / `win-x64` / `macos-x86_64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (from the release body) **and** a detached **ed25519 signature** → miniz-extract the binary (flattening the versioned subdir) into `resources::getDaemonDirectory()`. The whole archive is verified, so extracted members are trusted by transitivity (no per-member hash check). The pure, no-I/O core is split into `xmrig_updater_core.cpp` for unit tests; an env-gated (`DRAGONX_TEST_NETWORK=1`) test exercises the worker live.
The mining tab's pool section has an **"Update miner…"** button that downloads/verifies/installs the latest DRG-XMRig from the project Gitea (`util/XmrigUpdater` + `ui/windows/xmrig_download_dialog.h`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/latest` → pick the asset for this platform (`linux-x64` / `win-x64` / `macos-x86_64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (from the release body) **and** a detached **ed25519 signature** → miniz-extract the binary (flattening the versioned subdir) into `resources::getDaemonDirectory()`. The whole archive is verified, so extracted members are trusted by transitivity (no per-member hash check). The pure, no-I/O core is split into `xmrig_updater_core.cpp` for unit tests; an env-gated (`DRAGONX_TEST_NETWORK=1`) test exercises the worker live. A **"Browse all releases…"** button (the `/releases` list, newest first, pre-releases included) lets users pin an older or pre-release build — same verify/install path via `startInstallRelease()`; the picker UI is shared with the daemon updater (`ui/windows/release_list_view.h`).
**Signature verification is enforced** (`kXmrigRequireSignature = true` in `src/util/xmrig_updater.h`), checked against the public key pinned in `kXmrigSignaturePublicKeyBase64`. **Consequence for releases:** every `drg-xmrig` release MUST ship a detached signature per archive or the in-app updater refuses it. To cut a release: build the archives, then `scripts/sign-xmrig-release.sh sign <secret.key> <archive.zip>...` (OpenSSL-based, no extra deps) and upload each `<archive>.sig` as a release asset alongside its `.zip`. The signing **secret key must stay offline** (it is gitignored: `*.ed25519.key`); only its base64 public key is pinned in the source. To rotate the key, regenerate (`scripts/sign-xmrig-release.sh keygen`) and update `kXmrigSignaturePublicKeyBase64`. An emergency env override is not provided — disabling verification means setting `kXmrigSignaturePublicKeyBase64` empty (and rebuilding).
## Daemon updater (dragonxd)
Settings → **NODE & SECURITY → DAEMON BINARY** has a **"Check for updates…"** button that downloads/verifies/installs the latest **dragonxd full node** from the project Gitea — the full-node sibling of the xmrig updater (`util/DaemonUpdater` + `ui/windows/daemon_download_dialog.h`, pure no-I/O core in `daemon_updater_core.cpp`; gated full-node-only via `supportsFullNodeLifecycleActions()`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/latest` → pick the archive for this platform (`linux-amd64` / `macos` / `win64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (parsed from the release body's markdown **checksum table**, not xmrig's `<hash> <name>` lines) **and** a detached **ed25519 signature** → miniz-extract the three executables (`dragonxd`/`dragonx-cli`/`dragonx-tx`, flattening the versioned subdir) into `resources::getDaemonDirectory()`. The archive also bundles Sapling params/asmap, which the updater deliberately leaves to the wallet's own resource extraction. Install is **atomic and safe while the node runs** (POSIX `rename()` replaces the in-use binary; Windows moves the locked `.exe` aside to `.old`); the new binary takes effect on the **next daemon start**, so the Done screen offers **"Restart daemon now"** (`App::restartDaemon()`). A **"Browse all releases…"** button (shared `release_list_view.h` picker) lets users pin a specific/older/pre-release node build via `startInstallRelease()` — with a downgrade caution, since an older binary may not match current chain data.
**Signature verification is enforced** (`kDaemonRequireSignature = true` in `src/util/daemon_updater.h`), checked against `kDaemonSignaturePublicKeyBase64`. **Consequence for releases:** every `dragonx` release MUST ship a detached `<archive>.sig` per platform archive or the in-app updater refuses it (as of v1.0.2 the releases publish SHA-256 but **no** signatures yet — sign + upload them to enable in-app updates). To cut a release: `scripts/sign-daemon-release.sh sign <secret.key> dragonx-<ver>-{linux-amd64,macos,win64}.zip` (OpenSSL-based) and upload each `.sig` next to its `.zip`. The signing **secret key stays offline** (gitignored `*.ed25519.key`; this repo's is `dragonx-daemon.ed25519.key`); only the base64 public key is pinned. To rotate: `scripts/sign-daemon-release.sh keygen` and update `kDaemonSignaturePublicKeyBase64`. The generic SHA-256 / ed25519 primitives are shared with the miner updater (`util::sha256Hex` / `util::verifyXmrigSignature`).
## Versioning
The version has a **single source of truth**: `project(... VERSION 1.2.0 ...)` plus `DRAGONX_VERSION_SUFFIX` in `CMakeLists.txt`. CMake generates `build/.../generated/dragonx_generated_version.h` from `src/config/version.h.in`. Do not hand-edit generated version output or hardcode version strings — bump the `project()` version in `CMakeLists.txt`.

View File

@@ -15,7 +15,7 @@ if(APPLE)
endif()
project(ObsidianDragon
VERSION 1.3.0
VERSION 2.0.0
LANGUAGES C CXX
DESCRIPTION "DragonX Cryptocurrency Wallet"
)
@@ -502,6 +502,8 @@ set(APP_SOURCES
src/util/lite_server_probe.cpp
src/util/xmrig_updater.cpp
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp
src/util/daemon_updater_core.cpp
src/util/secure_vault.cpp
src/ui/effects/framebuffer.cpp
src/ui/effects/blur_shader.cpp
@@ -744,6 +746,30 @@ if(DRAGONX_LITE_BACKEND_READY)
if(UNIX)
target_link_libraries(lite_smoke PRIVATE ${CMAKE_DL_LIBS} pthread)
endif()
# Real-backend SEND smoke tool — drives the exact GUI send path (bridge.execute("send", ...)).
add_executable(lite_send_smoke
tools/lite_send_smoke.cpp
src/wallet/lite_client_bridge.cpp
src/wallet/lite_owned_string.cpp
src/wallet/lite_rollout_policy.cpp
src/wallet/lite_connection_service.cpp
src/wallet/lite_result_parsers.cpp
)
target_include_directories(lite_send_smoke PRIVATE
${CMAKE_SOURCE_DIR}/src
${CMAKE_BINARY_DIR}/generated
${SODIUM_INCLUDE_DIR}
)
target_compile_definitions(lite_send_smoke PRIVATE DRAGONX_ENABLE_LITE_BACKEND=1)
target_link_libraries(lite_send_smoke PRIVATE
dragonx_lite_backend ${DRAGONX_LITE_BACKEND_EXTRA_LIBS}
nlohmann_json::nlohmann_json
${SODIUM_LIBRARY}
)
if(UNIX)
target_link_libraries(lite_send_smoke PRIVATE ${CMAKE_DL_LIBS} pthread)
endif()
endif()
# Platform-specific settings
@@ -1011,6 +1037,8 @@ if(BUILD_TESTING)
src/util/lite_server_probe.cpp
src/util/xmrig_updater.cpp
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp
src/util/daemon_updater_core.cpp
${MINIZ_SOURCES}
)

View File

@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<!-- Application identity —————————————————————————————— -->
<assemblyIdentity
type="win32"
name="DragonX.ObsidianDragon.Wallet"
version="1.2.0.0"
processorArchitecture="amd64"
/>
<description>ObsidianDragon Wallet</description>
<!-- Common Controls v6 (themed buttons, etc.) ————————— -->
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
<!-- DPI awareness (Per-Monitor V2) ————————————————————— -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2,PerMonitor</dpiAwareness>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
</windowsSettings>
</application>
<!-- Supported OS declarations (Windows 7 → 11) ———————— -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>

View File

@@ -7,7 +7,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ABI_VERSION="sdxl-c-v1"
LINK_MODE="imported"
BACKEND_DIR="$PROJECT_ROOT/external/SilentDragonXLite/lib"
BACKEND_DIR="$PROJECT_ROOT/third_party/silentdragonxlite/lib"
BACKEND_SOURCE_DIR=""
BUILD_BACKEND_DIR=""
BACKEND_DEPENDENCY_DIR=""
@@ -314,6 +314,33 @@ validate_backend_dependency_source() {
fi
}
# Ensure the Sapling proving params are present in the core crate (rust-embed bakes them in at build
# time). They are the fixed Zcash trusted-setup output — not buildable — so fetch + verify them from
# git.dragonx.is when absent. Override the source with SAPLING_PARAMS_BASE_URL.
SAPLING_PARAMS_BASE_URL="${SAPLING_PARAMS_BASE_URL:-https://git.dragonx.is/DragonX/zcash-params/releases/download/sapling-v1}"
ensure_sapling_params() {
local dir="$1"
[[ -n "$dir" ]] || return 0
mkdir -p "$dir"
local specs=(
"sapling-spend.params:8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13"
"sapling-output.params:2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4"
)
local spec name want path got
for spec in "${specs[@]}"; do
name="${spec%%:*}"; want="${spec##*:}"; path="$dir/$name"
if [[ -f "$path" ]] && [[ "$(compute_sha256 "$path")" == "$want" ]]; then
info "sapling param present and verified: $name"
continue
fi
info "fetching $name from $SAPLING_PARAMS_BASE_URL"
curl -fsSL "$SAPLING_PARAMS_BASE_URL/$name" -o "$path" || die "failed to download sapling param: $name"
got="$(compute_sha256 "$path")"
[[ "$got" == "$want" ]] || { rm -f "$path"; die "sapling param $name sha256 mismatch (got $got, want $want)"; }
info "downloaded and verified $name"
done
}
prepare_backend_source() {
BUILD_BACKEND_DIR="$BACKEND_SOURCE_DIR"
@@ -348,6 +375,9 @@ prepare_backend_source() {
[[ -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/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
# "vendor" relative to the build root, so expose it inside the prepared root too.
[[ -d "$BACKEND_SOURCE_DIR/vendor" ]] && ln -s "$BACKEND_SOURCE_DIR/vendor" "$prepared_root/vendor"
[[ -f "$BACKEND_SOURCE_DIR/silentdragonxlitelib.h" ]] && ln -s "$BACKEND_SOURCE_DIR/silentdragonxlitelib.h" "$prepared_root/silentdragonxlitelib.h"
local replacement="silentdragonxlitelib = { path = \"$BACKEND_DEPENDENCY_DIR\" }"
@@ -489,6 +519,8 @@ build_with_cargo() {
export SODIUM_LIB_DIR="$BUILD_BACKEND_DIR/libsodium-mingw"
fi
[[ -n "$BACKEND_DEPENDENCY_DIR" ]] && ensure_sapling_params "$BACKEND_DEPENDENCY_DIR/zcash-params"
local cargo_cmd=(cargo build --locked --lib --release)
if [[ -n "$RUST_TARGET" ]]; then
cargo_cmd+=(--target "$RUST_TARGET")

35
scripts/gen-lite-checkpoints.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Generate SDXL lite-wallet mainnet checkpoint entries from a fully-synced dragonxd.
# Each entry is (height,"blockhash","serialized_sapling_tree") in checkpoints.rs format.
# Fills the 1,770,000 -> tip gap so wallets reseed close to their birthday on rescan,
# bounding the (divergence-prone) compact-block replay span. Usage:
# scripts/gen-lite-checkpoints.sh [start] [step] > /tmp/new_checkpoints.txt
set -euo pipefail
CLI=${DRAGONX_CLI:-/home/d/dragonx/src/dragonx-cli}
START=${1:-1770000}
STEP=${2:-10000}
tip=$("$CLI" getblockcount)
end=$(( (tip / STEP) * STEP ))
# Sanity: confirm the method reproduces a KNOWN checkpoint tree before trusting it.
ref_hash=$("$CLI" getblockhash 1760000 | tr -d '"[:space:]')
ref_tree=$("$CLI" getblockmerkletree 1760000 | tr -d '"[:space:]')
expect_hash="0000545a45b8d4ee4e4b423cb1ea74d67e3a04c320c6ea2f59ee06c08f91a117"
if [ "$ref_hash" != "$expect_hash" ]; then
echo "ABORT: getblockhash 1760000 = $ref_hash != known $expect_hash" >&2; exit 1
fi
echo "# self-check: 1760000 hash matches; tree len=${#ref_tree}" >&2
n=0
h=$START
while [ "$h" -le "$end" ]; do
hash=$("$CLI" getblockhash "$h" | tr -d '"[:space:]')
tree=$("$CLI" getblockmerkletree "$h" | tr -d '"[:space:]')
if [ -z "$hash" ] || [ -z "$tree" ]; then echo "ABORT: empty hash/tree at $h" >&2; exit 1; fi
printf '\t(%s,"%s",\n\t\t"%s"\n\t),\n' "$h" "$hash" "$tree"
n=$((n+1))
h=$((h+STEP))
done
echo "# generated $n checkpoints from $START to $end (tip=$tip)" >&2

67
scripts/sign-daemon-release.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Sign dragonx full-node release archives for the wallet's in-app daemon updater (ed25519).
#
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset.
#
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl needed. OpenSSL's ed25519 is PureEdDSA (RFC 8032),
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible
# (the same flow the wallet's unit tests verify for the miner updater).
#
# Usage:
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file
#
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
set -euo pipefail
die() { echo "error: $*" >&2; exit 1; }
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
# Raw 32-byte ed25519 public key (base64) from a private key file. The DER SubjectPublicKeyInfo for
# 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; }
cmd="${1:-}"; shift || true
case "$cmd" in
keygen)
prefix="${1:-dragonx-daemon}"
[ -e "$prefix.ed25519.key" ] && die "$prefix.ed25519.key already exists — refusing to overwrite"
openssl genpkey -algorithm ed25519 -out "$prefix.ed25519.key"
chmod 600 "$prefix.ed25519.key"
pub="$(pubkey_b64 "$prefix.ed25519.key")"
printf '%s\n' "$pub" > "$prefix.ed25519.pub.b64"
echo "secret key : $prefix.ed25519.key (KEEP OFFLINE, mode 600)"
echo "public key : $prefix.ed25519.pub.b64"
echo
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
echo " $pub"
;;
pubkey)
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
pubkey_b64 "$1"
;;
sign)
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
key="$1"; shift
[ -f "$key" ] || die "no such key: $key"
for f in "$@"; do
[ -f "$f" ] || die "no such file: $f"
raw="$(mktemp)"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
echo "signed: $f -> $f.sig"
done
echo "Upload each .sig as a release asset next to its archive."
;;
*)
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
;;
esac

View File

@@ -50,6 +50,7 @@
#include "ui/windows/address_transfer_dialog.h"
#include "ui/windows/bootstrap_download_dialog.h"
#include "ui/windows/xmrig_download_dialog.h"
#include "ui/windows/daemon_download_dialog.h"
#include "ui/windows/console_tab.h"
#include "ui/pages/settings_page.h"
#include "ui/theme.h"
@@ -431,6 +432,21 @@ wallet::LiteRolloutDecision resolveLiteRolloutDecision(config::Settings& setting
}
return decision;
}
// True when a z_sendmany failure means the wallet's shielded note data (witnesses/anchors) is
// out of sync with the chain — the symptom after a bootstrap/reindex without a full rescan. These
// are consensus/proof-build rejections, not user mistakes, and the only fix is a full rescan.
bool sendErrorNeedsRescan(const std::string& raw)
{
auto has = [&](const char* s) { return raw.find(s) != std::string::npos; };
return has("shielded-requirements-not-met") ||
has("shielded requirements not met") ||
has("missing sapling anchor") ||
has("Invalid sapling spend proof") ||
has("Invalid output proof") ||
has("bad-txns-sapling-") ||
has("anchor");
}
} // namespace
void App::rebuildLiteWallet(bool force)
@@ -588,19 +604,30 @@ void App::update()
// Fast refresh (mining stats + daemon memory) every second
// Skip when wallet is locked — no need to poll, and queued tasks
// would delay the PIN unlock worker task.
//
// Also enter this block when the embedded daemon is running even if RPC currently reads
// disconnected: during a long rescan the daemon holds cs_main, so getinfo times out and the
// socket is marked down — but the rescan/witness PROGRESS we parse comes from the daemon's
// stdout pipe (no RPC needed), and getrescaninfo still answers (it takes no lock). Without this,
// the daemon-output parser stopped exactly when a rescan started, so witness progress never
// surfaced. The RPC-dependent pollers inside stay individually gated (mining skips during
// warmup/rescan; the getrescaninfo poll only runs once rescanning is flagged).
if (network_refresh_.consumeDue(RefreshTimer::Fast)) {
if (rpcConnected && !state_.isLocked()) {
const bool daemonAlive = daemon_controller_ && daemon_controller_->isRunning();
if ((rpcConnected || daemonAlive) && !state_.isLocked()) {
// Skip the mining poll while the daemon is in warmup (e.g. during -rescan). Otherwise
// getmininginfo is rejected with -28 ("Rescanning...") every second and floods the log.
// The rescan progress poll below still runs — that's how we track the warmup/rescan.
if (!state_.warming_up) {
if (rpcConnected && !state_.warming_up && !runtime_rescan_active_) {
refreshMiningInfo();
}
// Poll getrescaninfo for rescan progress (if rescan flag is set)
// Use fast_rpc_ when available to avoid blocking on rpc_'s
// curl_mutex (which may be held by a long-running import).
if (state_.sync.rescanning && fast_worker_ && !rescan_status_poll_in_progress_) {
// Suppressed during a runtime rescanblockchain: the daemon holds cs_main for the whole
// scan, so every poll would block, and completion is signalled by the rescan RPC callback.
if (state_.sync.rescanning && !runtime_rescan_active_ && fast_worker_ && !rescan_status_poll_in_progress_) {
auto* rescanRpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
rescan_status_poll_in_progress_ = true;
fast_worker_->post([this, rescanRpc]() -> rpc::RPCWorker::MainCb {
@@ -632,6 +659,13 @@ void App::update()
rescan_confirmed_active_ = false;
state_.sync.rescan_progress = 1.0f;
state_.sync.rescan_status.clear();
state_.sync.building_witnesses = false;
state_.sync.witness_phase = 0;
state_.sync.witness_progress = 0.0f;
state_.sync.witness_remaining = 0;
witness_rebuild_total_blocks_ = 0;
witness_seen_txids_.clear();
witness_total_txs_ = 0;
}
// else: rescanning=false but not yet confirmed → pre-restart daemon; keep waiting.
};
@@ -697,14 +731,30 @@ void App::update()
// Check daemon output for rescan progress (offloaded to worker)
if (daemon_controller_ && daemon_controller_->isRunning()) {
std::string newOutput = daemon_controller_->outputSince(daemon_output_offset_);
if (!newOutput.empty() && fast_worker_) {
fast_worker_->post([this, output = std::move(newOutput)]() -> rpc::RPCWorker::MainCb {
// Parse on worker thread — pure string work, no shared state access
if (!newOutput.empty()) {
// Parse inline on the main thread. It's cheap string scanning of a small incremental
// buffer, and keeping it OFF the worker is essential: during a rescan the worker can
// be blocked for minutes on a getrescaninfo call (it waits on cs_main while the daemon
// holds it for a witness rebuild). On the worker, the parse queued behind that and the
// rescan/witness progress froze. Inline, progress updates every frame regardless.
const std::string output = std::move(newOutput);
bool foundRescan = false;
bool finished = false;
float rescanPct = 0.0f;
std::string lastStatus;
// Sapling witness rebuild progress. Two daemon signals:
// "Setting Initial Sapling Witness for tx <hash>, <i> of <N>" (the common one —
// one per wallet tx; <hash> is the stable per-tx key, <N> the total tx count)
// "Building Witnesses for block <h> <frac> complete, <n> remaining" (rarer)
bool foundWitness = false;
float witnessPct = -1.0f; // -1 = no fraction parsed this batch
int witnessRemaining = -1;
int witnessReadTotal = -1; // new daemon: the <total> in "<done> / <total>"
bool witnessRebuilt = false; // new daemon's completion line seen this batch
std::vector<std::string> witnessTxids; // distinct-tx keys seen this batch
int witnessTotalTxs = -1; // max N seen this batch
size_t pos = 0;
while (pos < output.size()) {
size_t eol = output.find('\n', pos);
@@ -771,10 +821,103 @@ void App::update()
finished = true;
}
}
// Sapling note witness rebuild: "Building Witnesses for block <h> <frac>
// complete, <n> remaining". The float before "complete" is a 0..1 fraction.
auto witIdx = line.find("Building Witnesses for block");
if (witIdx != std::string::npos) {
foundWitness = true;
auto compIdx = line.find(" complete", witIdx);
if (compIdx != std::string::npos) {
size_t numEnd = compIdx, numStart = numEnd;
while (numStart > 0 && (std::isdigit((unsigned char)line[numStart - 1]) ||
line[numStart - 1] == '.')) numStart--;
if (numStart < numEnd) {
try { witnessPct = std::stof(line.substr(numStart, numEnd - numStart)) * 100.0f; } catch (...) {}
}
}
auto remIdx = line.find(" remaining", witIdx);
if (remIdx != std::string::npos) {
size_t numEnd = remIdx, numStart = numEnd;
while (numStart > 0 && std::isdigit((unsigned char)line[numStart - 1])) numStart--;
if (numStart < numEnd) {
try { witnessRemaining = std::stoi(line.substr(numStart, numEnd - numStart)); } catch (...) {}
}
}
lastStatus = line;
}
// Return callback to apply results on main thread
return [this, foundRescan, finished, rescanPct, status = std::move(lastStatus)]() {
// Newer multi-threaded daemon: "Reading blocks for witness rebuild: <done> / <total>".
// This replaced the per-block "Building Witnesses" line and is an exact done/total
// count, so capture BOTH (total drives the denominator directly — no peak anchoring).
auto rdIdx = line.find("Reading blocks for witness rebuild:");
if (rdIdx != std::string::npos) {
foundWitness = true;
auto slash = line.find('/', rdIdx);
if (slash != std::string::npos) {
size_t dEnd = slash;
while (dEnd > rdIdx && !std::isdigit((unsigned char)line[dEnd - 1])) dEnd--;
size_t dStart = dEnd;
while (dStart > rdIdx && std::isdigit((unsigned char)line[dStart - 1])) dStart--;
size_t tStart = slash + 1;
while (tStart < line.size() && !std::isdigit((unsigned char)line[tStart])) tStart++;
size_t tEnd = tStart;
while (tEnd < line.size() && std::isdigit((unsigned char)line[tEnd])) tEnd++;
if (dStart < dEnd && tStart < tEnd) {
try {
long done = std::stol(line.substr(dStart, dEnd - dStart));
long total = std::stol(line.substr(tStart, tEnd - tStart));
if (total > 0 && done >= 0 && done <= total) {
witnessRemaining = static_cast<int>(total - done);
witnessReadTotal = static_cast<int>(total);
}
} catch (...) {}
}
}
lastStatus = line;
}
// New daemon's witness-rebuild completion line:
// "... rebuilt <n> note witness cache(s) to height <h> in <ms>ms using <t> thread(s)".
// The parallel rebuild logs no progress, so on completion snap the bar to 100%.
if (line.find("note witness cache") != std::string::npos &&
line.find("thread(s)") != std::string::npos) {
foundWitness = true;
witnessRebuilt = true;
lastStatus = line;
}
// Primary signal: "Setting Initial Sapling Witness for tx <hash>, <i> of <N>".
// <i> is the tx's slot in an unordered map (bounces — useless as progress), so
// we key on <hash> (one per tx) and count distinct txs against <N>. Extract the
// hash between "for tx " and the following comma, and N after " of ".
{
auto setIdx = line.find("Setting Initial Sapling Witness for tx ");
if (setIdx != std::string::npos) {
foundWitness = true;
size_t hStart = setIdx + std::string("Setting Initial Sapling Witness for tx ").size();
size_t comma = line.find(',', hStart);
if (comma != std::string::npos && comma > hStart) {
witnessTxids.push_back(line.substr(hStart, comma - hStart));
auto ofIdx = line.find(" of ", comma);
if (ofIdx != std::string::npos) {
size_t nStart = ofIdx + 4, nEnd = nStart;
while (nEnd < line.size() && std::isdigit((unsigned char)line[nEnd])) nEnd++;
if (nEnd > nStart) {
try {
int n = std::stoi(line.substr(nStart, nEnd - nStart));
if (n > witnessTotalTxs) witnessTotalTxs = n;
} catch (...) {}
}
}
}
lastStatus = line;
}
}
}
// Apply results directly — we are already on the main thread.
const std::string& status = lastStatus;
if (finished) {
if (state_.sync.rescanning) {
ui::Notifications::instance().success("Blockchain rescan complete");
@@ -783,6 +926,86 @@ void App::update()
rescan_confirmed_active_ = false;
state_.sync.rescan_progress = 1.0f;
state_.sync.rescan_status.clear();
// Witness rebuild finishes with the rescan it's part of.
state_.sync.building_witnesses = false;
state_.sync.witness_phase = 0;
state_.sync.witness_progress = 0.0f;
state_.sync.witness_remaining = 0;
witness_rebuild_total_blocks_ = 0;
witness_seen_txids_.clear();
witness_total_txs_ = 0;
} else if (foundWitness) {
// Witness rebuild is the tail phase of a rescan — keep rescanning set so
// the broader gating holds, but surface the witness-specific progress.
state_.sync.rescanning = true;
rescan_confirmed_active_ = true;
state_.sync.building_witnesses = true;
// Which sub-phase is this batch? A "<n> remaining" token means the cache
// walk (phase 2); otherwise per-tx "Setting Initial" lines are the initial
// pass (phase 1). The two INTERLEAVE during a rescan (the daemon does both
// per block), so the phase is UPGRADE-ONLY: once the cache walk is seen it
// never drops back to the initial pass. Otherwise an interleaved initial
// line would flip the phase back and reset the bar every batch (thrash).
int phase = state_.sync.witness_phase;
if (witnessRemaining >= 0 || witnessRebuilt) phase = 2;
else if (!witnessTxids.empty() && phase < 2) phase = 1;
// Entering a higher sub-phase → reset its progress + accumulators so the
// bar restarts from 0 (the two phases have different scales). Upgrade-only,
// so this happens at most twice (→1, →2), never repeatedly.
if (phase != state_.sync.witness_phase) {
state_.sync.witness_phase = phase;
state_.sync.witness_progress = 0.0f;
state_.sync.witness_remaining = 0;
witness_rebuild_total_blocks_ = 0;
witness_seen_txids_.clear();
witness_total_txs_ = 0;
}
if (phase == 2) {
state_.sync.witness_remaining = witnessRemaining;
float p = -1.0f;
if (witnessRebuilt) {
// Parallel rebuild finished (it logs no progress) — snap to 100%.
p = 1.0f;
state_.sync.witness_remaining = 0;
} else if (witnessReadTotal > 0) {
// New daemon: exact done/total. Use the reported total as the
// denominator directly (no peak anchoring) for a smooth 0..1 sweep.
int done = witnessReadTotal - (witnessRemaining >= 0 ? witnessRemaining : 0);
p = static_cast<float>(done) / static_cast<float>(witnessReadTotal);
} else if (witnessRemaining >= 0) {
// Older daemon: bare "<n> remaining" with no total — anchor the % to
// the largest remaining seen (its first/largest value ≈ the full span).
if (witnessRemaining > witness_rebuild_total_blocks_)
witness_rebuild_total_blocks_ = witnessRemaining;
if (witness_rebuild_total_blocks_ > 0)
p = 1.0f - static_cast<float>(witnessRemaining) /
static_cast<float>(witness_rebuild_total_blocks_);
}
if (p >= 0.0f) {
if (p > 1.0f) p = 1.0f;
if (p > state_.sync.witness_progress) // monotonic within the phase
state_.sync.witness_progress = p;
}
} else if (phase == 1) {
// Initial pass: count DISTINCT witnessed txs / total. The set only
// grows (dedups the daemon's double-prints), so it's monotonic; the
// raw "<i>" can't be used — it's an unordered-map slot that bounces.
if (witnessTotalTxs > witness_total_txs_) witness_total_txs_ = witnessTotalTxs;
for (const auto& txid : witnessTxids) witness_seen_txids_.insert(txid);
if (witness_total_txs_ > 0 && !witness_seen_txids_.empty()) {
float p = static_cast<float>(witness_seen_txids_.size()) /
static_cast<float>(witness_total_txs_);
if (p > 1.0f) p = 1.0f;
if (p > state_.sync.witness_progress) // monotonic within the phase
state_.sync.witness_progress = p;
}
}
if (!status.empty()) {
state_.sync.rescan_status = status;
}
} else if (foundRescan) {
state_.sync.rescanning = true;
// Reading "Still rescanning" straight from the daemon log is hard proof the
@@ -796,8 +1019,6 @@ void App::update()
state_.sync.rescan_status = status;
}
}
};
});
}
} else if (!daemon_controller_ || !daemon_controller_->isRunning()) {
// Clear rescan state if daemon is not running (but preserve during restart)
@@ -849,7 +1070,15 @@ void App::update()
// Failures: route to the originating send UI when there is one (it shows
// its own error toast); otherwise surface a generic notification (this is
// how shield/merge/auto-shield failures become visible).
for (const auto& [opid, msg] : parsed.failureByOpid) {
for (const auto& [opid, rawMsg] : parsed.failureByOpid) {
// Auto-work-around the daemon's note-selection fee-gap: re-issues the send with a
// self-output so it covers the fee. If a retry was issued, defer the outcome to it.
if (maybeRetrySendForFeeGap(opid, rawMsg)) {
continue;
}
std::string msg = sendErrorNeedsRescan(rawMsg)
? rawMsg + "\n\n" + TR("send_err_needs_rescan")
: rawMsg;
if (!invokeSendResultCallback(opid, false, msg)) {
ui::Notifications::instance().error(msg);
}
@@ -892,15 +1121,36 @@ void App::update()
});
}
// After a bootstrap the preserved wallet.dat has stale spent-state and must be reconciled
// against the freshly-imported chain, or the first send tries to spend already-spent notes and
// is rejected. The -rescan restart can't do it (the snapshot lacks pre-snapshot blocks), so we
// detect the lowest available block height and run a runtime rescanblockchain from there. Wait
// until the daemon is connected and out of warmup so the probe and rescan can actually run.
if (post_bootstrap_rescan_pending_ && rpcConnected && state_.connected &&
!state_.warming_up && !state_.isLocked() && !state_.sync.rescanning &&
!runtime_rescan_active_ && !bootstrap_downloading_ &&
state_.sync.blocks > 1) { // wait until the tip is known so the probe has a real range
post_bootstrap_rescan_pending_ = false;
ui::Notifications::instance().info("Bootstrap complete — reconciling your wallet with the new chain data.");
detectLowestAvailableBlockHeight([this](bool ok, int lowest, bool fullHistory) {
if (ok && !fullHistory) {
runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base
} else {
rescanBlockchain(); // full-history node (or probe failed): -rescan restart works
}
});
}
// Per-category refresh with tab-aware intervals
// Skip when wallet is locked — same reason as above.
if (rpcConnected && !state_.isLocked()) {
const bool walletDataPage = currentPageNeedsWalletDataRefresh();
if (network_refresh_.consumeDue(RefreshTimer::Core)) {
if (!runtime_rescan_active_ && network_refresh_.consumeDue(RefreshTimer::Core)) {
refreshCoreData();
}
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28
if (!state_.warming_up) {
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28 — and during a
// runtime rescan, when the daemon holds cs_main and these calls would block.
if (!state_.warming_up && !runtime_rescan_active_) {
if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
refreshTransactionData();
@@ -1674,6 +1924,7 @@ void App::render()
// Bootstrap download from settings
ui::BootstrapDownloadDialog::render();
ui::XmrigDownloadDialog::render();
ui::DaemonUpdateDialog::render();
// Windows Defender antivirus help dialog
renderAntivirusHelpDialog();
@@ -1793,7 +2044,21 @@ void App::renderStatusBar()
ImGui::SameLine(0, sbSectionGap);
ImGui::TextDisabled("|");
ImGui::SameLine(0, sbSeparatorGap);
if (state_.sync.rescanning) {
if (state_.sync.building_witnesses) {
// Witness rebuild is the tail phase of a rescan — show its own progress with priority.
// Phase 1 (initial pass) and phase 2 (cache walk) have distinct labels so the progress
// restart at the phase boundary reads as a new step rather than a glitch.
const ImVec4 witCol(0.6f, 0.8f, 1.0f, 1.0f);
const char* witLabel = (state_.sync.witness_phase == 2)
? TR("sb_witness_cache") : TR("sb_building_witnesses");
if (state_.sync.witness_progress > 0.01f) {
ImGui::TextColored(witCol, "%s %.0f%%", witLabel, state_.sync.witness_progress * 100.0f);
} else {
int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
ImGui::TextColored(witCol, "%s%s", witLabel, dotStr);
}
} else if (state_.sync.rescanning) {
// Show rescan progress (takes priority over sync)
// Use animated dots if progress is unknown (0%)
if (state_.sync.rescan_progress > 0.01f) {
@@ -2971,6 +3236,86 @@ void App::rescanBlockchain()
});
}
void App::repairWallet()
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
daemon::DaemonController::LifecycleOperation::RepairWallet,
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
if (!decision.allowed) {
ui::Notifications::instance().warning(decision.warning);
return;
}
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)...");
// -zapwallettxes=2 deletes and rebuilds every wallet tx/note record, then rescans the whole
// chain — so reuse the rescan status UI (status bar + warmup-end completion detection). Same
// confirmed-active gating as rescan: the first poll may still reach the pre-restart daemon.
state_.sync.rescanning = true;
rescan_confirmed_active_ = false;
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = decision.status;
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
// Set the zap flag BEFORE stopping so it's ready when we restart
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
DEBUG_LOGF("[App] Wallet-repair flag set, zapOnNextStart=%d\n", daemon_controller_->zapOnNextStart() ? 1 : 0);
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
DEBUG_LOGF("[App] Stopping daemon for wallet repair...\n");
AppDaemonLifecycleRuntime runtime(*this);
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
});
}
void App::reinstallBundledDaemon()
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
if (!resources::getBundledDaemonInfo().available) {
ui::Notifications::instance().warning("This build has no bundled daemon to install");
return;
}
if (!daemon_controller_ || !isUsingEmbeddedDaemon()) {
ui::Notifications::instance().warning("Reinstalling the daemon requires the embedded daemon");
return;
}
DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n");
ui::Notifications::instance().info("Installing bundled daemon — the node will stop, update, and restart...");
async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) {
AppDaemonLifecycleRuntime runtime(*this);
daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_);
// Stop the daemon so its binary is no longer locked (Windows) / in use (ETXTBSY on Linux).
runtime.stopDaemonWithPolicy();
// Wait (bounded, ~12s) for the process to exit and release the binary before overwriting.
for (int i = 0; i < 120 && daemon::EmbeddedDaemon::isRpcPortInUse()
&& !ctx.cancelled() && !ctx.shuttingDown(); ++i) {
ctx.sleepForMs(100);
}
// Overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones.
const bool ok = resources::reextractBundledDaemon();
DEBUG_LOGF("[App] Reinstall bundled daemon: %s\n", ok ? "ok" : "FAILED");
// Restart on the freshly-installed binary.
runtime.resetOutputOffset();
if (!ctx.cancelled() && !ctx.shuttingDown()) {
runtime.startDaemon();
}
});
}
void App::deleteBlockchainData()
{
if (!supportsFullNodeLifecycleActions()) {
@@ -3266,7 +3611,7 @@ void App::renderShutdownScreen()
force_quit_confirm_ = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("force_quit_warning"));
ui::material::Tooltip("%s", TR("force_quit_warning"));
}
ImGui::PopStyleColor(3);
}
@@ -3484,38 +3829,42 @@ void App::renderLoadingOverlay(float contentH)
}
// -------------------------------------------------------------------
// 2c. Live daemon console tail (init/warmup only) — show the last few lines the node
// printed so the user can watch real progress (UpdateTip height=…, Verifying blocks…)
// without leaving the blocked overlay. Full-node only (lite has no daemon_controller_).
// 2c. Sapling note witness rebuild progress (post-rescan phase, runs inside warmup)
// -------------------------------------------------------------------
if ((state_.daemon_initializing || state_.warming_up) && daemon_controller_) {
const auto lines = daemon_controller_->recentLines(4);
if (!lines.empty()) {
ImFont* logFont = Type().caption();
if (!logFont) logFont = ImGui::GetFont();
const float blockW = std::min(ws.x * 0.8f, 560.0f);
const ImU32 logCol = IM_COL32(140, 150, 165, 150);
curY += gap * 0.5f;
for (const auto& raw : lines) {
// Trim trailing CR/whitespace; skip blanks.
std::string line = raw;
while (!line.empty() && (line.back() == '\r' || line.back() == '\n' ||
line.back() == ' ' || line.back() == '\t'))
line.pop_back();
if (line.empty()) continue;
// Truncate (with an ellipsis) to keep each line on one row within blockW.
if (logFont->CalcTextSizeA(logFont->LegacySize, FLT_MAX, 0.0f, line.c_str()).x > blockW) {
while (line.size() > 1 &&
logFont->CalcTextSizeA(logFont->LegacySize, FLT_MAX, 0.0f, (line + "").c_str()).x > blockW)
line.pop_back();
line += "";
}
dl->AddText(logFont, logFont->LegacySize,
ImVec2(wp.x + cx - blockW * 0.5f, curY), logCol, line.c_str());
curY += logFont->LegacySize + 2.0f;
}
curY += gap;
if (state_.sync.building_witnesses) {
float progress = state_.sync.witness_progress;
if (progress < 0.0f) progress = 0.0f;
if (progress > 1.0f) progress = 1.0f;
float barRadius = loadElem("progress-bar", 3.0f);
float barX = wp.x + cx - barW * 0.5f;
ImVec2 barMin(barX, curY);
ImVec2 barMax(barX + barW, curY + barH);
dl->AddRectFilled(barMin, barMax,
ui::schema::UI().resolveColor("var(--progress-track)", IM_COL32(255, 255, 255, 30)), barRadius);
ImVec2 fillMax(barMin.x + barW * progress, barMax.y);
if (fillMax.x > barMin.x + 1.0f) {
dl->AddRectFilled(barMin, fillMax,
ui::schema::UI().resolveColor("var(--primary)", IM_COL32(255, 218, 0, 200)), barRadius);
}
curY += barH + gap;
char wbuf[128];
if (state_.sync.witness_phase == 2 && progress > 0.01f && state_.sync.witness_remaining > 0)
snprintf(wbuf, sizeof(wbuf), "Rebuilding witness cache %.0f%% — %d blocks left",
progress * 100.0f, state_.sync.witness_remaining);
else if (state_.sync.witness_phase == 2 && progress > 0.01f)
snprintf(wbuf, sizeof(wbuf), "Rebuilding witness cache %.0f%%", progress * 100.0f);
else if (progress > 0.01f)
snprintf(wbuf, sizeof(wbuf), "Setting initial Sapling witnesses %.0f%%", progress * 100.0f);
else
snprintf(wbuf, sizeof(wbuf), "Rebuilding Sapling note witnesses…");
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, wbuf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
IM_COL32(150, 150, 150, 255), wbuf);
curY += ts.y + gap;
}
// -------------------------------------------------------------------

View File

@@ -305,7 +305,21 @@ public:
bool isEmbeddedDaemonRunning() const;
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); }
void rescanBlockchain(); // restart daemon with -rescan flag
void rescanBlockchain(); // restart daemon with -rescan flag (full-history nodes)
// Runtime rescanblockchain RPC starting at a snapshot-available height. Unlike the
// -rescan restart, this works on bootstrapped/pruned nodes (which lack pre-snapshot
// block data), reconciling the wallet's stale spent-state without a daemon restart.
void runtimeRescan(int startHeight);
// Async binary-search probe for the lowest block height the node still has on disk.
// cb(ok, lowestHeight, fullHistory): fullHistory==true when genesis is present (a normal,
// non-bootstrapped node). Runs on the UI thread via the RPC worker callbacks.
void detectLowestAvailableBlockHeight(std::function<void(bool ok, int lowestHeight, bool fullHistory)> cb);
// Flag that a bootstrap just finished so the wallet auto-reconciles spent-state once the
// daemon is back up (consumed in update()).
void markPostBootstrapRescanPending() { post_bootstrap_rescan_pending_ = true; }
bool runtimeRescanActive() const { return runtime_rescan_active_; }
void repairWallet(); // restart daemon with -zapwallettxes=2 (wipe & rebuild wallet tx records)
void reinstallBundledDaemon(); // stop daemon, overwrite installed binary with the bundled one, restart
void deleteBlockchainData(); // stop daemon, delete chain data, restart fresh
bool stopDaemonForBootstrap(); // stop daemon + disconnect for bootstrap, returns true if was running
bool isBootstrapDownloading() const { return bootstrap_downloading_; }
@@ -410,7 +424,18 @@ private:
const std::string& from,
const std::string& to,
double amount,
const std::string& memo);
const std::string& memo,
double fee = 0.0);
// Work around a dragonxd note-selection bug: its z_sendmany picks notes to cover the recipient
// total but not the miner fee, so a shielded send whose largest notes sum exactly to the amount
// fails with "Insufficient shielded funds, have H, need H+fee" despite ample balance. When a
// failed opid matches that (H >= the requested amount), re-issue the send once with a tiny
// self-output that lifts the daemon's selection target past the boundary so it grabs another
// note; the recipient still receives the exact amount. Returns true if a retry was issued.
bool maybeRetrySendForFeeGap(const std::string& opid, const std::string& rawMsg);
void resendWithFeeGapWorkaround(const std::string& from, const std::string& to,
double amount, double fee, const std::string& memo,
std::function<void(bool, const std::string&)> callback);
void markPendingSendTransactionSucceeded(const std::string& opid,
const std::string& txid);
void removePendingSendTransactions(const std::vector<std::string>& opids,
@@ -603,6 +628,25 @@ private:
// Gates the "rescan complete" detection so a getrescaninfo poll that hits the still-running
// pre-restart daemon (which reports rescanning=false) can't fire a false "complete" instantly.
bool rescan_confirmed_active_ = false;
// A runtime rescanblockchain RPC is in flight (vs the -rescan daemon restart). While set,
// the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for
// the whole scan and would block them); completion is signalled by the rescan RPC callback.
bool runtime_rescan_active_ = false;
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
// that reconciles the preserved wallet.dat against the freshly-imported chain.
bool post_bootstrap_rescan_pending_ = false;
// Largest "blocks remaining" seen during the current witness-rebuild phase. The daemon's
// "Building Witnesses for block" fraction resets every call (it's re-invoked per connected
// block, each walking from its own start height to the tip), so we derive a stable, monotonic
// overall percentage from how far "remaining" has fallen below this peak. Reset per phase.
int witness_rebuild_total_blocks_ = 0;
// The daemon's primary witness signal is "Setting Initial Sapling Witness for tx <hash>, <i>
// of <N>", logged once per wallet tx as its initial witness is set. The <i> is the tx's slot in
// an UNORDERED map, so it bounces wildly (was the cause of the resetting progress). The honest
// monotonic metric is how many DISTINCT txs have been witnessed (the set only grows; it also
// dedups the daemon's occasional double-prints) over the reported total N.
std::unordered_set<std::string> witness_seen_txids_;
int witness_total_txs_ = 0;
bool opid_poll_in_progress_ = false;
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
@@ -617,9 +661,13 @@ private:
std::string to;
std::string memo;
double amount = 0.0;
double fee = 0.0;
std::int64_t timestamp = 0;
};
std::unordered_map<std::string, PendingSendInfo> pending_send_info_;
// Opids issued as a fee-gap auto-retry (see maybeRetrySendForFeeGap). Tracked so a retry that
// fails again is reported to the user instead of looping.
std::unordered_set<std::string> send_feegap_retried_opids_;
// z_sendmany UI callbacks held until the opid reaches a terminal status, so the
// user isn't told "sent successfully" before the tx is actually built/broadcast.
std::unordered_map<std::string, std::function<void(bool, const std::string&)>>

View File

@@ -767,7 +767,8 @@ void App::upsertPendingSendTransaction(const std::string& opid,
const std::string& from,
const std::string& to,
double amount,
const std::string& memo)
const std::string& memo,
double fee)
{
if (opid.empty()) return;
@@ -778,6 +779,7 @@ void App::upsertPendingSendTransaction(const std::string& opid,
pendingInfo.to = to;
pendingInfo.amount = std::abs(amount);
pendingInfo.memo = memo;
pendingInfo.fee = fee;
TransactionInfo pending;
pending.txid = opid;
@@ -1144,6 +1146,33 @@ void App::refreshCoreData()
state_.warmup_description.clear();
connection_status_ = TR("connected");
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
// A -rescan runs entirely INSIDE daemon warmup (every RPC returns -28 until the
// scan finishes), so warmup completing IS the rescan completing. This is the
// reliable completion signal: some daemons lack getrescaninfo (it returns
// "Method not found") or never print a "Done rescanning"/bench line, which left
// the older detectors stuck at 99% — the user would then kill it prematurely.
// rescan_confirmed_active_ ensures we actually observed this rescan running (set
// by the getrescaninfo / daemon-log pollers) before declaring it done.
if (state_.sync.rescanning && rescan_confirmed_active_) {
state_.sync.rescanning = false;
rescan_confirmed_active_ = false;
state_.sync.rescan_progress = 1.0f;
state_.sync.rescan_status.clear();
state_.sync.building_witnesses = false;
state_.sync.witness_phase = 0;
state_.sync.witness_progress = 0.0f;
state_.sync.witness_remaining = 0;
witness_rebuild_total_blocks_ = 0;
witness_seen_txids_.clear();
witness_total_txs_ = 0;
// Notes/witnesses were rebuilt — force a fresh history + balance pull.
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
ui::Notifications::instance().success("Blockchain rescan complete");
}
NetworkRefreshService::applyConnectionInfoResult(state_, result.info);
// Trigger full data refresh now that daemon is ready
refreshData();
@@ -2337,7 +2366,7 @@ void App::sendTransaction(const std::string& from, const std::string& to,
} catch (const std::exception& e) {
result_str = e.what();
}
return [this, callback, ok, result_str, from, to, amount, memo]() {
return [this, callback, ok, result_str, from, to, amount, fee, memo]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) {
// A send changes address balances — refresh on next cycle
@@ -2352,7 +2381,7 @@ void App::sendTransaction(const std::string& from, const std::string& to,
// "sent successfully" for an operation that may still fail.
if (!result_str.empty()) {
pending_opids_.push_back(result_str);
upsertPendingSendTransaction(result_str, from, to, amount, memo);
upsertPendingSendTransaction(result_str, from, to, amount, memo, fee);
if (callback) pending_send_callbacks_[result_str] = callback;
} else if (callback) {
callback(true, result_str); // no opid to track — report as-is
@@ -2365,4 +2394,229 @@ void App::sendTransaction(const std::string& from, const std::string& to,
});
}
// Parse the daemon's "Insufficient shielded funds, have <H>, need <N>" message. std::stod stops at
// the trailing comma/text, so it extracts each amount cleanly. Returns false if it isn't that error.
static bool parseInsufficientShielded(const std::string& msg, double& have, double& need)
{
if (msg.find("Insufficient shielded funds") == std::string::npos) return false;
auto hp = msg.find("have ");
auto np = msg.find("need ");
if (hp == std::string::npos || np == std::string::npos) return false;
try {
have = std::stod(msg.substr(hp + 5));
need = std::stod(msg.substr(np + 5));
} catch (...) {
return false;
}
return true;
}
bool App::maybeRetrySendForFeeGap(const std::string& opid, const std::string& rawMsg)
{
double have = 0.0, need = 0.0;
if (!parseInsufficientShielded(rawMsg, have, need)) return false;
auto it = pending_send_info_.find(opid);
if (it == pending_send_info_.end()) return false;
const PendingSendInfo info = it->second; // copy before any cleanup
// Only shielded sends hit the bug; a transparent "from" uses a different (correct) selector.
if (info.from.empty() || info.from[0] != 'z') return false;
// Discriminator: the daemon stopped once it covered the amount but short of the fee, so the
// selected total (have) is >= the amount. A genuine shortfall reports have < amount (it grabbed
// every note and still couldn't reach the amount) — don't "retry" those.
if (have + 1e-9 < info.amount) return false;
// Never retry a retry (the self-output already widened the target; a second failure is real).
if (send_feegap_retried_opids_.count(opid)) return false;
const double fee = info.fee > 0.0 ? info.fee : 0.0001;
// Hand the waiting UI callback to the retry so the user sees the final outcome, not the
// intermediate "insufficient" we're working around.
std::function<void(bool, const std::string&)> cb;
auto cbIt = pending_send_callbacks_.find(opid);
if (cbIt != pending_send_callbacks_.end()) {
cb = cbIt->second;
pending_send_callbacks_.erase(cbIt);
}
DEBUG_LOGF("[send] fee-gap workaround: retrying %s with self-output (have=%.8f need=%.8f amount=%.8f fee=%.8f)\n",
opid.c_str(), have, need, info.amount, fee);
resendWithFeeGapWorkaround(info.from, info.to, info.amount, fee, info.memo, std::move(cb));
return true;
}
void App::resendWithFeeGapWorkaround(const std::string& from, const std::string& to,
double amount, double fee, const std::string& memo,
std::function<void(bool, const std::string&)> callback)
{
if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback(false, "Not connected");
return;
}
// recipients = the real recipient + a tiny self-output (= fee) back to `from`. The extra output
// raises the daemon's note-selection target (nTotalOut) above the single largest note, so its
// greedy picker grabs another note and can therefore also cover the miner fee. The recipient
// still receives EXACTLY `amount`; the self-output and any change return to `from`.
nlohmann::json recipients = nlohmann::json::array();
nlohmann::json primary;
primary["address"] = to;
primary["amount"] = util::formatAmountFixed(amount);
if (!memo.empty()) primary["memo"] = memo;
recipients.push_back(primary);
nlohmann::json selfOut;
selfOut["address"] = from;
selfOut["amount"] = util::formatAmountFixed(fee);
recipients.push_back(selfOut);
send_progress_active_ = true;
++send_submissions_in_flight_;
worker_->post([this, from, to, amount, fee, memo, recipients, callback]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string result_str;
try {
rpc::RPCClient::TraceScope trace("Send tab / Fee-gap retry");
auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee});
result_str = result.get<std::string>();
ok = true;
} catch (const std::exception& e) {
result_str = e.what();
}
return [this, callback, ok, result_str, from, to, amount, fee, memo]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) {
addresses_dirty_ = true;
transactions_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markWalletMutationRefresh();
if (!result_str.empty()) {
pending_opids_.push_back(result_str);
send_feegap_retried_opids_.insert(result_str); // a retry of a retry is a real error
upsertPendingSendTransaction(result_str, from, to, amount, memo, fee);
if (callback) pending_send_callbacks_[result_str] = callback;
} else if (callback) {
callback(true, result_str);
}
} else {
send_progress_active_ = false;
if (callback) callback(false, result_str);
}
};
});
}
// --------------------------------------------------------------------------------------------
// Bootstrapped/pruned-node rescan support
//
// A node restored from a bootstrap snapshot only has block data from the snapshot base upward;
// blocks below it are absent on disk. The startup -rescan flag rescans from genesis and fails on
// such a node ("error in HDD data"), and rescanblockchain(0) hits the same missing blocks. The
// fix is to rescan from a height the snapshot includes — found here by binary-searching for the
// lowest height whose block data the node can actually read.
// --------------------------------------------------------------------------------------------
void App::detectLowestAvailableBlockHeight(std::function<void(bool, int, bool)> cb)
{
if (!rpc_ || !rpc_->isConnected()) {
if (cb) cb(false, 0, false);
return;
}
const int tip = state_.sync.blocks;
if (tip <= 1) {
// No usable tip yet (or a brand-new chain) — treat as full history, nothing to probe.
if (cb) cb(false, 0, true);
return;
}
// Shared search window; each probe halves it. Invariant maintained: block at `hi` is readable.
struct SearchState { int lo; int hi; std::function<void(bool, int, bool)> cb; };
auto st = std::make_shared<SearchState>(SearchState{0, tip, std::move(cb)});
auto step = std::make_shared<std::function<void()>>();
*step = [this, st, step]() {
if (st->lo >= st->hi) {
const bool fullHistory = (st->lo <= 1); // genesis/early block present → not bootstrapped
if (st->cb) st->cb(true, st->lo, fullHistory);
return;
}
const int mid = st->lo + (st->hi - st->lo) / 2;
rpc_->getBlock(mid, [st, step, mid](const nlohmann::json& result, const std::string& error) {
if (error.empty() && !result.is_null()) {
st->hi = mid; // block data present → lowest available is <= mid
} else {
st->lo = mid + 1; // block data missing → lowest available is > mid
}
(*step)();
});
};
(*step)();
}
void App::runtimeRescan(int startHeight)
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
if (!state_.connected || !rpc_ || !rpc_->isConnected() || !worker_) {
ui::Notifications::instance().warning("Not connected to daemon");
return;
}
if (runtime_rescan_active_) return;
if (startHeight < 0) startHeight = 0;
DEBUG_LOGF("[App] Starting runtime rescanblockchain from height %d\n", startHeight);
// The rescan runs inside the daemon (holds cs_main/cs_wallet) — it does not restart. We own
// completion via this RPC's callback; rescan_confirmed_active_ keeps the -rescan-style pollers
// from misreading state, and runtime_rescan_active_ suppresses the per-second pollers that
// would otherwise pile up behind the held lock.
runtime_rescan_active_ = true;
state_.sync.rescanning = true;
rescan_confirmed_active_ = true;
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "...";
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
ui::Notifications::instance().info("Rescanning blockchain from block " + std::to_string(startHeight) +
" — this can take a while.");
worker_->post([this, startHeight]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string err;
try {
rpc::RPCClient::TraceScope trace("Settings / Runtime rescan");
rpc_->call("rescan", {startHeight}); // hush "rescan <height>"; blocks until the scan finishes
ok = true;
} catch (const std::exception& e) {
err = e.what();
}
return [this, ok, err]() {
runtime_rescan_active_ = false;
rescan_confirmed_active_ = false;
state_.sync.rescanning = false;
state_.sync.rescan_status.clear();
state_.sync.building_witnesses = false;
state_.sync.witness_phase = 0;
state_.sync.witness_progress = 0.0f;
state_.sync.witness_remaining = 0;
witness_rebuild_total_blocks_ = 0;
witness_seen_txids_.clear();
witness_total_txs_ = 0;
if (ok) {
state_.sync.rescan_progress = 1.0f;
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
ui::Notifications::instance().success("Blockchain rescan complete");
refreshData();
} else {
state_.sync.rescan_progress = 0.0f;
ui::Notifications::instance().error("Rescan failed: " + err);
}
};
});
}
} // namespace dragonx

View File

@@ -774,6 +774,8 @@ void App::renderFirstRunWizard() {
auto finalProg = bootstrap_->getProgress();
if (finalProg.state == util::Bootstrap::State::Completed) {
bootstrap_.reset();
// Reconcile the preserved wallet.dat against the new chain once the daemon is up.
markPostBootstrapRescanPending();
wizard_phase_ = WizardPhase::EncryptOffer;
} else {
wizard_phase_ = WizardPhase::BootstrapFailed;
@@ -1022,7 +1024,7 @@ void App::renderFirstRunWizard() {
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Download from mirror (bootstrap2.dragonx.is).\nUse this if the main download is slow or failing.");
ui::material::Tooltip("Download from mirror (bootstrap2.dragonx.is).\nUse this if the main download is slow or failing.");
}
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);

View File

@@ -96,12 +96,23 @@ bool DaemonController::rescanOnNextStart() const
return daemon_->rescanOnNextStart();
}
void DaemonController::setZapOnNextStart(bool enabled)
{
daemon_->setZapOnNextStart(enabled);
}
bool DaemonController::zapOnNextStart() const
{
return daemon_->zapOnNextStart();
}
void DaemonController::prepareLifecycleOperation(const LifecycleDecision& decision,
const config::Settings* settings)
{
if (settings) syncSettings(settings);
if (decision.resetCrashCount) resetCrashCount();
if (decision.setRescanOnNextStart) setRescanOnNextStart(true);
if (decision.setZapOnNextStart) setZapOnNextStart(true);
}
DaemonController::ShutdownDecision DaemonController::shutdownDecision(

View File

@@ -31,6 +31,7 @@ public:
enum class LifecycleOperation {
ManualRestart,
Rescan,
RepairWallet, // restart with -zapwallettxes=2 (wipe & rebuild wallet tx records)
DeleteBlockchainData,
BootstrapStop
};
@@ -46,6 +47,7 @@ public:
bool setRescanOnNextStart = false;
bool disconnectRpc = false;
int restartDelayMs = 0;
bool setZapOnNextStart = false;
};
class LifecycleTaskContext {
@@ -102,6 +104,8 @@ public:
void resetCrashCount();
void setRescanOnNextStart(bool enabled);
bool rescanOnNextStart() const;
void setZapOnNextStart(bool enabled);
bool zapOnNextStart() const;
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
bool externalDaemonDetected,
@@ -141,6 +145,13 @@ public:
}
return {operation, true, daemonRunning, "rescan-blockchain", "Starting rescan...", "",
false, true, false, 3000};
case LifecycleOperation::RepairWallet:
if (!usingEmbeddedDaemon || !hasDaemon) {
return {operation, false, daemonRunning, "", "",
"Wallet repair requires embedded daemon. Restart your daemon with -zapwallettxes=2 manually."};
}
return {operation, true, daemonRunning, "repair-wallet", "Repairing wallet...", "",
false, false, false, 3000, true};
case LifecycleOperation::DeleteBlockchainData:
if (!usingEmbeddedDaemon || !hasDaemon) {
return {operation, false, daemonRunning, "", "",
@@ -190,6 +201,7 @@ public:
}
break;
case LifecycleOperation::Rescan:
case LifecycleOperation::RepairWallet:
case LifecycleOperation::DeleteBlockchainData:
runtime.stopDaemonWithPolicy();
result.stopped = true;
@@ -206,6 +218,7 @@ public:
}
if (decision.operation == LifecycleOperation::Rescan ||
decision.operation == LifecycleOperation::RepairWallet ||
decision.operation == LifecycleOperation::DeleteBlockchainData) {
runtime.resetOutputOffset();
}

View File

@@ -482,8 +482,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-maxconnections=" + std::to_string(max_connections_));
}
// Add wallet-repair flag if requested (one-shot). -zapwallettxes=2 wipes all wallet tx/note
// records and rebuilds them from the chain; it implies -rescan, so don't also pass -rescan.
if (zap_on_next_start_.exchange(false)) {
DEBUG_LOGF("[INFO] Adding -zapwallettxes=2 flag for wallet repair (zap & rebuild)\n");
args.push_back("-zapwallettxes=2");
rescan_on_next_start_.store(false); // implied by zap; avoid redundant -rescan
} else if (rescan_on_next_start_.exchange(false)) {
// Add -rescan flag if requested (one-shot)
if (rescan_on_next_start_.exchange(false)) {
DEBUG_LOGF("[INFO] Adding -rescan flag for blockchain rescan\n");
args.push_back("-rescan");
}

View File

@@ -183,6 +183,14 @@ public:
void setRescanOnNextStart(bool v) { rescan_on_next_start_ = v; }
bool rescanOnNextStart() const { return rescan_on_next_start_.load(); }
/**
* @brief Request a wallet repair (-zapwallettxes=2) on the next daemon start. This deletes all
* wallet transaction/note records and rebuilds them from the chain (keys are kept); the
* daemon implicitly rescans afterwards. One-shot, like the rescan flag.
*/
void setZapOnNextStart(bool v) { zap_on_next_start_ = v; }
bool zapOnNextStart() const { return zap_on_next_start_.load(); }
/** Get number of consecutive daemon crashes (resets on successful start or manual reset) */
int getCrashCount() const { return crash_count_.load(); }
/** Reset crash counter (call on successful connection or manual restart) */
@@ -222,6 +230,7 @@ private:
int max_connections_ = 0; // 0 = daemon default
std::atomic<int> crash_count_{0}; // consecutive crash counter
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
};
} // namespace daemon

View File

@@ -127,6 +127,19 @@ struct SyncInfo {
float rescan_progress = 0.0f; // 0.0 - 1.0
std::string rescan_status; // e.g. "Rescanning... 25%"
// Sapling note witness rebuild — a distinct, often-long phase after a rescan/zap. The daemon
// reports it in TWO sub-phases with different signals, so we track which is active:
// 1 = initial pass ("Setting Initial Sapling Witness for tx <hash>, <i> of <N>") — progress
// is distinct-txs-witnessed / N (the <i> bounces, so it can't be used directly).
// 2 = witness-cache walk ("Building Witnesses for block <h> <frac> complete, <n> remaining")
// — progress derived from how far "remaining" has fallen from its per-phase peak.
// The two are sequential with different scales, so progress is NOT carried across the boundary
// (that would pin the bar at the initial pass's ~100% through the whole cache walk).
bool building_witnesses = false;
int witness_phase = 0; // 0 none, 1 initial-witness pass, 2 witness-cache walk
float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase
int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1)
bool isSynced() const { return !syncing && blocks > 0 && blocks >= headers - 2; }
};

View File

@@ -5,6 +5,8 @@
#include <filesystem>
#include <vector>
#include <cstdio>
#include <cctype>
#include <chrono>
#ifdef _WIN32
#include <windows.h>
@@ -225,11 +227,13 @@ bool needsParamsExtraction()
if (spendRes && resourceNeedsUpdate(spendRes, spendPath)) return true;
if (outputRes && resourceNeedsUpdate(outputRes, outputPath)) return true;
// Also check if daemon binaries need updating
// Daemon binaries are only auto-placed when MISSING (never auto-overwritten on a size
// mismatch) — the user may be running a specific dragonxd. Replacing the bundled daemon is
// an explicit action via Settings → daemon binary. So only trigger extraction if it's absent.
#ifdef HAS_EMBEDDED_DAEMON
const auto* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
std::string daemonPath = daemonDir + pathSep + RESOURCE_DRAGONXD;
if (daemonRes && resourceNeedsUpdate(daemonRes, daemonPath)) return true;
if (daemonRes && !std::filesystem::exists(daemonPath)) return true;
#endif
#ifdef HAS_EMBEDDED_XMRIG
@@ -369,12 +373,12 @@ bool extractEmbeddedResources()
#ifdef HAS_EMBEDDED_DAEMON
DEBUG_LOGF("[INFO] Daemon extraction directory: %s\n", daemonDir.c_str());
// Daemon binaries are placed ONLY when missing — never auto-overwritten on a size mismatch
// (the user may run a specific dragonxd; replacing it is an explicit Settings action).
const EmbeddedResource* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
if (daemonRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONXD;
if (resourceNeedsUpdate(daemonRes, dest)) {
if (std::filesystem::exists(dest))
DEBUG_LOGF("[INFO] Updating stale dragonxd (size mismatch)...\n");
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonxd (%zu MB)...\n", daemonRes->size / (1024*1024));
if (!extractResource(daemonRes, dest)) {
success = false;
@@ -388,9 +392,7 @@ bool extractEmbeddedResources()
const EmbeddedResource* cliRes = getEmbeddedResource(RESOURCE_DRAGONX_CLI);
if (cliRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_CLI;
if (resourceNeedsUpdate(cliRes, dest)) {
if (std::filesystem::exists(dest))
DEBUG_LOGF("[INFO] Updating stale dragonx-cli (size mismatch)...\n");
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-cli (%zu MB)...\n", cliRes->size / (1024*1024));
if (!extractResource(cliRes, dest)) {
success = false;
@@ -404,9 +406,7 @@ bool extractEmbeddedResources()
const EmbeddedResource* txRes = getEmbeddedResource(RESOURCE_DRAGONX_TX);
if (txRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_TX;
if (resourceNeedsUpdate(txRes, dest)) {
if (std::filesystem::exists(dest))
DEBUG_LOGF("[INFO] Updating stale dragonx-tx (size mismatch)...\n");
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-tx (%zu MB)...\n", txRes->size / (1024*1024));
if (!extractResource(txRes, dest)) {
success = false;
@@ -590,6 +590,120 @@ bool forceExtractXmrig()
#endif
}
// Scan a binary blob for the daemon's version stamp: 'v' <maj>.<min>.<rev> optionally followed by
// '-' <commit hash (>=6 hex)>, e.g. "v1.0.2-ddd851dc1". Returns the first match, or "" if none.
static std::string scanBinaryVersion(const uint8_t* data, std::size_t size)
{
if (!data || size < 6) return "";
auto isdig = [](uint8_t c) { return std::isdigit(static_cast<unsigned char>(c)) != 0; };
auto isxd = [](uint8_t c) { return std::isxdigit(static_cast<unsigned char>(c)) != 0; };
for (std::size_t i = 0; i + 5 < size; ++i) {
if (data[i] != 'v') continue;
std::size_t k = i + 1, s;
s = k; while (k < size && isdig(data[k])) ++k; if (k == s) continue; // major
if (k >= size || data[k] != '.') continue; ++k;
s = k; while (k < size && isdig(data[k])) ++k; if (k == s) continue; // minor
if (k >= size || data[k] != '.') continue; ++k;
s = k; while (k < size && isdig(data[k])) ++k; if (k == s) continue; // revision
std::size_t end = k;
if (k < size && data[k] == '-') { // optional -<commit>
std::size_t h = k + 1, hs = h;
while (h < size && isxd(data[h])) ++h;
if (h - hs >= 6) end = h;
}
return std::string(reinterpret_cast<const char*>(data) + i, end - i);
}
return "";
}
DaemonBinaryInfo getInstalledDaemonInfo()
{
DaemonBinaryInfo info;
std::string daemonDir = getDaemonDirectory();
#ifdef _WIN32
info.path = daemonDir + "\\" + RESOURCE_DRAGONXD;
#else
info.path = daemonDir + "/" + RESOURCE_DRAGONXD;
#endif
std::error_code ec;
if (!std::filesystem::exists(info.path, ec)) return info; // exists stays false
info.exists = true;
info.size = std::filesystem::file_size(info.path, ec);
if (ec) info.size = 0;
auto ftime = std::filesystem::last_write_time(info.path, ec);
if (!ec) {
// Convert filesystem clock → system_clock epoch (pre-C++20 portable approximation).
auto sysTime = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
ftime - decltype(ftime)::clock::now() + std::chrono::system_clock::now());
info.modifiedEpoch =
static_cast<std::int64_t>(std::chrono::system_clock::to_time_t(sysTime));
}
// Read the binary and scan for its version stamp (one-off; caller caches the result).
std::ifstream f(info.path, std::ios::binary);
if (f) {
f.seekg(0, std::ios::end);
std::streamoff len = f.tellg();
f.seekg(0, std::ios::beg);
if (len > 0) {
std::vector<uint8_t> buf(static_cast<std::size_t>(len));
f.read(reinterpret_cast<char*>(buf.data()), len);
info.version = scanBinaryVersion(buf.data(), static_cast<std::size_t>(f.gcount()));
}
}
return info;
}
BundledDaemonInfo getBundledDaemonInfo()
{
BundledDaemonInfo info;
#ifdef HAS_EMBEDDED_DAEMON
const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONXD);
if (res && res->data && res->size > 0) {
info.available = true;
info.size = res->size;
// The embedded bytes are constant for this build — scan once.
static const std::string cachedVersion = scanBinaryVersion(res->data, res->size);
info.version = cachedVersion;
}
#endif
return info;
}
bool reextractBundledDaemon()
{
#ifdef HAS_EMBEDDED_DAEMON
std::string daemonDir = getDaemonDirectory();
#ifdef _WIN32
const char pathSep = '\\';
#else
const char pathSep = '/';
#endif
bool ok = true;
bool wroteAny = false;
const char* names[] = { RESOURCE_DRAGONXD, RESOURCE_DRAGONX_CLI, RESOURCE_DRAGONX_TX };
for (const char* name : names) {
const EmbeddedResource* res = getEmbeddedResource(name);
if (!res) continue;
std::string dest = daemonDir + pathSep + name;
DEBUG_LOGF("[INFO] reextractBundledDaemon: writing %s (%zu MB)\n", name, res->size / (1024*1024));
if (!extractResource(res, dest)) {
DEBUG_LOGF("[ERROR] reextractBundledDaemon: failed to write %s\n", name);
ok = false;
continue;
}
wroteAny = true;
#ifndef _WIN32
chmod(dest.c_str(), 0755);
#endif
}
return ok && wroteAny;
#else
return false;
#endif
}
std::string getXmrigPath()
{
std::string daemonDir = getDaemonDirectory();

View File

@@ -30,6 +30,31 @@ bool needsParamsExtraction();
// Get the params directory path
std::string getParamsDirectory();
// --- Daemon binary management (Settings → daemon binary panel) ------------------------------
// Info about the dragonxd binary currently installed in the dragonx/ extraction directory.
struct DaemonBinaryInfo {
bool exists = false;
std::string path;
std::uintmax_t size = 0;
std::string version; // scanned from the binary ("vX.Y.Z-<commit>"), empty if not found
std::int64_t modifiedEpoch = 0; // last-write time as unix epoch seconds, 0 if unknown
};
// Info about the dragonxd binary bundled inside this wallet build.
struct BundledDaemonInfo {
bool available = false; // a daemon resource is embedded in this build
std::uintmax_t size = 0;
std::string version;
};
// Read + scan the installed dragonxd (reads the file; call off the UI thread or cache the result).
DaemonBinaryInfo getInstalledDaemonInfo();
// Info about the bundled daemon (scans the embedded bytes once, cached).
BundledDaemonInfo getBundledDaemonInfo();
// Force-overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones. The
// caller should stop the daemon first. Returns true if all present resources were written.
bool reextractBundledDaemon();
// Resource names
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";

View File

@@ -668,7 +668,8 @@ void RPCClient::stop(Callback cb, ErrorCallback err)
void RPCClient::rescanBlockchain(int startHeight, Callback cb, ErrorCallback err)
{
doRPC("rescanblockchain", {startHeight}, cb, err);
// hush/komodo daemons expose this as "rescan <height>", not bitcoin's "rescanblockchain".
doRPC("rescan", {startHeight}, cb, err);
}
void RPCClient::z_validateAddress(const std::string& address, Callback cb, ErrorCallback err)
@@ -776,7 +777,8 @@ void RPCClient::getInfo(UnifiedCallback cb)
void RPCClient::rescanBlockchain(int startHeight, UnifiedCallback cb)
{
doRPC("rescanblockchain", {startHeight},
// hush/komodo daemons expose this as "rescan <height>", not bitcoin's "rescanblockchain".
doRPC("rescan", {startHeight},
[cb](const json& result) {
if (cb) cb(result, "");
},

View File

@@ -7,6 +7,7 @@
#include "../colors.h"
#include "../typography.h"
#include "../layout.h"
#include "../tooltip_style.h"
#include "imgui.h"
#include "imgui_internal.h"
@@ -271,7 +272,7 @@ inline bool IconButton(const char* icon, const char* tooltip, bool enabled) {
// Tooltip
if (tooltip && hovered) {
ImGui::SetTooltip("%s", tooltip);
material::Tooltip("%s", tooltip);
}
return pressed && enabled;

View File

@@ -6,6 +6,7 @@
#include "colors.h"
#include "type.h"
#include "tooltip_style.h"
#include "../layout.h"
#include "../schema/element_styles.h"
#include "../schema/color_var_resolver.h"

View File

@@ -0,0 +1,66 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// App-wide tooltip styling: a compact (small padding) and slightly-translucent tooltip. ImGui has
// no tooltip-specific padding (tooltips inherit WindowPadding, shared with every window) and PopupBg
// is shared with menus/combos, so we can't restyle tooltips via the global style without side
// effects. Instead, every tooltip goes through these wrappers, which scope the style to the tooltip:
// dragonx::ui::material::Tooltip("fmt", ...); // replaces ImGui::SetTooltip
// if (material::BeginTooltip()) { ...; material::EndTooltip(); } // replaces ImGui::Begin/EndTooltip
#pragma once
#include <cstdarg>
#include "imgui.h"
#include "../layout.h"
namespace dragonx {
namespace ui {
namespace material {
// Scope tooltip-only styling: small padding + a slightly-transparent background.
inline void PushTooltipStyle()
{
const float s = Layout::dpiScale();
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f * s, 4.0f * s));
ImVec4 bg = ImGui::GetStyle().Colors[ImGuiCol_PopupBg];
bg.w = 0.85f; // slightly transparent (the global PopupBg is ~0.98 opaque)
ImGui::PushStyleColor(ImGuiCol_PopupBg, bg);
}
inline void PopTooltipStyle()
{
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// Drop-in replacement for ImGui::SetTooltip (same printf-style signature). (No IM_FMTARGS: GCC
// rejects the format attribute on an inline definition; call sites already passed SetTooltip's check.)
inline void Tooltip(const char* fmt, ...)
{
PushTooltipStyle();
va_list args;
va_start(args, fmt);
ImGui::SetTooltipV(fmt, args);
va_end(args);
PopTooltipStyle();
}
// Drop-in replacements for ImGui::BeginTooltip / ImGui::EndTooltip (must be paired, as before).
inline bool BeginTooltip()
{
PushTooltipStyle();
return ImGui::BeginTooltip();
}
inline void EndTooltip()
{
ImGui::EndTooltip();
PopTooltipStyle();
}
} // namespace material
} // namespace ui
} // namespace dragonx

View File

@@ -15,6 +15,8 @@
#include "../windows/console_tab.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../resources/embedded_resources.h"
#include <ctime>
#include "../../rpc/rpc_client.h"
#include "../../rpc/connection.h"
#include "../../rpc/rpc_worker.h"
@@ -38,6 +40,7 @@
#include "../windows/export_all_keys_dialog.h"
#include "../windows/export_transactions_dialog.h"
#include "../windows/bootstrap_download_dialog.h"
#include "../windows/daemon_download_dialog.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <nlohmann/json.hpp>
@@ -146,6 +149,19 @@ struct SettingsPageState {
bool confirm_clear_ztx = false;
bool confirm_delete_blockchain = false;
bool confirm_rescan = false;
// Rescan dialog: probe the node's available block range so a bootstrapped/pruned node gets a
// runtime rescan from a snapshot-available height instead of the (failing) -rescan-from-genesis.
bool rescan_height_detecting = false;
bool rescan_height_detected = false;
bool rescan_full_history = true; // genesis present → traditional -rescan restart
int rescan_start_height = 0; // editable pre-fill for the runtime rescan
bool confirm_repair_wallet = false;
bool confirm_reinstall_daemon = false;
// Cached daemon-binary status for the "daemon binary" panel (loaded once / on Refresh,
// since reading the installed binary to scan its version is a one-off disk read).
bool daemon_info_loaded = false;
dragonx::resources::DaemonBinaryInfo installed_daemon;
dragonx::resources::BundledDaemonInfo bundled_daemon;
bool confirm_restart_daemon = false;
bool confirm_lite_redownload = false;
effects::ScrollFadeShader fade_shader;
@@ -627,7 +643,7 @@ void RenderSettingsPage(App* app) {
ImGui::EndDisabled();
ImGui::PopStyleColor();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s", skin.validationError.c_str());
material::Tooltip("%s", skin.validationError.c_str());
} else {
std::string lbl = skin.name;
if (!skin.author.empty()) lbl += " (" + skin.author + ")";
@@ -669,7 +685,7 @@ void RenderSettingsPage(App* app) {
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_theme_hotkey"));
material::Tooltip("%s", TR("tt_theme_hotkey"));
ImGui::SameLine(0, comboGap);
ImGui::AlignTextToFramePadding();
@@ -692,7 +708,7 @@ void RenderSettingsPage(App* app) {
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_layout_hotkey"));
material::Tooltip("%s", TR("tt_layout_hotkey"));
ImGui::SameLine(0, comboGap);
ImGui::AlignTextToFramePadding();
@@ -709,7 +725,7 @@ void RenderSettingsPage(App* app) {
app->settings()->save();
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_language"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language"));
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) {
@@ -717,7 +733,7 @@ void RenderSettingsPage(App* app) {
Notifications::instance().info("Theme list refreshed");
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(TR("tt_scan_themes"),
material::Tooltip(TR("tt_scan_themes"),
schema::SkinManager::getUserSkinsDirectory().c_str());
}
ImGui::PopFont();
@@ -747,7 +763,7 @@ void RenderSettingsPage(App* app) {
Layout::setUserFontScale(s_settingsState.font_scale);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_font_scale"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_font_scale"));
ImGui::PopFont();
}
@@ -815,20 +831,20 @@ void RenderSettingsPage(App* app) {
}
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_low_spec"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_low_spec"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("simple_background", "simple_bg").c_str(), &s_settingsState.gradient_background)) {
schema::SkinManager::instance().setGradientMode(s_settingsState.gradient_background);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_simple_bg"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_simple_bg"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &s_settingsState.reduce_motion)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_reduce_motion"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reduce_motion"));
ImGui::BeginDisabled(s_settingsState.low_spec_mode);
@@ -838,14 +854,14 @@ void RenderSettingsPage(App* app) {
app->settings()->setScanlineEnabled(s_settingsState.scanline_enabled);
app->settings()->save();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_scanline"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_scanline"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("theme_effects", "effects").c_str(), &s_settingsState.theme_effects_enabled)) {
effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_theme_effects"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
// Row 1: Acrylic preset slider + Noise slider (side by side, labels above)
float effCtrlMinW = S.drawElement("components.settings-page", "effects-input-min-width").size;
@@ -871,7 +887,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_blur"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_blur"));
float afterRow1Y = ImGui::GetCursorScreenPos().y;
float lblH = ImGui::GetTextLineHeight() + ImGui::GetStyle().ItemSpacing.y;
@@ -891,7 +907,7 @@ void RenderSettingsPage(App* app) {
saveSettingsPageState(app->settings());
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_noise"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_noise"));
ImGui::SetCursorScreenPos(ImVec2(baseX, afterRow1Y));
@@ -907,7 +923,7 @@ void RenderSettingsPage(App* app) {
saveSettingsPageState(app->settings());
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_ui_opacity"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_ui_opacity"));
float afterRow2Y = ImGui::GetCursorScreenPos().y;
ImGui::SetCursorScreenPos(ImVec2(rightX, row2Y - lblH));
@@ -922,7 +938,7 @@ void RenderSettingsPage(App* app) {
saveSettingsPageState(app->settings());
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_window_opacity"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_window_opacity"));
ImGui::SetCursorScreenPos(ImVec2(baseX, afterRow2Y));
@@ -949,11 +965,11 @@ void RenderSettingsPage(App* app) {
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_theme_hotkey"));
material::Tooltip("%s", TR("tt_theme_hotkey"));
if (active_is_custom) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "*");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_custom_theme"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_theme"));
}
ImGui::SameLine();
if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) {
@@ -961,7 +977,7 @@ void RenderSettingsPage(App* app) {
Notifications::instance().info("Theme list refreshed");
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(TR("tt_scan_themes"),
material::Tooltip(TR("tt_scan_themes"),
schema::SkinManager::getUserSkinsDirectory().c_str());
}
ImGui::PopFont();
@@ -992,7 +1008,7 @@ void RenderSettingsPage(App* app) {
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_layout_hotkey"));
material::Tooltip("%s", TR("tt_layout_hotkey"));
ImGui::PopFont();
}
@@ -1015,7 +1031,7 @@ void RenderSettingsPage(App* app) {
app->settings()->save();
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_language"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language"));
ImGui::PopFont();
}
@@ -1044,7 +1060,7 @@ void RenderSettingsPage(App* app) {
Layout::setUserFontScale(s_settingsState.font_scale);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_font_scale"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_font_scale"));
ImGui::PopFont();
}
@@ -1112,18 +1128,18 @@ void RenderSettingsPage(App* app) {
}
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_low_spec"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_low_spec"));
if (ImGui::Checkbox(TrId("settings_gradient_bg", "gradient_bg").c_str(), &s_settingsState.gradient_background)) {
schema::SkinManager::instance().setGradientMode(s_settingsState.gradient_background);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_simple_bg_alt"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_simple_bg_alt"));
if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &s_settingsState.reduce_motion)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_reduce_motion"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reduce_motion"));
ImGui::BeginDisabled(s_settingsState.low_spec_mode);
@@ -1132,14 +1148,14 @@ void RenderSettingsPage(App* app) {
app->settings()->setScanlineEnabled(s_settingsState.scanline_enabled);
app->settings()->save();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_scanline"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_scanline"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("theme_effects", "theme_fx").c_str(), &s_settingsState.theme_effects_enabled)) {
effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_theme_effects"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
float ctrlW = std::max(S.drawElement("components.settings-page", "effects-input-min-width").size,
availWidth - pad * 2.0f);
@@ -1159,7 +1175,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_blur"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_blur"));
ImGui::TextUnformatted(TR("noise"));
ImGui::SetNextItemWidth(ctrlW);
@@ -1175,7 +1191,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_noise"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_noise"));
ImGui::TextUnformatted(TR("ui_opacity"));
ImGui::SetNextItemWidth(ctrlW);
@@ -1188,7 +1204,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_ui_opacity"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_ui_opacity"));
ImGui::TextUnformatted(TR("window_opacity"));
ImGui::SetNextItemWidth(ctrlW);
@@ -1200,7 +1216,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_window_opacity"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_window_opacity"));
ImGui::EndDisabled(); // low-spec
ImGui::PopFont();
@@ -1259,26 +1275,26 @@ void RenderSettingsPage(App* app) {
float sp = cbSpacing * scale;
ImGui::Checkbox(TrId("save_z_transactions", "save_ztx").c_str(), &s_settingsState.save_ztxs);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_save_ztx"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_ztx"));
ImGui::SameLine(0, sp);
ImGui::Checkbox(TrId("auto_shield", "auto_shld").c_str(), &s_settingsState.auto_shield);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_auto_shield"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_shield"));
ImGui::SameLine(0, sp);
ImGui::Checkbox(TrId("use_tor", "tor").c_str(), &s_settingsState.use_tor);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_tor"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tor"));
if (showDaemonOptions) {
ImGui::SameLine(0, sp);
if (ImGui::Checkbox(TrId("keep_daemon", "keep_dmn").c_str(), &s_settingsState.keep_daemon_running)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_keep_daemon"));
material::Tooltip("%s", TR("tt_keep_daemon"));
ImGui::SameLine(0, sp);
if (ImGui::Checkbox(TrId("stop_external", "stop_ext").c_str(), &s_settingsState.stop_external_daemon)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_stop_external"));
material::Tooltip("%s", TR("tt_stop_external"));
}
ImGui::SameLine(0, sp);
if (ImGui::Checkbox(TrId("verbose_logging", "verbose").c_str(), &s_settingsState.verbose_logging)) {
@@ -1286,7 +1302,7 @@ void RenderSettingsPage(App* app) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_verbose"));
material::Tooltip("%s", TR("tt_verbose"));
if (scale < 1.0f) ImGui::SetWindowFontScale(1.0f);
}
@@ -1325,28 +1341,28 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("settings_address_book"), ImVec2(bw, 0), S.resolveFont("button")))
AddressBookDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_address_book"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_address_book"));
ImGui::SameLine(0, btnSpacing);
if (TactileButton(TR("settings_validate_address"), ImVec2(bw, 0), S.resolveFont("button")))
ValidateAddressDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_validate"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_validate"));
if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); }
if (TactileButton(TR("settings_request_payment"), ImVec2(bw, 0), S.resolveFont("button")))
RequestPaymentDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_request_payment"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_request_payment"));
if (btnsPerRow >= 3) { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } else { ImGui::SameLine(0, btnSpacing); }
if (TactileButton(TR("settings_shield_mining"), ImVec2(bw, 0), S.resolveFont("button")))
ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_shield_mining"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining"));
ImGui::SameLine(0, btnSpacing);
if (TactileButton(TR("settings_merge_to_address"), ImVec2(bw, 0), S.resolveFont("button")))
ShieldDialog::show(ShieldDialog::Mode::MergeToAddress);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_merge"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge"));
if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); }
if (TactileButton(TR("settings_clear_ztx"), ImVec2(bw, 0), S.resolveFont("button"))) {
s_settingsState.confirm_clear_ztx = true;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_clear_ztx"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clear_ztx"));
}
ImGui::Dummy(ImVec2(0, bottomPad));
@@ -1410,23 +1426,23 @@ void RenderSettingsPage(App* app) {
if (TactileButton(r1[0], ImVec2(0, 0), btnFont))
app->showImportKeyDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[0]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[0]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[1], ImVec2(0, 0), btnFont))
app->showExportKeyDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[1]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[1]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[2], ImVec2(0, 0), btnFont))
ExportAllKeysDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[2]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[2]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[3], ImVec2(0, 0), btnFont))
app->showBackupDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[3]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[3]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[4], ImVec2(0, 0), btnFont))
ExportTransactionsDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[4]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[4]);
if (showFullNodeLifecycleActions) {
// Right-align Setup Wizard + Download Bootstrap
@@ -1445,11 +1461,11 @@ void RenderSettingsPage(App* app) {
}
if (TactileButton(bsLabel, ImVec2(0, 0), btnFont))
BootstrapDownloadDialog::show(app);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_download_bootstrap"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_download_bootstrap"));
ImGui::SameLine(0, scaledSp);
if (TactileButton(wizLabel, ImVec2(0, 0), btnFont))
app->restartWizard();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_wizard"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wizard"));
}
if (scale < 1.0f) ImGui::SetWindowFontScale(1.0f);
@@ -1660,7 +1676,7 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("settings_open_data_dir"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openFolder(util::Platform::getLiteWalletDataDir());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_open_data_dir"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_data_dir"));
// ---- Backup & keys (open wallet only) ----------------------------------
if (app->liteWallet() && app->liteWallet()->walletOpen()) {
@@ -1844,7 +1860,7 @@ void RenderSettingsPage(App* app) {
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s", TR("tt_lite_redownload"));
material::Tooltip("%s", TR("tt_lite_redownload"));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
scanning ? TR("lite_redownload_running") : TR("lite_redownload_desc"));
@@ -1911,7 +1927,7 @@ void RenderSettingsPage(App* app) {
}
}
if (hovered) {
ImGui::SetTooltip("%s", TR("tt_open_dir"));
material::Tooltip("%s", TR("tt_open_dir"));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
if (ImGui::IsItemClicked())
@@ -1932,7 +1948,7 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("settings_open_data_dir"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openFolder(util::Platform::getDragonXDataDir());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_open_data_dir"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_data_dir"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -1950,7 +1966,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(leftX - sectionOrigin.x + rpcHalfLblW);
ImGui::SetNextItemWidth(rpcHalfInputW);
ImGui::InputText("##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_rpc_host"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host"));
float afterRow1Y = ImGui::GetCursorScreenPos().y;
ImGui::SetCursorScreenPos(ImVec2(rpcRightColX, row1Y));
@@ -1959,7 +1975,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(rpcRightColX - sectionOrigin.x + rpcHalfLblW);
ImGui::SetNextItemWidth(rpcHalfInputW);
ImGui::InputText("##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_rpc_user"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user"));
ImGui::SetCursorScreenPos(ImVec2(leftX, std::max(afterRow1Y, ImGui::GetCursorScreenPos().y)));
@@ -1971,7 +1987,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(leftX - sectionOrigin.x + rpcHalfLblW);
ImGui::SetNextItemWidth(rpcHalfInputW);
ImGui::InputText("##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_rpc_port"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port"));
float afterRow2Y = ImGui::GetCursorScreenPos().y;
ImGui::SetCursorScreenPos(ImVec2(rpcRightColX, row2Y));
@@ -1981,7 +1997,7 @@ void RenderSettingsPage(App* app) {
ImGui::SetNextItemWidth(rpcHalfInputW);
ImGui::InputText("##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password),
ImGuiInputTextFlags_Password);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_rpc_pass"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass"));
ImGui::SetCursorScreenPos(ImVec2(leftX, std::max(afterRow2Y, ImGui::GetCursorScreenPos().y)));
@@ -1996,56 +2012,6 @@ void RenderSettingsPage(App* app) {
ImGui::PopStyleColor();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Node maintenance buttons (full-node build only)
if (app->supportsFullNodeLifecycleActions()) {
ImFont* btnFont = S.resolveFont("button");
float nodeBtnW;
{
if (btnFont) ImGui::PushFont(btnFont);
nodeBtnW = rowBtnW({TR("test_connection"), TR("rescan")});
if (btnFont) ImGui::PopFont(/* btnFont */);
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::BeginDisabled(!app->isConnected());
if (TactileButton(TR("test_connection"), ImVec2(nodeBtnW, 0), btnFont)) {
if (app->rpc() && app->rpc()->isConnected() && app->worker()) {
app->worker()->post([rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
try {
rpc::RPCClient::TraceScope trace("Settings / Test connection");
rpc->call("getinfo");
return []() {
Notifications::instance().success("RPC connection OK");
};
} catch (const std::exception& e) {
std::string err = e.what();
return [err]() {
Notifications::instance().error("RPC error: " + err);
};
}
});
} else {
Notifications::instance().warning("Not connected to daemon");
}
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_test_conn"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("rescan"), ImVec2(nodeBtnW, 0), btnFont)) {
s_settingsState.confirm_rescan = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_rescan"));
ImGui::EndDisabled();
// Delete blockchain button (always available when using embedded daemon)
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y + Layout::spacingSm()));
ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon());
if (TactileButton(TR("delete_blockchain"), ImVec2(0, 0), btnFont)) {
s_settingsState.confirm_delete_blockchain = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_delete_blockchain"));
ImGui::EndDisabled();
}
}
ImGui::PopFont();
@@ -2073,13 +2039,13 @@ void RenderSettingsPage(App* app) {
if (!isEncrypted) {
if (TactileButton(TR("settings_encrypt_wallet"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showEncryptDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_encrypt"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_encrypt"));
ImGui::SameLine(0, Layout::spacingMd());
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_not_encrypted"));
} else {
if (TactileButton(TR("settings_change_passphrase"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showChangePassphraseDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_change_pass"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pass"));
ImGui::SameLine(0, Layout::spacingMd());
if (isLocked) {
ImGui::PushFont(Type().iconSmall());
@@ -2090,7 +2056,7 @@ void RenderSettingsPage(App* app) {
} else {
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->lockWallet();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_lock"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
ImGui::SameLine(0, Layout::spacingSm());
ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN);
@@ -2102,7 +2068,7 @@ void RenderSettingsPage(App* app) {
ImGui::SetCursorScreenPos(ImVec2(rightX, ImGui::GetCursorScreenPos().y + Layout::spacingXs()));
if (TactileButton(TR("settings_remove_encryption"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showDecryptDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_remove_encrypt"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_encrypt"));
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -2126,7 +2092,7 @@ void RenderSettingsPage(App* app) {
app->settings()->setAutoLockTimeout(timeoutValues[selTimeout]);
app->settings()->save();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_auto_lock"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_lock"));
ImGui::PopItemWidth();
}
@@ -2142,17 +2108,17 @@ void RenderSettingsPage(App* app) {
if (!hasPIN) {
if (TactileButton(TR("settings_set_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
app->showPinSetupDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_set_pin"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_set_pin"));
ImGui::SameLine(0, Layout::spacingMd());
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_quick_unlock_pin"));
} else {
if (TactileButton(TR("settings_change_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
app->showPinChangeDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_change_pin"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pin"));
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton(TR("settings_remove_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
app->showPinRemoveDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_remove_pin"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_pin"));
ImGui::SameLine(0, Layout::spacingMd());
ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD);
@@ -2172,6 +2138,146 @@ void RenderSettingsPage(App* app) {
// Advance cursor past both columns
float maxBottom = std::max(leftBottom, rightBottom);
ImGui::SetCursorScreenPos(ImVec2(sectionOrigin.x, maxBottom));
// ---- DAEMON BINARY — full-width row beneath the NODE + SECURITY columns ----
if (app->supportsFullNodeLifecycleActions()) {
ImFont* dbBtnFont = S.resolveFont("button");
if (!s_settingsState.daemon_info_loaded) {
s_settingsState.installed_daemon = dragonx::resources::getInstalledDaemonInfo();
s_settingsState.bundled_daemon = dragonx::resources::getBundledDaemonInfo();
s_settingsState.daemon_info_loaded = true;
}
const auto& inst = s_settingsState.installed_daemon;
const auto& bun = s_settingsState.bundled_daemon;
auto fmtDate = [](std::int64_t epoch) -> std::string {
if (epoch <= 0) return "";
std::time_t t = static_cast<std::time_t>(epoch);
std::tm tmv{};
#ifdef _WIN32
localtime_s(&tmv, &t);
#else
localtime_r(&t, &tmv);
#endif
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tmv);
return std::string(buf);
};
const float dbLeftX = sectionOrigin.x;
const ImVec4 dbDim = ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium());
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_binary"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Installed | Bundled side by side across the full container width.
const float dbStartY = ImGui::GetCursorScreenPos().y;
const float dbLineH = ImGui::GetTextLineHeightWithSpacing();
const float dbCol2X = dbLeftX + std::min(contentW * 0.4f, 340.0f);
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, dbStartY));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_installed"));
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, dbStartY + dbLineH));
if (inst.exists) {
ImGui::TextUnformatted(inst.version.empty() ? TR("unknown") : inst.version.c_str());
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, dbStartY + dbLineH * 2));
ImGui::TextColored(dbDim, "%s · %s",
util::Platform::formatFileSize(inst.size).c_str(),
fmtDate(inst.modifiedEpoch).c_str());
} else {
ImGui::TextColored(dbDim, "%s", TR("daemon_not_installed"));
}
ImGui::SetCursorScreenPos(ImVec2(dbCol2X, dbStartY));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_bundled"));
ImGui::SetCursorScreenPos(ImVec2(dbCol2X, dbStartY + dbLineH));
if (bun.available) {
ImGui::TextUnformatted(bun.version.empty() ? TR("unknown") : bun.version.c_str());
ImGui::SetCursorScreenPos(ImVec2(dbCol2X, dbStartY + dbLineH * 2));
ImGui::TextColored(dbDim, "%s", util::Platform::formatFileSize(bun.size).c_str());
} else {
ImGui::TextColored(dbDim, "%s", TR("daemon_none_bundled"));
}
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, dbStartY + dbLineH * 3 + Layout::spacingXs()));
if (bun.available) {
const bool sameSize = inst.exists && inst.size == bun.size;
if (!inst.exists)
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("daemon_status_missing"));
else if (sameSize)
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("daemon_status_match"));
else
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("daemon_status_differ"));
}
// In-app node update: download + verify the latest dragonxd from the project Gitea.
// Refresh the cached daemon info once an install has completed.
if (ui::DaemonUpdateDialog::consumeInstalled())
s_settingsState.daemon_info_loaded = false;
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TR("daemon_update_check"), ImVec2(0, 0), dbBtnFont)) {
ui::DaemonUpdateDialog::show(app, inst.exists ? inst.version : std::string());
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_daemon_update_check"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// All node actions on one full-width toolbar row: daemon-binary actions first
// (Install bundled | Refresh), then maintenance (Test connection | Rescan |
// Delete blockchain | Repair wallet).
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, ImGui::GetCursorScreenPos().y));
ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon() || !bun.available);
if (TactileButton(TR("daemon_install_bundled"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_reinstall_daemon = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_daemon_install_bundled"));
ImGui::EndDisabled();
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("refresh"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.daemon_info_loaded = false;
}
ImGui::SameLine(0, Layout::spacingMd());
ImGui::BeginDisabled(!app->isConnected());
if (TactileButton(TR("test_connection"), ImVec2(0, 0), dbBtnFont)) {
if (app->rpc() && app->rpc()->isConnected() && app->worker()) {
app->worker()->post([rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
try {
rpc::RPCClient::TraceScope trace("Settings / Test connection");
rpc->call("getinfo");
return []() { Notifications::instance().success("RPC connection OK"); };
} catch (const std::exception& e) {
std::string err = e.what();
return [err]() { Notifications::instance().error("RPC error: " + err); };
}
});
} else {
Notifications::instance().warning("Not connected to daemon");
}
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_test_conn"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("rescan"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_rescan = true;
// Re-probe the available block range each time the dialog is opened.
s_settingsState.rescan_height_detecting = false;
s_settingsState.rescan_height_detected = false;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_rescan"));
ImGui::EndDisabled();
ImGui::SameLine(0, Layout::spacingMd());
ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon());
if (TactileButton(TR("delete_blockchain"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_delete_blockchain = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_delete_blockchain"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("repair_wallet"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_repair_wallet = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_repair_wallet"));
ImGui::EndDisabled();
}
}
ImGui::Dummy(ImVec2(0, bottomPad));
@@ -2216,29 +2322,29 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(0, Layout::spacingXs());
ImGui::SetNextItemWidth(inputTxW);
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_tx_url"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
ImGui::SameLine(pad + halfW + Layout::spacingLg());
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("address_url"));
ImGui::SameLine(0, Layout::spacingXs());
ImGui::SetNextItemWidth(inputAddrW);
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_addr_url"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Row 2: Checkboxes + Block Explorer button (on one line)
ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_custom_fees"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
ImGui::SameLine(0, Layout::spacingLg());
ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_fetch_prices"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
ImGui::SameLine(0, Layout::spacingLg());
if (TactileButton(TR("block_explorer"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://explorer.dragonx.is");
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_block_explorer"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_block_explorer"));
ImGui::Dummy(ImVec2(0, bottomPad));
ImGui::Unindent(pad);
@@ -2339,18 +2445,18 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TrId("website", "about_website").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://dragonx.is");
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_website"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_website"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("report_bug", "about_bug").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon/issues");
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_report_bug"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_report_bug"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("save_settings", "about_save").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
saveSettingsPageState(app->settings());
Notifications::instance().success("Settings saved");
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_save_settings"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_settings"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("reset_to_defaults", "about_reset").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
if (app->settings()) {
@@ -2358,7 +2464,7 @@ void RenderSettingsPage(App* app) {
Notifications::instance().info("Settings reloaded from disk");
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_reset_settings"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reset_settings"));
}
ImGui::Dummy(ImVec2(0, bottomPad));
@@ -2388,7 +2494,7 @@ void RenderSettingsPage(App* app) {
if (ImGui::Button("##DebugToggle", ImVec2(availWidth, ImGui::GetFrameHeight()))) {
s_settingsState.debug_expanded = !s_settingsState.debug_expanded;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse") : TR("tt_debug_expand"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse") : TR("tt_debug_expand"));
ImGui::PopStyleColor(3);
// Draw overline label + arrow on top of the invisible button
@@ -2468,7 +2574,7 @@ void RenderSettingsPage(App* app) {
s_settingsState.debug_cats_dirty = true;
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", debugTips[i]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", debugTips[i]);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
@@ -2491,7 +2597,7 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("settings_restart_daemon"), ImVec2(0, 0), S.resolveFont("button"))) {
s_settingsState.confirm_restart_daemon = true;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_restart_daemon"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
}
ImGui::Dummy(ImVec2(0, bottomPad));
@@ -2617,8 +2723,22 @@ void RenderSettingsPage(App* app) {
}
}
// Confirm: rescan blockchain (restarts the daemon, re-scans the whole chain — long but safe)
// Confirm: rescan blockchain. On a normal (full-history) node this restarts the daemon with
// -rescan; on a bootstrapped/pruned node that would fail (pre-snapshot blocks are absent), so we
// probe the lowest available block height and run a runtime rescanblockchain from a confirmed,
// editable height instead.
if (s_settingsState.confirm_rescan) {
// Kick off the one-shot block-range probe the first frame the dialog is open.
if (!s_settingsState.rescan_height_detecting && !s_settingsState.rescan_height_detected) {
s_settingsState.rescan_height_detecting = true;
app->detectLowestAvailableBlockHeight([](bool ok, int lowest, bool fullHistory) {
s_settingsState.rescan_height_detecting = false;
s_settingsState.rescan_height_detected = true;
s_settingsState.rescan_full_history = (!ok) || fullHistory;
s_settingsState.rescan_start_height = (ok && !fullHistory) ? lowest : 0;
});
}
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
ImGui::PushFont(Type().iconLarge());
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), ICON_MD_WARNING);
@@ -2627,9 +2747,25 @@ void RenderSettingsPage(App* app) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("warning"));
ImGui::Spacing();
const bool detecting = s_settingsState.rescan_height_detecting;
const bool bootstrapped = s_settingsState.rescan_height_detected && !s_settingsState.rescan_full_history;
if (detecting) {
ImGui::TextWrapped("%s", TR("rescan_detecting"));
} else if (bootstrapped) {
ImGui::TextWrapped("%s", TR("rescan_bootstrapped_msg"));
ImGui::Spacing();
ImGui::Text("%s", TR("rescan_from_height"));
ImGui::SetNextItemWidth(160.0f);
ImGui::InputInt("##rescanHeight", &s_settingsState.rescan_start_height);
if (s_settingsState.rescan_start_height < 0) s_settingsState.rescan_start_height = 0;
} else {
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe"));
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
@@ -2639,10 +2775,77 @@ void RenderSettingsPage(App* app) {
s_settingsState.confirm_rescan = false;
}
ImGui::SameLine();
ImGui::BeginDisabled(detecting);
if (ImGui::Button(TrId("rescan", "rescan_confirm").c_str(), ImVec2(btnW, 40))) {
if (bootstrapped) {
app->runtimeRescan(s_settingsState.rescan_start_height);
} else {
app->rescanBlockchain();
}
s_settingsState.confirm_rescan = false;
}
ImGui::EndDisabled();
EndOverlayDialog();
}
}
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
if (s_settingsState.confirm_repair_wallet) {
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
ImGui::PushFont(Type().iconLarge());
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), ICON_MD_WARNING);
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("warning"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_repair_wallet_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button(TrId("cancel", "repair_wallet_cancel").c_str(), ImVec2(btnW, 40))) {
s_settingsState.confirm_repair_wallet = false;
}
ImGui::SameLine();
if (ImGui::Button(TrId("repair_wallet", "repair_wallet_confirm").c_str(), ImVec2(btnW, 40))) {
app->repairWallet();
s_settingsState.confirm_repair_wallet = false;
}
EndOverlayDialog();
}
}
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
if (s_settingsState.confirm_reinstall_daemon) {
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
ImGui::PushFont(Type().iconLarge());
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), ICON_MD_WARNING);
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("warning"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_reinstall_daemon_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button(TrId("cancel", "reinstall_daemon_cancel").c_str(), ImVec2(btnW, 40))) {
s_settingsState.confirm_reinstall_daemon = false;
}
ImGui::SameLine();
if (ImGui::Button(TrId("daemon_install_bundled", "reinstall_daemon_confirm").c_str(), ImVec2(btnW, 40))) {
app->reinstallBundledDaemon();
s_settingsState.daemon_info_loaded = false; // refresh the panel after the swap
s_settingsState.confirm_reinstall_daemon = false;
}
EndOverlayDialog();
}
}

View File

@@ -690,7 +690,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
} else {
float iconCX = (indMin.x + indMax.x) * 0.5f;
DrawNavIcon(dl, item.page, iconCX, iconCY, iconS, textCol);
if (hovered) ImGui::SetTooltip("%s", NavLabel(item));
if (hovered) material::Tooltip("%s", NavLabel(item));
}
// Badge indicator
@@ -826,7 +826,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
ImVec2 iconSz = iconFont->CalcTextSizeA(eIconFsz, 1000.0f, 0.0f, exitIcon);
dl->AddText(iconFont, eIconFsz,
ImVec2(cx - iconSz.x * 0.5f, cy - iconSz.y * 0.5f), exitCol, exitIcon);
if (exitHover) ImGui::SetTooltip("%s", TR("exit"));
if (exitHover) material::Tooltip("%s", TR("exit"));
}
}

113
src/ui/widgets/copy_field.h Normal file
View File

@@ -0,0 +1,113 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Shared "click-to-copy field" widgets used by the key-export and QR-popup dialogs, so an address
// renders identically in both. A field is a bordered box of wrapping text whose whole area copies
// to the clipboard on click; long secrets/addresses are grouped into space-separated blocks.
#pragma once
#include <string>
#include <cstring>
#include "imgui.h"
#include "../material/colors.h"
#include "../material/tooltip_style.h"
#include "../../util/i18n.h"
namespace dragonx {
namespace ui {
namespace widgets {
// Draw `text` wrapped within wrapW with every visual line horizontally centered (returns the height
// used). ImGui has no center+wrap, so we walk the wrap points the way it would.
inline float DrawCenteredWrapped(ImDrawList* dl, ImFont* font, float fs, ImVec2 origin,
float wrapW, ImU32 col, const char* text)
{
const float scale = fs / font->LegacySize;
const float lineH = ImGui::GetTextLineHeight();
const char* s = text;
const char* e = text + std::strlen(text);
float y = origin.y;
while (s < e) {
const char* wrap = font->CalcWordWrapPositionA(scale, s, e, wrapW);
if (wrap <= s) wrap = s + 1;
const ImVec2 lw = font->CalcTextSizeA(fs, 1.0e30f, 0.0f, s, wrap);
float lx = origin.x + (wrapW - lw.x) * 0.5f;
if (lx < origin.x) lx = origin.x;
dl->AddText(font, fs, ImVec2(lx, y), col, s, wrap);
y += lineH;
s = wrap;
while (s < e && (*s == ' ' || *s == '\n')) s++; // ImGui consumes the break char
}
return y - origin.y;
}
// Group a long string into space-separated blocks so it reads in scannable chunks and wraps on
// block boundaries. Callers copy the ORIGINAL (un-grouped) string.
inline std::string ChunkString(const std::string& s, size_t group)
{
if (group == 0 || s.size() <= group) return s;
std::string out;
out.reserve(s.size() + s.size() / group + 1);
for (size_t i = 0; i < s.size(); ++i) {
if (i && (i % group) == 0) out.push_back(' ');
out.push_back(s[i]);
}
return out;
}
// A wrapping, click-to-copy "field": draws `display` in a bordered box (the whole box is the copy
// affordance) and returns true when clicked. `center` centers each wrapped line. The caller is
// responsible for the actual clipboard write (so secrets can use an auto-clearing clipboard).
inline bool CopyField(const char* id, const std::string& display, float width, float minH, bool center)
{
namespace m = material;
ImDrawList* dl = ImGui::GetWindowDrawList();
ImGuiStyle& st = ImGui::GetStyle();
ImFont* font = ImGui::GetFont();
const float fs = ImGui::GetFontSize();
const float padX = st.FramePadding.x + 4.0f;
const float padY = st.FramePadding.y + 4.0f;
const float wrapW = (width - padX * 2.0f) > 1.0f ? (width - padX * 2.0f) : 1.0f;
const ImVec2 ts = ImGui::CalcTextSize(display.c_str(), nullptr, false, wrapW);
float h = ts.y + padY * 2.0f;
if (h < minH) h = minH;
const ImVec2 p0 = ImGui::GetCursorScreenPos();
const ImVec2 p1 = ImVec2(p0.x + width, p0.y + h);
ImGui::InvisibleButton(id, ImVec2(width, h));
const bool hovered = ImGui::IsItemHovered();
const bool clicked = ImGui::IsItemClicked();
dl->AddRectFilled(p0, p1, m::WithAlpha(m::OnSurface(), hovered ? 18 : 8), 6.0f);
dl->AddRect(p0, p1, m::WithAlpha(m::OnSurface(), hovered ? 70 : 30), 6.0f, 0, 1.0f);
const float textY = p0.y + (h - ts.y) * 0.5f; // vertical center
if (center)
DrawCenteredWrapped(dl, font, fs, ImVec2(p0.x + padX, textY), wrapW, m::OnSurface(), display.c_str());
else
dl->AddText(font, fs, ImVec2(p0.x + padX, textY), m::OnSurface(), display.c_str(), nullptr, wrapW);
if (hovered) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("copy"));
}
return clicked;
}
// An address field: chunked by 4 for readability, width clamped to its text, box centered in the
// available region. Copies the raw address to the clipboard on click; returns true when clicked.
inline bool AddressCopyField(const char* id, const std::string& address)
{
const std::string at = ChunkString(address, 4);
const float avail = ImGui::GetContentRegionAvail().x;
const float padX = ImGui::GetStyle().FramePadding.x + 4.0f;
float boxW = ImGui::CalcTextSize(at.c_str()).x + padX * 2.0f + 2.0f; // clamp to text width
if (boxW > avail) boxW = avail;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (avail > boxW ? (avail - boxW) * 0.5f : 0.0f));
const bool clicked = CopyField(id, at, boxW, 0.0f, /*center=*/true);
if (clicked) ImGui::SetClipboardText(address.c_str());
return clicked;
}
} // namespace widgets
} // namespace ui
} // namespace dragonx

View File

@@ -258,7 +258,7 @@ void AddressBookDialog::render(App* app)
}
ImGui::TextDisabled("%s", addr_display.c_str());
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", entry.address.c_str());
material::Tooltip("%s", entry.address.c_str());
}
ImGui::TableNextColumn();

View File

@@ -172,7 +172,7 @@ public:
ImGui::PushID(i);
ImGui::InvisibleButton("##icon", ImVec2(cellSz, cellSz));
if (ImGui::IsItemClicked()) s_selectedIcon = i;
if (hov) ImGui::SetTooltip("%s", iconName);
if (hov) material::Tooltip("%s", iconName);
ImGui::PopID();
col = (col + 1) % cols;

View File

@@ -385,13 +385,13 @@ void RenderSharedAddressList(App* app, float listH, float availW,
// edge of a row = reorder (move here).
if (s_dropTargetIdx >= 0 && s_dropTargetIdx < (int)rows.size() && s_dropMode == 0) {
const auto& target = rows[s_dropTargetIdx];
ImGui::SetTooltip("%s\n%s\n\n%s %s",
material::Tooltip("%s\n%s\n\n%s %s",
truncateAddress(addr.address, 32).c_str(),
row.isZ ? TR("shielded") : TR("transparent"),
TR("transfer_to"),
truncateAddress(target.info->address, 32).c_str());
} else {
ImGui::SetTooltip("%s\n%s\n\n%s",
material::Tooltip("%s\n%s\n\n%s",
truncateAddress(addr.address, 32).c_str(),
row.isZ ? TR("shielded") : TR("transparent"),
TR("address_reorder_hint"));
@@ -474,7 +474,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
else app->favoriteAddress(addr.address);
btnClicked = true;
}
if (bHov) ImGui::SetTooltip("%s", row.favorite ? TR("remove_favorite") : TR("favorite_address"));
if (bHov) material::Tooltip("%s", row.favorite ? TR("remove_favorite") : TR("favorite_address"));
}
// Eye button (zero balance or hidden)
@@ -500,7 +500,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
else app->hideAddress(addr.address);
btnClicked = true;
}
if (bHov) ImGui::SetTooltip("%s", row.hidden ? TR("restore_address") : TR("hide_address"));
if (bHov) material::Tooltip("%s", row.hidden ? TR("restore_address") : TR("hide_address"));
}
// ---- Type icon or custom icon ----
@@ -624,7 +624,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
ImGui::SetCursorScreenPos(ImVec2(rowPos.x, rowY[row_idx]));
ImGui::InvisibleButton("##addr", ImVec2(innerW, rowH));
if (ImGui::IsItemHovered() && !s_dragActive) {
ImGui::SetTooltip("%s", addr.address.c_str());
material::Tooltip("%s", addr.address.c_str());
}
// Context menu

View File

@@ -904,7 +904,7 @@ static void RenderBalanceClassic(App* app)
else app->favoriteAddress(addr.address);
btnClicked = true;
}
if (bHov) ImGui::SetTooltip("%s", row.favorite ? TR("remove_favorite") : TR("favorite_address"));
if (bHov) material::Tooltip("%s", row.favorite ? TR("remove_favorite") : TR("favorite_address"));
}
// Eye button (zero balance or hidden)
@@ -926,7 +926,7 @@ static void RenderBalanceClassic(App* app)
else app->hideAddress(addr.address);
btnClicked = true;
}
if (bHov) ImGui::SetTooltip("%s", row.hidden ? TR("restore_address") : TR("hide_address"));
if (bHov) material::Tooltip("%s", row.hidden ? TR("restore_address") : TR("hide_address"));
}
// Content zone ends before buttons
@@ -1023,7 +1023,7 @@ static void RenderBalanceClassic(App* app)
// Tooltip with full address
if (ImGui::IsItemHovered() && !btnClicked) {
ImGui::SetTooltip("%s", addr.address.c_str());
material::Tooltip("%s", addr.address.c_str());
}
// Right-click context menu
@@ -1180,7 +1180,7 @@ static void RenderBalanceClassic(App* app)
// Show the full, untruncated address — two z-addresses can truncate to the
// same first/last window, so the truncated text alone can't disambiguate.
if (!tx.address.empty())
ImGui::SetTooltip("%s", tx.address.c_str());
material::Tooltip("%s", tx.address.c_str());
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::History);
}

View File

@@ -179,7 +179,7 @@ void BlockInfoDialog::render(App* app)
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("click_to_copy"));
material::Tooltip("%s", TR("click_to_copy"));
}
if (ImGui::IsItemClicked()) {
ImGui::SetClipboardText(s_block_hash.c_str());
@@ -257,7 +257,7 @@ void BlockInfoDialog::render(App* app)
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("block_click_prev"));
material::Tooltip("%s", TR("block_click_prev"));
}
if (ImGui::IsItemClicked() && s_height > 1) {
s_height--;
@@ -279,7 +279,7 @@ void BlockInfoDialog::render(App* app)
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("block_click_next"));
material::Tooltip("%s", TR("block_click_next"));
}
if (ImGui::IsItemClicked()) {
s_height++;

View File

@@ -127,7 +127,7 @@ private:
startDownload(mirrorUrl);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("bootstrap_mirror_tooltip"));
material::Tooltip("%s", TR("bootstrap_mirror_tooltip"));
}
ImGui::SameLine();
if (TactileButton(TR("cancel"), ImVec2(btnSm, 0))) {
@@ -226,6 +226,8 @@ private:
if (s_bootstrap->isDone()) {
auto finalProg = s_bootstrap->getProgress();
if (finalProg.state == util::Bootstrap::State::Completed) {
// Reconcile the preserved wallet.dat against the new chain once the daemon is back up.
s_app->markPostBootstrapRescanPending();
s_state = State::Done;
} else {
s_errorMsg = finalProg.error;

View File

@@ -493,7 +493,7 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
s_prev_daemon_enabled = s_daemon_messages_enabled;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("console_show_daemon_output"));
material::Tooltip("%s", TR("console_show_daemon_output"));
}
ImGui::SameLine();
@@ -511,7 +511,7 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
s_prev_errors_only = s_errors_only_enabled;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("console_show_errors_only"));
material::Tooltip("%s", TR("console_show_errors_only"));
}
ImGui::SameLine();
@@ -530,7 +530,7 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
s_prev_rpc_trace_enabled = s_rpc_trace_enabled;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("console_show_rpc_trace"));
material::Tooltip("%s", TR("console_show_rpc_trace"));
}
ImGui::SameLine();
@@ -547,7 +547,7 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
s_prev_app_enabled = s_app_messages_enabled;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("console_show_app_output"));
material::Tooltip("%s", TR("console_show_app_output"));
}
ImGui::SameLine();
@@ -582,7 +582,7 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", has_selection_ ? TR("console_copy_selected") : TR("console_copy_all"));
material::Tooltip("%s", has_selection_ ? TR("console_copy_selected") : TR("console_copy_all"));
}
ImGui::SameLine();
@@ -592,7 +592,7 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
show_commands_popup_ = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("console_show_rpc_ref"));
material::Tooltip("%s", TR("console_show_rpc_ref"));
}
ImGui::SameLine();
@@ -629,14 +629,14 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
s_console_zoom = std::max(zoomMin, s_console_zoom - zoomStep);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(TR("console_zoom_out"), s_console_zoom * 100.0f);
material::Tooltip(TR("console_zoom_out"), s_console_zoom * 100.0f);
}
ImGui::SameLine();
if (TactileButton(ICON_MD_ADD, ImVec2(btnSz, btnSz), Type().iconMed())) {
s_console_zoom = std::min(zoomMax, s_console_zoom + zoomStep);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(TR("console_zoom_in"), s_console_zoom * 100.0f);
material::Tooltip(TR("console_zoom_in"), s_console_zoom * 100.0f);
}
}
}
@@ -1404,9 +1404,9 @@ void ConsoleTab::renderCommandsPopup()
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (cmd.params[0] != '\0')
ImGui::SetTooltip(TR("console_click_insert_params"), cmd.name, cmd.params);
material::Tooltip(TR("console_click_insert_params"), cmd.name, cmd.params);
else
ImGui::SetTooltip(TR("console_click_insert"), cmd.name);
material::Tooltip(TR("console_click_insert"), cmd.name);
}
ImGui::PopStyleColor(3);
if (showParams) {

View File

@@ -0,0 +1,254 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Modal dialog to download / update the dragonxd full node from the project Gitea, driven by
// util::DaemonUpdater. Sibling of XmrigDownloadDialog (the miner updater). States follow the
// updater: Checking -> UpToDate/UpdateAvailable -> Downloading/Verifying/Extracting -> Done /
// Failed. Header-only (static inline state). The new binary is installed into the daemon directory
// without touching the running node; on Done the user is offered a daemon restart to apply it.
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "../../app.h"
#include "../../resources/embedded_resources.h" // resources::getDaemonDirectory()
#include "../../util/daemon_updater.h"
#include "../../util/i18n.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "../theme.h"
#include "release_list_view.h"
#include "imgui.h"
namespace dragonx {
namespace ui {
class DaemonUpdateDialog {
public:
// `installedVersion` is the version scanned from the installed dragonxd (may be empty/unknown),
// used to tell "up to date" from "update available".
static void show(App* app, const std::string& installedVersion) {
if (!app) return;
s_app = app;
s_open = true;
s_notified = false;
s_installed_flag = false; // start each session clean (don't leak a prior install's flag)
s_installed_version = installedVersion;
s_rows.clear();
s_releases.clear();
s_updater = std::make_unique<util::DaemonUpdater>();
s_updater->startCheck(installedVersion);
}
static bool isOpen() { return s_open; }
// True (once) after an install succeeded, so the Settings panel can refresh its cached daemon
// info. Clears on read.
static bool consumeInstalled() {
const bool v = s_installed_flag;
s_installed_flag = false;
return v;
}
static void render() {
if (!s_open || !s_app || !s_updater) {
if (!s_open) s_updater.reset(); // closed: drop the updater (dtor joins the worker)
return;
}
using namespace material;
const float dp = Layout::dpiScale();
if (BeginOverlayDialog(TR("daemon_update_title"), &s_open, 480.0f, 0.94f)) {
const auto p = s_updater->getProgress();
using St = util::DaemonUpdater::State;
switch (p.state) {
case St::Checking: renderChecking(dp, p); break;
case St::UpToDate:
case St::UpdateAvailable: renderPrompt(dp, p); break;
case St::Unavailable: renderUnavailable(dp, p); break;
case St::Listing: renderListing(dp, p); break;
case St::ReleaseList: renderReleaseList(dp, p); break;
case St::Downloading:
case St::Verifying:
case St::Extracting: renderProgress(dp, p); break;
case St::Done: renderDone(dp, p); break;
case St::Failed: renderFailed(dp, p); break;
default: break;
}
EndOverlayDialog();
}
if (!s_open) s_updater.reset();
}
private:
using Progress = util::DaemonUpdater::Progress;
using St = util::DaemonUpdater::State;
static float fullW() { return ImGui::GetContentRegionAvail().x; }
static void installAction(const char* label) {
using namespace material;
if (TactileButton(label, ImVec2(fullW(), 0)))
s_updater->startInstall(resources::getDaemonDirectory());
}
static void renderChecking(float, const Progress&) {
using namespace material;
Type().text(TypeStyle::Body2, TR("daemon_update_checking"));
}
static void renderUnavailable(float, const Progress& p) {
using namespace material;
Type().text(TypeStyle::Subtitle2, TR("daemon_update_unavailable_title"));
ImGui::Spacing();
ImGui::TextWrapped("%s", p.status_text.empty()
? TR("daemon_update_unavailable_body")
: p.status_text.c_str());
ImGui::Spacing();
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
static void renderPrompt(float, const Progress& p) {
using namespace material;
const std::string installed = p.installed_tag.empty() ? TR("xmrig_none") : p.installed_tag;
if (p.state == St::UpdateAvailable) {
Type().text(TypeStyle::Subtitle2, TR("daemon_update_available"));
ImGui::Spacing();
ImGui::Text("%s %s", TR("daemon_update_latest"), p.latest_tag.c_str());
ImGui::Text("%s %s", TR("daemon_update_installed"), installed.c_str());
ImGui::Spacing();
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_verify_note"));
ImGui::Spacing();
installAction(TR("daemon_update_download_install"));
} else {
Type().text(TypeStyle::Subtitle2, TR("daemon_update_up_to_date"));
ImGui::Spacing();
ImGui::Text("%s %s", TR("daemon_update_installed"), p.latest_tag.c_str());
ImGui::Spacing();
installAction(TR("daemon_update_reinstall"));
}
ImGui::Spacing();
// Browse all releases (pre-releases / older versions).
if (TactileButton(TR("daemon_update_browse"), ImVec2(fullW(), 0))) {
s_rows.clear();
s_updater->startListReleases();
}
ImGui::Spacing();
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
static void renderListing(float, const Progress&) {
using namespace material;
Type().text(TypeStyle::Body2, TR("daemon_update_loading"));
}
static void renderReleaseList(float dp, const Progress&) {
using namespace material;
// Snapshot the worker's list + build the rows once per listing (not every frame — the
// release bodies are large). Caches are cleared when a new listing starts (browse / show).
if (s_rows.empty()) {
s_releases = s_updater->getReleases();
const std::string token = util::currentDaemonPlatformToken();
const std::string instCore = util::daemonVersionCore(s_installed_version);
s_rows.reserve(s_releases.size());
for (const auto& r : s_releases) {
ReleaseRow row;
row.tag = r.tag;
row.title = r.name;
row.date = r.publishedAt.size() >= 10 ? r.publishedAt.substr(0, 10) : r.publishedAt;
row.prerelease = r.prerelease;
row.hasAsset = util::selectDaemonAsset(r, token) >= 0;
row.installed = !instCore.empty() && util::daemonVersionCore(r.tag) == instCore;
s_rows.push_back(std::move(row));
}
}
// Downgrade caution: an older node binary may not match the current chain data.
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_downgrade_note"));
ImGui::Spacing();
bool back = false;
const int idx = RenderReleaseList(s_rows, dp, &back);
if (back) { s_rows.clear(); s_updater->startCheck(s_installed_version); return; }
if (idx >= 0 && idx < static_cast<int>(s_releases.size())) {
const util::DaemonRelease rel = s_releases[idx];
s_rows.clear();
s_updater->startInstallRelease(resources::getDaemonDirectory(), rel);
}
}
static void renderProgress(float dp, const Progress& p) {
using namespace material;
const char* title = p.state == St::Downloading ? TR("daemon_update_downloading")
: p.state == St::Verifying ? TR("daemon_update_verifying")
: TR("daemon_update_installing");
Type().text(TypeStyle::Subtitle2, title);
ImGui::Spacing();
const float barH = 8.0f * dp;
const float barW = fullW();
const ImVec2 bMin = ImGui::GetCursorScreenPos();
const ImVec2 bMax(bMin.x + barW, bMin.y + barH);
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(bMin, bMax, IM_COL32(255, 255, 255, 30), 4.0f * dp);
const float fillW = barW * (p.percent / 100.0f);
if (fillW > 0)
dl->AddRectFilled(bMin, ImVec2(bMin.x + fillW, bMax.y), Primary(), 4.0f * dp);
ImGui::Dummy(ImVec2(0, barH));
ImGui::Spacing();
ImGui::Text("%s", p.status_text.c_str());
ImGui::Spacing();
// Cancel aborts the in-flight transfer promptly (the curl progress callback returns abort).
if (TactileButton(TR("cancel"), ImVec2(fullW(), 0))) {
s_updater->cancel();
s_open = false;
}
}
static void renderDone(float, const Progress& p) {
using namespace material;
// Tell the Settings panel to refresh its cached daemon info — once, not every frame (a
// re-scan reads the whole daemon binary).
if (!s_notified) { s_installed_flag = true; s_notified = true; }
Type().textColored(TypeStyle::Subtitle2, Success(), TR("daemon_update_installed_ok"));
ImGui::Spacing();
ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str());
ImGui::Spacing();
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_restart_note"));
ImGui::Spacing();
if (s_app->isUsingEmbeddedDaemon()) {
if (TactileButton(TR("daemon_update_restart_now"), ImVec2(fullW(), 0))) {
s_app->restartDaemon();
s_open = false;
}
ImGui::Spacing();
if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false;
} else {
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
}
static void renderFailed(float, const Progress& p) {
using namespace material;
Type().textColored(TypeStyle::Subtitle2, Error(), TR("daemon_update_failed"));
ImGui::Spacing();
ImGui::TextWrapped("%s", p.error.empty() ? TR("daemon_update_unknown_error") : p.error.c_str());
ImGui::Spacing();
installAction(TR("retry"));
ImGui::Spacing();
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
static inline bool s_open = false;
static inline bool s_installed_flag = false;
static inline bool s_notified = false;
static inline std::string s_installed_version;
static inline std::vector<ReleaseRow> s_rows; // built once per listing (UI rows)
static inline std::vector<util::DaemonRelease> s_releases; // matching releases (install by index)
static inline App* s_app = nullptr;
static inline std::unique_ptr<util::DaemonUpdater> s_updater;
};
} // namespace ui
} // namespace dragonx

View File

@@ -205,7 +205,7 @@ static void copyButton(const char* id, const std::string& text, float x, float y
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(x + Layout::spacingSm(), y),
hovered ? Primary() : OnSurfaceMedium(), ICON_MD_CONTENT_COPY);
if (hovered) ImGui::SetTooltip("%s", TR("click_to_copy"));
if (hovered) material::Tooltip("%s", TR("click_to_copy"));
if (clicked) {
ImGui::SetClipboardText(text.c_str());
Notifications::instance().success(TR("copied_to_clipboard"));
@@ -1066,7 +1066,7 @@ static void renderBlockDetailModal(App* app) {
if (app->rpc() && app->rpc()->isConnected())
fetchBlockDetail(app, s_detail_height - 1);
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Previous block");
if (ImGui::IsItemHovered()) material::Tooltip("Previous block");
ImGui::PopID();
ImGui::PopFont();
ImGui::SameLine();
@@ -1080,7 +1080,7 @@ static void renderBlockDetailModal(App* app) {
if (app->rpc() && app->rpc()->isConnected())
fetchBlockDetail(app, s_detail_height + 1);
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Next block");
if (ImGui::IsItemHovered()) material::Tooltip("Next block");
ImGui::PopID();
ImGui::PopFont();
}
@@ -1117,7 +1117,7 @@ static void renderBlockDetailModal(App* app) {
ImGui::GetCursorScreenPos().x, ImGui::GetCursorScreenPos().y);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", s_detail_hash.c_str());
material::Tooltip("%s", s_detail_hash.c_str());
}
ImGui::Spacing();
@@ -1218,7 +1218,7 @@ static void renderBlockDetailModal(App* app) {
WithAlpha(OnSurface(), 10),
S.drawElement("tabs.explorer", "row-rounding").size);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", txid.c_str());
material::Tooltip("%s", txid.c_str());
}
// Draw icon + text

View File

@@ -136,7 +136,7 @@ void ImportKeyDialog::render(App* app)
ImGui::SameLine();
ImGui::TextDisabled("(?)");
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("import_key_tooltip"));
material::Tooltip("%s", TR("import_key_tooltip"));
}
// Validation indicator inline with title — check preview text during hover,

View File

@@ -4,6 +4,8 @@
#include "key_export_dialog.h"
#include "../../app.h"
#include "../../wallet/lite_wallet_controller.h"
#include <nlohmann/json.hpp>
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
#include "../../util/i18n.h"
@@ -11,7 +13,10 @@
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "../material/colors.h"
#include "../layout.h"
#include "../widgets/qr_code.h"
#include "../widgets/copy_field.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
namespace dragonx {
@@ -61,15 +66,20 @@ void KeyExportDialog::render(App* app)
TR("export_private_key") : TR("export_viewing_key");
auto& S = schema::UI();
auto win = S.window("dialogs.key-export");
auto warningBox = S.drawElement("dialogs.key-export", "warning-box");
auto addrInput = S.input("dialogs.key-export", "address-input");
auto revealBtn = S.button("dialogs.key-export", "reveal-button");
auto keyDisplay = S.drawElement("dialogs.key-export", "key-display");
auto copyBtn = S.button("dialogs.key-export", "copy-button");
auto closeBtn = S.button("dialogs.key-export", "close-button");
(void)addrInput;
(void)keyDisplay;
(void)copyBtn;
if (material::BeginOverlayDialog(title, &s_open, win.width, 0.94f)) {
// Modal scales to 85% of the window width. BeginOverlayDialog multiplies the width by
// Layout::dpiScale(); divide it out here so the final card is exactly 85% at any font scale.
const float cardW = (0.85f * ImGui::GetMainViewport()->Size.x) / Layout::dpiScale();
if (material::BeginOverlayDialog(title, &s_open, cardW, 0.94f)) {
ImGui::Spacing();
// Warning section with colored background
@@ -79,13 +89,37 @@ void KeyExportDialog::render(App* app)
ImGui::BeginChild("WarningBox", ImVec2(-1, 0),
ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_Borders);
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), " %s", TR("warning_upper"));
// Heading — warning icon (drawn with the icon font, not the text font, so it isn't tofu) +
// larger h6 text, centered, for emphasis.
{
ImFont* iconF = material::Type().iconMed();
ImFont* textF = material::Type().h6();
const char* icon = ICON_MD_WARNING;
const char* txt = TR("warning_upper");
ImGui::PushFont(iconF); const float iw = ImGui::CalcTextSize(icon).x; ImGui::PopFont();
ImGui::PushFont(textF); const float tw = ImGui::CalcTextSize(txt).x; ImGui::PopFont();
const float sp = ImGui::GetStyle().ItemSpacing.x;
const float wa = ImGui::GetContentRegionAvail().x;
const float total = iw + sp + tw;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (wa > total ? (wa - total) * 0.5f : 0.0f));
const ImVec4 red(1.0f, 0.42f, 0.42f, 1.0f);
ImGui::PushFont(iconF); ImGui::TextColored(red, "%s", icon); ImGui::PopFont();
ImGui::SameLine(0, sp);
ImGui::PushFont(textF); ImGui::TextColored(red, "%s", txt); ImGui::PopFont();
}
ImGui::Spacing();
if (s_key_type == KeyType::Private) {
ImGui::TextWrapped(" %s", TR("key_export_private_warning"));
} else {
ImGui::TextWrapped(" %s", TR("key_export_viewing_warning"));
// Body — centered wrapped text.
{
const char* body = (s_key_type == KeyType::Private)
? TR("key_export_private_warning") : TR("key_export_viewing_warning");
ImDrawList* wdl = ImGui::GetWindowDrawList();
ImFont* wf = ImGui::GetFont();
const float wfs = ImGui::GetFontSize();
const float wa = ImGui::GetContentRegionAvail().x;
const ImVec2 wp = ImGui::GetCursorScreenPos();
const float bh = widgets::DrawCenteredWrapped(wdl, wf, wfs, wp, wa, material::OnSurface(), body);
ImGui::Dummy(ImVec2(wa, bh));
}
ImGui::EndChild();
@@ -95,32 +129,9 @@ void KeyExportDialog::render(App* app)
ImGui::Separator();
ImGui::Spacing();
// Address display
// Address — click-to-copy field (chunked, width-clamped, centered).
ImGui::Text("%s", TR("address_label"));
// Determine if it's a z-address (longer) or t-address
bool is_zaddr = s_address.length() > 50;
if (is_zaddr) {
// Use multiline for z-addresses
char addr_buf[512];
strncpy(addr_buf, s_address.c_str(), sizeof(addr_buf) - 1);
addr_buf[sizeof(addr_buf) - 1] = '\0';
// Fit the field to the wrapped address (no excess empty space below it).
(void)addrInput;
const float addrFieldH =
ImGui::CalcTextSize(addr_buf, nullptr, false,
ImGui::GetContentRegionAvail().x - ImGui::GetStyle().FramePadding.x * 2.0f).y
+ ImGui::GetStyle().FramePadding.y * 2.0f + 4.0f;
ImGui::InputTextMultiline("##Address", addr_buf, sizeof(addr_buf),
ImVec2(-1, addrFieldH), ImGuiInputTextFlags_ReadOnly);
} else {
char addr_buf[128];
strncpy(addr_buf, s_address.c_str(), sizeof(addr_buf) - 1);
addr_buf[sizeof(addr_buf) - 1] = '\0';
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##Address", addr_buf, sizeof(addr_buf), ImGuiInputTextFlags_ReadOnly);
}
widgets::AddressCopyField("##addrcopy", s_address);
ImGui::Spacing();
@@ -140,7 +151,44 @@ void KeyExportDialog::render(App* app)
// Check if z-address or t-address
bool is_zaddress = (s_address.length() > 50 || s_address[0] == 'z');
if (s_key_type == KeyType::Private) {
if (auto* lite = app->liteWallet()) {
// Lite wallet: there is no daemon RPC — export the key locally via the SDXL
// backend (a local, network-free op) instead of the z_exportkey/dumpprivkey
// RPCs, which fail with "Not connected" when no full node is present.
const bool wantViewing = (s_key_type != KeyType::Private);
if (wantViewing && !is_zaddress) {
s_error = TR("key_export_viewing_keys_zonly");
s_fetching = false;
} else {
auto r = lite->exportPrivateKeys(s_address);
std::string found;
if (r.ok) {
try {
auto j = nlohmann::json::parse(r.privateKeysJson);
const nlohmann::json* entry = nullptr;
if (j.is_array()) {
for (auto& e : j)
if (e.contains("address") && e["address"] == s_address) { entry = &e; break; }
if (!entry && !j.empty()) entry = &j.front();
} else if (j.is_object()) {
entry = &j;
}
const char* field = wantViewing ? "viewing_key" : "private_key";
if (entry && entry->contains(field) && (*entry)[field].is_string())
found = (*entry)[field].get<std::string>();
} catch (...) {}
wallet::secureWipeLiteSecret(r.privateKeysJson);
}
if (!found.empty()) {
s_key = found;
s_show_key = wantViewing; // viewing keys are less sensitive
} else {
s_error = r.ok ? std::string("Key not available for this address") : r.error;
}
wallet::secureWipeLiteSecret(found);
s_fetching = false;
}
} else if (s_key_type == KeyType::Private) {
// Export private key
std::string addr = s_address;
std::string method = is_zaddress ? "z_exportkey" : "dumpprivkey";
@@ -201,51 +249,52 @@ void KeyExportDialog::render(App* app)
ImGui::SameLine();
ImGui::TextDisabled("%s", TR("key_export_click_retrieve"));
} else {
// Key has been fetched - display it
// Key fetched. Layout: [ key text (click-to-copy) + Show/Hide below ] [ QR | square ].
const float gap = 12.0f;
const float avail = ImGui::GetContentRegionAvail().x;
// Larger, responsive QR: ~30% of the content width, clamped to a comfortable range.
float qrSize = avail * 0.30f;
if (qrSize < 200.0f) qrSize = 200.0f;
if (qrSize > 340.0f) qrSize = 340.0f;
float keyW = avail - qrSize - gap;
const bool sideBySide = keyW >= 200.0f; // too narrow -> stack the QR under the key
if (!sideBySide) keyW = avail;
// Fit the field to the wrapped key (same length whether shown or masked).
(void)keyDisplay;
char key_buf[1024];
if (s_show_key) {
strncpy(key_buf, s_key.c_str(), sizeof(key_buf) - 1);
} else {
std::string masked(s_key.length(), '*');
strncpy(key_buf, masked.c_str(), sizeof(key_buf) - 1);
// Chunk for readability, but keep a Bech32 HRP (e.g. "secret-extended-key-main") intact
// rather than slicing it into 4s — only the part from the '1' separator on is chunked.
auto chunkKey = [](const std::string& s) -> std::string {
const size_t one = s.find('1');
if (one != std::string::npos && one > 0) {
bool isHrp = true;
for (size_t i = 0; i < one; ++i) {
const char c = s[i];
if (!((c >= 'a' && c <= 'z') || c == '-')) { isHrp = false; break; }
}
key_buf[sizeof(key_buf) - 1] = '\0';
const float keyFieldH =
ImGui::CalcTextSize(key_buf, nullptr, false,
ImGui::GetContentRegionAvail().x - ImGui::GetStyle().FramePadding.x * 2.0f).y
+ ImGui::GetStyle().FramePadding.y * 2.0f + 4.0f;
ImGui::InputTextMultiline("##Key", key_buf, sizeof(key_buf),
ImVec2(-1, keyFieldH), ImGuiInputTextFlags_ReadOnly);
if (isHrp) return s.substr(0, one) + " " + widgets::ChunkString(s.substr(one), 4);
}
return widgets::ChunkString(s, 4);
};
const std::string shown = s_show_key ? s_key : std::string(s_key.size(), '*');
const std::string keyText = chunkKey(shown);
// Action row: Show/Hide · Copy · QR. Auto-width buttons (size 0) so the label text never
// clips at the user's font scale, and ONE shared font so they all match.
// Left column: key text (click-to-copy), with the Show/Hide button just below it.
ImGui::BeginGroup();
if (widgets::CopyField("##keycopy", keyText, keyW, 0.0f, /*center=*/true)) {
app->copySecretToClipboard(s_key); // auto-clearing clipboard
}
ImGui::Spacing();
ImFont* actionFont = S.resolveFont(copyBtn.font);
if (material::StyledButton(s_show_key ? TR("hide") : TR("show"), ImVec2(0, 0), actionFont)) {
if (material::StyledButton(s_show_key ? TR("hide") : TR("show"), ImVec2(0, 0),
material::Type().subtitle1())) {
s_show_key = !s_show_key;
if (!s_show_key) s_show_qr = false; // hiding the key also hides its QR
}
ImGui::EndGroup();
ImGui::SameLine();
if (sideBySide) ImGui::SameLine(0, gap);
else ImGui::Spacing();
if (material::StyledButton(TR("copy_to_clipboard"), ImVec2(0, 0), actionFont)) {
// Auto-clearing clipboard: the key (as sensitive as the seed) is wiped after ~45s.
app->copySecretToClipboard(s_key);
}
// QR (only once revealed) — for scanning the key into another wallet.
if (s_show_key) {
ImGui::SameLine();
if (material::StyledButton(s_show_qr ? TR("hide_qr") : TR("show_qr"), ImVec2(0, 0), actionFont)) {
s_show_qr = !s_show_qr;
}
}
if (s_show_qr && s_show_key && !s_key.empty()) {
// QR to the right of the key when revealed; an empty placeholder square while hidden.
const ImVec2 sq0 = ImGui::GetCursorScreenPos();
if (s_show_key && !s_key.empty()) {
if (s_qr_cached != s_key) { // (re)generate the QR texture when the key changes
if (s_qr_tex) { FreeQRTexture(s_qr_tex); s_qr_tex = 0; }
int qw = 0, qh = 0;
@@ -253,11 +302,20 @@ void KeyExportDialog::render(App* app)
s_qr_cached = s_key;
}
if (s_qr_tex) {
ImGui::Spacing();
const float qrSize = 180.0f;
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - qrSize) * 0.5f);
// White quiet-zone backing so the code stays scannable on dark themes.
ImGui::GetWindowDrawList()->AddRectFilled(
sq0, ImVec2(sq0.x + qrSize, sq0.y + qrSize), IM_COL32_WHITE, 4.0f);
RenderQRCode(s_qr_tex, qrSize);
} else {
ImGui::Dummy(ImVec2(qrSize, qrSize));
}
} else {
// Empty placeholder square — same footprint the QR will occupy once revealed.
ImGui::Dummy(ImVec2(qrSize, qrSize));
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 sq1(sq0.x + qrSize, sq0.y + qrSize);
dl->AddRectFilled(sq0, sq1, material::WithAlpha(material::OnSurface(), 8), 6.0f);
dl->AddRect(sq0, sq1, material::WithAlpha(material::OnSurface(), 45), 6.0f, 0, 1.0f);
}
}

View File

@@ -291,7 +291,7 @@ void RenderMarketTab(App* app)
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip(TR("market_trade_on"), currentExchange.name.c_str());
material::Tooltip(TR("market_trade_on"), currentExchange.name.c_str());
}
ImGui::SetCursorScreenPos(savedCur);
}
@@ -490,7 +490,7 @@ void RenderMarketTab(App* app)
s_history_initialized = false;
s_last_refresh_time = ImGui::GetTime();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("market_refresh_price"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("market_refresh_price"));
// Timestamp text to the left of refresh button
if (s_last_refresh_time > 0.0) {

View File

@@ -148,7 +148,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", idleOn ? TR("mining_idle_on_tooltip") : TR("mining_idle_off_tooltip"));
material::Tooltip("%s", idleOn ? TR("mining_idle_on_tooltip") : TR("mining_idle_off_tooltip"));
}
idleRightEdge = btnX - 4.0f * dp;
@@ -178,7 +178,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", threadScaling
material::Tooltip("%s", threadScaling
? TR("mining_idle_scale_on_tooltip")
: TR("mining_idle_scale_off_tooltip"));
}
@@ -213,7 +213,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", gpuAware
material::Tooltip("%s", gpuAware
? TR("mining_idle_gpu_on_tooltip")
: TR("mining_idle_gpu_off_tooltip"));
}
@@ -248,7 +248,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_idle_delay"));
material::Tooltip("%s", TR("tt_idle_delay"));
idleRightEdge = comboX - 4.0f * dp;
}
@@ -284,7 +284,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_idle_delay"));
material::Tooltip("%s", TR("tt_idle_delay"));
idleRightEdge = comboX - 4.0f * dp;
}
@@ -313,7 +313,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("mining_idle_threads_idle_tooltip"));
material::Tooltip("%s", TR("mining_idle_threads_idle_tooltip"));
idleRightEdge = comboX - 4.0f * dp;
}
@@ -352,7 +352,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("mining_idle_threads_active_tooltip"));
material::Tooltip("%s", TR("mining_idle_threads_active_tooltip"));
idleRightEdge = comboX - 4.0f * dp;
}
}
@@ -439,7 +439,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_benchmark_cancel"));
material::Tooltip("%s", TR("mining_benchmark_cancel"));
}
const char* cancelIcon = ICON_MD_CLOSE;
ImVec2 cIcoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, cancelIcon);
@@ -471,7 +471,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
s_benchmark.reset();
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_benchmark_dismiss"));
material::Tooltip("%s", TR("mining_benchmark_dismiss"));
}
const char* okIcon = ICON_MD_CHECK;
ImVec2 oIcoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, okIcon);
@@ -498,7 +498,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
dl->AddRectFilled(ImVec2(btnX, btnY), ImVec2(btnX + btnSz, btnY + btnSz),
StateHover(), btnSz * 0.5f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_benchmark_tooltip"));
material::Tooltip("%s", TR("mining_benchmark_tooltip"));
}
const char* benchIcon = ICON_MD_SPEED;
@@ -556,7 +556,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
minerBusy ? OnSurfaceDisabled() : OnSurfaceMedium(), xbtn);
if (xhov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", minerBusy ? TR("xmrig_stop_mining_first")
material::Tooltip("%s", minerBusy ? TR("xmrig_stop_mining_first")
: TR("xmrig_update_title"));
}
if (xclk && !minerBusy) XmrigDownloadDialog::show(app);
@@ -864,13 +864,13 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
if (!disabled)
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (isToggling)
ImGui::SetTooltip("%s", isMiningActive ? TR("mining_stopping_tooltip") : TR("mining_starting_tooltip"));
material::Tooltip("%s", isMiningActive ? TR("mining_stopping_tooltip") : TR("mining_starting_tooltip"));
else if (isSyncing && !s_pool_mode)
ImGui::SetTooltip(TR("mining_syncing_tooltip"), state.sync.verification_progress * 100.0);
material::Tooltip(TR("mining_syncing_tooltip"), state.sync.verification_progress * 100.0);
else if (poolBlockedBySolo)
ImGui::SetTooltip("%s", TR("mining_stop_solo_for_pool"));
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
else
ImGui::SetTooltip("%s", isMiningActive ? TR("stop_mining") : TR("start_mining"));
material::Tooltip("%s", isMiningActive ? TR("stop_mining") : TR("start_mining"));
}
// Click action — pool or solo

View File

@@ -180,7 +180,7 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
soloMiningAvailable ? TR("mining_filter_tip_solo") : TR("mining_filter_tip_pool"),
TR("mining_filter_tip_pool")
};
ImGui::SetTooltip("%s", tips[s_earnings_filter]);
material::Tooltip("%s", tips[s_earnings_filter]);
}
ImGui::SetCursorScreenPos(savedCur);
}
@@ -287,7 +287,7 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
ImGui::InvisibleButton("##DiffCopy", ImVec2(diffSz.x + Layout::spacingMd(), capFont->LegacySize + 4 * dp));
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_click_copy_difficulty"));
material::Tooltip("%s", TR("mining_click_copy_difficulty"));
dl->AddLine(ImVec2(col1X + valOffX, cy + capFont->LegacySize + 1 * dp),
ImVec2(col1X + valOffX + diffSz.x, cy + capFont->LegacySize + 1 * dp),
WithAlpha(OnSurface(), 60), 1.0f * dp);
@@ -308,7 +308,7 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
ImGui::InvisibleButton("##BlockCopy", ImVec2(blkSz.x + Layout::spacingMd(), capFont->LegacySize + 4 * dp));
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_click_copy_block"));
material::Tooltip("%s", TR("mining_click_copy_block"));
dl->AddLine(ImVec2(col2X + valOffX, cy + capFont->LegacySize + 1 * dp),
ImVec2(col2X + valOffX + blkSz.x, cy + capFont->LegacySize + 1 * dp),
WithAlpha(OnSurface(), 60), 1.0f * dp);
@@ -346,7 +346,7 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
ImGui::InvisibleButton("##MiningAddrCopy", ImVec2(addrTextSz.x + Layout::spacingMd(), capFont->LegacySize + 4 * dp));
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_click_copy_address"));
material::Tooltip("%s", TR("mining_click_copy_address"));
dl->AddLine(ImVec2(col3X + valOffX, cy + capFont->LegacySize + 1 * dp),
ImVec2(col3X + valOffX + addrTextSz.x, cy + capFont->LegacySize + 1 * dp),
WithAlpha(OnSurface(), 60), 1.0f * dp);
@@ -518,7 +518,7 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
ImGui::SetCursorScreenPos(ImVec2(barX, barY));
ImGui::InvisibleButton("##rambar", ImVec2(barW, barH));
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
material::BeginTooltip();
if (selfRAM >= 1024.0)
ImGui::Text(TR("ram_wallet_gb"), selfRAM / 1024.0);
else
@@ -529,7 +529,7 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
ImGui::Text(TR("ram_daemon_mb"), daemonRAM, app->getDaemonMemDiag().c_str());
ImGui::Separator();
ImGui::Text(TR("ram_system_gb"), usedRAM / 1024.0, totalRAM / 1024.0);
ImGui::EndTooltip();
material::EndTooltip();
}
}
@@ -662,7 +662,7 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
dragonx::util::Platform::openUrl(url);
}
if (ImGui::IsItemHovered() && !mtx.txid.empty()) {
ImGui::SetTooltip("%s", TR("mining_open_in_explorer"));
material::Tooltip("%s", TR("mining_open_in_explorer"));
}
}

View File

@@ -118,7 +118,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
}
if (soloMiningAvailable && poolHov && !soloMiningActive) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (poolHov && soloMiningActive && !s_pool_mode) {
ImGui::SetTooltip("%s", TR("mining_stop_solo_for_pool"));
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
}
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
@@ -188,7 +188,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_saved_pools"));
material::Tooltip("%s", TR("mining_saved_pools"));
}
ImFont* icoFont = Type().iconSmall();
const char* dropIcon = ICON_MD_ARROW_DROP_DOWN;
@@ -217,7 +217,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", alreadySaved ? TR("mining_already_saved") : TR("mining_save_pool_url"));
material::Tooltip("%s", alreadySaved ? TR("mining_already_saved") : TR("mining_save_pool_url"));
}
ImFont* icoFont = Type().iconSmall();
const char* saveIcon = alreadySaved ? ICON_MD_BOOKMARK : ICON_MD_BOOKMARK_BORDER;
@@ -293,7 +293,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
if (inXZone) {
pdl->AddRectFilled(xMin, xMax, IM_COL32(255, 80, 80, 30));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_remove"));
material::Tooltip("%s", TR("mining_remove"));
} else if (rowHov) {
// Show faint X when row is hovered
ImFont* icoF = Type().iconSmall();
@@ -350,9 +350,9 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
if (ImGui::IsItemHovered()) {
std::string currentWorkerStr(s_pool_worker);
if (currentWorkerStr.empty()) {
ImGui::SetTooltip("%s", TR("mining_generate_z_address_hint"));
material::Tooltip("%s", TR("mining_generate_z_address_hint"));
} else {
ImGui::SetTooltip("%s", TR("mining_payout_tooltip"));
material::Tooltip("%s", TR("mining_payout_tooltip"));
}
}
@@ -370,7 +370,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_saved_addresses"));
material::Tooltip("%s", TR("mining_saved_addresses"));
}
ImFont* icoFont = Type().iconSmall();
const char* dropIcon = ICON_MD_ARROW_DROP_DOWN;
@@ -399,7 +399,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", alreadySaved ? TR("mining_already_saved") : TR("mining_save_payout_address"));
material::Tooltip("%s", alreadySaved ? TR("mining_already_saved") : TR("mining_save_payout_address"));
}
ImFont* icoFont = Type().iconSmall();
const char* saveIcon = alreadySaved ? ICON_MD_BOOKMARK : ICON_MD_BOOKMARK_BORDER;
@@ -471,7 +471,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
isCurrent ? Primary() : OnSurface(), addr.c_str());
// Tooltip for long addresses
if (rowHov && !inXZone)
ImGui::SetTooltip("%s", addr.c_str());
material::Tooltip("%s", addr.c_str());
// X button — flush with right edge, icon centered
{
ImVec2 xMin(rowMax.x - wXZoneW, rowMin.y);
@@ -479,7 +479,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
if (inXZone) {
pdl->AddRectFilled(xMin, xMax, IM_COL32(255, 80, 80, 30));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_remove"));
material::Tooltip("%s", TR("mining_remove"));
} else if (rowHov) {
ImFont* icoF = Type().iconSmall();
const char* xIcon = ICON_MD_CLOSE;
@@ -540,7 +540,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_reset_defaults"));
material::Tooltip("%s", TR("mining_reset_defaults"));
}
// Icon

View File

@@ -336,7 +336,7 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
if (hov) {
dl->AddCircleFilled(btnCenter, btnSize * 0.5f, StateHover());
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", toggleTip);
material::Tooltip("%s", toggleTip);
}
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(btnCenter.x - iconSz.x * 0.5f, btnCenter.y - iconSz.y * 0.5f),

View File

@@ -230,17 +230,16 @@ void RenderLiteNetworkTab(App* app)
spec.rounding = rnd;
spec.fillAlpha = selected ? 34 : 18;
DrawGlassPanel(dl, cardMin, cardMax, spec);
if (official) {
// Glow only the ACTIVE (selected/in-use) node — officials are distinguished by their pill.
if (selected) {
auto& fx = effects::ThemeEffects::instance();
if (fx.isEnabled()) {
fx.drawGlowPulse(dl, cardMin, cardMax, rnd);
}
// Always-visible static outline so officials are distinguishable even without effects.
// Always-visible pulsing outline so the active node stands out even without effects.
float pulse = 0.75f + 0.25f * (float)std::sin(ImGui::GetTime() * 2.0);
dl->AddRect(cardMin, cardMax, WithAlpha(Primary(), (int)(150 * pulse)), rnd, 0, 1.6f * dp);
}
if (selected)
dl->AddRectFilled(cardMin, ImVec2(cardMin.x + 3.0f * dp, cardMax.y), Primary(), rnd);
// Main click area selects the server (left of the hide strip).
ImGui::SetCursorScreenPos(cardMin);
@@ -302,7 +301,7 @@ void RenderLiteNetworkTab(App* app)
bool hideHov = ImGui::IsItemHovered();
if (hideHov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", hiddenList ? TR("lite_net_unhide") : TR("lite_net_hide"));
material::Tooltip("%s", hiddenList ? TR("lite_net_unhide") : TR("lite_net_hide"));
}
if (ImGui::IsItemClicked()) {
if (hiddenList) st->unhideLiteServer(sv.url);

View File

@@ -352,7 +352,7 @@ void RenderPeersTab(App* app)
ImGui::InvisibleButton("##BestBlockCopy", ImVec2(hashSz.x + Layout::spacingSm(), sub1->LegacySize + 2 * dp));
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s %s", TR("peers_click_copy"), hash.c_str());
material::Tooltip("%s %s", TR("peers_click_copy"), hash.c_str());
dl->AddLine(ImVec2(cx, valY + sub1->LegacySize + 1 * dp),
ImVec2(cx + hashSz.x, valY + sub1->LegacySize + 1 * dp),
WithAlpha(OnSurface(), 60), 1.0f * dp);
@@ -657,7 +657,7 @@ void RenderPeersTab(App* app)
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (!isRefreshing)
ImGui::SetTooltip("%s", TR("peers_refresh_tooltip"));
material::Tooltip("%s", TR("peers_refresh_tooltip"));
}
ImGui::PopID();
}
@@ -808,7 +808,7 @@ void RenderPeersTab(App* app)
}
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
material::BeginTooltip();
ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(8, 3));
if (ImGui::BeginTable("##PeerTT", 2, ImGuiTableFlags_SizingFixedFit)) {
auto TTRow = [](const char* label, const char* value) {
@@ -836,7 +836,7 @@ void RenderPeersTab(App* app)
ImGui::EndTable();
}
ImGui::PopStyleVar();
ImGui::EndTooltip();
material::EndTooltip();
}
if (i < state.peers.size() - 1) {

View File

@@ -6,8 +6,10 @@
#include "../../app.h"
#include "../../util/i18n.h"
#include "../widgets/qr_code.h"
#include "../widgets/copy_field.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "../layout.h"
#include "../theme.h"
#include "imgui.h"
@@ -59,12 +61,13 @@ void QRPopupDialog::render(App* app)
if (!s_open) return;
auto& S = schema::UI();
auto win = S.window("dialogs.qr-popup");
auto qr = S.drawElement("dialogs.qr-popup", "qr-code");
auto addrInput = S.input("dialogs.qr-popup", "address-input");
auto actionBtn = S.button("dialogs.qr-popup", "action-button");
if (material::BeginOverlayDialog(TR("qr_title"), &s_open, win.width, 0.94f)) {
// Match the key-export modal: 85% of the window width (divide out the dpiScale that
// BeginOverlayDialog re-applies, so the final card is exactly 85% at any font scale).
const float cardW = (0.85f * ImGui::GetMainViewport()->Size.x) / Layout::dpiScale();
if (material::BeginOverlayDialog(TR("qr_title"), &s_open, cardW, 0.94f)) {
// Label if present
if (!s_label.empty()) {
@@ -74,10 +77,14 @@ void QRPopupDialog::render(App* app)
ImGui::Spacing();
}
// Center the QR code
float qr_size = qr.size > 0 ? qr.size : 280;
// Center the QR code (responsive — larger, to suit the wider modal).
float window_width = ImGui::GetWindowWidth();
float qr_size = qr.size > 0 ? (float)qr.size : 280.0f;
const float responsive = window_width * 0.5f;
if (responsive > qr_size) qr_size = responsive;
if (qr_size > 420.0f) qr_size = 420.0f;
float padding = (window_width - qr_size) / 2.0f;
if (padding < 0.0f) padding = 0.0f;
ImGui::SetCursorPosX(padding);
@@ -95,39 +102,29 @@ void QRPopupDialog::render(App* app)
ImGui::Separator();
ImGui::Spacing();
// Address display
// Address — click-to-copy field (same formatting as the key-export dialog).
ImGui::Text("%s", TR("address_label"));
// Use multiline for z-addresses
if (s_address.length() > 50) {
char addr_buf[512];
strncpy(addr_buf, s_address.c_str(), sizeof(addr_buf) - 1);
addr_buf[sizeof(addr_buf) - 1] = '\0';
ImGui::InputTextMultiline("##QRAddress", addr_buf, sizeof(addr_buf),
ImVec2(-1, addrInput.height > 0 ? addrInput.height : 60), ImGuiInputTextFlags_ReadOnly);
} else {
char addr_buf[128];
strncpy(addr_buf, s_address.c_str(), sizeof(addr_buf) - 1);
addr_buf[sizeof(addr_buf) - 1] = '\0';
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##QRAddress", addr_buf, sizeof(addr_buf), ImGuiInputTextFlags_ReadOnly);
}
widgets::AddressCopyField("##QRAddress", s_address);
ImGui::Spacing();
// Buttons
float button_width = actionBtn.width;
float total_width = button_width * 2 + ImGui::GetStyle().ItemSpacing.x;
float start_x = (window_width - total_width) / 2.0f;
ImGui::SetCursorPosX(start_x);
// Buttons — size each to its label (so "Copy address" never clips), then center the pair.
ImFont* btnFont = S.resolveFont(actionBtn.font);
ImGui::PushFont(btnFont);
const float btnPad = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f;
float w_copy = ImGui::CalcTextSize(TR("copy_address")).x + btnPad;
float w_close = ImGui::CalcTextSize(TR("close")).x + btnPad;
ImGui::PopFont();
if (w_copy < actionBtn.width) w_copy = actionBtn.width;
if (w_close < actionBtn.width) w_close = actionBtn.width;
const float total_width = w_copy + w_close + ImGui::GetStyle().ItemSpacing.x;
ImGui::SetCursorPosX((window_width - total_width) / 2.0f);
if (material::StyledButton(TR("copy_address"), ImVec2(button_width, 0), S.resolveFont(actionBtn.font))) {
if (material::StyledButton(TR("copy_address"), ImVec2(w_copy, 0), btnFont)) {
ImGui::SetClipboardText(s_address.c_str());
}
ImGui::SameLine();
if (material::StyledButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(actionBtn.font))) {
if (material::StyledButton(TR("close"), ImVec2(w_close, 0), btnFont)) {
close();
}
material::EndOverlayDialog();

View File

@@ -274,7 +274,7 @@ static void RenderAddressDropdown(App* app, float width) {
s_cached_qr_data.clear(); // Force QR regeneration
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s\nBalance: %.8f %s%s",
material::Tooltip("%s\nBalance: %.8f %s%s",
addr.address.c_str(), addr.balance, DRAGONX_TICKER,
isCurrent ? "\n(selected)" : "");
}
@@ -842,7 +842,7 @@ void RenderReceiveTab(App* app)
ImGui::InvisibleButton("##QRClickCopy", ImVec2(totalQrSize, totalQrSize));
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s",
material::Tooltip("%s",
s_request_amount > 0 ? TR("click_copy_uri") : TR("click_copy_address"));
}
if (ImGui::IsItemClicked()) {

View File

@@ -0,0 +1,87 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Shared "Browse all releases" picker, used by both the miner updater (XmrigDownloadDialog) and the
// node updater (DaemonUpdateDialog). Renders a scrollable list of releases (tag, title, date, with
// pre-release / installed badges) inside the current overlay dialog; the caller maps its updater's
// release list into ReleaseRow values and acts on the clicked index.
#pragma once
#include <string>
#include <vector>
#include "../../util/i18n.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "imgui.h"
namespace dragonx {
namespace ui {
struct ReleaseRow {
std::string tag; // version tag, e.g. "v1.0.2"
std::string title; // human title (release "name"), shown dimmed
std::string date; // YYYY-MM-DD (already trimmed)
bool prerelease = false; // show a pre-release badge
bool hasAsset = true; // a build exists for this platform (else the row's Install is disabled)
bool installed = false; // matches the currently-installed version (Install -> Reinstall)
};
// Renders the picker. Returns the row index whose Install button was clicked this frame, or -1.
// Sets *back when the Back button is clicked. If `globalDisabledTooltip` is non-null, every Install
// button is disabled and shows that tooltip (e.g. "stop mining before updating the miner").
inline int RenderReleaseList(const std::vector<ReleaseRow>& rows, float dp, bool* back,
const char* globalDisabledTooltip = nullptr) {
using namespace material;
int clicked = -1;
Type().text(TypeStyle::Subtitle2, TR("upd_select_version"));
ImGui::Spacing();
const float listH = 300.0f * dp;
if (ImGui::BeginChild("##release_list", ImVec2(0, listH), true)) {
const ImVec4 dim = ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium());
const ImVec4 warn = ImVec4(1.0f, 0.78f, 0.25f, 1.0f);
const ImVec4 succ = ImGui::ColorConvertU32ToFloat4(Success());
for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
const ReleaseRow& r = rows[i];
ImGui::PushID(i);
ImGui::TextUnformatted(r.tag.c_str());
if (r.prerelease) { ImGui::SameLine(); ImGui::TextColored(warn, "[%s]", TR("upd_prerelease")); }
if (r.installed) { ImGui::SameLine(); ImGui::TextColored(succ, "[%s]", TR("upd_installed_badge")); }
if (!r.title.empty() || !r.date.empty()) {
std::string meta = r.title;
if (!r.title.empty() && !r.date.empty()) meta += " · ";
meta += r.date;
ImGui::TextColored(dim, "%s", meta.c_str());
}
const char* lbl = r.installed ? TR("upd_reinstall") : TR("upd_install");
const bool disabled = !r.hasAsset || globalDisabledTooltip != nullptr;
ImGui::BeginDisabled(disabled);
if (TactileButton(lbl, ImVec2(140.0f * dp, 0))) clicked = i;
ImGui::EndDisabled();
if (disabled && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", globalDisabledTooltip ? globalDisabledTooltip
: TR("upd_no_build_platform"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::PopID();
}
}
ImGui::EndChild();
ImGui::Spacing();
if (TactileButton(TR("upd_back"), ImVec2(ImGui::GetContentRegionAvail().x, 0))) *back = true;
return clicked;
}
} // namespace ui
} // namespace dragonx

View File

@@ -296,7 +296,7 @@ static void RenderSourceDropdown(App* app, float width) {
addr.address.c_str());
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s\nBalance: %.8f %s",
material::Tooltip("%s\nBalance: %.8f %s",
addr.address.c_str(), addr.balance, DRAGONX_TICKER);
}
ImGui::PopID();
@@ -351,7 +351,7 @@ static void RenderAddressSuggestions(const WalletState& state, float width, cons
snprintf(s_to_address, sizeof(s_to_address), "%s", suggestions[si].c_str());
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", suggestions[si].c_str());
material::Tooltip("%s", suggestions[si].c_str());
}
ImGui::PopID();
}
@@ -472,7 +472,7 @@ static void RenderAmountBar(ImDrawList* dl, double available, float innerW,
snprintf(tipBuf, sizeof(tipBuf), "%.8f / %.8f %s (%.1f%%)",
s_amount, maxAmount > 0 ? maxAmount : 0.0, DRAGONX_TICKER,
progress * 100.0f);
ImGui::SetTooltip("%s", tipBuf);
material::Tooltip("%s", tipBuf);
}
// Glass thumb circle at the fill edge
@@ -925,19 +925,19 @@ static void RenderActionButtons(App* app, float width, float vScale,
if (!can_send && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
if (!app->isConnected())
ImGui::SetTooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
else if (state.sync.syncing)
ImGui::SetTooltip("%s", TR("send_tooltip_syncing"));
material::Tooltip("%s", TR("send_tooltip_syncing"));
else if (s_from_address[0] == '\0')
ImGui::SetTooltip("%s", TR("send_tooltip_select_source"));
material::Tooltip("%s", TR("send_tooltip_select_source"));
else if (!is_valid_address)
ImGui::SetTooltip("%s", TR("send_tooltip_invalid_address"));
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
else if (s_amount <= 0)
ImGui::SetTooltip("%s", TR("send_tooltip_enter_amount"));
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
else if (total > available)
ImGui::SetTooltip("%s", TR("send_tooltip_exceeds_balance"));
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
else if (s_sending)
ImGui::SetTooltip("%s", TR("send_tooltip_in_progress"));
material::Tooltip("%s", TR("send_tooltip_in_progress"));
}
if (!can_send) ImGui::PopStyleColor(3);

View File

@@ -221,7 +221,7 @@ void RenderSettingsWindow(App* app, bool* p_open)
ImGui::EndDisabled();
ImGui::PopStyleColor();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip("%s", skin.validationError.c_str());
material::Tooltip("%s", skin.validationError.c_str());
}
} else {
std::string label = skin.name;
@@ -244,7 +244,7 @@ void RenderSettingsWindow(App* app, bool* p_open)
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_theme_hotkey"));
material::Tooltip("%s", TR("tt_theme_hotkey"));
// Show indicator if custom theme is active
if (active_is_custom) {
@@ -253,7 +253,7 @@ void RenderSettingsWindow(App* app, bool* p_open)
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), ICON_CUSTOM_THEME);
ImGui::PopFont();
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("tt_custom_theme"));
material::Tooltip("%s", TR("tt_custom_theme"));
}
}
@@ -265,7 +265,7 @@ void RenderSettingsWindow(App* app, bool* p_open)
}
ImGui::PopFont();
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(TR("tt_scan_themes"),
material::Tooltip(TR("tt_scan_themes"),
schema::SkinManager::getUserSkinsDirectory().c_str());
}

View File

@@ -842,7 +842,7 @@ void RenderTransactionsTab(App* app)
// Tooltip
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s\n%s\n%s", tx.address.c_str(),
material::Tooltip("%s\n%s\n%s", tx.address.c_str(),
tx.txid.c_str(), tx.getTimeString().c_str());
}

View File

@@ -11,6 +11,7 @@
#include <memory>
#include <string>
#include <vector>
#include "../../app.h"
#include "../../config/settings.h"
@@ -21,6 +22,7 @@
#include "../material/type.h"
#include "../material/colors.h"
#include "../theme.h"
#include "release_list_view.h"
#include "imgui.h"
namespace dragonx {
@@ -33,8 +35,11 @@ public:
s_app = app;
s_open = true;
s_persisted = false;
s_installed_tag = app->settings() ? app->settings()->getXmrigVersion() : std::string();
s_rows.clear();
s_releases.clear();
s_updater = std::make_unique<util::XmrigUpdater>();
s_updater->startCheck(app->settings() ? app->settings()->getXmrigVersion() : std::string());
s_updater->startCheck(s_installed_tag);
}
static bool isOpen() { return s_open; }
@@ -54,6 +59,8 @@ public:
case St::UpToDate:
case St::UpdateAvailable: renderPrompt(dp, p); break;
case St::Unavailable: renderUnavailable(dp, p); break;
case St::Listing: renderListing(dp, p); break;
case St::ReleaseList: renderReleaseList(dp, p); break;
case St::Downloading:
case St::Verifying:
case St::Extracting: renderProgress(dp, p); break;
@@ -121,9 +128,50 @@ private:
installAction(TR("xmrig_reinstall"));
}
ImGui::Spacing();
// Browse all releases (pre-releases / older versions).
if (TactileButton(TR("xmrig_browse_releases"), ImVec2(fullW(), 0))) {
s_rows.clear();
s_updater->startListReleases();
}
ImGui::Spacing();
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
static void renderListing(float, const Progress&) {
using namespace material;
Type().text(TypeStyle::Body2, TR("xmrig_loading_releases"));
}
static void renderReleaseList(float dp, const Progress&) {
using namespace material;
// Snapshot + build rows once per listing (not every frame). Cleared on browse / show.
if (s_rows.empty()) {
s_releases = s_updater->getReleases();
const std::string token = util::currentXmrigPlatformToken();
s_rows.reserve(s_releases.size());
for (const auto& r : s_releases) {
ReleaseRow row;
row.tag = r.tag;
row.title = r.name;
row.date = r.publishedAt.size() >= 10 ? r.publishedAt.substr(0, 10) : r.publishedAt;
row.prerelease = r.prerelease;
row.hasAsset = util::selectXmrigAsset(r, token) >= 0;
row.installed = !s_installed_tag.empty() && r.tag == s_installed_tag;
s_rows.push_back(std::move(row));
}
}
// Can't replace a running miner binary — disable installs while mining (TOCTOU guard).
const char* disabledNote = s_app->isPoolMinerRunning() ? TR("xmrig_stop_mining_first") : nullptr;
bool back = false;
const int idx = RenderReleaseList(s_rows, dp, &back, disabledNote);
if (back) { s_rows.clear(); s_updater->startCheck(s_installed_tag); return; }
if (idx >= 0 && !s_app->isPoolMinerRunning() && idx < static_cast<int>(s_releases.size())) {
const util::XmrigRelease rel = s_releases[idx];
s_rows.clear();
s_updater->startInstallRelease(resources::getDaemonDirectory(), rel);
}
}
static void renderProgress(float dp, const Progress& p) {
using namespace material;
const char* title = p.state == St::Downloading ? TR("xmrig_downloading")
@@ -180,6 +228,9 @@ private:
static inline bool s_open = false;
static inline bool s_persisted = false;
static inline std::string s_installed_tag;
static inline std::vector<ReleaseRow> s_rows; // built once per listing (UI rows)
static inline std::vector<util::XmrigRelease> s_releases; // matching releases (install by index)
static inline App* s_app = nullptr;
static inline std::unique_ptr<util::XmrigUpdater> s_updater;
};

478
src/util/daemon_updater.cpp Normal file
View File

@@ -0,0 +1,478 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// DaemonUpdater background worker (libcurl download + miniz extract). The pure, no-I/O helpers it
// calls (release parsing, asset/platform matching, the checksum-table parser, version core) live in
// daemon_updater_core.cpp; the generic SHA-256 / ed25519 verification is reused from the miner
// updater (xmrig_updater_core.cpp) via util::sha256Hex / util::verifyXmrigSignature.
#include "daemon_updater.h"
#include "xmrig_updater.h" // util::sha256Hex, util::verifyXmrigSignature (shared crypto)
#include "logger.h"
#include <curl/curl.h>
#include <miniz.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <iterator>
namespace fs = std::filesystem;
namespace dragonx {
namespace util {
namespace {
// Bound the archive/member sizes before they can fill memory/disk — a defense even ahead of the
// checksum check (which only runs once the file is fully downloaded). The full-node archive bundles
// the daemon binaries plus Sapling params, so the caps are well above the miner updater's.
constexpr curl_off_t kMaxArchiveBytes = 256LL * 1024 * 1024; // 256 MiB
constexpr std::size_t kMaxMemberBytes = 128u * 1024 * 1024; // 128 MiB per extracted file
size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp)
{
static_cast<std::string*>(userp)->append(static_cast<char*>(contents), size * nmemb);
return size * nmemb;
}
size_t writeFileCb(void* contents, size_t size, size_t nmemb, void* userp)
{
return std::fwrite(contents, size, nmemb, static_cast<FILE*>(userp));
}
// libcurl progress callback: publish live byte counts and abort the transfer on cancel().
int xferCb(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t, curl_off_t)
{
auto* up = static_cast<DaemonUpdater*>(clientp);
return up->onDownloadProgress(static_cast<double>(dlnow), static_cast<double>(dltotal)) ? 0 : 1;
}
std::string baseName(const std::string& path)
{
const auto slash = path.find_last_of("/\\");
return slash == std::string::npos ? path : path.substr(slash + 1);
}
std::string toLower(std::string s)
{
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
} // namespace
DaemonUpdater::~DaemonUpdater()
{
cancel_requested_ = true;
if (worker_.joinable()) worker_.join();
}
void DaemonUpdater::setProgress(State state, const std::string& text, double done, double total)
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.state = state;
progress_.status_text = text;
if (done > 0) progress_.downloaded_bytes = done;
if (total > 0) progress_.total_bytes = total;
progress_.percent = (progress_.total_bytes > 0)
? static_cast<float>(100.0 * progress_.downloaded_bytes / progress_.total_bytes)
: progress_.percent;
if (state == State::Failed) progress_.error = text;
}
DaemonUpdater::Progress DaemonUpdater::getProgress() const
{
std::lock_guard<std::mutex> lk(mutex_);
return progress_;
}
bool DaemonUpdater::onDownloadProgress(double downloadedBytes, double totalBytes)
{
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.downloaded_bytes = downloadedBytes;
if (totalBytes > 0) progress_.total_bytes = totalBytes;
progress_.percent = (progress_.total_bytes > 0)
? static_cast<float>(100.0 * progress_.downloaded_bytes / progress_.total_bytes)
: progress_.percent;
}
return !cancel_requested_.load(); // false -> curl aborts the transfer
}
bool DaemonUpdater::isDone() const
{
std::lock_guard<std::mutex> lk(mutex_);
return progress_.state == State::Done || progress_.state == State::Failed ||
progress_.state == State::Unavailable;
}
void DaemonUpdater::cancel()
{
cancel_requested_ = true;
}
std::string DaemonUpdater::httpGet(const std::string& url)
{
CURL* curl = curl_easy_init();
if (!curl) return {};
std::string result;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeStringCb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &result);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
const CURLcode res = curl_easy_perform(curl);
long httpCode = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
curl_easy_cleanup(curl);
if (res != CURLE_OK || httpCode < 200 || httpCode >= 300) {
DEBUG_LOGF("[daemon-updater] GET %s failed: %s (HTTP %ld)\n",
url.c_str(), curl_easy_strerror(res), httpCode);
return {};
}
return result;
}
bool DaemonUpdater::downloadToFile(const std::string& url, const std::string& destPath)
{
FILE* fp = std::fopen(destPath.c_str(), "wb");
if (!fp) return false;
CURL* curl = curl_easy_init();
if (!curl) { std::fclose(fp); return false; }
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 60L);
curl_easy_setopt(curl, CURLOPT_MAXFILESIZE_LARGE, kMaxArchiveBytes); // refuse oversized bodies
// Live progress + cancellation: the callback publishes byte counts and aborts on cancel().
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferCb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
const CURLcode res = curl_easy_perform(curl);
long httpCode = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
curl_easy_cleanup(curl);
std::fclose(fp);
if (res != CURLE_OK || httpCode < 200 || httpCode >= 300) {
DEBUG_LOGF("[daemon-updater] download %s failed: %s (HTTP %ld)\n",
url.c_str(), curl_easy_strerror(res), httpCode);
std::error_code ec; fs::remove(destPath, ec);
return false;
}
return true;
}
void DaemonUpdater::startCheck(const std::string& installedVersion)
{
if (worker_running_.exchange(true)) return;
cancel_requested_ = false;
if (worker_.joinable()) worker_.join();
{
std::lock_guard<std::mutex> lk(mutex_);
progress_ = Progress{};
progress_.installed_tag = installedVersion;
progress_.state = State::Checking;
progress_.status_text = "Checking for the latest node…";
}
worker_ = std::thread([this, installedVersion] { runCheck(installedVersion); worker_running_ = false; });
}
void DaemonUpdater::startInstall(const std::string& targetDir)
{
if (worker_running_.exchange(true)) return;
cancel_requested_ = false;
if (worker_.joinable()) worker_.join();
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.state = State::Downloading;
progress_.error.clear();
progress_.status_text = "Preparing…";
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
progress_.total_bytes = 0;
progress_.percent = 0.0f;
}
worker_ = std::thread([this, targetDir] { runInstall(targetDir); worker_running_ = false; });
}
void DaemonUpdater::startListReleases()
{
if (worker_running_.exchange(true)) return;
cancel_requested_ = false;
if (worker_.joinable()) worker_.join();
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.state = State::Listing;
progress_.error.clear();
progress_.status_text = "Loading releases…";
progress_.downloaded_bytes = 0; // clear any prior op's progress
progress_.total_bytes = 0;
progress_.percent = 0.0f;
}
worker_ = std::thread([this] { runListReleases(); worker_running_ = false; });
}
void DaemonUpdater::startInstallRelease(const std::string& targetDir, DaemonRelease release)
{
if (worker_running_.exchange(true)) return;
cancel_requested_ = false;
if (worker_.joinable()) worker_.join();
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.state = State::Downloading;
progress_.error.clear();
progress_.status_text = "Preparing…";
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
progress_.total_bytes = 0;
progress_.percent = 0.0f;
}
worker_ = std::thread([this, targetDir, release = std::move(release)] {
installResolved(targetDir, release);
worker_running_ = false;
});
}
std::vector<DaemonRelease> DaemonUpdater::getReleases() const
{
std::lock_guard<std::mutex> lk(mutex_);
return releases_;
}
void DaemonUpdater::runListReleases()
{
const std::string body = httpGet(kReleasesUrl);
if (body.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
std::vector<DaemonRelease> list = parseDaemonReleaseList(body);
const bool empty = list.empty();
{
std::lock_guard<std::mutex> lk(mutex_);
releases_ = std::move(list);
}
if (empty) { setProgress(State::Failed, "No releases found."); return; }
setProgress(State::ReleaseList, "Select a version to install.");
}
void DaemonUpdater::runCheck(std::string installedVersion)
{
const std::string body = httpGet(kApiUrl);
if (body.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
const DaemonRelease rel = parseDaemonRelease(body);
if (!rel.ok) { setProgress(State::Failed, rel.error.empty() ? "Invalid release data." : rel.error); return; }
const std::string token = currentDaemonPlatformToken();
const int idx = selectDaemonAsset(rel, token);
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.latest_tag = rel.tag;
progress_.installed_tag = installedVersion;
}
if (idx < 0) {
setProgress(State::Unavailable, "No node build is available for this platform (" +
(token.empty() ? "unknown" : token) + ").");
return;
}
// Compare by vN.N.N core so an installed "v1.0.2-<commit>" matches a release "v1.0.2".
const std::string instCore = daemonVersionCore(installedVersion);
const bool updateAvailable = instCore.empty() || instCore != daemonVersionCore(rel.tag);
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.update_available = updateAvailable;
}
if (updateAvailable)
setProgress(State::UpdateAvailable, "A new node version is available (" + rel.tag + ").");
else
setProgress(State::UpToDate, "The node is up to date (" + rel.tag + ").");
}
void DaemonUpdater::runInstall(std::string targetDir)
{
setProgress(State::Checking, "Checking for the latest node…");
const std::string apiBody = httpGet(kApiUrl);
if (apiBody.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
const DaemonRelease rel = parseDaemonRelease(apiBody);
if (!rel.ok) { setProgress(State::Failed, rel.error.empty() ? "Invalid release data." : rel.error); return; }
installResolved(targetDir, rel);
}
void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRelease& rel)
{
const std::string token = currentDaemonPlatformToken();
const int idx = selectDaemonAsset(rel, token);
if (idx < 0) {
setProgress(State::Unavailable, "No node build is available for this platform (" +
(token.empty() ? "unknown" : token) + ").");
return;
}
const DaemonReleaseAsset& asset = rel.assets[idx];
const auto checksums = parseDaemonChecksums(rel.body);
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.latest_tag = rel.tag;
}
if (cancel_requested_) { setProgress(State::Failed, "Cancelled."); return; }
std::error_code ec;
fs::create_directories(targetDir, ec);
// 1. Download the archive.
const std::string zipPath = (fs::path(targetDir) / ".dragonx-daemon-download.zip").string();
setProgress(State::Downloading, "Downloading " + asset.name + "", 0,
static_cast<double>(asset.size));
if (!downloadToFile(asset.downloadUrl, zipPath)) {
setProgress(State::Failed, "Download failed.");
return;
}
if (cancel_requested_) { fs::remove(zipPath, ec); setProgress(State::Failed, "Cancelled."); return; }
// 2. Verify the downloaded archive. Read it once, then (a) compare its SHA-256 to the published
// checksum and (b) verify a detached ed25519 signature over the archive bytes against the
// pinned key, so a checksum rewritten in a tampered release body is not sufficient to install.
setProgress(State::Verifying, "Verifying download…");
{
std::ifstream f(zipPath, std::ios::binary);
if (!f) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not read the downloaded archive.");
return;
}
const std::string bytes((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (f.bad()) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not read the downloaded archive.");
return;
}
// 2a. SHA-256 (integrity / transit corruption).
const std::string actual = sha256Hex(bytes.data(), bytes.size());
const auto it = checksums.find(toLower(asset.name)); // keys are lowercased
if (it == checksums.end()) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "No published checksum for " + asset.name + " — refusing to install.");
return;
}
if (actual != it->second) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "Archive checksum mismatch — refusing to install (possible tampering).");
return;
}
// 2b. Detached ed25519 signature (authenticity) against the pinned key.
const std::string pubKey = kDaemonSignaturePublicKeyBase64;
if (!pubKey.empty()) {
const int sigIdx = selectDaemonSignatureAsset(rel, asset.name);
if (sigIdx < 0) {
if (kDaemonRequireSignature) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "No signature published for this release — refusing to install.");
return;
}
DEBUG_LOGF("[daemon-updater] no signature asset for %s; proceeding on checksum only\n",
asset.name.c_str());
} else {
setProgress(State::Verifying, "Verifying signature…");
const std::string sigContent = httpGet(rel.assets[sigIdx].downloadUrl);
if (sigContent.empty() || !verifyXmrigSignature(bytes, sigContent, pubKey)) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "Signature verification failed — refusing to install.");
return;
}
}
} else if (kDaemonRequireSignature) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "No signing key is pinned in this build — refusing to install.");
return;
}
}
// 3. Extract the daemon binaries (flatten the versioned subdir). No per-member hash check is
// needed: the whole archive was already verified above (SHA-256 + ed25519 signature), so
// every member is authentic by transitivity. The archive also carries params/asmap, which
// this updater deliberately leaves to the wallet's own resource extraction.
setProgress(State::Extracting, "Installing node…");
const std::vector<std::string> wanted = daemonExtractBasenames(token);
const std::string daemonName = wanted.front(); // "dragonxd" / "dragonxd.exe"
mz_zip_archive zip{};
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not open the downloaded archive.");
return;
}
bool daemonInstalled = false;
bool failed = false;
const int numFiles = static_cast<int>(mz_zip_reader_get_num_files(&zip));
for (int i = 0; i < numFiles && !failed; ++i) {
mz_zip_archive_file_stat st;
if (!mz_zip_reader_file_stat(&zip, i, &st)) continue;
if (mz_zip_reader_is_file_a_directory(&zip, i)) continue;
const std::string base = baseName(st.m_filename);
if (std::find(wanted.begin(), wanted.end(), base) == wanted.end()) continue; // skip params/asmap/etc.
// Reject an implausibly large member before decompressing it into memory.
if (st.m_uncomp_size > kMaxMemberBytes) { failed = true; break; }
size_t outSize = 0;
void* mem = mz_zip_reader_extract_to_heap(&zip, i, &outSize, 0);
if (!mem) { failed = true; break; }
const std::string finalPath = (fs::path(targetDir) / base).string();
const std::string tmpPath = finalPath + ".tmp";
// Tidy any leftover from a previous update (the old binary moved aside, freed after restart).
fs::remove(finalPath + ".old", ec);
{
std::ofstream of(tmpPath, std::ios::binary | std::ios::trunc);
if (!of) { mz_free(mem); failed = true; break; }
of.write(static_cast<const char*>(mem), static_cast<std::streamsize>(outSize));
}
mz_free(mem);
// Atomic install that also works while the daemon is running: POSIX rename() replaces the
// path even if the old binary is in use (the running process keeps the unlinked inode). On
// Windows a running .exe cannot be renamed-over, so move it aside to ".old" first, then put
// the new file in place; the ".old" is cleaned up on a later update once the daemon restarts.
fs::rename(tmpPath, finalPath, ec);
if (ec) {
std::error_code mec;
fs::rename(finalPath, finalPath + ".old", mec);
ec.clear();
fs::rename(tmpPath, finalPath, ec);
}
if (ec) { fs::remove(tmpPath, ec); failed = true; break; }
#ifndef _WIN32
// Every wanted member is an executable in the daemon set — make them all runnable.
fs::permissions(finalPath,
fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec |
fs::perms::others_read | fs::perms::others_exec,
fs::perm_options::replace, ec);
#endif
if (base == daemonName) daemonInstalled = true;
}
mz_zip_reader_end(&zip);
fs::remove(zipPath, ec);
if (failed) { setProgress(State::Failed, "Could not verify/install the node binaries."); return; }
if (!daemonInstalled) { setProgress(State::Failed, "Daemon binary not found in the archive."); return; }
setProgress(State::Done, "Node installed (" + rel.tag + ").");
}
} // namespace util
} // namespace dragonx

189
src/util/daemon_updater.h Normal file
View File

@@ -0,0 +1,189 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// DaemonUpdater — fetch + verify + install the latest dragonxd full node from the DragonX Gitea.
//
// Sibling of util::XmrigUpdater (the miner updater): same query → download → verify → extract →
// install pipeline on a background thread, but for the full-node binaries instead of the miner.
// Flow: query the Gitea releases API for the latest dragonx release, pick the archive matching this
// platform (dragonx-<ver>-linux-amd64.zip / -win64.zip / -macos.zip), download it, verify it, then
// extract the daemon executables (dragonxd / dragonx-cli / dragonx-tx, flattening the versioned
// subdir the archive nests them in) into the daemon directory and chmod them executable.
//
// Security: download-and-execute, so verification is mandatory. TLS is verified (libcurl defaults),
// the host is the project's own Gitea over HTTPS, the archive's SHA-256 is checked against the
// checksum table published in the release body, AND a detached ed25519 signature over the archive
// bytes is verified against the key pinned below (kDaemonRequireSignature enforces it: an install
// is refused when no valid .sig is published). The inner binaries are trusted by transitivity once
// the whole archive is authenticated. The generic SHA-256 / ed25519 primitives are shared with the
// miner updater (util::sha256Hex / util::verifyXmrigSignature in xmrig_updater_core.cpp).
#pragma once
#include <atomic>
#include <cstddef>
#include <map>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace dragonx {
namespace util {
struct DaemonReleaseAsset {
std::string name;
std::string downloadUrl;
long long size = 0;
};
struct DaemonRelease {
bool ok = false;
std::string tag; // e.g. "v1.0.2"
std::string name; // human title (e.g. "Dragonx v1.0.2")
std::string body; // release notes markdown (holds the checksum table)
bool prerelease = false; // marked pre-release on the Gitea release
std::string publishedAt; // ISO-8601 publish timestamp (date shown in the UI)
std::vector<DaemonReleaseAsset> assets;
std::string error;
};
// ── Release signature (supply-chain hardening, mandatory for the daemon) ──────
//
// Pinned ed25519 public key (base64, 32 bytes) used to verify a detached signature over the
// downloaded archive. With kDaemonRequireSignature=true an install is refused unless the release
// publishes a "<archive-name>.sig" asset (base64 of, or raw, a 64-byte ed25519 signature over the
// archive bytes) that verifies against this key — see scripts/sign-daemon-release.sh. To rotate the
// key, regenerate with that script and replace the value below. If the key is set empty AND
// kDaemonRequireSignature=false, verification falls back to TLS + the published SHA-256 only.
inline constexpr const char* kDaemonSignaturePublicKeyBase64 =
"d59hosHzh2LtHypgGvsMBlgrRGBhOfS0Xl40D/fEjzQ=";
inline constexpr bool kDaemonRequireSignature = true; // enforced: refuse installs without a valid .sig
// ── Pure helpers (no I/O; unit-tested) ───────────────────────────────────────
// Parse the Gitea GET /releases/latest JSON into a DaemonRelease (ok=false + error on failure).
DaemonRelease parseDaemonRelease(const std::string& json);
// Parse the Gitea GET /releases (array) JSON into the list of releases, newest first, skipping
// drafts. Empty on parse failure. Lets the user browse + pin a specific (or pre-release) version.
std::vector<DaemonRelease> parseDaemonReleaseList(const std::string& json);
// The asset-name token for the host platform: "linux-amd64", "win64", "macos", or "" if
// unknown/unsupported (e.g. linux-arm64, for which no build is published -> Unavailable).
std::string currentDaemonPlatformToken();
// Index of the asset whose name matches the platform token (e.g. ends with "-linux-amd64.zip"),
// or -1 if none.
int selectDaemonAsset(const DaemonRelease& release, const std::string& platformToken);
// Parse the release-body markdown checksum table ( "| <archive>.zip | `<sha256hex>` |" rows ) into
// { archive-name -> lowercase-hex }. Header/separator/prose rows (no 64-hex token) are ignored.
std::map<std::string, std::string> parseDaemonChecksums(const std::string& body);
// The binary file basenames to extract for a platform: {"dragonxd","dragonx-cli","dragonx-tx"} on
// POSIX, the ".exe" variants on Windows. The first entry is always the daemon itself.
std::vector<std::string> daemonExtractBasenames(const std::string& platformToken);
// Index of the detached-signature asset for a given archive (name "<archive>.sig" or
// "<archive>.minisig"), or -1 if none is published.
int selectDaemonSignatureAsset(const DaemonRelease& release, const std::string& archiveName);
// Reduce a version string to its "vMAJOR.MINOR.PATCH" core, dropping any "-<commit>" suffix the
// binary scanner appends, so an installed "v1.0.2-ddd851dc1" compares equal to a release "v1.0.2".
// Returns the input unchanged if it holds no vN.N.N pattern.
std::string daemonVersionCore(const std::string& version);
// ── Background worker ────────────────────────────────────────────────────────
class DaemonUpdater {
public:
enum class State {
Idle,
Checking,
UpToDate,
UpdateAvailable,
Unavailable, // no daemon build is published for this platform (terminal, not an error)
Listing, // fetching the full release list (Browse all releases)
ReleaseList, // release list fetched; awaiting the user's pick
Downloading,
Verifying,
Extracting,
Done,
Failed
};
struct Progress {
State state = State::Idle;
double downloaded_bytes = 0;
double total_bytes = 0;
float percent = 0.0f;
std::string status_text;
std::string error; // non-empty on Failed
std::string latest_tag; // tag reported by the API (once checked)
std::string installed_tag; // caller-supplied current install (for update detection)
bool update_available = false;
};
// Gitea releases API for the dragonx full node.
static constexpr const char* kApiUrl =
"https://git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/latest";
// Full release list (newest first, includes pre-releases) for the "Browse all releases" picker.
static constexpr const char* kReleasesUrl =
"https://git.dragonx.is/api/v1/repos/DragonX/dragonx/releases?limit=50";
DaemonUpdater() = default;
~DaemonUpdater();
DaemonUpdater(const DaemonUpdater&) = delete;
DaemonUpdater& operator=(const DaemonUpdater&) = delete;
// Query the latest release on a background thread. `installedVersion` (may be empty/unknown) is
// compared (by vN.N.N core) to the API tag to set Progress.update_available. End state:
// UpToDate / UpdateAvailable / Failed.
void startCheck(const std::string& installedVersion);
// Download → verify archive → extract (flatten) → install into `targetDir` on a background
// thread. Re-fetches the latest release so it is self-contained. End state: Done / Failed. The
// new binary takes effect on the next daemon start (the caller offers a restart).
void startInstall(const std::string& targetDir);
// Fetch the full release list on a background thread (for "Browse all releases"). End state:
// ReleaseList (then getReleases() holds the list, newest first) / Failed.
void startListReleases();
// Snapshot of the release list fetched by startListReleases().
std::vector<DaemonRelease> getReleases() const;
// Install a SPECIFIC release (chosen from the browse list) into `targetDir` — same verify/extract
// path as startInstall, but pinned to `release` instead of latest. End state: Done / Failed.
void startInstallRelease(const std::string& targetDir, DaemonRelease release);
void cancel();
Progress getProgress() const;
bool isDone() const; // true once the worker reached a terminal state (Done/Failed/Unavailable)
// Internal: called by the libcurl progress callback. Publishes live download bytes and returns
// false to ask curl to abort (when cancel() was requested). Public only so the C callback in the
// .cpp can reach it without leaking curl types into this header.
bool onDownloadProgress(double downloadedBytes, double totalBytes);
private:
void runCheck(std::string installedVersion);
void runListReleases();
void runInstall(std::string targetDir);
void installResolved(const std::string& targetDir, const DaemonRelease& rel); // shared install body
void setProgress(State state, const std::string& text, double done = 0, double total = 0);
bool downloadToFile(const std::string& url, const std::string& destPath);
std::string httpGet(const std::string& url);
mutable std::mutex mutex_;
Progress progress_;
std::vector<DaemonRelease> releases_;
std::atomic<bool> cancel_requested_{false};
std::atomic<bool> worker_running_{false};
std::thread worker_;
};
} // namespace util
} // namespace dragonx

View File

@@ -0,0 +1,206 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Pure (no-I/O) core of the daemon updater: release-JSON parsing, platform/asset matching, the
// markdown checksum-table parser, version normalization, and signature-asset selection. Split from
// daemon_updater.cpp (the libcurl/miniz worker) so it can be unit-tested without curl/miniz. The
// generic SHA-256 / ed25519 primitives are reused from the miner updater (xmrig_updater_core.cpp).
#include "daemon_updater.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <sstream>
using json = nlohmann::json;
namespace dragonx {
namespace util {
namespace {
std::string toLower(std::string s)
{
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
bool isHex64(const std::string& s)
{
if (s.size() != 64) return false;
for (unsigned char c : s)
if (!std::isxdigit(c)) return false;
return true;
}
} // namespace
namespace {
// Fill a DaemonRelease from one Gitea release JSON object (ok=true iff it has a tag).
DaemonRelease parseOneDaemonRelease(const json& j)
{
DaemonRelease r;
if (!j.is_object()) return r;
if (j.contains("tag_name") && j["tag_name"].is_string())
r.tag = j["tag_name"].get<std::string>();
if (j.contains("name") && j["name"].is_string())
r.name = j["name"].get<std::string>();
if (j.contains("body") && j["body"].is_string())
r.body = j["body"].get<std::string>();
if (j.contains("prerelease") && j["prerelease"].is_boolean())
r.prerelease = j["prerelease"].get<bool>();
if (j.contains("published_at") && j["published_at"].is_string())
r.publishedAt = j["published_at"].get<std::string>();
if (j.contains("assets") && j["assets"].is_array()) {
for (const auto& a : j["assets"]) {
if (!a.is_object()) continue;
DaemonReleaseAsset asset;
if (a.contains("name") && a["name"].is_string())
asset.name = a["name"].get<std::string>();
if (a.contains("browser_download_url") && a["browser_download_url"].is_string())
asset.downloadUrl = a["browser_download_url"].get<std::string>();
if (a.contains("size") && a["size"].is_number_integer())
asset.size = a["size"].get<long long>();
if (!asset.name.empty() && !asset.downloadUrl.empty())
r.assets.push_back(std::move(asset));
}
}
if (!r.tag.empty()) r.ok = true;
return r;
}
} // namespace
DaemonRelease parseDaemonRelease(const std::string& jsonStr)
{
DaemonRelease r;
try {
r = parseOneDaemonRelease(json::parse(jsonStr));
if (!r.ok) r.error = "release JSON has no tag_name";
} catch (const std::exception& e) {
r.error = std::string("failed to parse release JSON: ") + e.what();
}
return r;
}
std::vector<DaemonRelease> parseDaemonReleaseList(const std::string& jsonStr)
{
std::vector<DaemonRelease> out;
try {
const json j = json::parse(jsonStr);
if (!j.is_array()) return out;
for (const auto& e : j) {
if (e.contains("draft") && e["draft"].is_boolean() && e["draft"].get<bool>())
continue; // skip drafts (not meant for end users)
DaemonRelease r = parseOneDaemonRelease(e);
if (r.ok) out.push_back(std::move(r));
}
} catch (const std::exception&) {
// malformed list -> empty (caller treats as "could not load releases")
}
return out;
}
std::string currentDaemonPlatformToken()
{
#if defined(_WIN32)
return "win64";
#elif defined(__APPLE__)
return "macos"; // single macOS archive (no arm/x86 split in the release naming)
#elif defined(__linux__)
#if defined(__aarch64__)
return "linux-arm64"; // no arm64 build published yet -> resolves to Unavailable
#else
return "linux-amd64";
#endif
#else
return "";
#endif
}
int selectDaemonAsset(const DaemonRelease& release, const std::string& platformToken)
{
if (platformToken.empty()) return -1;
const std::string needle = "-" + toLower(platformToken) + ".zip";
for (std::size_t i = 0; i < release.assets.size(); ++i) {
const std::string n = toLower(release.assets[i].name);
if (n.size() >= needle.size() &&
n.compare(n.size() - needle.size(), needle.size(), needle) == 0)
return static_cast<int>(i);
}
return -1;
}
std::map<std::string, std::string> parseDaemonChecksums(const std::string& body)
{
// The release body publishes checksums as a markdown table:
// | File | SHA-256 |
// |------|---------|
// | 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
// hash) and a token 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::istringstream in(body);
std::string line;
while (std::getline(in, line)) {
for (char& c : line)
if (c == '|' || c == '`') c = ' ';
std::istringstream ls(line);
std::string tok, hash, name;
while (ls >> tok) {
if (hash.empty() && isHex64(tok)) { hash = toLower(tok); continue; }
if (name.empty()) {
const std::string low = toLower(tok);
// Key by the lowercased name so the lookup (also lowercased) is case-insensitive,
// in case the markdown table and the JSON asset names differ in case.
if (low.size() >= 4 && low.compare(low.size() - 4, 4, ".zip") == 0) name = low;
}
}
if (!hash.empty() && !name.empty()) out[name] = hash;
}
return out;
}
std::vector<std::string> daemonExtractBasenames(const std::string& platformToken)
{
if (platformToken.rfind("win", 0) == 0)
return {"dragonxd.exe", "dragonx-cli.exe", "dragonx-tx.exe"};
return {"dragonxd", "dragonx-cli", "dragonx-tx"};
}
int selectDaemonSignatureAsset(const DaemonRelease& release, const std::string& archiveName)
{
if (archiveName.empty()) return -1;
for (std::size_t i = 0; i < release.assets.size(); ++i) {
const std::string& n = release.assets[i].name;
if (n == archiveName + ".sig" || n == archiveName + ".minisig")
return static_cast<int>(i);
}
return -1;
}
std::string daemonVersionCore(const std::string& version)
{
// Extract a leading "v?MAJOR.MINOR.PATCH" run, ignoring any "-<commit>" / build suffix. Hand-
// rolled (no <regex>) to stay light and dependency-free.
const std::size_t start = version.find_first_of("0123456789");
if (start == std::string::npos) return version;
std::size_t i = start;
int dots = 0;
for (; i < version.size(); ++i) {
const char c = version[i];
if (c >= '0' && c <= '9') continue;
if (c == '.' && dots < 2) { ++dots; continue; }
break;
}
if (dots < 2) return version; // not a full N.N.N — leave as-is
const bool hasV = start > 0 && (version[start - 1] == 'v' || version[start - 1] == 'V');
return (hasV ? "v" : "") + version.substr(start, i - start);
}
} // namespace util
} // namespace dragonx

View File

@@ -418,6 +418,27 @@ void I18n::loadBuiltinEnglish()
strings_["confirm_rescan_title"] = "Rescan Blockchain";
strings_["confirm_rescan_msg"] = "This restarts the daemon and re-scans the entire blockchain for your wallet's transactions. It can take a long time and the wallet stays offline until it finishes.";
strings_["confirm_rescan_safe"] = "Your wallet.dat and blockchain data are not deleted — only re-scanned.";
strings_["rescan_detecting"] = "Checking which blocks your node has on disk…";
strings_["rescan_bootstrapped_msg"] = "Your node was bootstrapped, so blocks below the snapshot aren't on disk and a rescan from genesis would fail. Rescan from a height your snapshot includes to reconcile your wallet's spent balance. Your wallet.dat and chain data are not deleted.";
strings_["rescan_from_height"] = "Rescan from block height:";
strings_["repair_wallet"] = "Repair Wallet";
strings_["tt_repair_wallet"] = "Wipe and rebuild the wallet's transaction records from the blockchain (fixes notes that fail to send after a rescan)";
strings_["confirm_repair_wallet_title"] = "Repair Wallet";
strings_["confirm_repair_wallet_msg"] = "This restarts the daemon with -zapwallettxes=2: it deletes all of the wallet's transaction and note records, then rebuilds them from the blockchain. Use this when transactions fail to build (\"Invalid sapling spend proof\" / \"shielded requirements not met\") even after a full rescan. It takes a long time and the wallet stays offline until it finishes.";
strings_["confirm_repair_wallet_safe"] = "Your keys, addresses and balance are preserved — only the cached transaction records are rebuilt.";
strings_["daemon_binary"] = "Daemon binary";
strings_["daemon_installed"] = "Installed";
strings_["daemon_bundled"] = "Bundled";
strings_["daemon_not_installed"] = "not installed";
strings_["daemon_none_bundled"] = "none in this build";
strings_["daemon_status_match"] = "Installed binary matches the bundled version.";
strings_["daemon_status_differ"] = "Installed binary differs from the bundled version.";
strings_["daemon_status_missing"] = "No daemon installed — install the bundled version.";
strings_["daemon_install_bundled"] = "Install bundled";
strings_["tt_daemon_install_bundled"] = "Stop the node, overwrite the installed dragonxd with the version bundled in this wallet build, then restart";
strings_["confirm_reinstall_daemon_title"] = "Install Bundled Daemon";
strings_["confirm_reinstall_daemon_msg"] = "This stops the daemon, overwrites the installed dragonxd (and dragonx-cli/dragonx-tx) with the versions bundled in this wallet build, then restarts the node. Use this to recover or update the node binary.";
strings_["confirm_reinstall_daemon_safe"] = "Your wallet, keys and blockchain data are not touched — only the daemon program files are replaced.";
strings_["confirm_restart_daemon_title"] = "Restart Daemon";
strings_["confirm_restart_daemon_msg"] = "This stops and restarts the daemon to apply the changed options. The wallet will briefly disconnect and reconnect.";
strings_["lite_maintenance"] = "Maintenance";
@@ -796,6 +817,9 @@ void I18n::loadBuiltinEnglish()
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
strings_["sb_rescanning"] = "Rescanning";
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
strings_["sb_building_witnesses"] = "Setting witnesses";
strings_["sb_witness_cache"] = "Rebuilding witnesses";
strings_["sb_importing_keys"] = "Importing keys";
strings_["sb_daemon_not_found"] = "Daemon not found";
strings_["sb_loading_config"] = "Loading configuration...";
@@ -1142,6 +1166,45 @@ void I18n::loadBuiltinEnglish()
strings_["xmrig_installed_ok"] = "Miner installed";
strings_["xmrig_update_failed"] = "Update failed";
strings_["xmrig_unknown_error"] = "Unknown error.";
strings_["xmrig_browse_releases"] = "Browse all releases…";
strings_["xmrig_loading_releases"] = "Loading releases…";
// --- Shared release picker ("Browse all releases") ---
strings_["upd_select_version"] = "Select a version to install";
strings_["upd_install"] = "Install";
strings_["upd_reinstall"] = "Reinstall";
strings_["upd_prerelease"] = "pre-release";
strings_["upd_installed_badge"] = "installed";
strings_["upd_no_build_platform"] = "No build for this platform";
strings_["upd_back"] = "Back";
// --- Daemon (full node) updater — Settings → daemon binary panel ---
strings_["daemon_update_check"] = "Check for updates…";
strings_["tt_daemon_update_check"] = "Download and verify the latest dragonxd full node from the project Gitea, then restart to apply";
strings_["daemon_update_title"] = "Update Node";
strings_["daemon_update_checking"] = "Checking for the latest node…";
strings_["daemon_update_unavailable_title"]= "Node updates unavailable";
strings_["daemon_update_unavailable_body"] = "No node build is available for this platform.";
strings_["daemon_update_available"] = "A new node version is available";
strings_["daemon_update_up_to_date"] = "The node is up to date";
strings_["daemon_update_latest"] = "Latest:";
strings_["daemon_update_installed"] = "Installed:";
strings_["daemon_update_version"] = "Version:";
strings_["daemon_update_verify_note"] = "The download is verified against the release's published SHA-256 and a pinned ed25519 signature before install.";
strings_["daemon_update_download_install"] = "Download & install";
strings_["daemon_update_reinstall"] = "Reinstall";
strings_["daemon_update_downloading"] = "Downloading…";
strings_["daemon_update_verifying"] = "Verifying…";
strings_["daemon_update_installing"] = "Installing…";
strings_["daemon_update_installed_ok"] = "Node installed";
strings_["daemon_update_restart_note"] = "Restart the daemon to start running the new version.";
strings_["daemon_update_restart_now"] = "Restart daemon now";
strings_["daemon_update_later"] = "Later";
strings_["daemon_update_failed"] = "Update failed";
strings_["daemon_update_unknown_error"] = "Unknown error.";
strings_["daemon_update_browse"] = "Browse all releases…";
strings_["daemon_update_loading"] = "Loading releases…";
strings_["daemon_update_downgrade_note"] = "Older versions may be incompatible with your current chain data. Installing a different version takes effect after a daemon restart.";
// --- Lite Network tab (server browser) ---
strings_["lite_console_title"] = "Console";
@@ -1293,6 +1356,7 @@ void I18n::loadBuiltinEnglish()
strings_["send_tx_sent"] = "Transaction sent!";
strings_["send_tx_success"] = "Transaction sent successfully!";
strings_["send_status_unconfirmed"] = "Transaction status could not be confirmed";
strings_["send_err_needs_rescan"] = "Your wallet's shielded note data is out of date with the blockchain (this happens after a bootstrap or reindex). Run a full rescan via Settings -> Rescan Blockchain and let it finish completely, then try sending again.";
strings_["send_txid_copied"] = "TxID copied to clipboard";
strings_["send_txid_label"] = "TxID: %s";
strings_["send_valid_shielded"] = "Valid shielded address";

View File

@@ -194,10 +194,70 @@ void XmrigUpdater::startInstall(const std::string& targetDir)
progress_.state = State::Downloading;
progress_.error.clear();
progress_.status_text = "Preparing…";
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
progress_.total_bytes = 0;
progress_.percent = 0.0f;
}
worker_ = std::thread([this, targetDir] { runInstall(targetDir); worker_running_ = false; });
}
void XmrigUpdater::startListReleases()
{
if (worker_running_.exchange(true)) return;
cancel_requested_ = false;
if (worker_.joinable()) worker_.join();
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.state = State::Listing;
progress_.error.clear();
progress_.status_text = "Loading releases…";
progress_.downloaded_bytes = 0; // clear any prior op's progress
progress_.total_bytes = 0;
progress_.percent = 0.0f;
}
worker_ = std::thread([this] { runListReleases(); worker_running_ = false; });
}
void XmrigUpdater::startInstallRelease(const std::string& targetDir, XmrigRelease release)
{
if (worker_running_.exchange(true)) return;
cancel_requested_ = false;
if (worker_.joinable()) worker_.join();
{
std::lock_guard<std::mutex> lk(mutex_);
progress_.state = State::Downloading;
progress_.error.clear();
progress_.status_text = "Preparing…";
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
progress_.total_bytes = 0;
progress_.percent = 0.0f;
}
worker_ = std::thread([this, targetDir, release = std::move(release)] {
installResolved(targetDir, release);
worker_running_ = false;
});
}
std::vector<XmrigRelease> XmrigUpdater::getReleases() const
{
std::lock_guard<std::mutex> lk(mutex_);
return releases_;
}
void XmrigUpdater::runListReleases()
{
const std::string body = httpGet(kReleasesUrl);
if (body.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
std::vector<XmrigRelease> list = parseXmrigReleaseList(body);
const bool empty = list.empty();
{
std::lock_guard<std::mutex> lk(mutex_);
releases_ = std::move(list);
}
if (empty) { setProgress(State::Failed, "No releases found."); return; }
setProgress(State::ReleaseList, "Select a version to install.");
}
void XmrigUpdater::runCheck(std::string installedTag)
{
const std::string body = httpGet(kApiUrl);
@@ -235,7 +295,11 @@ void XmrigUpdater::runInstall(std::string targetDir)
if (apiBody.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
const XmrigRelease rel = parseXmrigRelease(apiBody);
if (!rel.ok) { setProgress(State::Failed, rel.error.empty() ? "Invalid release data." : rel.error); return; }
installResolved(targetDir, rel);
}
void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRelease& rel)
{
const std::string token = currentXmrigPlatformToken();
const int idx = selectXmrigAsset(rel, token);
if (idx < 0) {

View File

@@ -38,7 +38,10 @@ struct XmrigReleaseAsset {
struct XmrigRelease {
bool ok = false;
std::string tag; // e.g. "v1.0.0"
std::string name; // human title (e.g. "DRG-XMRig v6.25.3")
std::string body; // release notes markdown (holds the checksum blocks)
bool prerelease = false; // marked pre-release on the Gitea release
std::string publishedAt; // ISO-8601 publish timestamp (date shown in the UI)
std::vector<XmrigReleaseAsset> assets;
std::string error;
};
@@ -61,6 +64,10 @@ inline constexpr bool kXmrigRequireSignature = true; // enforced: refus
// Parse the Gitea GET /releases/latest JSON into an XmrigRelease (ok=false + error on failure).
XmrigRelease parseXmrigRelease(const std::string& json);
// Parse the Gitea GET /releases (array) JSON into the list of releases, newest first, skipping
// drafts. Empty on parse failure. Lets the user browse + pin a specific (or pre-release) version.
std::vector<XmrigRelease> parseXmrigReleaseList(const std::string& json);
// The asset-name token for the host platform: "linux-x64", "win-x64", "macos-x64",
// "macos-arm64", or "" if unknown/unsupported.
std::string currentXmrigPlatformToken();
@@ -102,6 +109,8 @@ public:
UpToDate,
UpdateAvailable,
Unavailable, // no miner build is published for this platform (terminal, not an error)
Listing, // fetching the full release list (Browse all releases)
ReleaseList, // release list fetched; awaiting the user's pick
Downloading,
Verifying,
Extracting,
@@ -124,6 +133,9 @@ public:
// Gitea releases API for the DRG-XMRig fork.
static constexpr const char* kApiUrl =
"https://git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/latest";
// Full release list (newest first, includes pre-releases) for the "Browse all releases" picker.
static constexpr const char* kReleasesUrl =
"https://git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases?limit=50";
XmrigUpdater() = default;
~XmrigUpdater();
@@ -136,10 +148,21 @@ public:
void startCheck(const std::string& installedTag);
// Download → verify archive → extract (flatten) → verify binary → install into `targetDir` on a
// background thread. Re-fetches the release so it is self-contained. End state: Done / Failed.
// On Done, getProgress().latest_tag is the version that should be persisted as the installed tag.
// background thread. Re-fetches the latest release so it is self-contained. End state: Done /
// Failed. On Done, getProgress().latest_tag is the version that should be persisted as installed.
void startInstall(const std::string& targetDir);
// Fetch the full release list on a background thread (for "Browse all releases"). End state:
// ReleaseList (then getReleases() holds the list, newest first) / Failed.
void startListReleases();
// Snapshot of the release list fetched by startListReleases().
std::vector<XmrigRelease> getReleases() const;
// Install a SPECIFIC release (chosen from the browse list) into `targetDir` — same verify/extract
// path as startInstall, but pinned to `release` instead of latest. End state: Done / Failed.
void startInstallRelease(const std::string& targetDir, XmrigRelease release);
void cancel();
Progress getProgress() const;
bool isDone() const; // true once the worker reached a terminal state (Done/Failed/Unavailable)
@@ -151,13 +174,16 @@ public:
private:
void runCheck(std::string installedTag);
void runListReleases();
void runInstall(std::string targetDir);
void installResolved(const std::string& targetDir, const XmrigRelease& rel); // shared install body
void setProgress(State state, const std::string& text, double done = 0, double total = 0);
bool downloadToFile(const std::string& url, const std::string& destPath);
std::string httpGet(const std::string& url);
mutable std::mutex mutex_;
Progress progress_;
std::vector<XmrigRelease> releases_;
std::atomic<bool> cancel_requested_{false};
std::atomic<bool> worker_running_{false};
std::thread worker_;

View File

@@ -40,15 +40,22 @@ bool isHex64(const std::string& s)
} // namespace
XmrigRelease parseXmrigRelease(const std::string& jsonStr)
namespace {
// Fill an XmrigRelease from one Gitea release JSON object (ok=true iff it has a tag).
XmrigRelease parseOneXmrigRelease(const json& j)
{
XmrigRelease r;
try {
const json j = json::parse(jsonStr);
if (!j.is_object()) return r;
if (j.contains("tag_name") && j["tag_name"].is_string())
r.tag = j["tag_name"].get<std::string>();
if (j.contains("name") && j["name"].is_string())
r.name = j["name"].get<std::string>();
if (j.contains("body") && j["body"].is_string())
r.body = j["body"].get<std::string>();
if (j.contains("prerelease") && j["prerelease"].is_boolean())
r.prerelease = j["prerelease"].get<bool>();
if (j.contains("published_at") && j["published_at"].is_string())
r.publishedAt = j["published_at"].get<std::string>();
if (j.contains("assets") && j["assets"].is_array()) {
for (const auto& a : j["assets"]) {
if (!a.is_object()) continue;
@@ -63,14 +70,41 @@ XmrigRelease parseXmrigRelease(const std::string& jsonStr)
r.assets.push_back(std::move(asset));
}
}
if (r.tag.empty()) { r.error = "release JSON has no tag_name"; return r; }
r.ok = true;
if (!r.tag.empty()) r.ok = true;
return r;
}
} // namespace
XmrigRelease parseXmrigRelease(const std::string& jsonStr)
{
XmrigRelease r;
try {
r = parseOneXmrigRelease(json::parse(jsonStr));
if (!r.ok) r.error = "release JSON has no tag_name";
} catch (const std::exception& e) {
r.error = std::string("failed to parse release JSON: ") + e.what();
}
return r;
}
std::vector<XmrigRelease> parseXmrigReleaseList(const std::string& jsonStr)
{
std::vector<XmrigRelease> out;
try {
const json j = json::parse(jsonStr);
if (!j.is_array()) return out;
for (const auto& e : j) {
if (e.contains("draft") && e["draft"].is_boolean() && e["draft"].get<bool>())
continue; // skip drafts (not meant for end users)
XmrigRelease r = parseOneXmrigRelease(e);
if (r.ok) out.push_back(std::move(r));
}
} catch (const std::exception&) {
// malformed list -> empty (caller treats as "could not load releases")
}
return out;
}
std::string currentXmrigPlatformToken()
{
#if defined(_WIN32)

View File

@@ -0,0 +1 @@
{"id":15,"tag_name":"v1.0.2","target_commitish":"dragonx","name":"Dragonx v1.0.2","body":"## What is DragonX?\r\n\r\nDragonX is a privacy-focused cryptocurrency built on zero-knowledge mathematics. It uses the\r\nSapling protocol for shielded transactions and enforces mandatory z2z (shielded-to-shielded)\r\ntransactions after block 340,000, meaning funds can only be sent to shielded z-addresses at\r\nthe consensus level.\r\n\r\n### Bug Fixes\r\n * Fix sapling pool persistence — pool total no longer resets to 0 on node restart. Explorer nodes should reindex once after upgrading.\r\n\r\n### New Features\r\n * Add `subsidy` and `fees` fields to the `getblock` RPC response so explorers can display the correct 3 DRGX block reward separately from fees\r\n\r\n### Key Features\r\n\r\n * **RandomX Proof-of-Work** — CPU-mineable, ASIC-resistant mining algorithm\r\n * **Sapling zk-SNARKs** — zero-knowledge proofs for fully private transactions\r\n * **Mandatory shielded transactions** — z2z enforced at consensus after block 340,000\r\n * **Encrypted P2P** — all connections secured with TLS 1.3 via WolfSSL (AES-256-GCM and ChaCha20-Poly1305)\r\n * **Anonymous networking** — built-in Tor, i2p, and cjdns support\r\n * **Passive network spy protection** — prevents ISPs and observers from identifying transaction origins\r\n\r\n## Checksums\r\n\r\n| File | SHA-256 |\r\n|------|---------|\r\n| dragonx-1.0.2-linux-amd64.zip | `85f1dd908bfbdee6aaebabdc74848dc8963d45e7510172d84d0230c0fad6cc16` |\r\n| dragonx-1.0.2-macos.zip | `102e1f1ecab05def25465ad4867b19d46c7cc00a9fbb018ffb405bcb82cc6432` |\r\n| dragonx-1.0.2-win64.zip | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |\r\n\r\n## License\r\n\r\nDragonX is released under the [GNU General Public License v3 (GPLv3)](https://git.dragonx.is/DragonX/dragonx/src/branch/dragonx/COPYING).\r\n\r\nCopyright © 2024-2026 The DragonX Developers\r\n","url":"https://git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/15","html_url":"https://git.dragonx.is/DragonX/dragonx/releases/tag/v1.0.2","tarball_url":"https://git.dragonx.is/DragonX/dragonx/archive/v1.0.2.tar.gz","zipball_url":"https://git.dragonx.is/DragonX/dragonx/archive/v1.0.2.zip","upload_url":"https://git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/15/assets","draft":false,"prerelease":false,"created_at":"2026-03-19T10:09:18-05:00","published_at":"2026-03-19T10:09:18-05:00","author":{"id":1,"login":"DanS","login_name":"","source_id":0,"full_name":"","email":"dans@noreply.localhost","avatar_url":"https://git.dragonx.is/avatars/ac3f843e96162174082a0b0e2b02b4d894ea793139c2e181e5971483e233a946","html_url":"https://git.dragonx.is/DanS","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2026-02-27T13:12:08-06:00","restricted":false,"active":false,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":1,"username":"DanS"},"assets":[{"id":53,"name":"dragonx-1.0.2-linux-amd64.zip","size":61792264,"download_count":98,"created_at":"2026-03-19T10:21:03-05:00","uuid":"ff53b136-e128-438b-82d8-76b9f33b830a","browser_download_url":"https://git.dragonx.is/DragonX/dragonx/releases/download/v1.0.2/dragonx-1.0.2-linux-amd64.zip"},{"id":52,"name":"dragonx-1.0.2-macos.zip","size":59382241,"download_count":35,"created_at":"2026-03-19T10:21:03-05:00","uuid":"aefc7f79-ae87-4da9-a168-8b731166c080","browser_download_url":"https://git.dragonx.is/DragonX/dragonx/releases/download/v1.0.2/dragonx-1.0.2-macos.zip"},{"id":54,"name":"dragonx-1.0.2-win64.zip","size":62453466,"download_count":150,"created_at":"2026-03-19T10:21:06-05:00","uuid":"ab44576e-e354-4019-8f5a-a5536257e854","browser_download_url":"https://git.dragonx.is/DragonX/dragonx/releases/download/v1.0.2/dragonx-1.0.2-win64.zip"}]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,7 @@
#include "util/payment_uri.h"
#include "util/platform.h"
#include "util/xmrig_updater.h"
#include "util/daemon_updater.h"
#include "util/lite_server_probe.h"
#include "wallet/lite_connection_service.h"
#include "wallet/lite_diagnostics.h"
@@ -4604,6 +4605,190 @@ void testXmrigSignatureVerify()
EXPECT_FALSE(verifyXmrigSignature(data, "", pkB64));
}
// Reads any fixture file by repo-relative path under tests/fixtures/.
static std::string readFixtureFile(const std::string& rel)
{
std::ifstream f(std::string(DRAGONX_TEST_FIXTURE_DIR) + "/" + rel, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
}
void testXmrigReleaseListParsing()
{
using namespace dragonx::util;
const auto list = parseXmrigReleaseList(readFixtureFile("xmrig/releases_list.json"));
EXPECT_TRUE(list.size() >= 2);
EXPECT_EQ(list.front().tag, std::string("v6.25.3")); // newest first (Gitea order preserved)
for (const auto& r : list) {
EXPECT_TRUE(r.ok);
EXPECT_TRUE(!r.tag.empty());
EXPECT_TRUE(!r.assets.empty());
}
// Fail closed on non-array / garbage.
EXPECT_TRUE(parseXmrigReleaseList("not json").empty());
EXPECT_TRUE(parseXmrigReleaseList("{}").empty());
// Drafts are skipped; the pre-release flag is captured.
const std::string inj = R"([
{"tag_name":"v9.9.9","prerelease":true,"assets":[{"name":"a-linux-x64.zip","browser_download_url":"https://x/a","size":1}]},
{"tag_name":"v9.9.8","draft":true,"assets":[]}
])";
const auto injList = parseXmrigReleaseList(inj);
EXPECT_EQ(injList.size(), static_cast<std::size_t>(1)); // draft dropped
EXPECT_EQ(injList.front().tag, std::string("v9.9.9"));
EXPECT_TRUE(injList.front().prerelease);
}
// ── daemon updater pure core (util/daemon_updater.h) — driven by the real v1.0.2 release fixture ──
static std::string readDaemonFixture()
{
return readFixtureFile("daemon/release_latest.json");
}
void testDaemonReleaseParsing()
{
using namespace dragonx::util;
const auto rel = parseDaemonRelease(readDaemonFixture());
EXPECT_TRUE(rel.ok);
EXPECT_EQ(rel.tag, std::string("v1.0.2"));
EXPECT_EQ(rel.assets.size(), static_cast<std::size_t>(3)); // linux-amd64 / macos / win64
for (const auto& a : rel.assets) {
EXPECT_TRUE(!a.name.empty());
EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0);
EXPECT_TRUE(a.size > 0);
}
EXPECT_FALSE(parseDaemonRelease("not json at all").ok);
EXPECT_FALSE(parseDaemonRelease("{}").ok); // no tag_name
}
void testDaemonAssetSelection()
{
using namespace dragonx::util;
const auto rel = parseDaemonRelease(readDaemonFixture());
const int lin = selectDaemonAsset(rel, "linux-amd64");
const int mac = selectDaemonAsset(rel, "macos");
const int win = selectDaemonAsset(rel, "win64");
EXPECT_TRUE(lin >= 0 && mac >= 0 && win >= 0);
EXPECT_TRUE(lin != mac && mac != win && lin != win);
EXPECT_TRUE(rel.assets[lin].name.find("linux-amd64.zip") != std::string::npos);
EXPECT_TRUE(rel.assets[mac].name.find("macos.zip") != std::string::npos);
EXPECT_TRUE(rel.assets[win].name.find("win64.zip") != std::string::npos);
// Wrong/foreign tokens (e.g. the miner's naming) must NOT match the daemon archives.
EXPECT_EQ(selectDaemonAsset(rel, "linux-x64"), -1);
EXPECT_EQ(selectDaemonAsset(rel, "linux-arm64"), -1);
EXPECT_EQ(selectDaemonAsset(rel, ""), -1);
}
void testDaemonChecksumParsing()
{
using namespace dragonx::util;
const auto rel = parseDaemonRelease(readDaemonFixture());
const auto sums = parseDaemonChecksums(rel.body);
// The markdown checksum table ( "| <archive>.zip | `<sha256>` |" ) is parsed by archive name.
EXPECT_EQ(sums.at("dragonx-1.0.2-linux-amd64.zip"),
std::string("85f1dd908bfbdee6aaebabdc74848dc8963d45e7510172d84d0230c0fad6cc16"));
EXPECT_EQ(sums.at("dragonx-1.0.2-macos.zip"),
std::string("102e1f1ecab05def25465ad4867b19d46c7cc00a9fbb018ffb405bcb82cc6432"));
EXPECT_EQ(sums.at("dragonx-1.0.2-win64.zip"),
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
// Header/separator/prose rows are ignored.
EXPECT_TRUE(parseDaemonChecksums("| File | SHA-256 |\n|---|---|\njust prose, no hashes").empty());
// Keys are lowercased so the (also-lowercased) lookup is case-insensitive vs the JSON names.
const auto mixed = parseDaemonChecksums(
"| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"),
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
}
void testDaemonBasenamesAndVersionCore()
{
using namespace dragonx::util;
const auto posix = daemonExtractBasenames("linux-amd64");
EXPECT_EQ(posix.size(), static_cast<std::size_t>(3));
EXPECT_EQ(posix.front(), std::string("dragonxd")); // daemon binary is always first
EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-cli")) != posix.end());
EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-tx")) != posix.end());
const auto win = daemonExtractBasenames("win64");
EXPECT_EQ(win.front(), std::string("dragonxd.exe"));
EXPECT_TRUE(std::find(win.begin(), win.end(), std::string("dragonx-cli.exe")) != win.end());
// Version-core normalization: a scanned "vX.Y.Z-<commit>" must compare equal to a release tag.
EXPECT_EQ(daemonVersionCore("v1.0.2-ddd851dc1"), std::string("v1.0.2"));
EXPECT_EQ(daemonVersionCore("v1.0.2"), std::string("v1.0.2"));
EXPECT_EQ(daemonVersionCore("1.0.2"), std::string("1.0.2"));
EXPECT_EQ(daemonVersionCore("DragonX v1.0.2 (abc)"), std::string("v1.0.2"));
EXPECT_EQ(daemonVersionCore("v1.2"), std::string("v1.2")); // not full N.N.N -> unchanged
EXPECT_EQ(daemonVersionCore(""), std::string(""));
EXPECT_EQ(daemonVersionCore("nightly"), std::string("nightly"));
}
void testDaemonSignatureAssetSelection()
{
using namespace dragonx::util;
DaemonRelease rel; rel.ok = true; rel.tag = "v1.0.2";
rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip", "https://x/zip", 100});
EXPECT_EQ(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip"), -1); // none published
rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip.sig", "https://x/sig", 64});
EXPECT_TRUE(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip") >= 0);
EXPECT_EQ(selectDaemonSignatureAsset(rel, "other.zip"), -1);
}
void testDaemonPinnedKeyValidity()
{
using namespace dragonx::util;
// The daemon updater REQUIRES signatures, so the pinned key must be a valid 32-byte ed25519 key.
const std::string pin = kDaemonSignaturePublicKeyBase64;
EXPECT_TRUE(kDaemonRequireSignature);
EXPECT_TRUE(!pin.empty());
EXPECT_TRUE(sodium_init() >= 0);
unsigned char pk[crypto_sign_PUBLICKEYBYTES];
std::size_t n = 0; const char* end = nullptr;
const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n",
&n, &end, sodium_base64_VARIANT_ORIGINAL);
EXPECT_EQ(rc, 0);
EXPECT_EQ(n, static_cast<std::size_t>(crypto_sign_PUBLICKEYBYTES));
}
void testDaemonSignatureInterop()
{
using namespace dragonx::util;
// Known answer produced by scripts/sign-daemon-release.sh (OpenSSL ed25519) over the message
// below with the pinned key's secret half. Proves the pinned key + the release-signing flow
// produce signatures the wallet's libsodium verifier accepts (closes the OpenSSL<->libsodium
// interop loop), and that the pinned public key actually matches the signing key.
const std::string msg = "dragonx daemon archive payload bytes for signing test";
const std::string sigB64 =
"rmQ5qOw+W8vu56GeZrooD7Wh1N/WHRP4siD19Mxq/8WXQQuNrFY3DPCNU9C7jHB2jg/VfKrLVna57K/lkSDBDA==";
const std::string pin = kDaemonSignaturePublicKeyBase64;
EXPECT_TRUE(verifyXmrigSignature(msg, sigB64, pin));
EXPECT_TRUE(verifyXmrigSignature(msg, " " + sigB64 + "\n", pin)); // trailing newline tolerated
// Fails closed on tampered payload or wrong key.
EXPECT_FALSE(verifyXmrigSignature(msg + "x", sigB64, pin));
EXPECT_FALSE(verifyXmrigSignature(msg, sigB64, std::string(kXmrigSignaturePublicKeyBase64)));
}
void testDaemonReleaseListParsing()
{
using namespace dragonx::util;
const auto list = parseDaemonReleaseList(readFixtureFile("daemon/releases_list.json"));
EXPECT_TRUE(list.size() >= 2);
EXPECT_EQ(list.front().tag, std::string("v1.0.2")); // newest first
for (const auto& r : list) {
EXPECT_TRUE(r.ok);
EXPECT_TRUE(!r.assets.empty());
}
EXPECT_TRUE(parseDaemonReleaseList("not json").empty());
EXPECT_TRUE(parseDaemonReleaseList("{}").empty());
// Draft skipped; pre-release + name + published_at captured.
const std::string inj = R"([
{"tag_name":"v2.0.0","prerelease":true,"name":"RC","published_at":"2026-09-01T00:00:00Z","assets":[{"name":"dragonx-2.0.0-linux-amd64.zip","browser_download_url":"https://x/a","size":1}]},
{"tag_name":"v1.9.9","draft":true,"assets":[]}
])";
const auto injList = parseDaemonReleaseList(inj);
EXPECT_EQ(injList.size(), static_cast<std::size_t>(1));
EXPECT_TRUE(injList.front().prerelease);
EXPECT_EQ(injList.front().name, std::string("RC"));
EXPECT_EQ(injList.front().publishedAt.substr(0, 10), std::string("2026-09-01"));
}
// Live end-to-end exercise of the XmrigUpdater WORKER (real network + curl + miniz). Env-gated so
// CI / offline runs skip it; run with DRAGONX_TEST_NETWORK=1 to hit git.dragonx.is. Verifies the
// full download -> archive-checksum -> extract/flatten -> inner-binary-checksum -> install path.
@@ -4707,6 +4892,15 @@ int main()
testXmrigSignatureAssetSelection();
testXmrigPinnedKeyValidity();
testXmrigSignatureVerify();
testXmrigReleaseListParsing();
testDaemonReleaseParsing();
testDaemonAssetSelection();
testDaemonChecksumParsing();
testDaemonBasenamesAndVersionCore();
testDaemonSignatureAssetSelection();
testDaemonPinnedKeyValidity();
testDaemonSignatureInterop();
testDaemonReleaseListParsing();
testLiteServerHostParsing();
testLiteOfficialServerDetection();
testAtomicFileWrite();

View File

@@ -0,0 +1,2 @@
[net]
git-fetch-with-cli = true

View File

@@ -0,0 +1,2 @@
/target/

2751
third_party/silentdragonxlite/lib/Cargo.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
[package]
name = "qtlib"
version = "0.1.0"
authors = ["zecwallet", "The Hush Developers"]
edition = "2018"
[lib]
name = "silentdragonxlite"
crate-type = ["staticlib"]
[dependencies]
libc = "0.2.58"
lazy_static = "1.4.0"
blake3 = "0.3.4"
silentdragonxlitelib = { path = "../silentdragonxlite-cli/lib" }
socket2 = "0.3.11"

View File

@@ -0,0 +1,28 @@
ifeq ($(shell uname),Darwin)
EXT := dylib
CFLAGS := "-mmacosx-version-min=10.11"
else
EXT := a
CFLAGS :=
endif
PWD := $(shell pwd)
all: release
winrelease: target/x86_64-pc-windows-gnu/release/silentdragonxlite.lib
target/x86_64-pc-windows-gnu/release/silentdragonxlite.lib: src/lib.rs Cargo.toml
SODIUM_LIB_DIR="$(PWD)/libsodium-mingw/" cargo build --lib --release --target x86_64-pc-windows-gnu
release: target/release/silentdragonxlite.$(EXT)
debug: target/debug/silentdragonxlite.$(EXT)
target/release/silentdragonxlite.$(EXT): src/lib.rs Cargo.toml
LIBS="" CFLAGS=$(CFLAGS) cargo build --lib --release
target/debug/silentdragonxlite.$(EXT): src/lib.rs Cargo.toml
LIBS="" CFLAGS=$(CFLAGS) cargo build --lib
clean:
rm -rf target

Binary file not shown.

View File

@@ -0,0 +1,29 @@
#ifndef _hush_PAPER_RUST_H
#define _hush_PAPER_RUST_H
#ifdef __cplusplus
extern "C" {
#endif
extern bool litelib_wallet_exists (const char* chain_name);
extern char * litelib_initialize_new (bool dangerous, const char* server);
extern char * litelib_initialize_new_from_phrase
(bool dangerous, const char* server, const char* seed,
unsigned long long birthday, unsigned long long number,
bool overwrite);
extern char * litelib_initialize_existing (bool dangerous,const char* server);
extern char * litelib_execute (const char* s, const char* args);
extern void litelib_rust_free_string (char* s);
extern char * blake3_PW (char* pw);
extern bool litelib_check_server_online (const char* server);
extern void litelib_shutdown (void);
#ifdef __cplusplus
}
#endif
// This is a function implemented in connection.cpp that will process a string response from
// the litelib and turn into into a QString in a memory-safe way.
QString litelib_process_response(char* resp);
#endif

View File

@@ -0,0 +1,328 @@
#[macro_use]
extern crate lazy_static;
use libc::{c_char};
use std::ffi::{CStr, CString};
use std::sync::{Mutex, Arc};
use std::cell::RefCell;
use std::ptr;
use std::panic;
use silentdragonxlitelib::{commands, lightclient::{LightClient, LightClientConfig}};
/// Helper to create a CString, replacing null bytes to avoid panics
fn safe_cstring(s: &str) -> CString {
let cleaned: String = s.replace('\0', "");
CString::new(cleaned).unwrap_or_else(|_| CString::new("Error: failed to create CString").unwrap())
}
/// Helper to create an error CString
fn error_cstring(msg: &str) -> *mut c_char {
safe_cstring(&format!("Error: {}", msg)).into_raw()
}
// We'll use a MUTEX to store a global lightclient instance,
// so we don't have to keep creating it. We need to store it here, in rust
// because we can't return such a complex structure back to C++
lazy_static! {
static ref LIGHTCLIENT: Mutex<RefCell<Option<Arc<LightClient>>>> = Mutex::new(RefCell::new(None));
}
// Check if there is an existing wallet
#[no_mangle]
pub extern fn litelib_wallet_exists(chain_name: *const c_char) -> bool {
let chain_name_str = unsafe {
assert!(!chain_name.is_null());
CStr::from_ptr(chain_name).to_string_lossy().into_owned()
};
let config = LightClientConfig::create_unconnected(chain_name_str, None);
println!("Wallet exists: {}", config.wallet_exists());
config.wallet_exists()
}
//////hash blake3
#[no_mangle]
pub extern fn blake3_PW(pw: *const c_char) -> *mut c_char{
let passwd = unsafe {
assert!(!pw.is_null());
CStr::from_ptr(pw).to_string_lossy().into_owned()
};
let data = passwd.as_bytes();
// Hash an input all at once.
let hash1 = blake3::hash(data).to_hex();
// This is sensitive metadata, do not log it to stdout
//println!("\nBlake3 Hash: {}", hash1);
println!("\nBlake3 Hash calculated");
//let sttring = CString::new(hash1).unwrap();
let e_str = CString::new(format!("{}", hash1)).unwrap();
return e_str.into_raw();
}
/// Create a new wallet and return the seed for the newly created wallet.
#[no_mangle]
pub extern fn litelib_initialize_new(dangerous: bool,server: *const c_char) -> *mut c_char {
let server_str = unsafe {
assert!(!server.is_null());
CStr::from_ptr(server).to_string_lossy().into_owned()
};
let server = LightClientConfig::get_server_or_default(Some(server_str));
let (config, latest_block_height) = match LightClientConfig::create(server, dangerous) {
Ok((c, h)) => (c, h),
Err(e) => {
let e_str = CString::new(format!("Error: {}", e)).unwrap();
return e_str.into_raw();
}
};
let lightclient = match LightClient::new(&config, latest_block_height) {
Ok(l) => l,
Err(e) => {
let e_str = CString::new(format!("Error: {}", e)).unwrap();
return e_str.into_raw();
}
};
// Initialize logging
let _ = lightclient.init_logging();
let seed = match lightclient.do_seed_phrase() {
Ok(s) => s.dump(),
Err(e) => {
let e_str = CString::new(format!("Error: {}", e)).unwrap();
return e_str.into_raw();
}
};
let lc = Arc::new(lightclient);
match LightClient::start_mempool_monitor(lc.clone()) {
Ok(_) => {println!("Starting Mempool")},
Err(e) => {
println!("Couldnt start mempool {}", e)
}
}
match LIGHTCLIENT.lock() {
Ok(l) => { l.replace(Some(lc)); },
Err(poisoned) => { poisoned.into_inner().replace(Some(lc)); },
};
// Return the wallet's seed
let s_str = safe_cstring(&seed);
return s_str.into_raw();
}
/// Restore a wallet from the seed phrase
#[no_mangle]
pub extern "C" fn litelib_initialize_new_from_phrase(dangerous: bool, server: *const c_char,
seed: *const c_char, birthday: u64, number: u64, overwrite: bool) -> *mut c_char {
if server.is_null() || seed.is_null() {
println!("Server or seed is null");
return ptr::null_mut();
}
let server_str = unsafe {
CStr::from_ptr(server).to_string_lossy().into_owned()
};
let seed_str = unsafe {
CStr::from_ptr(seed).to_string_lossy().into_owned()
};
//println!("Initializing with server: {}, seed: {}", server_str, seed_str);
// Shut down the existing client if one is running, to stop background threads
if overwrite {
let old_lc = match LIGHTCLIENT.lock() {
Ok(l) => l.borrow().clone(),
Err(poisoned) => poisoned.into_inner().borrow().clone(),
};
if let Some(lc) = old_lc {
lc.shutdown();
}
}
let server = LightClientConfig::get_server_or_default(Some(server_str));
let (config, _latest_block_height) = match LightClientConfig::create(server, dangerous) {
Ok((c, h)) => {
println!("Config created successfully");
(c, h)
},
Err(e) => {
println!("Error creating config: {}", e);
let e_str = CString::new(format!("Error: {}", e)).unwrap_or_else(|_| CString::new("Error creating CString").unwrap());
return e_str.into_raw();
}
};
let lightclient = match LightClient::new_from_phrase(seed_str, &config, birthday, number, overwrite) {
Ok(l) => {
println!("LightClient created successfully");
l
},
Err(e) => {
println!("Error creating LightClient: {}", e);
let e_str = CString::new(format!("Error: {}", e)).unwrap_or_else(|_| CString::new("Error creating CString").unwrap());
return e_str.into_raw();
}
};
// Initialize logging
let _ = lightclient.init_logging();
let lc = Arc::new(lightclient);
match LightClient::start_mempool_monitor(lc.clone()) {
Ok(_) => println!("Starting Mempool"),
Err(e) => println!("Could not start mempool: {}", e)
}
match LIGHTCLIENT.lock() {
Ok(l) => { l.replace(Some(lc)); },
Err(poisoned) => { poisoned.into_inner().replace(Some(lc)); },
};
let c_str = safe_cstring("OK");
return c_str.into_raw();
}
// Initialize a new lightclient and store its value
#[no_mangle]
pub extern fn litelib_initialize_existing(dangerous: bool, server: *const c_char) -> *mut c_char {
let server_str = unsafe {
assert!(!server.is_null());
CStr::from_ptr(server).to_string_lossy().into_owned()
};
let server = LightClientConfig::get_server_or_default(Some(server_str));
let (config, _latest_block_height) = match LightClientConfig::create(server,dangerous) {
Ok((c, h)) => (c, h),
Err(e) => {
let e_str = CString::new(format!("Error: {}", e)).unwrap();
return e_str.into_raw();
}
};
let lightclient = match LightClient::read_from_disk(&config) {
Ok(l) => l,
Err(e) => {
let e_str = CString::new(format!("Error: {}", e)).unwrap();
return e_str.into_raw();
}
};
// Initialize logging
let _ = lightclient.init_logging();
let lc = Arc::new(lightclient);
match LightClient::start_mempool_monitor(lc.clone()) {
Ok(_) => {println!("Starting Mempool")},
Err(e) => {
println!("Couldnt start mempool {}",e)
}
}
match LIGHTCLIENT.lock() {
Ok(l) => { l.replace(Some(lc)); },
Err(poisoned) => { poisoned.into_inner().replace(Some(lc)); },
};
let c_str = safe_cstring("OK");
return c_str.into_raw();
}
#[no_mangle]
pub extern fn litelib_execute(cmd: *const c_char, args: *const c_char) -> *mut c_char {
let result = panic::catch_unwind(|| {
let cmd_str = unsafe {
assert!(!cmd.is_null());
CStr::from_ptr(cmd).to_string_lossy().into_owned()
};
let arg_str = unsafe {
assert!(!args.is_null());
CStr::from_ptr(args).to_string_lossy().into_owned()
};
let resp: String;
{
let lightclient: Arc<LightClient>;
{
let lc = match LIGHTCLIENT.lock() {
Ok(l) => l,
Err(poisoned) => poisoned.into_inner(),
};
if lc.borrow().is_none() {
return error_cstring("Light Client is not initialized");
}
lightclient = lc.borrow().as_ref().unwrap().clone();
};
let args = if arg_str.is_empty() { vec![] } else { vec![arg_str.as_ref()] };
resp = commands::do_user_command(&cmd_str, &args, lightclient.as_ref()).clone();
};
safe_cstring(&resp).into_raw()
});
match result {
Ok(ptr) => ptr,
Err(_) => error_cstring("Rust panic in litelib_execute"),
}
}
// Check is Server Connection is fine
#[no_mangle]
pub extern "C" fn litelib_check_server_online(server: *const c_char) -> bool {
let server_str = unsafe {
assert!(!server.is_null());
CStr::from_ptr(server).to_string_lossy().into_owned()
};
let server = LightClientConfig::get_server_or_default(Some(server_str));
let result = LightClientConfig::create(server, false);
match result {
Ok(_) => true,
Err(_) => false,
}
}
/// Cleanly shut down the light client, stopping mempool monitor threads.
/// Must be called before exit to prevent hangs.
#[no_mangle]
pub extern "C" fn litelib_shutdown() {
let lc_option = match LIGHTCLIENT.lock() {
Ok(l) => l.borrow().clone(),
Err(poisoned) => poisoned.into_inner().borrow().clone(),
};
if let Some(lc) = lc_option {
lc.shutdown();
}
}
/**
* Callers that receive string return values from other functions should call this to return the string
* back to rust, so it can be freed. Failure to call this function will result in a memory leak
*/
#[no_mangle]
pub extern fn litelib_rust_free_string(s: *mut c_char) {
unsafe {
if s.is_null() { return }
CString::from_raw(s)
};
}

View File

@@ -0,0 +1,80 @@
[package]
name = "silentdragonxlitelib"
version = "0.1.0"
edition = "2018"
[features]
default = ["embed_params"]
embed_params = []
[dependencies]
base58 = "0.1.0"
bs58 = { version = "0.2", features = ["check"] }
log = "0.4"
log4rs = "0.8.3"
dirs = "2.0.2"
http = "0.2"
hex = "0.3"
protobuf = "2"
byteorder = "1"
json = "0.12.0"
tiny-bip39 = "0.6.2"
secp256k1 = "=0.15.0"
sha2 = "0.8.0"
ripemd160 = "0.8.0"
lazy_static = "1.2.0"
rust-embed = { version = "5.1.0", features = ["debug-embed"] }
rand = "0.7.2"
sodiumoxide = "0.2.5"
ring = "0.16.9"
libflate = "0.1"
subtle = "2"
threadpool = "1.8.0"
num_cpus = "1.13.0"
tonic = { version = "0.1.1", features = ["tls", "tls-roots"] }
bytes = "0.4"
prost = "0.6"
prost-types = "0.6"
tokio = { version = "0.2", features = ["rt-threaded", "time", "stream", "fs", "macros", "uds", "full"] }
tokio-rustls = { version = "0.12.1", features = ["dangerous_configuration"] }
webpki = "0.21.0"
webpki-roots = "0.18.0"
[dependencies.bellman]
git = "https://git.dragonx.is/DragonX/librustzcash.git"
rev= "acff1444ec373e9c3e37b47ca95bfd358e45255b"
default-features = false
features = ["groth16"]
[dependencies.pairing]
git = "https://git.dragonx.is/DragonX/librustzcash.git"
rev= "acff1444ec373e9c3e37b47ca95bfd358e45255b"
[dependencies.zcash_client_backend]
git = "https://git.dragonx.is/DragonX/librustzcash.git"
rev= "acff1444ec373e9c3e37b47ca95bfd358e45255b"
default-features = false
[dependencies.zcash_primitives]
git = "https://git.dragonx.is/DragonX/librustzcash.git"
rev= "acff1444ec373e9c3e37b47ca95bfd358e45255b"
default-features = false
features = ["transparent-inputs"]
[dependencies.zcash_proofs]
git = "https://git.dragonx.is/DragonX/librustzcash.git"
rev= "acff1444ec373e9c3e37b47ca95bfd358e45255b"
default-features = false
[dependencies.ff]
git = "https://git.dragonx.is/DragonX/librustzcash.git"
rev= "acff1444ec373e9c3e37b47ca95bfd358e45255b"
features = ["ff_derive"]
[build-dependencies]
tonic-build = "0.1.1"
[dev-dependencies]
tempdir = "0.3.7"

View File

@@ -0,0 +1,13 @@
// Copyright The Hush Developers 2019-2022
// Released under the GPLv3
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.build_server(false)
.compile(
&["proto/service.proto", "proto/compact_formats.proto"],
&["proto"],
)?;
println!("cargo:rerun-if-changed=proto/service.proto");
Ok(())
}

View File

@@ -0,0 +1,65 @@
syntax = "proto3";
package cash.z.wallet.sdk.rpc;
option go_package = "lightwalletd/walletrpc";
option swift_prefix = "";
// Remember that proto3 fields are all optional. A field that is not present will be set to its zero value.
// bytes fields of hashes are in canonical little-endian format.
// CompactBlock is a packaging of ONLY the data from a block that's needed to:
// 1. Detect a payment to your shielded Sapling address
// 2. Detect a spend of your shielded Sapling notes
// 3. Update your witnesses to generate new Sapling spend proofs.
message CompactBlock {
uint32 protoVersion = 1; // the version of this wire format, for storage
uint64 height = 2; // the height of this block
bytes hash = 3; // the ID (hash) of this block, same as in block explorers
bytes prevHash = 4; // the ID (hash) of this block's predecessor
uint32 time = 5; // Unix epoch time when the block was mined
bytes header = 6; // (hash, prevHash, and time) OR (full header)
repeated CompactTx vtx = 7; // compact transactions from this block
}
message CompactTx {
// Index and hash will allow the receiver to call out to chain
// explorers or other data structures to retrieve more information
// about this transaction.
uint64 index = 1; // the index within the full block
bytes hash = 2; // the ID (hash) of this transaction, same as in block explorers
// The transaction fee: present if server can provide. In the case of a
// stateless server and a transaction with transparent inputs, this will be
// unset because the calculation requires reference to prior transactions.
// in a pure-Sapling context, the fee will be calculable as:
// valueBalance + (sum(vPubNew) - sum(vPubOld) - sum(tOut))
uint32 fee = 3;
repeated CompactSaplingSpend spends = 4;
repeated CompactSaplingOutput outputs = 5;
}
// CompactSaplingSpend is a Sapling Spend Description as described in 7.3 of the Zcash
// protocol specification.
message CompactSaplingSpend {
bytes nf = 1; // nullifier (see the Zcash protocol specification)
}
// output is a Sapling Output Description as described in section 7.4 of the
// Zcash protocol spec. Total size is 948.
message CompactSaplingOutput {
bytes cmu = 1; // note commitment u-coordinate
bytes epk = 2; // ephemeral public key
bytes ciphertext = 3; // first 52 bytes of ciphertext
}
/*
message CompactSpend {
bytes nf = 1; // nullifier (see the Zcash protocol specification)
}
message CompactOutput {
bytes cmu = 1; // note commitment u-coordinate
bytes epk = 2; // ephemeral public key
bytes ciphertext = 3; // first 52 bytes of ciphertext
}
*/

View File

@@ -0,0 +1,170 @@
syntax = "proto3";
package cash.z.wallet.sdk.rpc;
option go_package = "lightwalletd/walletrpc";
option swift_prefix = "";
import "compact_formats.proto";
// A BlockID message contains identifiers to select a block: a height or a
// hash. If the hash is present it takes precedence.
message BlockID {
uint64 height = 1;
bytes hash = 2;
}
// BlockRange technically allows ranging from hash to hash etc but this is not
// currently intended for support, though there is no reason you couldn't do
// it. Further permutations are left as an exercise.
message BlockRange {
BlockID start = 1;
BlockID end = 2;
}
// A TxFilter contains the information needed to identify a particular
// transaction: either a block and an index, or a direct transaction hash.
message TxFilter {
BlockID block = 1;
uint64 index = 2;
bytes hash = 3;
}
// RawTransaction contains the complete transaction data. It also optionally includes
// the block height in which the transaction was included
message RawTransaction {
bytes data = 1;
uint64 height = 2;
}
message SendResponse {
int32 errorCode = 1;
string errorMessage = 2;
}
// Empty placeholder. Someday we may want to specify e.g. a particular chain fork.
message ChainSpec {}
message Empty {}
message LightdInfo {
string version = 1;
string vendor = 2;
bool taddrSupport = 3;
string chainName = 4;
uint64 saplingActivationHeight = 5;
string consensusBranchId = 6; // This should really be u32 or []byte, but string for readability
uint64 blockHeight = 7;
uint64 difficulty = 8;
uint64 longestchain = 9;
uint64 notarized = 10;
}
message Coinsupply {
string result = 1;
string coin = 2;
uint64 height = 3;
uint64 supply = 4;
uint64 zfunds = 5;
uint64 total = 6;
}
message TransparentAddress {
string address = 1;
}
message TransparentAddressBlockFilter {
string address = 1;
BlockRange range = 2;
}
message Address {
string address = 1;
}
message AddressList {
repeated string addresses = 1;
}
message Balance {
int64 valueZat = 1;
}
message Exclude {
repeated bytes txid = 1;
}
// The TreeState is derived from the Hush getblockmerkletree rpc.
// https://faq.hush.is/rpc/getblockmerkletree.html
message TreeState {
string network = 1; // "main" or "test"
uint64 height = 2; // block height
string hash = 3; // block id
uint32 time = 4; // Unix epoch time when the block was mined
string saplingTree = 5; // sapling commitment tree state
}
// Results are sorted by height, which makes it easy to issue another
// request that picks up from where the previous left off.
message GetAddressUtxosArg {
repeated string addresses = 1;
uint64 startHeight = 2;
uint32 maxEntries = 3; // zero means unlimited
}
message GetAddressUtxosReply {
string address = 6;
bytes txid = 1;
int32 index = 2;
bytes script = 3;
int64 valueZat = 4;
uint64 height = 5;
}
message GetAddressUtxosReplyList {
repeated GetAddressUtxosReply addressUtxos = 1;
}
service CompactTxStreamer {
// Return the height of the tip of the best chain
rpc GetLatestBlock(ChainSpec) returns (BlockID) {}
// Return the compact block corresponding to the given block identifier
rpc GetBlock(BlockID) returns (CompactBlock) {}
// Return a list of consecutive compact blocks
rpc GetBlockRange(BlockRange) returns (stream CompactBlock) {}
// Return the requested full (not compact) transaction (as from zcashd)
rpc GetTransaction(TxFilter) returns (RawTransaction) {}
// Submit the given transaction to the Zcash network
rpc SendTransaction(RawTransaction) returns (SendResponse) {}
// Return the txids corresponding to the given t-address within the given block range
rpc GetTaddressTxids(TransparentAddressBlockFilter) returns (stream RawTransaction) {}
// wrapper for GetTaddressTxids
rpc GetAddressTxids(TransparentAddressBlockFilter) returns (stream RawTransaction) {}
rpc GetTaddressBalance(AddressList) returns (Balance) {}
rpc GetTaddressBalanceStream(stream Address) returns (Balance) {}
// Return the compact transactions currently in the mempool; the results
// can be a few seconds out of date. If the Exclude list is empty, return
// all transactions; otherwise return all *except* those in the Exclude list
// (if any); this allows the client to avoid receiving transactions that it
// already has (from an earlier call to this rpc). The transaction IDs in the
// Exclude list can be shortened to any number of bytes to make the request
// more bandwidth-efficient; if two or more transactions in the mempool
// match a shortened txid, they are all sent (none is excluded). Transactions
// in the exclude list that don't exist in the mempool are ignored.
rpc GetMempoolTx(Exclude) returns (stream CompactTx) {}
// Return a stream of current Mempool transactions. This will keep the output stream open while
// there are mempool transactions. It will close the returned stream when a new block is mined.
rpc GetMempoolStream(Empty) returns (stream RawTransaction) {}
// GetTreeState returns the note commitment tree state corresponding to the given block.
// See section 3.7 of the Zcash protocol specification. It returns several other useful
// values also (even though they can be obtained using GetBlock).
// The block can be specified by either height or hash.
rpc GetTreeState(BlockID) returns (TreeState) {}
rpc GetLatestTreeState(Empty) returns (TreeState) {}
rpc GetAddressUtxos(GetAddressUtxosArg) returns (GetAddressUtxosReplyList) {}
rpc GetAddressUtxosStream(GetAddressUtxosArg) returns (stream GetAddressUtxosReply) {}
// Return information about this lightwalletd instance and the blockchain
rpc GetLightdInfo(Empty) returns (LightdInfo) {}
// Testing-only, requires lightwalletd --ping-very-insecure (do not enable in production)
// rpc Ping(Duration) returns (PingResponse) {}
rpc GetCoinsupply(Empty) returns (Coinsupply) {}
}

View File

@@ -0,0 +1,65 @@
-----BEGIN CERTIFICATE-----
MIIFlTCCA32gAwIBAgIUCU7sjrIbYfA+bc2qWlyUo0dCU7swDQYJKoZIhvcNAQEL
BQAwWjELMAkGA1UEBhMCVVMxEzARBgNVBAgMClNvbWUtU3RhdGUxDTALBgNVBAoM
BEh1c2gxDTALBgNVBAsMBEh1c2gxGDAWBgNVBAMMD2xpdGUubXlodXNoLm9yZzAe
Fw0xOTEyMDIxNDUyMzBaFw0yMDEyMDExNDUyMzBaMFoxCzAJBgNVBAYTAlVTMRMw
EQYDVQQIDApTb21lLVN0YXRlMQ0wCwYDVQQKDARIdXNoMQ0wCwYDVQQLDARIdXNo
MRgwFgYDVQQDDA9saXRlLm15aHVzaC5vcmcwggIiMA0GCSqGSIb3DQEBAQUAA4IC
DwAwggIKAoICAQDhd3SLJuGQ3ivUWle6+Est+qNBghf2vMkcgj9NjaxeMjSMLPVq
Mxt0j/mJe3+z767yXbiBUKnaQyb7OcWtxUN3PusGmqnMAUuy/tdu9h+2ScYKThh2
JHQNdyN1y4c7sFbmntpMIIqm6/v95UXnnStQ+VBlS2/IhLYTgW31DEIiTpyx4jjW
xY+QD2+mqf4sSDm4Yq/r3Cxp6YWufEbhkXiHcF15JPk1d6jzkOkcjCJJCqwRMJ+5
60q31S1W0Ud/L7AqkOhAKFHmORfCXM0ae4Rive/ZgM688KYIXA9kQzA6ZMdD7VIL
4q4IoP1ZjlPhosFoUFB6lHORYp15+Gu43jbC2/SUPWJQbJ1XusjxysqngyJ53/Va
MN/iWhOmqBXjx1SqkyIV4W56GDezxT1MhM5zSSKgEHePyFzkGNYasEeHa1/hZoz+
zKG1oGLlMQe5TtI3AMZMfLz6t8qtRB+k+XW988mHJZ7BYOjW3KvdN16SOYdFF6K+
86MAQ8rNPgcTsnclhmDdjh7+PhQpkF3uqF1EeVTzb03s77Cx6nDc9GCnpXqg8tkE
HnJD0WFIXA29PCjWyebuksMBRahekYDR0kn8O0Km/eFAprH3v4qoSc1JLNJR2G20
eHVDnNFnR6QdwlM9+39NYUhJV43aj28wx1m0FXI+dSklblk4hkbGPPbFjQIDAQAB
o1MwUTAdBgNVHQ4EFgQUo5TCdrNgopYbxQSzSPFSlyCtE6YwHwYDVR0jBBgwFoAU
o5TCdrNgopYbxQSzSPFSlyCtE6YwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B
AQsFAAOCAgEAyPxq4ZyBtKJEQtzmqdkI+28Yw4qDSBE0dj4QQfOErgK5hX29Bk4e
Auh6j0eyuRA8gtwngsE8fAAg84kcH/b+hM2zFW4MqpgjigA7oqA51VkIg+8Q9zdF
IHweV2kuSunZ2ANcGrr7o/Qy5y8D7URWUpUu8J1ZNyLg7YYMtpyjllTYhbfKpAgM
HUX1STCRfSPTgM/JxIsnll8RgacfhUmoOzOsrvvZ39h8cZZo96ksBRL4gvVQ++Hm
gzNTbYQXukR26Xfv112AEj5Xo3z3fsLP1KxZxd2p6/24XYktpZf2J71Np2CONdV4
gFgxFfPwvPyDO5pKice018qlXz0euhvK5g++s+TrSeZwleDTW4spP3TdVXNB96iZ
rrFkTT0SEBtd6iKqeFAX89BpshCUqOlsFdrf10i2dDiKsqxMod6a8i17RrtZL3q7
S0nqCsnyc1QvfKIQi08vfMZHHMbSS0Cg/5H8ISexM/R4SQc6C//IhEd5hUJ7E9AC
Sepr0xu1JBrglech4N+brHpGZK3Crofzu+hV+qruY5Wg21bD99zigxrgi3YaQPlA
6TJVsGk68h00QSy+Ri9dyuvyPnIDyMQfZLLIKCwsxznYXLtmIp+UoA3otGTijhmc
ZxFdEhd3cJrSPwihb/IvQJICFp3ya1aI7dLsaZO9h9kPc8GGLi7tiq8=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFozCCA4ugAwIBAgIUaRW0/q8ERGZUZqv+TVkC5lOwgX4wDQYJKoZIhvcNAQEL
BQAwYTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAlVTMQswCQYDVQQHDAJVUzEPMA0G
A1UECgwGTXlIdXNoMQ0wCwYDVQQLDARIVVNIMRgwFgYDVQQDDA9saXRlLm15aHVz
aC5vcmcwHhcNMjAwMTAzMjAzMTAwWhcNMjEwMTAyMjAzMTAwWjBhMQswCQYDVQQG
EwJVUzELMAkGA1UECAwCVVMxCzAJBgNVBAcMAlVTMQ8wDQYDVQQKDAZNeUh1c2gx
DTALBgNVBAsMBEhVU0gxGDAWBgNVBAMMD2xpdGUubXlodXNoLm9yZzCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBAL/oJh+MSxGOedvEthiqdfMEDfjzJ7AW
wwvuBtVULvnIvJq0WTMK8INjkMqnj+/ZE9a1aOOSpwN+9CuknhiySTdqHGl6EigM
/S19ZdN/7aC881VrMCncguHUOa8HLq5F4R0YCGis1Cor9vXf0GVFJ2mDfPB0C8iz
C+gMGnkDwuBy51fUcWbxZ5diRYh7YlOIUxpmb24On+X7sw+7nbmV12x8v644xRKJ
MazPIVrxIwXZ3tjz2NR7IMu+SrtCgMAW56M63NJ+iZBYDRoFVMRGEfAgGaFV2Hqi
Dx7q89sO2bhVg2lBBOW7403S+T8eK1rGFj7iGJoRLm/cgWwozZteXynHzicYEvX6
w1h0lu/OPQQk5AKRn+iI1kKLlT1auKIBXpfnpELnie3XgzzLyKt0pobVrWdMutlJ
83Zo7LmnhJYlcG7Qb0UczSyaIn+3dWo2HTbiyzJD23gmUbzFD4AVF1Ee4x5yT4Hb
Aw0FpQXDHX2MT04xleM2HdjE86ruZfNCegvQdRtgKRAiVNe4kP5ZzB7OeDX6pgeE
/e07tiHiFb2Im7J/IR4PmIh6SuYI/QObFXFXfvwCk/iJCps15/PryGfXZLdz+2z1
av1nwSglBRqeRX8HUBIgNcY0Lyq82BKfq4ZU3fKiIDuNV16OxCnFGZRw+ByRNrKR
KsgaJEi7qH6DAgMBAAGjUzBRMB0GA1UdDgQWBBRd+rxIkdKrpLsgz5/KiqZOYzUB
KDAfBgNVHSMEGDAWgBRd+rxIkdKrpLsgz5/KiqZOYzUBKDAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBR9M0CnCvT1Zd5D/Wwy9ylH6CSFq6AEbdh
fMo8+NZl4J0FNji2Iv05xh+V3f+eyuf6oc8q0vsKeC5MZj/44AzqzxCSvMrUnh0V
GAiZQqLAVJIR/fi49bX9ku1yfQVJKzGFS93TcHMv9XYHJ4bSnQKlEOF5F6Wh9AO1
6GU/+vb0pSOfOv5+pUaj84AYCKQ8kp9nuNpOS12jyjc5hUogflPFAmeIpcIGjiso
Ln8+b+Xh0BZGNpdrvJ/wr8InxblMu5chtYrGAGo4mh4q3YWiwYJTkTIhoeTxF4eU
BOqMJa9lQgZE4T7V33jrIsuMPEZfACpj+gQNNzl8WQ+jzkZhBdYPTqhO9u1rlRXG
9VJfmuQ7+KLXAMFQgsHlX5Y5lux3CV36Knb5+1f/u0cdys1yb9mbQ/Ok3T8cuh6B
7Hs53JhAW47+CCnsNTaPzwti3wfzWhS2sjHz4IT1NcacsuDlxk7IykJ2U3auiufE
lRFpZoK81jsipEgRPBeF6OesXpldKxK9lnVJA/6ElApuo0amgg++fROQEpZSLyBM
lZdYrW6ZKnzCUZpNuwNHg1nfiit3RJ6hRLsk2jHrLDb5BVWzuJCNa7LK9bZ3IbUa
5jGb9Rplo+NgglQQYCflptksti9h5DN+GlVGxfJ9yzkg4/4ckmh75colcc1CTQAf
eER5vAF7og==
-----END CERTIFICATE-----

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,363 @@
// Copyright The Hush Developers 2019-2022
// Released under the GPLv3
use log::{info,error};
use std::sync::Arc;
use zcash_primitives::transaction::{TxId};
use crate::grpc_client::{ChainSpec, BlockId, BlockRange, RawTransaction, CompactBlock,
TransparentAddressBlockFilter, TxFilter, Empty, LightdInfo, Coinsupply};
use tonic::transport::{Channel, ClientTlsConfig};
use tokio_rustls::{rustls::ClientConfig};
use tonic::{Request};
use threadpool::ThreadPool;
use std::sync::mpsc::channel;
use crate::PubCertificate;
use crate::grpc_client::compact_tx_streamer_client::CompactTxStreamerClient;
mod danger {
use tokio_rustls::rustls;
use webpki;
pub struct NoCertificateVerification {}
impl rustls::ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(&self,
_roots: &rustls::RootCertStore,
_presented_certs: &[rustls::Certificate],
_dns_name: webpki::DNSNameRef<'_>,
_ocsp: &[u8]) -> Result<rustls::ServerCertVerified, rustls::TLSError> {
Ok(rustls::ServerCertVerified::assertion())
}
}
}
async fn get_client(uri: &http::Uri, no_cert: bool) -> Result<CompactTxStreamerClient<Channel>, Box<dyn std::error::Error>> {
let channel = if uri.scheme_str() == Some("http") {
Channel::builder(uri.clone()).connect().await?
} else {
let mut config = ClientConfig::new();
config.alpn_protocols.push(b"h2".to_vec());
config.root_store.add_server_trust_anchors(&webpki_roots::TLS_SERVER_ROOTS);
config.root_store.add_pem_file(
&mut PubCertificate::get("lightwalletd-lite.myhush.pem").unwrap().as_ref()).unwrap();
if no_cert {
config.dangerous()
.set_certificate_verifier(Arc::new(danger::NoCertificateVerification {}));
}
let tls = ClientTlsConfig::new()
.rustls_client_config(config)
.domain_name(uri.host().unwrap());
Channel::builder(uri.clone())
.tls_config(tls)
.connect()
.await?
};
Ok(CompactTxStreamerClient::new(channel))
}
// ==============
// GRPC code
// ==============
async fn get_lightd_info(uri: &http::Uri, no_cert: bool) -> Result<LightdInfo, Box<dyn std::error::Error>> {
let mut client = get_client(uri, no_cert).await?;
let request = Request::new(Empty {});
let response = client.get_lightd_info(request).await?;
Ok(response.into_inner())
}
pub fn get_info(uri: &http::Uri, no_cert: bool) -> Result<LightdInfo, String> {
let mut rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
rt.block_on(get_lightd_info(uri, no_cert)).map_err( |e| e.to_string())
}
async fn get_coinsupply_info(uri: &http::Uri, no_cert: bool) -> Result<Coinsupply, Box<dyn std::error::Error>> {
let mut client = get_client(uri, no_cert).await?;
let request = Request::new(Empty {});
let response = client.get_coinsupply(request).await?;
Ok(response.into_inner())
}
pub fn get_coinsupply(uri: http::Uri, no_cert: bool) -> Result<Coinsupply, String> {
let mut rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
rt.block_on(get_coinsupply_info(&uri, no_cert)).map_err( |e| e.to_string())
}
async fn get_block_range<F : 'static + std::marker::Send>(
uri: &http::Uri,
start_height: u64,
end_height: u64,
no_cert: bool,
pool: ThreadPool,
c: F
) -> Result<(), Box<dyn std::error::Error>>
where F : Fn(&[u8], u64) {
let mut client = get_client(uri, no_cert).await?;
let bs = BlockId { height: start_height, hash: vec![] };
let be = BlockId { height: end_height, hash: vec![] };
let request = Request::new(BlockRange { start: Some(bs), end: Some(be) });
let (tx, rx) = channel::<Option<CompactBlock>>();
let (ftx, frx) = channel();
pool.execute(move || {
while let Ok(Some(block)) = rx.recv() {
use prost::Message;
let mut encoded_buf = vec![];
if let Err(e) = block.encode(&mut encoded_buf) {
error!("Error encoding block: {:?}", e);
break;
}
c(&encoded_buf, block.height);
}
if let Err(e) = ftx.send(Ok(())) {
error!("Error sending completion signal: {:?}", e);
}
});
let mut response = client.get_block_range(request).await?.into_inner();
while let Some(block) = response.message().await? {
if let Err(e) = tx.send(Some(block)) {
error!("Error sending block to channel: {:?}", e);
break;
}
}
if let Err(e) = tx.send(None) {
error!("Error sending end signal to channel: {:?}", e);
}
frx.iter().take(1).collect::<Result<Vec<()>, String>>()?;
Ok(())
}
pub fn fetch_blocks<F : 'static + std::marker::Send>(uri: &http::Uri, start_height: u64, end_height: u64, no_cert: bool, pool: ThreadPool, c: F) -> Result<(), String>
where F : Fn(&[u8], u64) {
let mut rt = tokio::runtime::Runtime::new().map_err(|e| format!("Error creating runtime {:?}", e))?;
fetch_blocks_with_runtime(&mut rt, uri, start_height, end_height, no_cert, pool, c)
}
pub fn fetch_blocks_with_runtime<F : 'static + std::marker::Send>(rt: &mut tokio::runtime::Runtime, uri: &http::Uri, start_height: u64, end_height: u64, no_cert: bool, pool: ThreadPool, c: F) -> Result<(), String>
where F : Fn(&[u8], u64) {
match rt.block_on(get_block_range(uri, start_height, end_height, no_cert, pool, c)) {
Ok(o) => Ok(o),
Err(e) => {
let e = format!("Error fetching blocks {:?}", e);
error!("{}", e);
Err(e)
}
}
}
// get_address_txids GRPC call
async fn get_address_txids<F : 'static + std::marker::Send>(
uri: &http::Uri,
address: String,
start_height: u64,
end_height: u64,
no_cert: bool,
c: F
) -> Result<(), Box<dyn std::error::Error>>
where F : Fn(&[u8], u64) {
let mut client = match get_client(uri, no_cert).await {
Ok(client) => client,
Err(e) => {
error!("Error creating client: {:?}", e);
return Err(e.into());
}
};
let start = Some(BlockId{ height: start_height, hash: vec!()});
let end = Some(BlockId{ height: end_height, hash: vec!()});
let request = Request::new(TransparentAddressBlockFilter{ address, range: Some(BlockRange{ start, end }) });
let maybe_response = match client.get_address_txids(request).await {
Ok(response) => response,
Err(e) => {
error!("Error getting address txids: {:?}", e);
return Err(e.into());
}
};
let mut response = maybe_response.into_inner();
while let Some(tx) = response.message().await? {
c(&tx.data, tx.height);
}
Ok(())
}
// function to monitor mempool transactions
pub async fn monitor_mempool<F: 'static + std::marker::Send>(
uri: &http::Uri,
no_cert: bool,
mut c: F
) -> Result<(), Box<dyn std::error::Error>>
where
F: FnMut(RawTransaction) -> Result<(), Box<dyn std::error::Error>>,
{
let mut client = get_client(uri, no_cert)
.await
.map_err(|e| format!("Error getting client: {:?}", e))?;
let request = Request::new(Empty {});
let mut response = client
.get_mempool_stream(request)
.await
.map_err(|e| format!("{}", e))?
.into_inner();
while let Ok(Some(rtx)) = response.message().await {
if let Err(e) = c(rtx) {
info!("Error processing RawTransaction: {:?}", e);
}
}
Ok(())
}
pub fn fetch_transparent_txids<F : 'static + std::marker::Send>(
uri: &http::Uri,
address: String,
start_height: u64,
end_height: u64,
no_cert: bool,
c: F
) -> Result<(), String>
where F : Fn(&[u8], u64) {
let mut rt = match tokio::runtime::Runtime::new() {
Ok(r) => r,
Err(e) => {
let e = format!("Error creating runtime {:?}", e);
error!("{}", e);
return Err(e);
}
};
match rt.block_on(get_address_txids(uri, address.clone(), start_height, end_height, no_cert, c)) {
Ok(o) => Ok(o),
Err(e) => {
let e = format!("Error with get_address_txids runtime {:?}", e);
error!("{}", e);
return Err(e)
}
}
}
// get_transaction GRPC call
async fn get_transaction(uri: &http::Uri, txid: TxId, no_cert: bool)
-> Result<RawTransaction, Box<dyn std::error::Error>> {
let mut client = get_client(uri, no_cert).await?;
let request = Request::new(TxFilter { block: None, index: 0, hash: txid.0.to_vec() });
let response = client.get_transaction(request).await?;
Ok(response.into_inner())
}
pub fn fetch_full_tx(uri: &http::Uri, txid: TxId, no_cert: bool) -> Result<Vec<u8>, String> {
let mut rt = match tokio::runtime::Runtime::new() {
Ok(r) => r,
Err(e) => {
let errstr = format!("Error creating runtime {}", e.to_string());
error!("{}", errstr);
return Err(errstr);
}
};
match rt.block_on(get_transaction(uri, txid, no_cert)) {
Ok(rawtx) => Ok(rawtx.data.to_vec()),
Err(e) => {
let errstr = format!("Error in get_transaction runtime {}", e.to_string());
error!("{}", errstr);
Err(errstr)
}
}
}
// send_transaction GRPC call
async fn send_transaction(uri: &http::Uri, no_cert: bool, tx_bytes: Box<[u8]>) -> Result<String, Box<dyn std::error::Error>> {
let mut client = get_client(uri, no_cert).await?;
let request = Request::new(RawTransaction {data: tx_bytes.to_vec(), height: 0});
let response = client.send_transaction(request).await?;
let sendresponse = response.into_inner();
if sendresponse.error_code == 0 {
let mut txid = sendresponse.error_message;
if txid.starts_with("\"") && txid.ends_with("\"") {
txid = txid[1..txid.len()-1].to_string();
}
Ok(txid)
} else {
Err(Box::from(format!("Error: {:?}", sendresponse)))
}
}
pub fn broadcast_raw_tx(uri: &http::Uri, no_cert: bool, tx_bytes: Box<[u8]>) -> Result<String, String> {
let mut rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
rt.block_on(send_transaction(uri, no_cert, tx_bytes)).map_err( |e| e.to_string())
}
// get_latest_block GRPC call
async fn get_latest_block(uri: &http::Uri, no_cert: bool) -> Result<BlockId, Box<dyn std::error::Error>> {
let mut client = get_client(uri, no_cert).await?;
let request = Request::new(ChainSpec {});
let response = client.get_latest_block(request).await?;
Ok(response.into_inner())
}
pub fn fetch_latest_block(uri: &http::Uri, no_cert: bool) -> Result<BlockId, String> {
let mut rt = match tokio::runtime::Runtime::new() {
Ok(r) => r,
Err(e) => {
let errstr = format!("Error creating runtime {}", e.to_string());
error!("{}", errstr);
return Err(errstr);
}
};
rt.block_on(get_latest_block(uri, no_cert)).map_err(|e| {
let errstr = format!("Error getting latest block {}", e.to_string());
error!("{}", errstr);
errstr
})
}

View File

@@ -0,0 +1,29 @@
// Copyright The Hush Developers 2019-2022
// Released under the GPLv3
#[macro_use]
extern crate rust_embed;
pub mod lightclient;
pub mod grpcconnector;
pub mod lightwallet;
pub mod commands;
#[cfg(feature = "embed_params")]
#[derive(RustEmbed)]
#[folder = "zcash-params/"]
pub struct SaplingParams;
#[derive(RustEmbed)]
#[folder = "res/"]
pub struct PubCertificate;
// Anchor depth back from the tip for shielded spends. MUST be > 0: with 0 the wallet anchors to
// the absolute chain tip, which races the node committing that anchor and intermittently triggers
// "bad-txns-shielded-requirements-not-met" (missing sapling anchor) on broadcast. 4 matches upstream
// zecwallet-lite and uses a confirmed, reorg-stable anchor.
pub const ANCHOR_OFFSET: u32 = 4;
pub mod grpc_client {
tonic::include_proto!("cash.z.wallet.sdk.rpc");
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
//! Structs for handling supported address types.
use pairing::bls12_381::Bls12;
use zcash_primitives::primitives::PaymentAddress;
use zcash_client_backend::encoding::{decode_payment_address, decode_transparent_address};
use zcash_primitives::legacy::TransparentAddress;
/// An address that funds can be sent to.
pub enum RecipientAddress {
Shielded(PaymentAddress<Bls12>),
Transparent(TransparentAddress),
}
impl From<PaymentAddress<Bls12>> for RecipientAddress {
fn from(addr: PaymentAddress<Bls12>) -> Self {
RecipientAddress::Shielded(addr)
}
}
impl From<TransparentAddress> for RecipientAddress {
fn from(addr: TransparentAddress) -> Self {
RecipientAddress::Transparent(addr)
}
}
impl RecipientAddress {
pub fn from_str(s: &str, hrp_sapling_address: &str, b58_pubkey_address: [u8; 1], b58_script_address: [u8; 1]) -> Option<Self> {
// Try to match a sapling z address
if let Some(pa) = match decode_payment_address(hrp_sapling_address, s) {
Ok(ret) => ret,
Err(_) => None
}
{
Some(RecipientAddress::Shielded(pa)) // Matched a shielded address
} else if let Some(addr) = match decode_transparent_address(
&b58_pubkey_address, &b58_script_address, s) {
Ok(ret) => ret,
Err(_) => None
}
{
Some(RecipientAddress::Transparent(addr)) // Matched a transparent address
} else {
None // Didn't match anything
}
}
}

View File

@@ -0,0 +1,558 @@
use std::io::{self, Read, Write};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use pairing::bls12_381::{Bls12};
use ff::{PrimeField, PrimeFieldRepr};
use zcash_primitives::{
block::BlockHash,
merkle_tree::{CommitmentTree, IncrementalWitness},
sapling::Node,
serialize::{Vector, Optional},
transaction::{
components::{OutPoint},
TxId,
},
note_encryption::{Memo,},
zip32::{ExtendedFullViewingKey,},
JUBJUB,
primitives::{Diversifier, Note,},
jubjub::{
JubjubEngine,
fs::{Fs, FsRepr},
}
};
use zcash_primitives::zip32::ExtendedSpendingKey;
pub struct BlockData {
pub height: i32,
pub hash: BlockHash,
pub tree: CommitmentTree<Node>,
}
impl BlockData {
pub fn read<R: Read>(mut reader: R) -> io::Result<Self> {
let height = reader.read_i32::<LittleEndian>()?;
let mut hash_bytes = [0; 32];
reader.read_exact(&mut hash_bytes)?;
let tree = CommitmentTree::<Node>::read(&mut reader)?;
let endtag = reader.read_u64::<LittleEndian>()?;
if endtag != 11 {
println!("End tag for blockdata {}", endtag);
}
Ok(BlockData{
height,
hash: BlockHash{ 0: hash_bytes },
tree
})
}
pub fn write<W: Write>(&self, mut writer: W) -> io::Result<()> {
writer.write_i32::<LittleEndian>(self.height)?;
writer.write_all(&self.hash.0)?;
self.tree.write(&mut writer)?;
writer.write_u64::<LittleEndian>(11)
}
}
pub struct SaplingNoteData {
pub(super) account: usize,
pub(super) extfvk: ExtendedFullViewingKey, // Technically, this should be recoverable from the account number, but we're going to refactor this in the future, so I'll write it again here.
pub diversifier: Diversifier,
pub note: Note<Bls12>,
pub(super) witnesses: Vec<IncrementalWitness<Node>>,
pub(super) nullifier: [u8; 32],
pub spent: Option<TxId>, // If this note was confirmed spent
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
pub memo: Option<Memo>,
pub is_change: bool,
// TODO: We need to remove the unconfirmed_spent (i.e., set it to None) if the Tx has expired
}
/// Reads an FsRepr from [u8] of length 32
/// This will panic (abort) if length provided is
/// not correct
/// TODO: This is duplicate from rustzcash.rs
fn read_fs(from: &[u8]) -> FsRepr {
assert_eq!(from.len(), 32);
let mut f = <<Bls12 as JubjubEngine>::Fs as PrimeField>::Repr::default();
f.read_le(from).expect("length is 32 bytes");
f
}
// Reading a note also needs the corresponding address to read from.
pub fn read_note<R: Read>(mut reader: R) -> io::Result<(u64, Fs)> {
let value = reader.read_u64::<LittleEndian>()?;
let mut r_bytes: [u8; 32] = [0; 32];
reader.read_exact(&mut r_bytes)?;
let r = match Fs::from_repr(read_fs(&r_bytes)) {
Ok(r) => r,
Err(_) => return Err(io::Error::new(
io::ErrorKind::InvalidInput, "Couldn't parse randomness"))
};
Ok((value, r))
}
impl SaplingNoteData {
fn serialized_version() -> u64 {
1
}
pub fn new(
extfvk: &ExtendedFullViewingKey,
output: zcash_client_backend::wallet::WalletShieldedOutput
) -> Self {
let witness = output.witness;
let nf = {
let mut nf = [0; 32];
nf.copy_from_slice(
&output
.note
.nf(&extfvk.fvk.vk, witness.position() as u64, &JUBJUB),
);
nf
};
SaplingNoteData {
account: output.account,
extfvk: extfvk.clone(),
diversifier: output.to.diversifier,
note: output.note,
witnesses: vec![witness],
nullifier: nf,
spent: None,
unconfirmed_spent: None,
memo: None,
is_change: output.is_change,
}
}
// Reading a note also needs the corresponding address to read from.
pub fn read<R: Read>(mut reader: R) -> io::Result<Self> {
let _version = reader.read_u64::<LittleEndian>()?;
let account = reader.read_u64::<LittleEndian>()? as usize;
let extfvk = ExtendedFullViewingKey::read(&mut reader)?;
let mut diversifier_bytes = [0u8; 11];
reader.read_exact(&mut diversifier_bytes)?;
let diversifier = Diversifier{0: diversifier_bytes};
// To recover the note, read the value and r, and then use the payment address
// to recreate the note
let (value, r) = read_note(&mut reader)?; // TODO: This method is in a different package, because of some fields that are private
let maybe_note = extfvk.fvk.vk.into_payment_address(diversifier, &JUBJUB).unwrap().create_note(value, r, &JUBJUB);
let note = match maybe_note {
Some(n) => Ok(n),
None => Err(io::Error::new(io::ErrorKind::InvalidInput, "Couldn't create the note for the address"))
}?;
let witnesses = Vector::read(&mut reader, |r| IncrementalWitness::<Node>::read(r))?;
let mut nullifier = [0u8; 32];
reader.read_exact(&mut nullifier)?;
// Note that this is only the spent field, we ignore the unconfirmed_spent field.
// The reason is that unconfirmed spents are only in memory, and we need to get the actual value of spent
// from the blockchain anyway.
let spent = Optional::read(&mut reader, |r| {
let mut txid_bytes = [0u8; 32];
r.read_exact(&mut txid_bytes)?;
Ok(TxId{0: txid_bytes})
})?;
let memo = Optional::read(&mut reader, |r| {
let mut memo_bytes = [0u8; 512];
r.read_exact(&mut memo_bytes)?;
match Memo::from_bytes(&memo_bytes) {
Some(m) => Ok(m),
None => Err(io::Error::new(io::ErrorKind::InvalidInput, "Couldn't create the memo"))
}
})?;
let is_change: bool = reader.read_u8()? > 0;
Ok(SaplingNoteData {
account,
extfvk,
diversifier,
note,
witnesses,
nullifier,
spent,
unconfirmed_spent: None,
memo,
is_change,
})
}
pub fn write<W: Write>(&self, mut writer: W) -> io::Result<()> {
// Write a version number first, so we can later upgrade this if needed.
writer.write_u64::<LittleEndian>(SaplingNoteData::serialized_version())?;
writer.write_u64::<LittleEndian>(self.account as u64)?;
self.extfvk.write(&mut writer)?;
writer.write_all(&self.diversifier.0)?;
// Writing the note means writing the note.value and note.r. The Note is recoverable
// from these 2 values and the Payment address.
writer.write_u64::<LittleEndian>(self.note.value)?;
let mut rcm = [0; 32];
self.note.r.into_repr().write_le(&mut rcm[..])?;
writer.write_all(&rcm)?;
Vector::write(&mut writer, &self.witnesses, |wr, wi| wi.write(wr) )?;
writer.write_all(&self.nullifier)?;
Optional::write(&mut writer, &self.spent, |w, t| w.write_all(&t.0))?;
Optional::write(&mut writer, &self.memo, |w, m| w.write_all(m.as_bytes()))?;
writer.write_u8(if self.is_change {1} else {0})?;
// Note that we don't write the unconfirmed_spent field, because if the wallet is restarted,
// we don't want to be beholden to any expired txns
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct Utxo {
pub address: String,
pub txid: TxId,
pub output_index: u64,
pub script: Vec<u8>,
pub value: u64,
pub height: i32,
pub spent: Option<TxId>, // If this utxo was confirmed spent
pub unconfirmed_spent: Option<TxId>, // If this utxo was spent in a send, but has not yet been confirmed.
}
impl Utxo {
pub fn serialized_version() -> u64 {
return 1;
}
pub fn to_outpoint(&self) -> OutPoint {
OutPoint { hash: self.txid.0, n: self.output_index as u32 }
}
pub fn read<R: Read>(mut reader: R) -> io::Result<Self> {
let version = reader.read_u64::<LittleEndian>()?;
assert_eq!(version, Utxo::serialized_version());
let address_len = reader.read_i32::<LittleEndian>()?;
let mut address_bytes = vec![0; address_len as usize];
reader.read_exact(&mut address_bytes)?;
let address = String::from_utf8(address_bytes).unwrap();
assert_eq!(address.chars().take(1).collect::<Vec<char>>()[0], 'R');
let mut txid_bytes = [0; 32];
reader.read_exact(&mut txid_bytes)?;
let txid = TxId { 0: txid_bytes };
let output_index = reader.read_u64::<LittleEndian>()?;
let value = reader.read_u64::<LittleEndian>()?;
let height = reader.read_i32::<LittleEndian>()?;
let script = Vector::read(&mut reader, |r| {
let mut byte = [0; 1];
r.read_exact(&mut byte)?;
Ok(byte[0])
})?;
let spent = Optional::read(&mut reader, |r| {
let mut txbytes = [0u8; 32];
r.read_exact(&mut txbytes)?;
Ok(TxId{0: txbytes})
})?;
// Note that we don't write the unconfirmed spent field, because if the wallet is restarted, we'll reset any unconfirmed stuff.
Ok(Utxo {
address,
txid,
output_index,
script,
value,
height,
spent,
unconfirmed_spent: None::<TxId>,
})
}
pub fn write<W: Write>(&self, mut writer: W) -> io::Result<()> {
writer.write_u64::<LittleEndian>(Utxo::serialized_version())?;
writer.write_u32::<LittleEndian>(self.address.as_bytes().len() as u32)?;
writer.write_all(self.address.as_bytes())?;
writer.write_all(&self.txid.0)?;
writer.write_u64::<LittleEndian>(self.output_index)?;
writer.write_u64::<LittleEndian>(self.value)?;
writer.write_i32::<LittleEndian>(self.height)?;
Vector::write(&mut writer, &self.script, |w, b| w.write_all(&[*b]))?;
Optional::write(&mut writer, &self.spent, |w, txid| w.write_all(&txid.0))?;
// Note that we don't write the unconfirmed spent field, because if the wallet is restarted, we'll reset any unconfirmed stuff.
Ok(())
}
}
pub struct OutgoingTxMetadata {
pub address: String,
pub value : u64,
pub memo : Memo,
}
impl OutgoingTxMetadata {
pub fn read<R: Read>(mut reader: R) -> io::Result<Self> {
let address_len = reader.read_u64::<LittleEndian>()?;
let mut address_bytes = vec![0; address_len as usize];
reader.read_exact(&mut address_bytes)?;
let address = String::from_utf8(address_bytes).unwrap();
let value = reader.read_u64::<LittleEndian>()?;
let mut memo_bytes = [0u8; 512];
reader.read_exact(&mut memo_bytes)?;
let memo = Memo::from_bytes(&memo_bytes).unwrap();
Ok(OutgoingTxMetadata{
address,
value,
memo,
})
}
pub fn write<W: Write>(&self, mut writer: W) -> io::Result<()> {
// Strings are written as len + utf8
writer.write_u64::<LittleEndian>(self.address.as_bytes().len() as u64)?;
writer.write_all(self.address.as_bytes())?;
writer.write_u64::<LittleEndian>(self.value)?;
writer.write_all(self.memo.as_bytes())
}
}
#[derive(Debug)]
pub struct IncomingTxMetadata {
pub address: String,
pub value : u64,
pub memo : Memo,
pub incoming_mempool: bool,
}
impl IncomingTxMetadata {
pub fn read<R: Read>(mut reader: R) -> io::Result<Self> {
let address_len = reader.read_u64::<LittleEndian>()?;
let mut address_bytes = vec![0; address_len as usize];
reader.read_exact(&mut address_bytes)?;
let address = String::from_utf8(address_bytes).unwrap();
let value = reader.read_u64::<LittleEndian>()?;
let incoming_mempool = true;
// let position = 0;
let mut memo_bytes = [0u8; 512];
reader.read_exact(&mut memo_bytes)?;
let memo = Memo::from_bytes(&memo_bytes).unwrap();
Ok(IncomingTxMetadata{
address,
value,
memo,
incoming_mempool,
// position,
})
}
pub fn write<W: Write>(&self, mut writer: W) -> io::Result<()> {
// Strings are written as len + utf8
writer.write_u64::<LittleEndian>(self.address.as_bytes().len() as u64)?;
writer.write_all(self.address.as_bytes())?;
writer.write_u64::<LittleEndian>(self.value)?;
writer.write_all(self.memo.as_bytes())
}
}
pub struct WalletTx {
// Block in which this tx was included
pub block: i32,
// Timestamp of Tx. Added in v4
pub datetime: u64,
// Txid of this transaction. It's duplicated here (It is also the Key in the HashMap that points to this
// WalletTx in LightWallet::txs)
pub txid: TxId,
// List of all notes received in this tx. Some of these might be change notes.
pub notes: Vec<SaplingNoteData>,
// List of all Utxos received in this Tx. Some of these might be change notes
pub utxos: Vec<Utxo>,
// Total shielded value spent in this Tx. Note that this is the value of the wallet's notes spent.
// Some change may be returned in one of the notes above. Subtract the two to get the actual value spent.
// Also note that even after subtraction, you might need to account for transparent inputs and outputs
// to make sure the value is accurate.
pub total_shielded_value_spent: u64,
// Total amount of transparent funds that belong to us that were spent in this Tx.
pub total_transparent_value_spent : u64,
// All outgoing sapling sends to addresses outside this wallet
pub outgoing_metadata: Vec<OutgoingTxMetadata>,
pub incoming_metadata: Vec<IncomingTxMetadata>,
// Whether this TxID was downloaded from the server and scanned for Memos
pub full_tx_scanned: bool,
}
impl WalletTx {
pub fn serialized_version() -> u64 {
return 5;
}
pub fn new(height: i32, datetime: u64, txid: &TxId) -> Self {
WalletTx {
block: height,
datetime,
txid: txid.clone(),
notes: vec![],
utxos: vec![],
total_shielded_value_spent: 0,
total_transparent_value_spent: 0,
outgoing_metadata: vec![],
incoming_metadata: vec![],
full_tx_scanned: false,
}
}
pub fn read<R: Read>(mut reader: R) -> io::Result<Self> {
let version = reader.read_u64::<LittleEndian>()?;
assert!(version <= WalletTx::serialized_version(), "Version mismatch. Please restore with your Seed");
let block = reader.read_i32::<LittleEndian>()?;
let datetime = if version >= 4 {
reader.read_u64::<LittleEndian>()?
} else {
0
};
let mut txid_bytes = [0u8; 32];
reader.read_exact(&mut txid_bytes)?;
let txid = TxId{0: txid_bytes};
let notes = Vector::read(&mut reader, |r| SaplingNoteData::read(r))?;
let utxos = Vector::read(&mut reader, |r| Utxo::read(r))?;
let total_shielded_value_spent = reader.read_u64::<LittleEndian>()?;
let total_transparent_value_spent = reader.read_u64::<LittleEndian>()?;
let outgoing_metadata = Vector::read(&mut reader, |r| OutgoingTxMetadata::read(r))?;
// Read incoming_metadata only if version is 5 or higher
let incoming_metadata = if version >= 5 {
Vector::read(&mut reader, |r| IncomingTxMetadata::read(r))?
} else {
vec![]
};
let full_tx_scanned = reader.read_u8()? > 0;
Ok(WalletTx {
block,
datetime,
txid,
notes,
utxos,
total_shielded_value_spent,
total_transparent_value_spent,
outgoing_metadata,
incoming_metadata,
full_tx_scanned
})
}
pub fn write<W: Write>(&self, mut writer: W) -> io::Result<()> {
writer.write_u64::<LittleEndian>(WalletTx::serialized_version())?;
writer.write_i32::<LittleEndian>(self.block)?;
writer.write_u64::<LittleEndian>(self.datetime)?;
writer.write_all(&self.txid.0)?;
Vector::write(&mut writer, &self.notes, |w, nd| nd.write(w))?;
Vector::write(&mut writer, &self.utxos, |w, u| u.write(w))?;
writer.write_u64::<LittleEndian>(self.total_shielded_value_spent)?;
writer.write_u64::<LittleEndian>(self.total_transparent_value_spent)?;
// Write the outgoing metadata
Vector::write(&mut writer, &self.outgoing_metadata, |w, om| om.write(w))?;
Vector::write(&mut writer, &self.incoming_metadata, |w, om| om.write(w))?;
writer.write_u8(if self.full_tx_scanned {1} else {0})?;
Ok(())
}
}
pub struct SpendableNote {
pub txid: TxId,
pub nullifier: [u8; 32],
pub diversifier: Diversifier,
pub note: Note<Bls12>,
pub witness: IncrementalWitness<Node>,
pub extsk: ExtendedSpendingKey,
}
impl SpendableNote {
pub fn from(txid: TxId, nd: &SaplingNoteData, anchor_offset: usize, extsk: &Option<ExtendedSpendingKey>) -> Option<Self> {
// Include only notes that haven't been spent, or haven't been included in an unconfirmed spend yet.
if nd.spent.is_none() && nd.unconfirmed_spent.is_none() && extsk.is_some() &&
nd.witnesses.len() >= (anchor_offset + 1) {
let witness = nd.witnesses.get(nd.witnesses.len() - anchor_offset - 1);
witness.map(|w| SpendableNote {
txid,
nullifier: nd.nullifier,
diversifier: nd.diversifier,
note: nd.note.clone(),
witness: w.clone(),
extsk: extsk.clone().unwrap(),
})
} else {
None
}
}
}

View File

@@ -0,0 +1,126 @@
use ring::{
hmac::{self, Context, Key},
};
use lazy_static::lazy_static;
use secp256k1::{PublicKey, Secp256k1, SecretKey, SignOnly, VerifyOnly, Error};
lazy_static! {
static ref SECP256K1_SIGN_ONLY: Secp256k1<SignOnly> = Secp256k1::signing_only();
static ref SECP256K1_VERIFY_ONLY: Secp256k1<VerifyOnly> = Secp256k1::verification_only();
}
/// Random entropy, part of extended key.
type ChainCode = Vec<u8>;
const HARDENED_KEY_START_INDEX: u32 = 2_147_483_648; // 2 ** 31
/// KeyIndex indicates the key type and index of a child key.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum KeyIndex {
/// Normal key, index range is from 0 to 2 ** 31 - 1
Normal(u32),
/// Hardened key, index range is from 2 ** 31 to 2 ** 32 - 1
Hardened(u32),
}
impl KeyIndex {
/// Check index range.
pub fn is_valid(self) -> bool {
match self {
KeyIndex::Normal(i) => i < HARDENED_KEY_START_INDEX,
KeyIndex::Hardened(i) => i >= HARDENED_KEY_START_INDEX,
}
}
/// Generate Hardened KeyIndex from normalize index value.
pub fn hardened_from_normalize_index(i: u32) -> Result<KeyIndex, Error> {
if i < HARDENED_KEY_START_INDEX {
Ok(KeyIndex::Hardened(HARDENED_KEY_START_INDEX + i))
} else {
Ok(KeyIndex::Hardened(i))
}
}
/// Generate KeyIndex from raw index value.
pub fn from_index(i: u32) -> Result<Self, Error> {
if i < HARDENED_KEY_START_INDEX {
Ok(KeyIndex::Normal(i))
} else {
Ok(KeyIndex::Hardened(i))
}
}
}
impl From<u32> for KeyIndex {
fn from(index: u32) -> Self {
KeyIndex::from_index(index).expect("KeyIndex")
}
}
/// ExtendedPrivKey is used for child key derivation.
/// See [secp256k1 crate documentation](https://docs.rs/secp256k1) for SecretKey signatures usage.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtendedPrivKey {
pub private_key: SecretKey,
pub chain_code: ChainCode,
}
impl ExtendedPrivKey {
/// Generate an ExtendedPrivKey from seed
pub fn with_seed(seed: &[u8]) -> Result<ExtendedPrivKey, Error> {
let signature = {
let signing_key = Key::new(hmac::HMAC_SHA512, b"Bitcoin seed");
let mut h = Context::with_key(&signing_key);
h.update(&seed);
h.sign()
};
let sig_bytes = signature.as_ref();
let (key, chain_code) = sig_bytes.split_at(sig_bytes.len() / 2);
let private_key = SecretKey::from_slice(key)?;
Ok(ExtendedPrivKey {
private_key,
chain_code: chain_code.to_vec(),
})
}
fn sign_hardended_key(&self, index: u32) -> ring::hmac::Tag {
let signing_key = Key::new(hmac::HMAC_SHA512, &self.chain_code);
let mut h = Context::with_key(&signing_key);
h.update(&[0x00]);
h.update(&self.private_key[..]);
h.update(&index.to_be_bytes());
h.sign()
}
fn sign_normal_key(&self, index: u32) -> ring::hmac::Tag {
let signing_key = Key::new(hmac::HMAC_SHA512, &self.chain_code);
let mut h = Context::with_key(&signing_key);
let public_key = PublicKey::from_secret_key(&SECP256K1_SIGN_ONLY, &self.private_key);
h.update(&public_key.serialize());
h.update(&index.to_be_bytes());
h.sign()
}
/// Derive a child key from ExtendedPrivKey.
pub fn derive_private_key(&self, key_index: KeyIndex) -> Result<ExtendedPrivKey, Error> {
if !key_index.is_valid() {
return Err(Error::InvalidTweak);
}
let signature = match key_index {
KeyIndex::Hardened(index) => self.sign_hardended_key(index),
KeyIndex::Normal(index) => self.sign_normal_key(index),
};
let sig_bytes = signature.as_ref();
let (key, chain_code) = sig_bytes.split_at(sig_bytes.len() / 2);
let mut private_key = SecretKey::from_slice(key)?;
private_key.add_assign(&self.private_key[..])?;
Ok(ExtendedPrivKey {
private_key,
chain_code: chain_code.to_vec(),
})
}
}

View File

@@ -0,0 +1,123 @@
//! Abstractions over the proving system and parameters for ease of use.
use bellman::groth16::{prepare_verifying_key, Parameters, PreparedVerifyingKey};
use pairing::bls12_381::{Bls12, Fr};
use zcash_primitives::{
jubjub::{edwards, fs::Fs, Unknown},
primitives::{Diversifier, PaymentAddress, ProofGenerationKey},
redjubjub::{PublicKey, Signature},
transaction::components::Amount
};
use zcash_primitives::{
merkle_tree::CommitmentTreeWitness, prover::TxProver, sapling::Node,
transaction::components::GROTH_PROOF_SIZE, JUBJUB,
};
use zcash_proofs::sapling::SaplingProvingContext;
/// An implementation of [`TxProver`] using Sapling Spend and Output parameters provided
/// in-memory.
pub struct InMemTxProver {
spend_params: Parameters<Bls12>,
spend_vk: PreparedVerifyingKey<Bls12>,
output_params: Parameters<Bls12>,
}
impl InMemTxProver {
pub fn new(spend_params: &[u8], output_params: &[u8]) -> Self {
// Deserialize params
let spend_params = Parameters::<Bls12>::read(spend_params, false)
.expect("couldn't deserialize Sapling spend parameters file");
let output_params = Parameters::<Bls12>::read(output_params, false)
.expect("couldn't deserialize Sapling spend parameters file");
// Prepare verifying keys
let spend_vk = prepare_verifying_key(&spend_params.vk);
InMemTxProver {
spend_params,
spend_vk,
output_params,
}
}
}
impl TxProver for InMemTxProver {
type SaplingProvingContext = SaplingProvingContext;
fn new_sapling_proving_context(&self) -> Self::SaplingProvingContext {
SaplingProvingContext::new()
}
fn spend_proof(
&self,
ctx: &mut Self::SaplingProvingContext,
proof_generation_key: ProofGenerationKey<Bls12>,
diversifier: Diversifier,
rcm: Fs,
ar: Fs,
value: u64,
anchor: Fr,
witness: CommitmentTreeWitness<Node>,
) -> Result<
(
[u8; GROTH_PROOF_SIZE],
edwards::Point<Bls12, Unknown>,
PublicKey<Bls12>,
),
(),
> {
let (proof, cv, rk) = ctx.spend_proof(
proof_generation_key,
diversifier,
rcm,
ar,
value,
anchor,
witness,
&self.spend_params,
&self.spend_vk,
&JUBJUB,
)?;
let mut zkproof = [0u8; GROTH_PROOF_SIZE];
proof
.write(&mut zkproof[..])
.expect("should be able to serialize a proof");
Ok((zkproof, cv, rk))
}
fn output_proof(
&self,
ctx: &mut Self::SaplingProvingContext,
esk: Fs,
payment_address: PaymentAddress<Bls12>,
rcm: Fs,
value: u64,
) -> ([u8; GROTH_PROOF_SIZE], edwards::Point<Bls12, Unknown>) {
let (proof, cv) = ctx.output_proof(
esk,
payment_address,
rcm,
value,
&self.output_params,
&JUBJUB,
);
let mut zkproof = [0u8; GROTH_PROOF_SIZE];
proof
.write(&mut zkproof[..])
.expect("should be able to serialize a proof");
(zkproof, cv)
}
fn binding_sig(
&self,
ctx: &mut Self::SaplingProvingContext,
value_balance: Amount,
sighash: &[u8; 32],
) -> Result<Signature, ()> {
ctx.binding_sig(value_balance, sighash, &JUBJUB)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
use std::io::{self, Read, Write};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
pub fn read_string<R: Read>(mut reader: R) -> io::Result<String> {
// Strings are written as <littleendian> len + bytes
let str_len = reader.read_u64::<LittleEndian>()?;
let mut str_bytes = vec![0; str_len as usize];
reader.read_exact(&mut str_bytes)?;
let str = String::from_utf8(str_bytes).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
})?;
Ok(str)
}
pub fn write_string<W: Write>(mut writer: W, s: &String) -> io::Result<()> {
// Strings are written as len + utf8
writer.write_u64::<LittleEndian>(s.as_bytes().len() as u64)?;
writer.write_all(s.as_bytes())
}

View File

@@ -0,0 +1,585 @@
use std::io::{self, Read, Write};
use std::io::{Error, ErrorKind};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use pairing::bls12_381::{Bls12};
use sodiumoxide::crypto::secretbox;
use zcash_primitives::{
serialize::{Vector, Optional},
zip32::{ExtendedFullViewingKey, ExtendedSpendingKey},
primitives::{PaymentAddress},
};
use crate::lightclient::{LightClientConfig};
use crate::lightwallet::{LightWallet, utils};
#[derive(PartialEq, Debug, Clone)]
pub enum WalletTKeyType {
HdKey = 0,
ImportedKey = 1,
}
// A struct that holds z-address private keys or view keys
#[derive(Clone, Debug, PartialEq)]
pub struct WalletTKey {
pub(super) keytype: WalletTKeyType,
locked: bool,
pub(super) address: String,
pub(super) tkey: Option<secp256k1::SecretKey>,
// If locked, the encrypted key is here
enc_key: Option<Vec<u8>>,
nonce: Option<Vec<u8>>,
}
impl WalletTKey {
pub fn new_hdkey(key: secp256k1::SecretKey, address: String) -> Self {
WalletTKey {
keytype: WalletTKeyType::HdKey,
locked: false,
address,
tkey: Some(key),
enc_key: None,
nonce: None,
}
}
pub fn import_hdkey(key: secp256k1::SecretKey, address: String) -> Self {
WalletTKey {
keytype: WalletTKeyType::ImportedKey,
locked: false,
address,
tkey: Some(key),
enc_key: None,
nonce: None,
}
}
fn serialized_version() -> u8 {
return 1;
}
pub fn read<R: Read>(mut inp: R) -> io::Result<Self> {
let version = inp.read_u8()?;
assert!(version <= Self::serialized_version());
let keytype: WalletTKeyType = match inp.read_u32::<LittleEndian>()? {
0 => Ok(WalletTKeyType::HdKey),
1 => Ok(WalletTKeyType::ImportedKey),
n => Err(io::Error::new(ErrorKind::InvalidInput, format!("Unknown tkey type {}", n)))
}?;
let locked = inp.read_u8()? > 0;
let address = utils::read_string(&mut inp)?;
let tkey = Optional::read(&mut inp, |r| {
let mut tpk_bytes = [0u8; 32];
r.read_exact(&mut tpk_bytes)?;
secp256k1::SecretKey::from_slice(&tpk_bytes).map_err(|e| io::Error::new(ErrorKind::InvalidData, e))
})?;
let enc_key = Optional::read(&mut inp, |r|
Vector::read(r, |r| r.read_u8()))?;
let nonce = Optional::read(&mut inp, |r|
Vector::read(r, |r| r.read_u8()))?;
Ok(WalletTKey {
keytype,
locked,
address,
tkey,
enc_key,
nonce,
})
}
pub fn write<W: Write>(&self, mut out: W) -> io::Result<()> {
out.write_u8(Self::serialized_version())?;
out.write_u32::<LittleEndian>(self.keytype.clone() as u32)?;
out.write_u8(self.locked as u8)?;
utils::write_string(&mut out, &self.address)?;
Optional::write(&mut out, &self.tkey, |w, pk|
w.write_all(&pk[..])
)?;
// Write enc_key
Optional::write(&mut out, &self.enc_key, |o, v|
Vector::write(o, v, |o,n| o.write_u8(*n)))?;
// Write nonce
Optional::write(&mut out, &self.nonce, |o, v|
Vector::write(o, v, |o,n| o.write_u8(*n)))
}
pub fn lock(&mut self) -> io::Result<()> {
// For keys, encrypt the key into enckey
// assert that we have the encrypted key.
if self.enc_key.is_none() {
return Err(Error::new(ErrorKind::InvalidInput, "Can't lock when t-addr private key is not encrypted"));
}
self.tkey = None;
self.locked = true;
Ok(())
}
pub fn unlock(&mut self, key: &secretbox::Key) -> io::Result<()> {
// For imported keys, we need to decrypt from the encrypted key
let nonce = secretbox::Nonce::from_slice(&self.nonce.as_ref().unwrap()).unwrap();
let sk_bytes = match secretbox::open(&self.enc_key.as_ref().unwrap(), &nonce, &key) {
Ok(s) => s,
Err(_) => {return Err(io::Error::new(ErrorKind::InvalidData, "Decryption failed. Is your password correct?"));}
};
self.tkey = Some(secp256k1::SecretKey::from_slice(&sk_bytes[..]).map_err(|e|
io::Error::new(ErrorKind::InvalidData, format!("{}", e))
)?);
self.locked = false;
Ok(())
}
pub fn encrypt(&mut self, key: &secretbox::Key) -> io::Result<()> {
// For keys, encrypt the key into enckey
let nonce = secretbox::gen_nonce();
let sk_bytes = &self.tkey.unwrap()[..];
self.enc_key = Some(secretbox::seal(&sk_bytes, &nonce, &key));
self.nonce = Some(nonce.as_ref().to_vec());
self.tkey = None;
// Also lock after encrypt
self.lock()
}
pub fn remove_encryption(&mut self) -> io::Result<()> {
if self.locked {
return Err(Error::new(ErrorKind::InvalidInput, "Can't remove encryption while locked"));
}
self.enc_key = None;
self.nonce = None;
Ok(())
}
}
#[derive(PartialEq, Debug, Clone)]
pub enum WalletZKeyType {
HdKey = 0,
ImportedSpendingKey = 1,
ImportedViewKey = 2
}
// A struct that holds z-address private keys or view keys
#[derive(Clone, Debug, PartialEq)]
pub struct WalletZKey {
pub(super) keytype: WalletZKeyType,
locked: bool,
pub(super) extsk: Option<ExtendedSpendingKey>,
pub(super) extfvk: ExtendedFullViewingKey,
pub(super) zaddress: PaymentAddress<Bls12>,
// If this is a HD key, what is the key number
pub(super) hdkey_num: Option<u32>,
// If locked, the encrypted private key is stored here
enc_key: Option<Vec<u8>>,
nonce: Option<Vec<u8>>,
}
impl WalletZKey {
pub fn new_hdkey(hdkey_num: u32, extsk: ExtendedSpendingKey) -> Self {
let extfvk = ExtendedFullViewingKey::from(&extsk);
let zaddress = extfvk.default_address().unwrap().1;
WalletZKey {
keytype: WalletZKeyType::HdKey,
locked: false,
extsk: Some(extsk),
extfvk,
zaddress,
hdkey_num: Some(hdkey_num),
enc_key: None,
nonce: None,
}
}
pub fn new_locked_hdkey(hdkey_num: u32, extfvk: ExtendedFullViewingKey) -> Self {
let zaddress = extfvk.default_address().unwrap().1;
WalletZKey {
keytype: WalletZKeyType::HdKey,
locked: true,
extsk: None,
extfvk,
zaddress,
hdkey_num: Some(hdkey_num),
enc_key: None,
nonce: None
}
}
pub fn new_imported_sk(extsk: ExtendedSpendingKey) -> Self {
let extfvk = ExtendedFullViewingKey::from(&extsk);
let zaddress = extfvk.default_address().unwrap().1;
WalletZKey {
keytype: WalletZKeyType::ImportedSpendingKey,
locked: false,
extsk: Some(extsk),
extfvk,
zaddress,
hdkey_num: None,
enc_key: None,
nonce: None,
}
}
pub fn new_imported_viewkey(extfvk: ExtendedFullViewingKey) -> Self {
let zaddress = extfvk.default_address().unwrap().1;
WalletZKey {
keytype: WalletZKeyType::ImportedViewKey,
locked: false,
extsk: None,
extfvk,
zaddress,
hdkey_num: None,
enc_key: None,
nonce: None,
}
}
fn serialized_version() -> u8 {
return 1;
}
pub fn have_spending_key(&self) -> bool {
self.extsk.is_some() || self.enc_key.is_some() || self.hdkey_num.is_some()
}
pub fn read<R: Read>(mut inp: R) -> io::Result<Self> {
let version = inp.read_u8()?;
assert!(version <= Self::serialized_version());
let keytype: WalletZKeyType = match inp.read_u32::<LittleEndian>()? {
0 => Ok(WalletZKeyType::HdKey),
1 => Ok(WalletZKeyType::ImportedSpendingKey),
2 => Ok(WalletZKeyType::ImportedViewKey),
n => Err(io::Error::new(ErrorKind::InvalidInput, format!("Unknown zkey type {}", n)))
}?;
let locked = inp.read_u8()? > 0;
let extsk = Optional::read(&mut inp, |r| ExtendedSpendingKey::read(r))?;
let extfvk = ExtendedFullViewingKey::read(&mut inp)?;
let zaddress = extfvk.default_address().unwrap().1;
let hdkey_num = Optional::read(&mut inp, |r| r.read_u32::<LittleEndian>())?;
let enc_key = Optional::read(&mut inp, |r|
Vector::read(r, |r| r.read_u8()))?;
let nonce = Optional::read(&mut inp, |r|
Vector::read(r, |r| r.read_u8()))?;
Ok(WalletZKey {
keytype,
locked,
extsk,
extfvk,
zaddress,
hdkey_num,
enc_key,
nonce,
})
}
pub fn write<W: Write>(&self, mut out: W) -> io::Result<()> {
out.write_u8(Self::serialized_version())?;
out.write_u32::<LittleEndian>(self.keytype.clone() as u32)?;
out.write_u8(self.locked as u8)?;
Optional::write(&mut out, &self.extsk, |w, sk| ExtendedSpendingKey::write(sk, w))?;
ExtendedFullViewingKey::write(&self.extfvk, &mut out)?;
Optional::write(&mut out, &self.hdkey_num, |o, n| o.write_u32::<LittleEndian>(*n))?;
// Write enc_key
Optional::write(&mut out, &self.enc_key, |o, v|
Vector::write(o, v, |o,n| o.write_u8(*n)))?;
// Write nonce
Optional::write(&mut out, &self.nonce, |o, v|
Vector::write(o, v, |o,n| o.write_u8(*n)))
}
pub fn lock(&mut self) -> io::Result<()> {
match self.keytype {
WalletZKeyType::HdKey => {
// For HD keys, just empty out the keys, since they will be reconstructed from the hdkey_num
self.extsk = None;
self.locked = true;
},
WalletZKeyType::ImportedSpendingKey => {
// For imported keys, encrypt the key into enckey
// assert that we have the encrypted key.
if self.enc_key.is_none() {
return Err(Error::new(ErrorKind::InvalidInput, "Can't lock when imported key is not encrypted"));
}
self.extsk = None;
self.locked = true;
},
WalletZKeyType::ImportedViewKey => {
// For viewing keys, there is nothing to lock, so just return true
self.locked = true;
}
}
Ok(())
}
pub fn unlock(&mut self, config: &LightClientConfig, bip39_seed: &[u8], key: &secretbox::Key) -> io::Result<()> {
match self.keytype {
WalletZKeyType::HdKey => {
let (extsk, extfvk, address) =
LightWallet::get_zaddr_from_bip39seed(&config, &bip39_seed, self.hdkey_num.unwrap());
if address != self.zaddress {
return Err(io::Error::new(ErrorKind::InvalidData,
format!("zaddress mismatch at {}. {:?} vs {:?}", self.hdkey_num.unwrap(), address, self.zaddress)));
}
if extfvk != self.extfvk {
return Err(io::Error::new(ErrorKind::InvalidData,
format!("fvk mismatch at {}. {:?} vs {:?}", self.hdkey_num.unwrap(), extfvk, self.extfvk)));
}
self.extsk = Some(extsk);
},
WalletZKeyType::ImportedSpendingKey => {
// For imported keys, we need to decrypt from the encrypted key
let nonce = secretbox::Nonce::from_slice(&self.nonce.as_ref().unwrap()).unwrap();
let extsk_bytes = match secretbox::open(&self.enc_key.as_ref().unwrap(), &nonce, &key) {
Ok(s) => s,
Err(_) => {return Err(io::Error::new(ErrorKind::InvalidData, "Decryption failed. Is your password correct?"));}
};
self.extsk = Some(ExtendedSpendingKey::read(&extsk_bytes[..])?);
},
WalletZKeyType::ImportedViewKey => {
// Viewing key unlocking is basically a no op
}
};
self.locked = false;
Ok(())
}
pub fn encrypt(&mut self, key: &secretbox::Key) -> io::Result<()> {
match self.keytype {
WalletZKeyType::HdKey => {
// For HD keys, we don't need to do anything, since the hdnum has all the info to recreate this key
},
WalletZKeyType::ImportedSpendingKey => {
// For imported keys, encrypt the key into enckey
let nonce = secretbox::gen_nonce();
let mut sk_bytes = vec![];
self.extsk.as_ref().unwrap().write(&mut sk_bytes)?;
self.enc_key = Some(secretbox::seal(&sk_bytes, &nonce, &key));
self.nonce = Some(nonce.as_ref().to_vec());
},
WalletZKeyType::ImportedViewKey => {
// Encrypting a viewing key is a no-op
}
}
// Also lock after encrypt
self.lock()
}
pub fn remove_encryption(&mut self) -> io::Result<()> {
if self.locked {
return Err(Error::new(ErrorKind::InvalidInput, "Can't remove encryption while locked"));
}
match self.keytype {
WalletZKeyType::HdKey => {
// For HD keys, we don't need to do anything, since the hdnum has all the info to recreate this key
Ok(())
},
WalletZKeyType::ImportedSpendingKey => {
self.enc_key = None;
self.nonce = None;
Ok(())
},
WalletZKeyType::ImportedViewKey => {
// Removing encryption is a no-op for viewing keys
Ok(())
}
}
}
}
#[cfg(test)]
pub mod tests {
use zcash_client_backend::{
encoding::{encode_payment_address, decode_extended_spending_key, decode_extended_full_viewing_key}
};
use sodiumoxide::crypto::secretbox;
use crate::lightclient::LightClientConfig;
use super::WalletZKey;
fn get_config() -> LightClientConfig {
LightClientConfig {
server: "0.0.0.0:0".parse().unwrap(),
chain_name: "main".to_string(),
sapling_activation_height: 0,
consensus_branch_id: "000000".to_string(),
anchor_offset: 0,
data_dir: None,
}
}
#[test]
fn test_serialize() {
let config = get_config();
// Priv Key's address is "zs1fxgluwznkzm52ux7jkf4st5znwzqay8zyz4cydnyegt2rh9uhr9458z0nk62fdsssx0cqhy6lyv"
let privkey = "secret-extended-key-main1q0p44m9zqqqqpqyxfvy5w2vq6ahvxyrwsk2w4h2zleun4cft4llmnsjlv77lhuuknv6x9jgu5g2clf3xq0wz9axxxq8klvv462r5pa32gjuj5uhxnvps6wsrdg6xll05unwks8qpgp4psmvy5e428uxaggn4l29duk82k3sv3njktaaj453fdmfmj2fup8rls4egqxqtj2p5a3yt4070khn99vzxj5ag5qjngc4v2kq0ctl9q2rpc2phu4p3e26egu9w88mchjf83sqgh3cev";
let esk = decode_extended_spending_key(config.hrp_sapling_private_key(), privkey).unwrap().unwrap();
let wzk = WalletZKey::new_imported_sk(esk);
assert_eq!(encode_payment_address(config.hrp_sapling_address(), &wzk.zaddress), "zs1fxgluwznkzm52ux7jkf4st5znwzqay8zyz4cydnyegt2rh9uhr9458z0nk62fdsssx0cqhy6lyv".to_string());
let mut v: Vec<u8> = vec![];
// Serialize
wzk.write(&mut v).unwrap();
// Read it right back
let wzk2 = WalletZKey::read(&v[..]).unwrap();
{
assert_eq!(wzk, wzk2);
assert_eq!(wzk.extsk, wzk2.extsk);
assert_eq!(wzk.extfvk, wzk2.extfvk);
assert_eq!(wzk.zaddress, wzk2.zaddress);
}
}
#[test]
fn test_encrypt_decrypt_sk() {
let config = get_config();
// Priv Key's address is "zs1fxgluwznkzm52ux7jkf4st5znwzqay8zyz4cydnyegt2rh9uhr9458z0nk62fdsssx0cqhy6lyv"
let privkey = "secret-extended-key-main1q0p44m9zqqqqpqyxfvy5w2vq6ahvxyrwsk2w4h2zleun4cft4llmnsjlv77lhuuknv6x9jgu5g2clf3xq0wz9axxxq8klvv462r5pa32gjuj5uhxnvps6wsrdg6xll05unwks8qpgp4psmvy5e428uxaggn4l29duk82k3sv3njktaaj453fdmfmj2fup8rls4egqxqtj2p5a3yt4070khn99vzxj5ag5qjngc4v2kq0ctl9q2rpc2phu4p3e26egu9w88mchjf83sqgh3cev";
let esk = decode_extended_spending_key(config.hrp_sapling_private_key(), privkey).unwrap().unwrap();
let mut wzk = WalletZKey::new_imported_sk(esk);
assert_eq!(encode_payment_address(config.hrp_sapling_address(), &wzk.zaddress), "zs1fxgluwznkzm52ux7jkf4st5znwzqay8zyz4cydnyegt2rh9uhr9458z0nk62fdsssx0cqhy6lyv".to_string());
// Can't lock without encryption
assert!(wzk.lock().is_err());
// Encryption key
let key = secretbox::Key::from_slice(&[0; 32]).unwrap();
// Encrypt, but save the extsk first
let orig_extsk = wzk.extsk.clone().unwrap();
wzk.encrypt(&key).unwrap();
{
assert!(wzk.enc_key.is_some());
assert!(wzk.nonce.is_some());
}
// Now lock
assert!(wzk.lock().is_ok());
{
assert!(wzk.extsk.is_none());
assert_eq!(wzk.locked, true);
assert_eq!(wzk.zaddress, wzk.extfvk.default_address().unwrap().1);
}
// Can't remove encryption without unlocking
assert!(wzk.remove_encryption().is_err());
// Unlock
assert!(wzk.unlock(&config, &[], &key).is_ok());
{
assert_eq!(wzk.extsk, Some(orig_extsk));
}
// Remove encryption
assert!(wzk.remove_encryption().is_ok());
{
assert_eq!(wzk.enc_key, None);
assert_eq!(wzk.nonce, None);
}
}
#[test]
fn test_encrypt_decrypt_vk() {
let config = get_config();
// Priv Key's address is "zs1va5902apnzlhdu0pw9r9q7ca8s4vnsrp2alr6xndt69jnepn2v2qrj9vg3wfcnjyks5pg65g9dc"
let viewkey = "zxviews1qvvx7cqdqyqqpqqte7292el2875kw2fgvnkmlmrufyszlcy8xgstwarnumqye3tr3d9rr3ydjm9zl9464majh4pa3ejkfy779dm38sfnkar67et7ykxkk0z9rfsmf9jclfj2k85xt2exkg4pu5xqyzyxzlqa6x3p9wrd7pwdq2uvyg0sal6zenqgfepsdp8shestvkzxuhm846r2h3m4jvsrpmxl8pfczxq87886k0wdasppffjnd2eh47nlmkdvrk6rgyyl0ekh3ycqtvvje";
let extfvk = decode_extended_full_viewing_key(config.hrp_sapling_viewing_key(), viewkey).unwrap().unwrap();
let mut wzk = WalletZKey::new_imported_viewkey(extfvk);
assert_eq!(encode_payment_address(config.hrp_sapling_address(), &wzk.zaddress), "zs1va5902apnzlhdu0pw9r9q7ca8s4vnsrp2alr6xndt69jnepn2v2qrj9vg3wfcnjyks5pg65g9dc".to_string());
// Encryption key
let key = secretbox::Key::from_slice(&[0; 32]).unwrap();
// Encrypt
wzk.encrypt(&key).unwrap();
{
assert!(wzk.enc_key.is_none());
assert!(wzk.nonce.is_none());
}
// Now lock
assert!(wzk.lock().is_ok());
{
assert!(wzk.extsk.is_none());
assert_eq!(wzk.locked, true);
assert_eq!(wzk.zaddress, wzk.extfvk.default_address().unwrap().1);
}
// Can't remove encryption without unlocking
assert!(wzk.remove_encryption().is_err());
// Unlock
assert!(wzk.unlock(&config, &[], &key).is_ok());
{
assert_eq!(wzk.extsk, None);
}
// Remove encryption
assert!(wzk.remove_encryption().is_ok());
{
assert_eq!(wzk.enc_key, None);
assert_eq!(wzk.nonce, None);
}
}
}

133
tools/lite_send_smoke.cpp Normal file
View File

@@ -0,0 +1,133 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Real-backend SEND smoke harness for the lite wallet. Links the actual SDXL litelib_* backend
// (same imported target the app uses) and drives the EXACT send path the GUI uses
// (LiteClientBridge::execute("send", [{address,amount,memo}])), so it surfaces the same errors a
// GUI send would. ALWAYS run with an isolated HOME — it reads/writes ~/.silentdragonxlite there.
//
// lite_send_smoke --newaddr [server] # create a new wallet, print receive addresses
// lite_send_smoke --status [server] # open + sync + print balance/addresses
// lite_send_smoke --send <drgx> [server] # open + sync + self-send <drgx> to own z-addr
//
// Receive addresses are PUBLIC (meant to be shared to receive funds) so they are printed; seeds and
// private keys are never touched/printed here.
#include "wallet/lite_client_bridge.h"
#include <nlohmann/json.hpp>
#include <cmath>
#include <cstdio>
#include <functional>
#include <string>
#include <vector>
using namespace dragonx::wallet;
using nlohmann::json;
// Recursively collect address-looking strings from any JSON shape the backend returns.
static void collectAddrs(const json& n, std::vector<std::string>& zs, std::vector<std::string>& ts)
{
if (n.is_string()) {
const std::string s = n.get<std::string>();
if (s.rfind("zs", 0) == 0) zs.push_back(s);
else if (!s.empty() && (s[0] == 'R' || s[0] == 't')) ts.push_back(s);
} else if (n.is_array()) {
for (const auto& e : n) collectAddrs(e, zs, ts);
} else if (n.is_object()) {
for (const auto& kv : n.items()) collectAddrs(kv.value(), zs, ts);
}
}
int main(int argc, char** argv)
{
std::setvbuf(stdout, nullptr, _IONBF, 0); // unbuffered so output survives a timeout kill
std::string server = "https://lite.dragonx.is";
std::string mode;
double sendDrgx = 0.0;
bool doRescan = false;
for (int i = 1; i < argc; ++i) {
const std::string a = argv[i];
if (a == "--newaddr" || a == "--status" || a == "--list" || a == "--tree") mode = a;
else if (a == "--send") { mode = a; if (i + 1 < argc) sendDrgx = std::stod(argv[++i]); }
else if (a == "--rescan") doRescan = true; // force witness rebuild from the closest checkpoint
else server = a;
}
if (mode.empty()) { std::printf("usage: lite_send_smoke --newaddr|--status|--send <drgx> [server]\n"); return 2; }
auto bridge = LiteClientBridge::linkedSdxl();
std::printf("[send-smoke] server = %s\n", server.c_str());
std::printf("[send-smoke] available() = %s\n", bridge.available() ? "true" : "false");
if (!bridge.available()) { std::printf("[send-smoke] FAIL: backend not linked (%s)\n",
bridge.unavailableReason().c_str()); return 2; }
std::printf("[send-smoke] serverOnline = %s\n", bridge.checkServerOnline(server) ? "true" : "false");
if (mode == "--newaddr") {
auto r = bridge.initializeNew(false, server);
std::printf("[send-smoke] initializeNew ok=%d %s\n", r.ok, r.ok ? "" : r.error.c_str());
if (!r.ok) return 1;
} else {
auto r = bridge.initializeExisting(false, server);
std::printf("[send-smoke] initializeExisting ok=%d %s\n", r.ok, r.ok ? "" : r.error.c_str());
if (!r.ok) return 1;
if (doRescan) {
std::printf("[send-smoke] rescanning (rebuild witnesses from closest checkpoint)...\n");
auto rs = bridge.execute("rescan", "");
std::printf("[send-smoke] rescan ok=%d %.160s\n", rs.ok, rs.value.c_str());
} else {
std::printf("[send-smoke] syncing (may take a while)...\n");
auto s = bridge.execute("sync", "");
std::printf("[send-smoke] sync ok=%d\n", s.ok);
}
auto b = bridge.execute("balance", "");
std::printf("[send-smoke] balance = %.400s\n", b.value.c_str());
}
// Addresses (public — safe to print).
std::vector<std::string> zs, ts;
{
auto a = bridge.execute("addresses", "");
json j = json::parse(a.value, nullptr, /*allow_exceptions*/ false);
if (!j.is_discarded()) collectAddrs(j, zs, ts);
std::printf("[send-smoke] addresses: z=%zu t=%zu\n", zs.size(), ts.size());
for (const auto& z : zs) std::printf("[send-smoke] Z (fund this for z2z): %s\n", z.c_str());
for (const auto& t : ts) std::printf("[send-smoke] T (transparent) : %s\n", t.c_str());
if (zs.empty() && ts.empty()) std::printf("[send-smoke] addresses RAW = %.200s\n", a.value.c_str());
}
if (mode == "--list") {
auto l = bridge.execute("list", "");
std::printf("[send-smoke] list = %.3000s\n", l.value.c_str());
}
if (mode == "--tree") {
// Dump the wallet's latest Sapling commitment tree to diff against the node's
// `getblockmerkletree <height>` — a mismatch localizes a tree-build divergence.
auto t = bridge.execute("saplingtree", "");
std::printf("[send-smoke] saplingtree = %s\n", t.value.c_str());
}
if (mode == "--newaddr") {
std::printf("[send-smoke] save ok=%d\n", bridge.execute("save", "").ok);
} else if (mode == "--send") {
if (zs.empty()) { std::printf("[send-smoke] FAIL: no z-address to self-send to\n"); bridge.shutdown(); return 1; }
const long long zat = static_cast<long long>(std::llround(sendDrgx * 1e8));
json arr = json::array();
json o; o["address"] = zs.front(); o["amount"] = zat; o["memo"] = "lite send smoke";
arr.push_back(std::move(o));
std::printf("[send-smoke] SEND %.8f DRGX (%lld zat) -> own z-addr ...\n", sendDrgx, zat);
std::printf("[send-smoke] send args = %s\n", arr.dump().c_str());
auto res = bridge.execute("send", arr.dump());
std::printf("[send-smoke] send bridge_ok=%d\n", res.ok);
std::printf("[send-smoke] send RESULT = %.600s\n", res.value.c_str());
if (!res.error.empty()) std::printf("[send-smoke] send ERROR = %s\n", res.error.c_str());
std::printf("[send-smoke] save ok=%d\n", bridge.execute("save", "").ok);
}
bridge.shutdown();
std::printf("[send-smoke] done\n");
return 0;
}