44 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 a3b4f534d5 feat(recovery): import keys from a legacy wallet backup file (z_importwallet)
Phase 3: for a wallet.dat the current daemon can't open, recover its keys from the OLD wallet's export file.
Settings → "Import From Backup File…" opens a dialog that takes the path to a z_exportwallet / dumpwallet
dump (or SilentDragon's "Export all private keys") and calls z_importwallet — the daemon reads every key
and does ONE rescan, far faster and simpler than importing keys one at a time. On success it nudges the user
to shield any mined coinbase from the Shield screen.

- New App::importWalletFile(path, cb): runs z_importwallet on the long-call lane (no client timeout — the
  rescan can take a long time), then refreshAddresses + markWalletMutated. Full-node only.
- Only the PATH touches GUI memory, never the keys themselves (the daemon reads the file) — less secret
  exposure than a pasted-key field.
- New renderImportWalletFileDialog (typed path + Paste/Clear, progress, result) + a Settings card.

Reuses the existing long-call + z_importwallet infra; the careful single-key import dialog is untouched.
No send/sweep/encrypt call changed. Build + ctest green; the import/rescan itself is a daemon RPC, not
pure-testable, and wasn't exercised end-to-end here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 22:43:49 -05:00
DanSandClaude Opus 4.8 293b633a24 feat(recovery): inline unlock in the migrate-to-seed sweep step
Phase 2 of legacy-wallet migration. The sweep (z_shieldcoinbase / z_mergetoaddress) needs an unlocked
wallet, so an encrypted wallet left the "Sweep all funds" button silently disabled with no way forward.
The Sweep step now, when the wallet is locked, shows a passphrase field + Unlock button that calls
unlockWallet(pass, 600); once it unlocks, the (already-present) canSweep gate re-enables the sweep. The
passphrase buffer is sodium_memzero'd immediately after submit.

No fund-path change: this only unlocks the wallet (the daemon's own walletpassphrase), and the sweep/adopt
logic is untouched. Batched-coinbase progress is already handled — the Confirming step shows the live
remaining balance and the existing "Sweep remaining" flow ("too many inputs for one transaction") drains it
across z_shieldcoinbase's 50-UTXO batches.

Runtime/UI change (build + ctest green; not pure-testable). Still to verify on a real degraded+encrypted
wallet end-to-end: unlock -> coinbase shield -> confirm -> adopt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 22:28:35 -05:00
DanSandClaude Opus 4.8 ae4012adb7 feat(recovery): offer legacy-fund recovery when a degraded wallet can't shield its transparent funds
Phase 1 of automating legacy wallet.dat migration: a persistent, actionable banner that detects a wallet
whose transparent funds are STUCK and offers the migrate-to-seed recovery flow (showSeedMigrationDialog).

Corrected trigger (the important part): it fires ONLY when wallet_degraded_ is set — i.e. z_getnewaddress
failed with "HD seed not found", so the wallet genuinely cannot create a shielded address to move its
funds — AND there is a transparent balance to move. It is NOT driven by "the wallet has no mnemonic": a
non-mnemonic but functional wallet (one that self-healed with a retrofit HD seed on load) has
wallet_degraded_ == false, can still shield/spend, and is deliberately never flagged — matching the
existing "legacy wallets are not nagged" policy. Full-node only.

Implemented in the already-unit-tested node banner: new NodeBannerReason::LegacyFundsStuck +
NodeBannerAction::RecoverLegacyFunds, gated on a new legacy_funds_stuck input (App computes it from
wallet_degraded_ + transparentBalance). NoPeers still takes priority (can't recover before the node sees
the chain); lite never shows it. Pure decision tests added: fires when stuck, never for a functional
wallet, yields to NoPeers, suppressed on lite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 22:05:11 -05:00
DanSandClaude Opus 4.8 6c2a277751 fix(wallet): surface the real z_getnewaddress error; sweep coinbase in migrate-to-seed
From a legacy-wallet migration support case (mined T-funds, 3y-old Silent Dragon wallet.dat): the balance
shows but new-address and migrate both fail with unhelpful/silent results. Two fixes:

1) Real address-creation error. createNewAddress() swallowed the daemon's z_getnewaddress error and the UI
   showed a generic "Failed to create shielded address". It now keeps the daemon's reason (friendly-mapped
   via friendlyRpcError) in last_address_error_, which the Receive tab and request-payment dialog show
   instead of the generic string — so a legacy/degraded wallet sees the actual "HD seed not found". And when
   that specific error appears it flips wallet_degraded_ + warns once, even for an EXTERNAL daemon
   (detectWalletDegraded() only scans the embedded daemon's log and would otherwise miss it).

2) Migrate-to-seed sweep skipped coinbase. beginSweepToSeedWallet()'s z_mergetoaddress ignores coinbase
   UTXOs, so a miner's coinbase-only balance produced "Could not find any funds to merge", the legacy
   balance never reached ~0, and the adopt gate stalled silently. The sweep now falls back to
   z_shieldcoinbase("*", dest, fee, 50) to the SAME shielded destination when the merge reports no funds.
   z_shieldcoinbase returns an opid just like the merge, so the single-opid adopt gate (opid -> txid ->
   confirm -> balance ~0 -> adopt) is unchanged, and a batch limit / mixed wallet drains via the existing
   "Sweep remaining" re-sweep.

Fund-path: the z_mergetoaddress call and its args are byte-identical; the fallback only ADDS a
z_shieldcoinbase to the same dest when merge finds nothing, and nothing is adopted until the swept funds
confirm and the legacy balance is ~0 (gate unchanged). Runtime-verified behavior (clean build + ctest; the
RPC paths are not pure-testable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 19:47:38 -05:00
DanSandClaude Opus 4.8 a558a2a271 fix(explorer): commit the loadRangeCached/revision memoization the perf fix depends on
Repairs dev: 3ee9099 switched the Explorer call sites (and a test) to ExplorerBlockCache::loadRangeCached()
/ revision(), but those definitions were sitting uncommitted in the working tree (left over from the
explorer block-search feature, ebbeb84) and were not staged with that commit — so dev as pushed referenced
undefined members and would not compile on a clean checkout.

The infra: loadRangeCached() memoizes loadRange() per (min,max), invalidated by revision_, which is bumped
on every mutation that can change what loadRange returns (open/close/store/clear/prune/spill-reset); the
memo is capped at 16 ranges. My local builds passed only because the working tree already had these.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 19:15:19 -05:00
DanSandClaude Opus 4.8 3ee90993ed fix(resilience,perf): gate send on a known tip, surface dropped/stale txs, advance cached confs, memoize explorer
Backlog mediums (display/gating/caching only — no send/sweep/encrypt call or args changed):

Resilience:
- Gate Send/Review on a KNOWN peer-derived tip (SyncInfo::sendTipReady): a fresh reconnect or peerless node
  has tip_known=false, so its local height isn't a trustworthy network tip — sending is disabled with a
  clear tooltip until it is. Lite is exempt (its own sync gate). The z_sendmany call is untouched.
- History now shows "Dropped" for a conflicted tx (confirmations < 0) and "Expired?" for one unconfirmed
  > 1h, instead of "Pending" forever (new TransactionInfo::isConflicted/isStalePending; the tab inlines the
  check on its DisplayTx fields).
- A cached z_viewtransaction row's confirmations now advance by the blocks mined since it was sampled
  (observed_height), instead of freezing at the cached value; never goes backwards; pending (0) isn't
  extrapolated.

Performance:
- Explorer block list uses loadRangeCached() (memoized on the cache revision) instead of loadRange(), so it
  only hits SQLite after the cache changes, not every frame.

Tests: testResilienceMediums (tip gate, confirmations-advance, dropped/stale predicates) + loadRangeCached
memoization in testExplorerBlockCache (isolated on its own cache). Held: Overview/Balance per-frame list
cache, peers clippers, off-thread tx-history cache load (larger refactors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 19:12:32 -05:00
DanSandClaude Opus 4.8 b404bb2aff test(lite): set spendableShieldedBalance in the refresh fixture (realistic synced wallet)
Follow-up to 3a33e9c: testLiteRefreshModelAppliesToWalletState built its balance bundle without
spendableShieldedBalance (left 0), so the new "pending = total − spendable" unconfirmedBalance formula
reported the whole 2.0 shielded balance as pending (actual 2.0 vs expected 0.5). In real data
spendable_zbalance is a required parser field and a synced wallet reports all of it spendable; set the
fixture to match (= shieldedBalance), so unconfirmedBalance is the 0.5 mempool-incoming amount as intended.
No production code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 19:02:21 -05:00
DanSandClaude Opus 4.8 3a33e9cae9 fix(lite): unconfirmedBalance uses full-node pending semantics; drop pending-spent t-UTXOs from spendable
Lite-parity audit mediums (display / spendable-gating only — never over-reports spendable; no send call):
- unconfirmedBalance was just the backend's mempool-incoming figure, but the shared UI (balance_tab "pending"
  chip, the fast-poll hint) reads it as the full-node "total minus spendable" portion. Set it to the larger
  of the two (the not-yet-spendable shielded part vs the mempool-incoming), so neither is hidden and a note
  counted in both isn't double-reported.
- transparentBalance/spendableTransparentBalance included t-UTXOs already spent by a pending tx (the backend
  tbalance sums every utxo with spent==None). Subtract unconfirmed-spent t-UTXOs from the SPENDABLE figure
  only (clamped; gross transparent display unchanged) so the Send picker can't offer funds a pending tx
  already consumed. Falls back to no-subtraction when the notes call failed that cycle (as before).

Test: testLiteUnconfirmedAndTransparentSpendable. Multi-recipient history rows held (TransactionInfo is
single-output, same as the full node).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 18:58:32 -05:00
DanSandClaude Opus 4.8 46de4cbfee fix(fund): Tier-B parser/aggregation hardening — range-check amounts, int-satoshi sums, strict balance parse
Fund-correctness audit, Tier-B (parse/validation/display only — no send/sweep/encrypt call touched; each
change can only reject bad input or remove drift, never over-report):
- lite_result_parsers: cap every backend-reported amount at the 21,000,000 DRGX supply (note value,
  outgoing output value + running sum, received amount, the net-amount fallback magnitude), and replace the
  two bare `return false` paths in the outgoing-metadata loop — which left ok=false with an EMPTY error and
  silently rejected the whole tx list — with fail() carrying a reason.
- network_refresh_service: accumulate per-address balances in integer satoshis (util::toSats) instead of
  summing doubles, so many notes can't drift by an ulp per addition (ten 0.1 notes now sum to exactly 1.0);
  skip non-finite amounts.
- readBalanceString: strict decimal parse (digits / one '.' / optional leading '-') instead of std::stod,
  which silently accepted "1.5abc", whitespace, hex, exponents and nan/inf.

Tests: testLiteParserAmountRangeChecks + integer-satoshi drift + strict-parse cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 18:55:02 -05:00
DanSandClaude Opus 4.8 d7fee801d3 fix(rpc): abort any in-flight call before a UI-thread disconnect, not just maintenance calls
Concurrency audit (finding #2, previously held for sign-off): abortMaintenanceCalls() — the choke point run
before every UI-thread rpc_->disconnect() (wallet switch/adopt/restore/rebuild, handleLostConnection,
shutdown) — only called requestAbort() when maint_calls_>0. An ordinary in-flight refresh/scan (not counted
as a maintenance call) holds curl_mutex_ for its whole curl_easy_perform, so disconnect() blocked the UI
for up to the call timeout (≈30s, or 120s for a wallet scan).

Abort unconditionally so disconnect() never waits on the curl lock. The abort flag is cleared by the next
tryConnect()/onConnected() before reconnecting. A user send that happens to be in flight during a teardown
is now aborted too, but that is handled as a lost reply — "outcome unknown, check History", never a
double-send (see the lost-reply classifier, 08a4427) — so no funds are at risk. The z_sendmany/broadcast
call and its args are unchanged.

Threading fix — clean build + ctest; the UI-block is runtime-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 18:48:22 -05:00
DanSandClaude Opus 4.8 4f4c6b7a16 fix(rpc): drop a stale connect result after teardown; don't let open() erase a pending abort
Concurrency audit, connect-lifecycle races (no fund-path call touched — connect-result gating + abort-flag
lifetime only):
- The connect task's MainCb ran onConnected() on any successful probe without re-checking that a wallet
  switch / adopt / restore / shutdown had already torn the connection down — so a probe that succeeded
  against the old daemon could start a refresh against one about to be stopped. Capture rpc_session_ when
  posting (main thread) and, in the MainCb, drop the result (requestAbort + disconnect, not a blocking bare
  disconnect) when the session changed or daemon_restarting_/shutting_down_ is set. Same guard on the
  cookie-auth retry post.
- RPCClient::open() called clearAbort(), so a requestAbort() issued while a connect was still queued was
  erased, stalling teardown/shutdown. open() no longer clears it; the two reconnect entry points
  (tryConnect for rpc_, onConnected for fast_rpc_) clear it on the main thread first, skipped while
  shutting_down_ so a shutdown abort survives. A fresh client defaults to not-aborted, so first connects
  are unaffected.

HELD (not in this commit): making abortMaintenanceCalls() abort unconditionally (the UI-freeze-on-disconnect
finding) — it would also abort an in-flight user send during teardown, a send-lifecycle behaviour change
that wants explicit sign-off (it is fund-safe via the lost-reply "outcome unknown" handling, but still a
behaviour change).

Threading fix — clean build + ctest; the races are runtime-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 18:41:21 -05:00
DanSandClaude Opus 4.8 5563becad2 fix(i18n): thread-safe translation for the daemon monitor thread (don't race loadLanguage)
Concurrency audit: I18n::translate() returns a const char* pointing INTO strings_, and loadLanguage()
clears/rebuilds strings_ on the main thread at a language switch. The EmbeddedDaemon state/monitor callback
builds its status strings via TR() off the main thread, so a concurrent language switch could free the
map node out from under that pointer — a use-after-free data race.

- Add a shared_mutex guarding strings_: loadLanguage() takes it exclusively for the whole clear+rebuild
  (its helpers loadBuiltinEnglish/overlayTranslations are called only from there and don't lock, so no
  recursion); a new translateCopy()/trCopy() takes a shared lock and returns an owned std::string.
- The lock-free translate()/TR() stays for main-thread UI (same thread as the writer, so never races).
- The four daemon-state setDaemonStatus(TR(...)) calls in the monitor callback now use util::trCopy(...).

Synchronization only; no fund-path call touched. Threading fix — clean build + ctest; the race is runtime-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 18:38:46 -05:00
DanSandClaude Opus 4.8 6301cd5984 fix(security): synchronize SecureVault::scope_ between the main thread and worker security ops
Concurrency audit: SecureVault::scope_ (std::string) is written by setWalletScope() on the main thread
(wallet switch) while getVaultPath() — reached from retrieve()/hasVault()/changePin()/store() — runs on
RPC worker threads during a send-time or startup unlock. A concurrent wallet switch and a worker vault op
is a data race on the string (and could resolve the wrong vault file).

Guard scope_ with a small mutex: setWalletScope() assigns under it; getVaultPath() copies it out under the
lock and uses the copy. The lock is held only for the string copy — never across Argon2id or file I/O —
and getVaultPath() never calls setWalletScope(), so no deadlock. Argon2id params, key derivation, AEAD,
the vault file format and retrieve/store/changePin are byte-identical. SecureVault is held via unique_ptr
and used by pointer, so the added (non-movable) mutex is fine.

Synchronization only. Threading fix — clean build + ctest; the race is runtime-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 18:34:59 -05:00
DanSandClaude Opus 4.8 9b6971095c fix(daemon): synchronize EmbeddedDaemon fields written across the monitor/start/stop threads
Concurrency audit: last_error_ was written unlocked at the five startProcess() error sites (while the
monitor thread could be in setState() writing it under error_mutex_) and read unlocked in start() — a
data race on a std::string. process_pid_ / stdout_fd_ (POSIX) and process_handle_ (Windows) are plain
scalars written by startProcess, the stop paths and the monitor (reap) and read by accessors.

- Route every startProcess() last_error_ write through a new locked setLastError(); read the start()
  detail via the already-locked getLastError().
- Make process_pid_ / stdout_fd_ / process_handle_ std::atomic (trivially atomic scalars/handles), so
  individual loads/stores no longer race. Process start/stop ordering and the shutdown hardening are
  unchanged — this only removes the data races, not the (already-serialized) sequencing.

Synchronization only; no fund-path call touched. Threading fix — verified by a clean build + ctest; the
race itself is runtime-only. (The Windows process_handle_ atomic is reasoning/compile-only on this Linux box.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 18:31:57 -05:00
DanSandClaude Opus 4.8 8a6d523062 fix(lite-backend): release the pending lock when a send expires; un-spend reorged-out UTXOs
Resilience audit, vendored SDXL core (post-hoc wallet bookkeeping only — the send/build/broadcast path,
note/UTXO selection, fees, recipients, memo and the post-broadcast pending-marking are byte-identical):

- cleanup_mempool never cleared unconfirmed_spent, so a send that was dropped / never mined / reorged out
  locked its notes and UTXOs until the app was reloaded ("insufficient funds" on a funded wallet). It now
  releases the lock once the send is provably dead. A send is built with Builder::new(tip) (expiry = tip+20,
  mineable through tip+20), so the lock is held until the chain reaches tip+21 (MEMPOOL_KEEP = DELTA+1) —
  one block past the old mempool-drop point, which would have released the notes while the tx could still be
  mined. A txid already in `txs` was mined (scan settled its inputs) and is skipped. mempool_txs is released
  before `txs` is taken, so the two locks are never nested.
- invalidate_block reset nd.spent on reorged-out spenders but never walked wtx.utxos, so a transparent UTXO
  whose spender was reorged out kept spent=Some(removed txid) and dropped out of the balance until a rescan.
  It now resets utxo.spent the same way (unconfirmed_spent left as-is, as for notes — the send may be re-mined
  on the new branch).
- Removed the stale "TODO remove unconfirmed_spent on expiry" in data.rs (now done).

Verified with the scratch-mirror rtest.sh (cargo 1.63, scratch HOME): cargo test --release --lib green,
129 passed / 0 failed / 12 ignored, including the oracle differential tests (updated in lockstep to clear
the sim's pending flag at the same expiry point) and test_z_mempool_expiry. The C-ABI wrapper and the
artifact rebuild / lite_smoke (needs a live server) were not run; they don't touch these functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 18:13:33 -05:00
DanSandClaude Opus 4.8 16a1ca02e8 fix(lite): lock/unlock parity — shared attempt-lockout, immediate unlock state, no dead PIN, surfaced encrypt failure
Lite-parity audit, FIX C (gating/validation/presentation only — no encrypt/unlock/send backend call changed):
- Lite unlock went straight to the backend with no lockout. New App::unlockLiteWallet() runs the SAME
  escalating, persisted attempt-lockout as the full node (restoreLockoutState/applyUnlockFailure), wired into
  the Settings unlock, the send-time prompt and the lock screen. A wrong-passphrase failure counts; an
  already-unlocked/not-encrypted result does not.
- state_.locked is now cleared immediately on a successful unlock (the ~2s status poll lagged, so the UI
  stayed "locked" after a correct passphrase).
- PIN quick-unlock controls are hidden in lite Settings — lite has no PIN vault, so they were dead.
- A create/restore whose passphrase encryption FAILS now raises a one-shot visible error
  (takeEncryptionFailureNotice -> lite_encrypt_failed_unencrypted) instead of only a log line; the wallet is
  left open but unencrypted. Fixed the flag on BOTH the lifecycle and the direct create/restore encrypt paths.
- A background sync save is skipped while an encrypted wallet is unlocked (the backend save re-locks it),
  resuming once locked.

Compile- and unit-verified (testLiteWalletControllerLockUnlockParity covers the save-deferral + encrypt-failure
notice, run twice for the flaky guard); the lock-screen lockout/PIN-hiding UX itself is runtime/GUI-only and
not exercised here — worth a manual lite GUI pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 15:33:28 -05:00
DanSandClaude Opus 4.8 0cb1df3bfe fix(migrate): require a reorg-safe sweep depth + re-verify before adopt; make the wallet.dat swap crash-safe
Resilience audit, migrate-to-seed (the sweep RPC is byte-identical — see below):
- Adopt was enabled at >= 1 confirmation and never re-read state before stopping the daemon, so a 1-block
  reorg after the swap could un-mine the sweep and strand the funds in the .bak. Adopt now needs a
  reorg-safe depth (>= 10, matching the History "confirmed" threshold), and the button runs
  verifyThenAdoptSeedWallet(): a read-only gettransaction(confs) + z_gettotalbalance minconf 0 (includes
  unconfirmed, fixing the old minconf-1 "no funds" gate) immediately before the daemon stop, refusing while
  syncing or if any read fails. The remainder re-sweep still shows once mined (>=1).
- The wallet.dat swap was rename(legacy->bak) then non-atomic copy(new->wallet.dat): a hard kill left
  wallet.dat missing or half-written. New order: probe the new file is a BDB wallet; stage a size-verified
  copy beside wallet.dat; COPY (never overwrite) the legacy wallet to the timestamped .bak and verify it;
  then ONE atomic fs::rename of the staged file over wallet.dat. At every instant wallet.dat is the whole
  old or whole new wallet, and a verified .bak exists before the replace. On any failure only our scratch
  files are removed; the user's wallet is untouched.

Byte-identical: z_mergetoaddress (beginSweepToSeedWallet, ["ANY_TADDR","ANY_ZADDR"], amounts, fee, recipient,
memo) and beginAdoptSeedWallet's disconnect/vault-removal/stop/rescan/restart logic are unchanged; only the
adopt gating, two new read-only RPCs, presentation, and the file-move safety change. Pure tests:
sweepConfsAreReorgSafe + adoptReverifyPasses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 15:22:17 -05:00
DanSandClaude Opus 4.8 6f369376ec fix(lite): surface sync failures instead of a frozen "Syncing 99.9%"; don't report a lost server as "Synced"
Lite-parity audit, sync-progress semantics (state/presentation only — no send/sync backend call changed):
- A scan failure set scan->failed + lite_error, but no UI read lite_error, so the user saw "Syncing 99.9%"
  forever. The status bar now shows the error text on a lite scan/server failure.
- After the initial catch-up, a server loss on the cheap probe path (ProbeFailed) left the last "Synced"
  model on screen. The worker now re-publishes that model demoted (not complete, reason "server unreachable").
- refreshModelImpl's height-read "synced" override no longer fires while syncstatus says the backend is
  still scanning or is indeterminate, and a server-bound read failure in a full cycle demotes to not-synced.
- The syncstatus parser no longer treats a missing/unrecognised "syncing" field as fully synced: it reports
  an explicit "indeterminate" (not complete). Explicit "false" stays caught-up; the fake backend's
  syncing:true 1000/1000 behaviour is unchanged.

Tests: parser cases for missing/odd/idle "syncing" (testLiteSyncStatus*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 15:16:36 -05:00
DanSandClaude Opus 4.8 02fa1a228a fix(lite): per-address spendable honours the backend spendable flag; flag imported view-only addresses
Lite-parity audit: per-address spendableBalance was set equal to the gross balance, ignoring the backend's
per-note 'spendable' flag (spending key present AND note at/below the anchor height), and has_spending_key
was effectively always true (mapAddress never set spendabilityKnown). So the Send auto-picker could select
an address that can't fund the send, and an imported view-only (zxviews) address looked spendable.

- Parser: carry the backend per-note 'spendable' flag when present (Sapling notes; transparent utxos keep
  the confirmed/unspent rule). It can only narrow spendability, never widen it.
- Mapper: derive per-address spending-key evidence from positive signals only — a spendable note proves a
  key (spendable), a confirmed long-mature (>=100 blocks) still-non-spendable note proves view-only
  (!spendable); fresh/immature/no notes stay unknown so nothing is mislabelled.
- Controller: info.balance stays the gross sum; info.spendableBalance now counts only backend-spendable
  outputs; a tolerated partial refresh keeps the last-known spendable balance and view-only verdict.

Display/selection only — gating can only get stricter for view-only/unanchored funds; no send/sweep/encrypt
call changes. Test: testLitePerAddressSpendableAndViewOnly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 15:11:47 -05:00
DanSandClaude Opus 4.8 099d5df3e1 fix(lite): refuse backend calls after a retired bridge is shut down; make the shutdown flag atomic
Concurrency audit (lite): LiteClientBridge::shutdownCalled_ was a plain bool, set by retire()/destruction
on one thread and read nowhere by the detached sync/console/broadcast threads. A retired controller's
in-flight execute() (send/save/sync) therefore still ran litelib_* against the PROCESS-GLOBAL client,
which may already belong to a NEW controller after a server switch.

- shutdownCalled_ is now std::atomic<bool>; shutdown() uses exchange() so exactly one caller tears the
  client down (idempotent) and the flag is published for the detached threads.
- execute() and initializeNew/NewFromPhrase/Existing refuse once the bridge is shut down (shutDownResult),
  so a stale command can't reach the global client.

Gating only — doSend/doShield, the JSON send payload, amounts/memo/recipients, persistAfterBroadcast,
encrypt/unlock/export and api_.execute argument passing are byte-identical when the bridge is live.
Test: testLiteClientBridgeRefusesAfterShutdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 15:07:18 -05:00
DanSandClaude Opus 4.8 df98f98669 perf(history): don't rebuild the O(N) tx display cache on every block for a confirmation-only change
Performance audit: tx_revision bumps on every new block because each tx's confirmation count rises, so
the History tab's groupByTxid + merge + sort + deep-copy display cache fully rebuilt once per ~36s block
on the UI thread, even when nothing but the counts changed.

Add WalletState::tx_structure_revision, bumped alongside tx_revision EXCEPT when setTransactionsIfChanged
sees a pure confirmation-count delta (new sameTransactionLayout: every row equal ignoring the confirmation
COUNT, but the pending/confirmations==0 state — which drives the pending-first sort and shield merge — must
still match). Every other writer bumps both (conservative default). The History tab's groups cache and
display cache now key on tx_structure_revision; on a confirmation-only delta it patches dtx.confirmations
in place (rows, order and indices into state.transactions are unchanged) instead of rebuilding, and re-stamps
the filter snapshot so "export current filter" stays valid. Displayed confirmations still update every block.

No fund path touched; display/caching only. Test: testTxStructureRevision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 14:44:13 -05:00
DanSandClaude Opus 4.8 6d58802127 fix(lock): clear in-flight unlock/PIN/encrypt flags on disconnect so the lock screen can't wedge
Concurrency audit: RPCWorker::stop() discards any MainCb the fast worker pushed after the last drain,
and lock_unlock_in_progress_ / pin_in_progress_ / encrypt_in_progress_ are cleared only inside those
callbacks. So a disconnect (3-failure threshold, daemon restart) mid-unlock destroyed the callback that
would have cleared the flag, leaving the lock screen stuck on "Unlocking..." until the app was restarted.

Clear the three flags in onDisconnected() right after the fast worker is torn down (gated on it having
existed, since only fast-lane-posted callbacks can be lost this way). Chosen over draining the worker,
which would run stale callbacks after state_/rpc_session_ were already reset. The user can retry after
reconnect and the real encryption state is re-read on connect. No fund-path call is touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 14:39:08 -05:00
DanSandClaude Opus 4.8 165675b131 fix(lite): render a lite send as a negative debit, not a green "+"; recover unauthored-send amounts
Lite-parity audit, two History-correctness findings (display/parse only — no send/broadcast call touched):
- A lite send carried a POSITIVE tx.amount, so History, the Details dialog and CSV export rendered an
  outgoing payment as a green "+" receipt (the full node uses a negative debit). The mapper already
  computes signedAmountZatoshis (negative for sends, positive for receives); applyLiteRefreshModelToWalletState
  now uses it for tx.amount, so the shared UI shows a red "-" and CSV a negative value. The summary cards
  use std::abs and are unaffected.
- A send this wallet did not author (seed restore / another device) has empty outgoing_metadata, so the
  parser summed it to 0 and showed amount 0 with a blank address, understating Sent totals. parseTransactionRecord
  now falls back to |record.amount| (the net debit, incl. fee) when the metadata sum is 0, tolerant of a
  missing/odd field; explicit outgoing_metadata still wins.

Tests: testLiteSendAmountIsNegative (send maps to <0, receive stays >0) and
testLiteForeignSendFallsBackToRecordAmount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 14:37:23 -05:00
DanSandClaude Opus 4.8 08a4427e31 fix(send): treat a lost send reply as "outcome unknown", not a plain failure, to prevent a double-send
Resilience audit, highest-value finding: a z_sendmany (full node) or lite broadcast whose REPLY is
lost — a transport timeout / connection cut AFTER the request already left us — was reported as an
ordinary "Transaction failed". The user then retries and can broadcast the same payment twice, because
the daemon may already hold the operation (z_sendmany returns an opid and runs async).

Classify the ambiguous case and surface it distinctly, without touching the send itself:
- New pure helpers in rpc/rpc_failure.h: sendReplyMayBeLost() (an RpcTransportError that was requestSent
  with no HTTP status and a non-OK curl code — reply-timeout / got-nothing / our-own-abort; definitive
  rejections and any HTTP status are excluded), liteSendErrorMayBeInFlight() (a transport-style gRPC text
  heuristic, vetoing connect-phase and server-rejection text), and a readable-English marker tag.
- app_network.cpp: the z_sendmany catch records the flag; the failure branch (non-background only) tags
  the callback text and forces a wallet re-read so a committed send shows in History. onDisconnected()
  tags already-pending send callbacks the same way (they exist only after an opid was returned).
- Lite UserSend (app.cpp): a transport-style broadcast error is tagged identically.
- Send tab: a tagged outcome shows a non-success warning pointing at History and clears the compose form
  so any resend must be retyped deliberately; it is NOT presented as a retryable failure.

The actual rpc_->call("z_sendmany", {from, recipients, 1, fee}), the lite send/broadcast call, and all
amounts/fees/recipients/memo bytes are byte-identical; only result-handling and presentation change.
Pure classifier/marker regression tests added (testSendMaybeInFlight).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 14:30:16 -05:00
DanSandClaude Opus 4.8 f4460beddd fix(sweep,lite): don't misread a funded key as empty; guard lite send amount; use spendable balance
Fund-correctness audit, sweep + lite paths:
- Key-import sweep: balAt() swallowed every RPC error as 0.0, so a transient
  timeout/warmup after the import misclassified a funded key as "no funds". Let
  the failure propagate to the outer handler so the user sees a real error and
  can retry, instead of a false __NOFUNDS__.
- Lite send: the double→zatoshi conversion was an unchecked llround+cast — a
  negative/NaN amount would wrap to a huge uint64. Route it through
  util::coinsToZatoshis and reject invalid amounts before broadcast.
- Lite balance: the spend gate used the gross shielded total (including
  unverified/unconfirmed notes). Use the backend's spendable_zbalance (a required
  field) for spendablePrivateBalance/Total, clamped to the shielded total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 13:10:55 -05:00
DanSandClaude Opus 4.8 70d5c233c0 fix(send): stop a fee-tier change from re-sending Max; compare affordability in satoshis
Fund-correctness audit, send path:
- Flagship wrong-send: s_send_max was only cleared on a successful send, so after
  Max → typing a smaller amount → changing the fee tier in the confirm dialog,
  the amount was silently overwritten back to the maximum and Confirm sent far
  more than the user typed. Clear s_send_max on any manual amount edit (DRGX and
  USD inputs), so a later fee bump never rewrites a hand-entered amount.
- A valid exact-maximum send was blocked by 1 ulp because amount+fee<=available
  was computed in IEEE doubles. New util::toSats/canAfford compare in int64
  satoshis; wired into the Review gate, the confirm-time re-check, the Send
  tooltip and the amount-error banner. This only unblocks a correct send; it
  changes no sent amount.
- Added util::coinsToZatoshis (reject non-finite / <=0 / rounds-to-0 / over-supply)
  and regression tests for formatAmountFixed, toSats/canAfford and coinsToZatoshis
  (incl. the exact failing inputs the audit found).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 13:10:55 -05:00
DanSandClaude Opus 4.8 c0d10e9664 security(hygiene): best-effort scrubbing of residual in-memory secret copies (Tier 4)
Defense-in-depth against a local attacker / core dump reading un-zeroed heap
copies of secrets (threat model is local, not remote). Best-effort by nature —
std::string SSO/reallocation and ImGui buffer growth can still strand copies —
so these shrink the exposure window rather than guarantee absence:
- rpc_client performCall: a scope guard now sodium_memzeroes the request body +
  payload tree (walletpassphrase / *_importkey args) after the call and detaches
  CURLOPT_POSTFIELDS. Request content sent to curl is unchanged.
- New ui::scrubImGuiInputTextState() zeroes ImGui's retained InputText buffers
  (live/revert/callback/deactivated) — the app memzeroed its own char[]s but
  ImGui kept copies. Called from wipeSecrets()/wipeDisplayedSecrets(). PINNED to
  ImGui 1.92.6 via a #error guard so a vendored bump forces a re-audit (the stb
  undo buffer is an incomplete type in the header, so it's left as a noted gap).
- Lite exportSeed/exportPrivateKeys wipe result.value + the parsed JSON tree.
- Seed-word grid (SplitSeedWords/RenderSeedWordGrid) zeroes its per-frame word
  and cell copies; the wizard word/chip vectors are zeroed before clearing.
- ChatStore::clear() + ResetChatTab() zero decrypted message bodies on
  lock/switch/teardown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 12:05:04 -05:00
DanSandClaude Opus 4.8 d16bbf3695 security: payment-URI amount validation, export filename sanitize, chat crash-guard, HTTPS-only, conf 0600, pool auto-lock
Six bounded defensive fixes:
- Payment-URI amount is now strictly validated (reject NaN/inf/sign/garbage,
  <=8 decimals, 0..21,000,000) at parse time, so a crafted QR/URI can't inject a
  bogus amount into the Send form. The z_sendmany call the user confirms is
  unchanged; this is input validation only.
- export-all-keys filename is sanitized (basename-only, no traversal) via a
  shared util::sanitizeExportFilename (factored out of the CSV export dialog).
- Chat append()/upsert() wrap the strict serialize in try/catch so a malformed
  (invalid-UTF-8, attacker-influenceable) body is skipped, not crashed on —
  matching appendBatch's existing drop behavior.
- http_download (used by the updaters + pool stats) restricts transfers AND
  redirects to HTTPS and bounds the download path's redirects, so a redirect
  can't downgrade to http:// or another scheme.
- The default DRGX.conf (rpcuser/rpcpassword) is written 0600 from the start via
  writeFileAtomically, closing the brief world-readable ofstream+chmod window.
- Auto-lock is suppressed only for SOLO mining (needs the wallet), not pool
  mining (pays an external address), which was holding the wallet open forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 11:39:38 -05:00
DanSandClaude Opus 4.8 83ce73f26b security(console): mask key-dump command RESULTS in the scrollback
The console redacted secret command ARGS but showed the OUTPUT of key-revealing
commands (dumpprivkey / z_exportkey / z_exportviewingkey / z_exportmnemonic /
dumpwallet / z_exportwallet, and the lite seed/export verbs) in full — visible
in the scrollback, Copy All, Ctrl+C and the filter. Now, when the submitted
command is key-revealing, its result is held ONLY in a member buffer (never in
the scrollback model, so Copy-All etc. can't leak it) and a masked placeholder
is shown. An explicit "Copy secret" button routes through the auto-clearing
clipboard; the buffer auto-wipes after 60s, on Clear, and on wallet lock
(via wipeDisplayedSecrets). The command string passed to the executor is
byte-identical. New pure helper ConsoleCommandRevealsSecret is unit-testable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 11:39:38 -05:00
DanSandClaude Opus 4.8 31e9c2ee2c security(exposure): owner-only ACLs on Windows, auto-clearing export copy, require SHA-256 for bootstrap
- writeFileAtomically honored restrictPermissions only on POSIX (0600); on
  Windows it was a no-op, so exported keys / seed backups / the vault / chat
  exports inherited the parent dir's ACL. It now builds a PROTECTED owner-only
  DACL (current-user SID, SE_DACL_PROTECTED) on the temp file before the atomic
  rename; best-effort, falling back to default ACLs on failure. (Verified it
  compiles under mingw-w64; the POSIX path is unchanged.)
- The key-export dialog's Copy wrote the private key to the plain, permanent
  clipboard; it now goes through copySecretToClipboard() (45s armed auto-wipe +
  its own "copied, auto-clears" notice), like the seed backup copy.
- Bootstrap archive verification dropped the collision-prone MD5 fallback: it now
  requires a published SHA-256 and fails closed otherwise. (Authenticity vs a
  malicious server still needs a detached ed25519 signature like the updaters —
  a separate release-coordinated change.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 10:34:47 -05:00
DanSandClaude Opus 4.8 6b0f2eda22 security: scrub on-screen secrets on lock; redact lite console secret args; no-shell wallet rebuild
- Locking the wallet (manual / auto / lite) now calls wipeDisplayedSecrets(),
  which scrubs + closes any revealed seed phrase, migration seed, or exported
  key — previously a locked wallet could leave the phrase/key on screen and
  resident. Runs synchronously, before the async lock round-trip.
- The console secret-arg redaction list only had full-node RPC names, so lite
  verbs (import / timport / encrypt / decrypt / unlock) echoed keys and
  passphrases into the scrollback and up-arrow history; added them.
- The offline wallet-rebuild helper was invoked via a shell string
  (popen "sh -c") built from paths including the wallet filename, so a crafted
  name could inject a command; it now refuses any path with a shell
  metacharacter (rc stays -1 and the existing verify-before-swap fails safe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 09:28:25 -05:00
DanSandClaude Opus 4.8 dc1617a2c1 security(lite): harden the C-ABI string boundary + backend result parsing
- lite_owned_string: the destructor and move-assignment no longer copy the raw
  value before freeing (nothing reads the copy there, and a std::string copy can
  throw bad_alloc — from the noexcept destructor that meant std::terminate, and
  it could also double-attempt the copy after a throw). They now free only; the
  copy happens solely in intoResult(), which also moves (not copies) the value
  into its result so no extra plaintext lingers.
- lite_result_parsers: jsonToUnsigned rejected nothing on the string path, so
  std::stoull accepted "-1" and wrapped it to a huge balance; now requires a
  leading digit. jsonToSigned rejects leading whitespace/'+'. The outgoing-
  metadata sum rejects negative values and guards against signed overflow (UB),
  so a hostile/buggy backend can't forge or overflow a transaction amount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 09:28:25 -05:00
DanSandClaude Opus 4.8 f213878a7c refactor(ui): semantic status text uses material tokens instead of hardcoded colors
Finishes the feedback-conventions hygiene thread: ~30 hardcoded ImVec4 green /
red / amber literals used for success / error / warning text now use
material::Success/Error/WarningVec4(), so semantic status colors follow the
active skin and stay legible in every theme (several hardcoded greens/reds read
poorly on the light/pastel skins). Covers validate-address, transaction-details,
key-export, export-all-keys, about, the Settings confirmations/lock state, and
the encryption/decryption dialogs. Brand/logo colors and muted white/grey
(disabled) text are left as-is; dynamic-alpha animated indicators are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 08:51:10 -05:00
DanSandClaude Opus 4.8 8d8e523a26 i18n(migrate,sweep): translate status/error strings + add a hygiene regression guard
- The import-key sweep and migrate-to-seed flows set several user-visible
  status/error strings (shown via sweep_status_ / seed_migration_status_ /
  rescan_status) as bare English; routed them through TR() with additive keys.
- Extended scripts/check-source-hygiene.sh with two low-false-positive content
  guards so this debt can't regrow: a bare-English migrate/sweep status
  assignment, and a Tactile button sized with a raw (unscaled) pixel literal.
  Passes clean on the tree (362 files); the dev sweep harness is allowlisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 08:44:55 -05:00
DanSandClaude Opus 4.8 8b07f776e5 feat(ux): copy-to-clipboard confirmations + scale wizard buttons for HiDPI
Feedback-convention + HiDPI cleanup (from the audit's living-hygiene threads):
- Copy actions that were silent now toast a confirmation: transaction
  address/txid (context menu, click-to-copy, detail buttons), peer and banned-
  peer addresses, the Settings data-dir copy, the chat message "Copy", and the
  key-export result copy.
- The first-run wizard's Skip/Back buttons used raw ImVec2(120/80,0) widths that
  rendered tiny at HiDPI; they now multiply by ui::Layout::dpiScale().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 08:44:55 -05:00
DanSandClaude Opus 4.8 8b46ead72a feat(export): CSV export scope — All / current filter / date range
Finishes the transactions-export partial. The dialog now offers three scopes:
- All transactions (unchanged default; exported with no copy).
- Current History filter — the Transactions tab publishes a TxFilterSnapshot of
  the visible legs (a merged autoshield row contributes both its send+receive
  legs, so the CSV stays a per-leg record like an unfiltered export); the radio
  is disabled with an explanation when no filter is active, and a snapshot that
  predates the current tx list (revision mismatch) is refused rather than
  silently exporting everything.
- Date range (YYYY-MM-DD, inclusive end-of-day; pending/dateless rows excluded
  with a note; invalid/reversed dates blocked inline).
The live count reflects what will actually be exported. formatTransactionsCsv
and the atomic write are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 00:19:24 -05:00
DanSandClaude Opus 4.8 8961cbdfc9 feat(export): sanitize CSV filename + Open-folder button
The transactions CSV export now sanitizes the typed filename (basename only,
illegal chars → '_', no leading dots/trailing dots-or-spaces, length-capped,
always .csv) so it can't be pointed outside the config dir, and the success
screen gets an "Open folder" button. (All-vs-filter / date-range export scope is
left for a follow-up — it needs tx-tab filter-snapshot plumbing.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:51:07 -05:00
DanSandClaude Opus 4.8 794150b11b feat(chat,notifications): first-run chat intro card; clickable toast history + "+N" badge
- Chat: a dismissible first-run intro card (how chat works — encrypted, sent as
  shielded txs with a small fee, tied to the seed) with a "Copy my chat address"
  button, shown in the empty thread pane; dismissal persisted. The copy logic is
  shared with the existing sidebar button (moved to a helper, unchanged).
- Toasts: clicking a toast now opens the existing notification-history panel, and
  a "+N more" badge appears when several live toasts are queued (render only ever
  showed the newest). Badge opacity is a ui.toml token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:51:07 -05:00
DanSandClaude Opus 4.8 2e74a3ca69 feat(explorer,keys): distinguish lookup failures; key-export unlock affordance
- Explorer: a hash lookup now tells a transport/RPC failure apart from a genuine
  not-found (RPC -5/-8), showing "Lookup failed: <friendly error>" instead of
  "no block or transaction found"; block-detail errors routed through
  friendlyRpcError. getblock/getblockhash/getrawtransaction calls unchanged.
- Key-export: when the wallet is locked, the dialog now says "Unlock your wallet
  first" with an Unlock button (lite → requestLiteUnlock; full-node → close to
  reveal the lock screen) instead of a raw error, and the fetching state animates
  with LoadingDots. The export RPC/args are unchanged.

(i18n.cpp carries the additive keys for these and the chat/CSV commits below.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:51:06 -05:00
DanSandClaude Opus 4.8 e94d88e723 fix(console): confirmed destructive commands now run; gate z_exportmnemonic
Two gaps in the destructive-command confirm:
- Regression: a command confirmed via the command-reference dialog's "Run" was
  replayed through submitConsoleCommand, which re-armed the destructive gate and
  silently did nothing until the user retyped it. The dialog now pre-arms the
  exact-line confirm so the replay executes.
- z_exportmnemonic (full-node seed-phrase reveal) wasn't in the reference
  catalog, so a pasted z_exportmnemonic ran instantly. Added a small
  secret-revealing fallback set (z_exportmnemonic, z_exportviewingkey) to the
  destructive check. Command execution is otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:51:06 -05:00
DanSandClaude Opus 4.8 097b2ea014 feat(ux): wizard handoff note, daemon restart reminder, settings polish (code)
The code changes intended with the settings_window removal in a90aa29 (a bad
git-add pathspec aborted staging them there, leaving that commit with only the
file deletions and CMakeLists still referencing them):
- First-run handoff info toast at the completion point.
- Daemon-updater "restart pending" reminder (Settings → Daemon Binary) + the
  Done screen's warning-level "restart required" headline.
- Debug restart-note raised to a readable color with the restart prompt beneath
  it; DEBUG OPTIONS header shows a lock + explanatory caption (auth gate
  unchanged); a few hardcoded literals → material tokens / dpiScale.
- Drop the legacy settings_window from the build (CMakeLists + the dev-only
  references) to match the file deletions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:36:15 -05:00
DanSandClaude Opus 4.8 a43ac0156f feat(chat): make the key-verify lock clickable with a fingerprint dialog
The header key-verify lock was an unexplained hover-only icon. It's now
clickable, opening a dialog that shows the contact's identity-key fingerprint
and explains comparing it out-of-band, with a Copy-key button and a clearer
tooltip. The crypto/verification logic is unchanged (the wording is careful that
the lock means "encrypted to this key", since there is no trusted/verified flag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:35:32 -05:00
DanSandClaude Opus 4.8 534120d3de feat(peers): success toasts for ban/unban/clear + confirm the bulk Clear-All
Peer ban actions only toasted on failure; success was silent. Added success
toasts for ban/unban/clear-all, and the bulk "Clear All Bans" now takes a
confirming second click (per-row unban stays one-click). The setban/clearbanned
RPC calls are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:35:32 -05:00
DanSandClaude Opus 4.8 a90aa29478 feat(ux): wizard handoff note, daemon restart reminder, settings polish; drop legacy settings_window
- First-run handoff: one info toast ("node starting, will keep syncing in the
  background") at the completion point, instead of dropping the user onto a busy
  blank window with no explanation.
- Daemon updater: a newer installed binary now sets a persistent "restart
  pending" reminder (surfaced in Settings → Daemon Binary with a Restart button)
  and the updater's Done screen leads with a warning-level "restart required"
  headline — easy to miss before.
- Settings: the debug-categories "restart required" note is raised from ~35%
  alpha to a readable color with the restart prompt moved directly beneath it;
  the DEBUG OPTIONS header shows a lock + explanatory caption so it's clearly a
  gated diagnostics section (the auth gate itself is unchanged); a couple of
  hardcoded color/size literals now use material tokens / dpiScale.
- Removed the legacy duplicate settings_window.cpp/.h (only reachable via a dev
  flag, never in production) so edits can't silently miss one of two settings UIs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:35:32 -05:00
74 changed files with 2862 additions and 1016 deletions
-2
View File
@@ -553,7 +553,6 @@ set(APP_SOURCES
src/ui/windows/console_selection_controller.cpp src/ui/windows/console_selection_controller.cpp
src/ui/windows/console_tab_helpers.cpp src/ui/windows/console_tab_helpers.cpp
src/ui/windows/console_text_layout.cpp src/ui/windows/console_text_layout.cpp
src/ui/windows/settings_window.cpp
src/ui/pages/settings_page.cpp src/ui/pages/settings_page.cpp
src/ui/windows/about_dialog.cpp src/ui/windows/about_dialog.cpp
src/ui/windows/faq_dialog.cpp src/ui/windows/faq_dialog.cpp
@@ -704,7 +703,6 @@ set(APP_HEADERS
src/ui/windows/console_selection_controller.h src/ui/windows/console_selection_controller.h
src/ui/windows/console_tab.h src/ui/windows/console_tab.h
src/ui/windows/console_tab_helpers.h src/ui/windows/console_tab_helpers.h
src/ui/windows/settings_window.h
src/ui/windows/about_dialog.h src/ui/windows/about_dialog.h
src/ui/windows/faq_dialog.h src/ui/windows/faq_dialog.h
src/ui/windows/faq_content.h src/ui/windows/faq_content.h
+1
View File
@@ -1226,6 +1226,7 @@ pill-rounding = { size = 12.0 }
padding-x = { size = 12.0 } padding-x = { size = 12.0 }
glass-fill-alpha = { size = 18.0 } glass-fill-alpha = { size = 18.0 }
accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha) accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha)
badge-fill-alpha = { size = 0.35 } # "+N more" badge fill opacity (fraction of the accent alpha)
glass-border-alpha = { size = 35.0 } glass-border-alpha = { size = 35.0 }
progress-bar-height = { size = 2.0 } progress-bar-height = { size = 2.0 }
duration-info = { size = 1.0 } duration-info = { size = 1.0 }
+30
View File
@@ -48,6 +48,36 @@ for f in "${files[@]}"; do
fi fi
done done
# ── Content guards (UX-hygiene threads) ──────────────────────────────────────
# Low-false-positive regression checks for debt the UX audit cleared, so it can't
# silently regrow. Each is deliberately narrow; widen only with an allowlist.
for f in "${files[@]}"; do
[[ "$f" == *.cpp ]] || continue
[[ -f "$f" ]] || continue
# app_sweep.cpp is the dev screenshot-sweep harness: its fixture strings are never shipped UI.
[[ "$(basename "$f")" == "app_sweep.cpp" ]] && continue
# (1) Tactile button sized with a raw multi-digit pixel literal that ignores HiDPI.
# Real sizes multiply by ui::Layout::dpiScale() / dp / hScale, or come from schema.
if hits=$(grep -nE 'Tactile(Small)?Button\([^;]*ImVec2\([1-9][0-9]+(\.[0-9]+f?)?,' "$f" \
| grep -vE 'dpiScale|\* *dp\b|\* *hdpi|hScale|schema|Scale\('); then
while IFS= read -r line; do
echo "✗ unscaled button size (multiply the px by ui::Layout::dpiScale()): $f:$line" >&2
fail=1
done <<< "$hits"
fi
# (2) User-visible migrate/sweep status assigned a bare English literal instead of TR(...).
# "__SENTINEL__" comparisons and empty strings are fine.
if hits=$(grep -nE '(sweep_status_|seed_migration_status_)[[:space:]]*=[[:space:]]*"[A-Za-z]' "$f" \
| grep -vE 'TR\(|"__'); then
while IFS= read -r line; do
echo "✗ untranslated status string (route through TR(...)): $f:$line" >&2
fail=1
done <<< "$hits"
fi
done
if (( fail )); then if (( fail )); then
echo "" >&2 echo "" >&2
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2 echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
+191 -29
View File
@@ -36,11 +36,11 @@
#include "ui/windows/explorer_tab.h" #include "ui/windows/explorer_tab.h"
#include "ui/windows/seed_display.h" #include "ui/windows/seed_display.h"
#include "ui/windows/market_tab.h" #include "ui/windows/market_tab.h"
#include "ui/windows/settings_window.h"
#include "ui/windows/about_dialog.h" #include "ui/windows/about_dialog.h"
#include "ui/windows/faq_dialog.h" #include "ui/windows/faq_dialog.h"
#include "embedded/IconsMaterialDesign.h" #include "embedded/IconsMaterialDesign.h"
#include "ui/windows/key_export_dialog.h" #include "ui/windows/key_export_dialog.h"
#include "ui/imgui_secret_scrub.h" // scrubImGuiInputTextState — zero ImGui's retained secret buffers
#include "ui/windows/transaction_details_dialog.h" #include "ui/windows/transaction_details_dialog.h"
#include "ui/windows/qr_popup_dialog.h" #include "ui/windows/qr_popup_dialog.h"
#include "ui/windows/validate_address_dialog.h" #include "ui/windows/validate_address_dialog.h"
@@ -144,6 +144,28 @@ void App::wipeSecrets()
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET) sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET) sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET) sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET)
ui::scrubImGuiInputTextState(); // zero ImGui's retained copies of whatever secret was last typed
}
// Scrub + close any secret that is currently ON SCREEN (revealed seed phrase, migration seed, exported
// key) when the wallet locks — locking shouldn't leave a phrase/key visible or resident. Called on every
// lock transition (manual, auto, lite). Separate from wipeSecrets(), which is the exit-path scrub.
void App::wipeDisplayedSecrets()
{
if (!seed_backup_phrase_.empty()) {
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
seed_backup_phrase_.clear();
}
if (!seed_migration_seed_.empty()) {
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
seed_migration_seed_.clear();
}
seed_backup_fetch_started_ = false;
show_seed_backup_ = false;
sodium_memzero(export_result_, sizeof(export_result_));
ui::KeyExportDialog::hide(); // zeros its static s_key via secureWipeLiteSecret
console_tab_.wipeSecretResult(); // drop any masked key-dump result held in the console
ui::scrubImGuiInputTextState(); // zero ImGui's retained copy of the last-typed passphrase/PIN
} }
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread // Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
@@ -943,6 +965,9 @@ void App::update()
lite_wallet_->pumpAsyncOpen(); lite_wallet_->pumpAsyncOpen();
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore). // Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
lite_wallet_->pumpLifecycleResult(); lite_wallet_->pumpLifecycleResult();
// A create/restore whose passphrase encryption failed left the wallet UNENCRYPTED (previously log-only): say so.
if (lite_wallet_->takeEncryptionFailureNotice())
ui::Notifications::instance().error(TR("lite_encrypt_failed_unencrypted"), 15.0f);
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks. // Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
lite_wallet_->pumpWalletOps(); lite_wallet_->pumpWalletOps();
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of // A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
@@ -1028,7 +1053,11 @@ void App::update()
case LiteOpKind::None: case LiteOpKind::None:
default: default:
if (lite_send_callback_) { if (lite_send_callback_) {
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error); // A transport-style failure may mean the server accepted the tx but the reply was
// lost: tag it so the Send tab says "check History" instead of inviting a resend.
const std::string liteErr = (!broadcast.ok && rpc::liteSendErrorMayBeInFlight(broadcast.error))
? rpc::markSendMaybeInFlight(broadcast.error) : broadcast.error;
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : liteErr);
lite_send_callback_ = nullptr; lite_send_callback_ = nullptr;
} }
break; break;
@@ -1963,6 +1992,12 @@ void App::render()
} }
settings_->setWizardCompleted(true); settings_->setWizardCompleted(true);
settings_->save(); settings_->save();
// Soften the abrupt handoff: tell the user the node is starting + will keep syncing in the
// background, instead of dropping them onto a busy/blank main window with no explanation.
if (!state_.connected)
ui::Notifications::instance().info(
settings_->getEncryptionPending() ? TR("wiz_handoff_encrypting") : TR("wiz_handoff_starting"),
10.0f);
} }
// Process deferred encryption from wizard (runs in background) // Process deferred encryption from wizard (runs in background)
@@ -2387,6 +2422,8 @@ void App::render()
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this); console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
else else
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this); console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
// One-time: route the console's "Copy secret" through the auto-clearing clipboard.
console_tab_.setCopySecretHandler([this](const std::string& s) { copySecretToClipboard(s); });
} }
console_tab_.render(*console_exec_); console_tab_.render(*console_exec_);
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when // Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
@@ -2528,9 +2565,6 @@ void App::render()
// content area, status bar, and every other widget in this window. // content area, status bar, and every other widget in this window.
// ==================================================================== // ====================================================================
PERF_BEGIN(_perfDialogs); PERF_BEGIN(_perfDialogs);
if (show_settings_) {
ui::RenderSettingsWindow(this, &show_settings_);
}
if (show_about_) { if (show_about_) {
ui::RenderAboutDialog(this, &show_about_); ui::RenderAboutDialog(this, &show_about_);
@@ -2548,7 +2582,11 @@ void App::render()
if (show_import_key_) { if (show_import_key_) {
renderImportKeyDialog(); renderImportKeyDialog();
} }
if (show_import_wallet_file_) {
renderImportWalletFileDialog();
}
if (show_export_key_) { if (show_export_key_) {
renderExportKeyDialog(); renderExportKeyDialog();
} }
@@ -2682,6 +2720,10 @@ void App::renderNodeStatusBanner()
in.node_busy = busy.busy; in.node_busy = busy.busy;
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
in.auth_failed = rpc_auth_failed_; in.auth_failed = rpc_auth_failed_;
// Corrected trigger: ONLY a degraded wallet (wallet_degraded_ — z_getnewaddress fails, can't shield) that
// actually holds transparent funds. A merely-non-mnemonic but functional wallet has wallet_degraded_ ==
// false and is never flagged here. Full-node only (in.lite returns early regardless).
in.legacy_funds_stuck = !isLiteBuild() && wallet_degraded_ && state_.transparentBalance > 0.0001;
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in); const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
if (!banner.show) return; if (!banner.show) return;
@@ -2720,6 +2762,8 @@ void App::renderNodeStatusBanner()
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break; title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
case ui::NodeBannerReason::AuthFailed: case ui::NodeBannerReason::AuthFailed:
title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break; title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break;
case ui::NodeBannerReason::LegacyFundsStuck:
title = TR("node_banner_legacy_title"); icon = ICON_MD_KEY; break;
case ui::NodeBannerReason::FullNodeOffline: case ui::NodeBannerReason::FullNodeOffline:
default: default:
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break; title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
@@ -2729,6 +2773,7 @@ void App::renderNodeStatusBanner()
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect"); if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart"); else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config"); else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config");
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) actionLabel = TR("node_banner_recover");
// The NoPeers case deliberately carries no passthrough detail (connection_status is just // The NoPeers case deliberately carries no passthrough detail (connection_status is just
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a // "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
@@ -2736,6 +2781,7 @@ void App::renderNodeStatusBanner()
std::string detailText; std::string detailText;
if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail"); if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail");
else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail"); else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail");
else if (banner.reason == ui::NodeBannerReason::LegacyFundsStuck) detailText = TR("node_banner_legacy_detail");
else detailText = banner.detail; else detailText = banner.detail;
const float padX = ui::Layout::spacingLg(); const float padX = ui::Layout::spacingLg();
@@ -2825,6 +2871,7 @@ void App::renderNodeStatusBanner()
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) { if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon(); if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect(); else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) showSeedMigrationDialog();
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) { else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) {
const std::string conf = rpc::Connection::getDefaultConfPath(); const std::string conf = rpc::Connection::getDefaultConfPath();
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(), util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
@@ -3079,6 +3126,10 @@ void App::renderStatusBar()
} else if (rpc_busy_gate_->busy()) { } else if (rpc_busy_gate_->busy()) {
// The node holds back our requests (a long operation holds its cs_main): data may be stale. // The node holds back our requests (a long operation holds its cs_main): data may be stale.
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy")); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
} else if (isLiteBuild() && !state_.sync.lite_error.empty()) {
// Lite scan/server failure: show the error instead of a frozen 'Syncing 99.9%'.
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Error()), TR("sb_lite_sync_error"),
state_.sync.lite_error.c_str());
} else if (state_.sync.syncing) { } else if (state_.sync.syncing) {
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers; int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
int blocksLeft = chainTip - state_.sync.blocks; int blocksLeft = chainTip - state_.sync.blocks;
@@ -3315,7 +3366,7 @@ void App::renderStatusBar()
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol); ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
} }
if (clicked) { if (clicked || notes.consumeHistoryRequest()) { // bell click, or a click on the toast itself
alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
ImGui::OpenPopup("##AlertHistoryPopup"); ImGui::OpenPopup("##AlertHistoryPopup");
} }
@@ -3462,14 +3513,20 @@ void App::renderLiteFirstRunPrompt()
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f), ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
// Best-effort zero of the seed-word statics (they hold the whole phrase) before they're dropped.
auto wipeStr = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
auto wipeWords = [&](std::vector<std::string>& v) { for (auto& w : v) wipeStr(w); v.clear(); };
auto wipeChips = [&](std::vector<std::pair<std::string, bool>>& v) {
for (auto& c : v) { wipeStr(c.first); c.second = false; } v.clear();
};
auto finish = [&]() { auto finish = [&]() {
wallet::secureWipeLiteSecret(seed); wallet::secureWipeLiteSecret(seed);
sodium_memzero(restoreSeed, sizeof(restoreSeed)); sodium_memzero(restoreSeed, sizeof(restoreSeed));
restoreBirthday = 0; restoreBirthday = 0;
restoring = false; restoring = false;
restoreErr.clear(); restoreErr.clear();
words.clear(); wipeWords(words);
chips.clear(); wipeChips(chips);
progress = 0; progress = 0;
step = 0; step = 0;
skipConfirm = false; skipConfirm = false;
@@ -3577,7 +3634,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::Spacing(); ImGui::Spacing(); ImGui::Spacing(); ImGui::Spacing();
if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) { if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
chips.clear(); wipeChips(chips);
for (const auto& w : words) chips.emplace_back(w, false); for (const auto& w : words) chips.emplace_back(w, false);
std::mt19937 rng{std::random_device{}()}; std::mt19937 rng{std::random_device{}()};
std::shuffle(chips.begin(), chips.end(), rng); std::shuffle(chips.begin(), chips.end(), rng);
@@ -3588,7 +3645,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed); if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) { if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120.0f * ui::Layout::dpiScale(), 0))) {
if (!skipConfirm) { if (!skipConfirm) {
skipConfirm = true; // require a second, deliberate click skipConfirm = true; // require a second, deliberate click
} else { } else {
@@ -3651,9 +3708,9 @@ void App::renderLiteFirstRunPrompt()
} }
if (!verified) ImGui::EndDisabled(); if (!verified) ImGui::EndDisabled();
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { skipConfirm = false; step = 1; } if (ui::material::TactileButton(TR("appx_back"), ImVec2(80.0f * ui::Layout::dpiScale(), 0))) { skipConfirm = false; step = 1; }
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) { if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120.0f * ui::Layout::dpiScale(), 0))) {
if (!skipConfirm) { if (!skipConfirm) {
skipConfirm = true; // require a second, deliberate click skipConfirm = true; // require a second, deliberate click
} else { } else {
@@ -3750,7 +3807,7 @@ void App::renderLiteFirstRunPrompt()
} }
ImGui::EndDisabled(); ImGui::EndDisabled();
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { if (ui::material::TactileButton(TR("appx_back"), ImVec2(80.0f * ui::Layout::dpiScale(), 0))) {
sodium_memzero(restoreSeed, sizeof(restoreSeed)); sodium_memzero(restoreSeed, sizeof(restoreSeed));
restoreErr.clear(); restoreErr.clear();
step = 0; step = 0;
@@ -3788,12 +3845,13 @@ void App::renderLiteUnlockPrompt()
const float btnW = 130.0f * dp; const float btnW = 130.0f * dp;
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered; bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
if (doUnlock) { if (doUnlock) {
const bool ok = lite_wallet_->unlockWallet(pass); std::string unlockErr;
const bool ok = unlockLiteWallet(std::string(pass), &unlockErr);
sodium_memzero(pass, sizeof(pass)); sodium_memzero(pass, sizeof(pass));
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f); if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
else { else {
wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed")); wallet::liteLog(std::string("Unlock failed: ") + unlockErr);
ui::Notifications::instance().error(TR("lite_unlock_failed")); ui::Notifications::instance().error(unlockErr);
} }
lite_unlock_prompt_ = false; lite_unlock_prompt_ = false;
ImGui::CloseCurrentPopup(); ImGui::CloseCurrentPopup();
@@ -3895,6 +3953,85 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
ImGui::EndDisabled(); ImGui::EndDisabled();
} }
void App::renderImportWalletFileDialog()
{
namespace m = ui::material;
float dp = ui::Layout::dpiScale();
m::OverlayDialogSpec ov;
ov.title = TR("import_wallet_file_title");
ov.p_open = &show_import_wallet_file_;
ov.style = m::OverlayStyle::BlurFloat;
ov.cardWidth = 640.0f;
ov.idSuffix = "importwalletfile";
if (!m::BeginOverlayDialog(ov)) return;
if (ImGui::IsWindowAppearing()) {
import_wallet_file_done_ = false;
import_wallet_file_ok_ = false;
import_wallet_file_status_.clear();
}
if (ImGui::IsKeyPressed(ImGuiKey_Escape) && !import_wallet_file_busy_) show_import_wallet_file_ = false;
m::DialogWarningHeader(TR("import_wallet_file_note"));
ImGui::Spacing();
if (!state_.connected) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_need_node"));
ImGui::Spacing();
}
ImGui::TextUnformatted(TR("import_wallet_file_path_label"));
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
ImGui::BeginDisabled(import_wallet_file_busy_);
ImGui::InputText("##importwalletpath", import_wallet_file_path_, sizeof(import_wallet_file_path_));
ImGui::EndDisabled();
if (!import_wallet_file_busy_) {
if (m::TactileButton(TR("paste"), ImVec2(104 * dp, 0))) {
const char* clip = ImGui::GetClipboardText();
if (clip) {
std::string t = dragonx::util::trimmedClipboardText(clip);
snprintf(import_wallet_file_path_, sizeof(import_wallet_file_path_), "%s", t.c_str());
}
}
ImGui::SameLine();
if (m::TactileButton(TR("clear"), ImVec2(104 * dp, 0))) import_wallet_file_path_[0] = '\0';
}
ImGui::Spacing();
if (import_wallet_file_busy_) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s%s",
TR("import_wallet_file_importing"), m::LoadingDots());
ImGui::Spacing();
} else if (import_wallet_file_done_) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(
import_wallet_file_ok_ ? m::Success() : m::ReadableError()));
ImGui::TextWrapped("%s", import_wallet_file_status_.c_str());
ImGui::PopStyleColor();
ImGui::Spacing();
}
ImGui::BeginDisabled(import_wallet_file_busy_ || !state_.connected || import_wallet_file_path_[0] == '\0');
if (m::TactileButton(TR("import_wallet_file_import_btn"), ImVec2(190 * dp, 0))) {
import_wallet_file_busy_ = true;
import_wallet_file_done_ = false;
import_wallet_file_status_.clear();
importWalletFile(import_wallet_file_path_, [this](bool ok, const std::string& err) {
import_wallet_file_busy_ = false;
import_wallet_file_done_ = true;
import_wallet_file_ok_ = ok;
import_wallet_file_status_ = ok ? std::string(TR("import_wallet_file_ok")) : err;
});
}
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::BeginDisabled(import_wallet_file_busy_);
if (m::TactileButton(TR("close"), ImVec2(110 * dp, 0))) show_import_wallet_file_ = false;
ImGui::EndDisabled();
m::EndOverlayDialog();
}
void App::renderImportKeyDialog() void App::renderImportKeyDialog()
{ {
namespace m = ui::material; namespace m = ui::material;
@@ -4322,8 +4459,11 @@ void App::renderExportKeyDialog()
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key")); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_), ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly); ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
if (m::TactileButton(TR("copy"))) if (m::TactileButton(TR("copy"))) {
ImGui::SetClipboardText(export_result_); // A private key is a secret — route through the auto-clearing clipboard path (45s armed wipe
// + its own "copied, auto-clears" toast) instead of a plain, permanent clipboard write.
copySecretToClipboard(export_result_);
}
} else if (export_error_) { } else if (export_error_) {
ImGui::Spacing(); ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f); ImGui::PushTextWrapPos(0.0f);
@@ -4707,7 +4847,7 @@ void App::renderSeedBackupDialog()
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextWrapped("%s", TR("seed_backup_intro")); ImGui::TextWrapped("%s", TR("seed_backup_intro"));
ImGui::Spacing(); ImGui::Spacing();
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_)); { auto words = ui::SplitSeedWords(seed_backup_phrase_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
ImGui::Spacing(); ImGui::Spacing();
if (!seed_backup_status_.empty()) { if (!seed_backup_status_.empty()) {
ImGui::PushTextWrapPos(0.0f); ImGui::PushTextWrapPos(0.0f);
@@ -4877,7 +5017,7 @@ void App::renderSeedMigrationDialog()
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched. // distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
{ {
ui::material::GlassSectionScope seedPanel; ui::material::GlassSectionScope seedPanel;
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_)); { auto words = ui::SplitSeedWords(seed_migration_seed_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
} }
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr")); ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
@@ -4999,7 +5139,7 @@ void App::renderSeedMigrationDialog()
ImGui::Spacing(); ImGui::Spacing();
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled(); if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0))) if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
beginAdoptSeedWallet(); verifyThenAdoptSeedWallet(); // fresh minconf-0, sync-aware balance check before the swap
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled(); if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0))) if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
@@ -5024,6 +5164,22 @@ void App::renderSeedMigrationDialog()
{ {
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0; const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
if (!canSweep) seed_migration_confirm_sweep_ = false; // never keep a stale armed state if (!canSweep) seed_migration_confirm_sweep_ = false; // never keep a stale armed state
// The sweep (z_shieldcoinbase / z_mergetoaddress) needs an unlocked wallet, so an encrypted
// wallet silently disabled the button. Offer an inline unlock instead of a dead end.
if (state_.isLocked()) {
static char s_mig_unlock_pass[128] = {0};
ImGui::TextWrapped("%s", TR("mig_unlock_prompt"));
ImGui::SetNextItemWidth(220 * dp);
const bool submit = ImGui::InputText("##mig_unlock", s_mig_unlock_pass, sizeof(s_mig_unlock_pass),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
if ((ui::material::TactileButton(TR("mig_unlock_btn"), ImVec2(120 * dp, 0)) || submit)
&& s_mig_unlock_pass[0]) {
unlockWallet(s_mig_unlock_pass, 600);
sodium_memzero(s_mig_unlock_pass, sizeof(s_mig_unlock_pass)); // don't retain the secret
}
ImGui::Spacing();
}
if (!seed_migration_confirm_sweep_) { if (!seed_migration_confirm_sweep_) {
if (!canSweep) ImGui::BeginDisabled(); if (!canSweep) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0))) if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
@@ -5063,7 +5219,8 @@ void App::renderSeedMigrationDialog()
case SeedMigrationStep::Confirming: { case SeedMigrationStep::Confirming: {
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty" const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
const bool confirmed = seed_migration_sweep_confs_ >= 1; const bool mined = seed_migration_sweep_confs_ >= 1; // remainder re-sweep stays offered once mined
const bool confirmed = dragonx::sweepConfsAreReorgSafe(seed_migration_sweep_confs_); // adopt needs reorg-safe depth
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 && const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
seed_migration_legacy_remaining_ <= dust; seed_migration_legacy_remaining_ <= dust;
ImGui::TextWrapped("%s", TR("mig_confirming")); ImGui::TextWrapped("%s", TR("mig_confirming"));
@@ -5083,8 +5240,8 @@ void App::renderSeedMigrationDialog()
ImGui::Spacing(); ImGui::Spacing();
if (confirmed && legacyEmpty) { if (confirmed && legacyEmpty) {
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0))) if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
beginAdoptSeedWallet(); verifyThenAdoptSeedWallet(); // re-reads confs + balance right before the daemon stop
} else if (confirmed && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) { } else if (mined && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
// Only claim a remainder once the legacy balance was actually measured; while a sweep is // Only claim a remainder once the legacy balance was actually measured; while a sweep is
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0 // mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown. // sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
@@ -5108,6 +5265,10 @@ void App::renderSeedMigrationDialog()
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0))) if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
seed_migration_confirm_sweep_ = false; seed_migration_confirm_sweep_ = false;
} }
} else if (mined && legacyEmpty) {
// Mined but not yet reorg-safe deep: say so instead of the misleading "waiting to be mined".
char dbuf[200]; snprintf(dbuf, sizeof(dbuf), TR("mig_waiting_depth"), dragonx::kSweepAdoptMinConfs);
ImGui::TextWrapped("%s", dbuf);
} else { } else {
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine")); ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
} }
@@ -6036,16 +6197,16 @@ bool App::startEmbeddedDaemon()
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter. // NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
switch (state) { switch (state) {
case daemon::EmbeddedDaemon::State::Starting: case daemon::EmbeddedDaemon::State::Starting:
setDaemonStatus(TR("sb_starting_daemon")); setDaemonStatus(util::trCopy("sb_starting_daemon"));
break; break;
case daemon::EmbeddedDaemon::State::Running: case daemon::EmbeddedDaemon::State::Running:
setDaemonStatus(TR("sb_dragonxd_running")); setDaemonStatus(util::trCopy("sb_dragonxd_running"));
break; break;
case daemon::EmbeddedDaemon::State::Stopping: case daemon::EmbeddedDaemon::State::Stopping:
setDaemonStatus(TR("sb_dragonxd_stopping")); setDaemonStatus(util::trCopy("sb_dragonxd_stopping"));
break; break;
case daemon::EmbeddedDaemon::State::Stopped: case daemon::EmbeddedDaemon::State::Stopped:
setDaemonStatus(TR("sb_dragonxd_stopped")); setDaemonStatus(util::trCopy("sb_dragonxd_stopped"));
break; break;
case daemon::EmbeddedDaemon::State::Error: case daemon::EmbeddedDaemon::State::Error:
setDaemonStatus("Error: " + msg); setDaemonStatus("Error: " + msg);
@@ -8182,6 +8343,7 @@ void App::restartDaemon()
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load()); isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
if (!decision.allowed) return; if (!decision.allowed) return;
if (refuseIfNodeBusy()) return; if (refuseIfNodeBusy()) return;
daemon_restart_pending_.store(false); // a restart is actually happening — clear the reminder
daemon_restarting_ = true; daemon_restarting_ = true;
daemon_controller_->prepareLifecycleOperation(decision, settings_.get()); daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
+29 -5
View File
@@ -114,6 +114,7 @@ class App {
public: public:
App(); App();
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05) void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
void wipeDisplayedSecrets(); // scrub + close any on-screen secret (seed/key displays) on wallet lock
~App(); ~App();
// Non-copyable // Non-copyable
@@ -332,6 +333,10 @@ public:
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock` // Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger). // doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
bool lockLiteWallet(); bool lockLiteWallet();
// Unlock the lite wallet with the same escalating attempt-lockout as the full node (persisted across restarts)
// and set state_.locked=false right away on success (the poll lags). Takes the passphrase by value and wipes
// it. On failure *err (if given) holds a user-facing reason ("Unlock failed..." or the lockout wait).
bool unlockLiteWallet(std::string passphrase, std::string* err = nullptr);
// (Re)build the lite controller from current settings so a changed lite-server selection // (Re)build the lite controller from current settings so a changed lite-server selection
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp). // takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
void rebuildLiteWallet(bool force = false); void rebuildLiteWallet(bool force = false);
@@ -448,6 +453,9 @@ public:
// Address operations // Address operations
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr); void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr); void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
// The daemon's actual reason the last new-address RPC failed (friendly-mapped), or "" on success. Lets
// the UI show e.g. "HD seed not found" (legacy/degraded wallet) instead of a generic "couldn't create".
const std::string& lastAddressError() const { return last_address_error_; }
// Hide/unhide addresses from the address list (persisted in settings) // Hide/unhide addresses from the address list (persisted in settings)
void hideAddress(const std::string& addr); void hideAddress(const std::string& addr);
@@ -557,10 +565,11 @@ public:
private: private:
App& app_; App& app_;
}; };
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance // Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call (any call, not just a
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that // maintenance one: disconnect() needs the curl lock the call holds, which would freeze the UI), and
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most // refuse maintenance calls that start before the next connect (onConnected() lifts that). The abort
// 30 s, as before), so a user's own send is never cut off mid-call. // flag is cleared by the next tryConnect(). A user send aborted mid-flight is handled as a lost reply
// ("outcome unknown — check History"), never a double-send.
void abortMaintenanceCalls(); void abortMaintenanceCalls();
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced // Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
// full node that is past warmup, not rescanning and not busy. // full node that is past warmup, not rescanning and not busy.
@@ -641,6 +650,9 @@ public:
// Dialog triggers (used by settings page to open modal dialogs) // Dialog triggers (used by settings page to open modal dialogs)
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
void showImportWalletFileDialog() { show_import_wallet_file_ = true; } // import a whole legacy key dump
// Import every key from an old wallet's z_exportwallet/dumpwallet dump file (z_importwallet, one rescan).
void importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback);
void showExportKeyDialog() { show_export_key_ = true; } void showExportKeyDialog() { show_export_key_ = true; }
void showBackupDialog() { show_backup_ = true; } void showBackupDialog() { show_backup_ = true; }
void showSeedBackupDialog() { show_seed_backup_ = true; } void showSeedBackupDialog() { show_seed_backup_ = true; }
@@ -769,6 +781,9 @@ public:
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles. * Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
*/ */
void restartDaemon(); void restartDaemon();
// A newer daemon binary is installed but the running node is still the old one until a restart.
void markDaemonRestartPending() { daemon_restart_pending_.store(true); }
bool daemonRestartPending() const { return daemon_restart_pending_.load(); }
// Switch the active wallet: persist the new -wallet=<name>, stop the node, restart on it // Switch the active wallet: persist the new -wallet=<name>, stop the node, restart on it
// (rescan only if it was never synced in this datadir). Per-wallet data follows automatically // (rescan only if it was never synced in this datadir). Per-wallet data follows automatically
// via the identity-scoped caches (P1). No-op if that wallet is already active. // via the identity-scoped caches (P1). No-op if that wallet is already active.
@@ -1184,6 +1199,7 @@ private:
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
void verifyThenAdoptSeedWallet(); // re-read sweep confs + minconf-0 balance, then beginAdoptSeedWallet() iff safe
void provisionChatIdentityFromSecret(std::string secret); void provisionChatIdentityFromSecret(std::string secret);
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to) std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring // A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
@@ -1311,6 +1327,7 @@ private:
// Daemon restart (e.g. after changing debug log categories) // Daemon restart (e.g. after changing debug log categories)
std::atomic<bool> daemon_restarting_{false}; std::atomic<bool> daemon_restarting_{false};
std::atomic<bool> daemon_restart_pending_{false}; // updated binary installed, awaiting a restart
// Wallet-switch failure recovery: the switch worker sets wallet_switch_failed_ when the new // Wallet-switch failure recovery: the switch worker sets wallet_switch_failed_ when the new
// wallet's daemon won't start/stay up; the main loop then reverts active_wallet_file to // wallet's daemon won't start/stay up; the main loop then reverts active_wallet_file to
@@ -1339,6 +1356,7 @@ private:
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session. // so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
bool wallet_auto_recovered_ = false; // a salvage happened this session bool wallet_auto_recovered_ = false; // a salvage happened this session
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
std::string last_address_error_; // daemon reason the last z_getnewaddress/getnewaddress failed ("" = ok)
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys) bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
@@ -1401,10 +1419,15 @@ private:
// UI State // UI State
bool quit_requested_ = false; bool quit_requested_ = false;
bool show_demo_window_ = false; bool show_demo_window_ = false;
bool show_settings_ = false;
bool show_about_ = false; bool show_about_ = false;
bool show_faq_ = false; bool show_faq_ = false;
bool show_import_key_ = false; bool show_import_key_ = false;
bool show_import_wallet_file_ = false; // the "import keys from a backup file" modal is open
char import_wallet_file_path_[1024] = {0}; // typed path to the old wallet's key dump (not a secret)
bool import_wallet_file_busy_ = false; // a z_importwallet long call is in flight
bool import_wallet_file_done_ = false; // terminal result reached (ok/err in import_wallet_file_ok_)
bool import_wallet_file_ok_ = false;
std::string import_wallet_file_status_; // result / error text shown in the modal
bool show_export_key_ = false; bool show_export_key_ = false;
bool show_backup_ = false; bool show_backup_ = false;
bool show_seed_backup_ = false; bool show_seed_backup_ = false;
@@ -2046,6 +2069,7 @@ private:
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
void renderImportKeyDialog(); void renderImportKeyDialog();
void renderImportWalletFileDialog(); // typed-path import of a legacy key dump (z_importwallet)
void renderExportKeyDialog(); void renderExportKeyDialog();
void renderBackupDialog(); void renderBackupDialog();
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic) void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
+349 -51
View File
@@ -41,6 +41,10 @@
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress #include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic #include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
#include <cctype> #include <cctype>
#ifndef _WIN32
#include <fcntl.h> // open/fsync: flush the staged wallet + .bak before the atomic swap
#include <unistd.h>
#endif
#include "config/settings.h" #include "config/settings.h"
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing #include "wallet/lite_wallet_controller.h" // lite send/new-address routing
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest #include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
@@ -559,15 +563,31 @@ void App::tryConnect()
attempt, isUsingEmbeddedDaemon() ? "yes" : "no"); attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
} }
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb { // A previous teardown leaves rpc_'s abort flag set (open() no longer clears it, so an abort issued
// while this connect is still queued is not erased). This is the reconnect entry point: clear it here
// on the main thread, after the daemon_restarting_/shutting_down_ gates above.
if (rpc_ && !shutting_down_.load()) rpc_->clearAbort();
const std::uint64_t connectSession = rpc_session_; // main thread; a teardown/onConnected bumps it
worker_->post([this, config, daemonStarting, externalDetected, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls); bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
std::string connectErr = rpc_->getLastConnectError(); std::string connectErr = rpc_->getLastConnectError();
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind(); const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
bool warmingUp = rpc_->isWarmingUp(); bool warmingUp = rpc_->isWarmingUp();
std::string warmupStatus = rpc_->getWarmupStatus(); std::string warmupStatus = rpc_->getWarmupStatus();
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr, return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
connectKind]() { connectKind, connectSession]() {
// A probe that succeeded after a wallet switch / adopt / restore / shutdown already tore the
// connection down must not run onConnected() against a daemon that is about to be stopped.
if (connected && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
VERBOSE_LOGF("[connect #%d] Dropping stale connect result (teardown raced the probe)\n", attempt);
if (rpc_) {
rpc_->requestAbort(); // a worker call may hold curl_mutex_; don't block the UI on disconnect()
rpc_->disconnect();
}
connection_in_progress_ = false;
return;
}
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
if (connected) connect_backoff_.reset(); if (connected) connect_backoff_.reset();
if (connected) { if (connected) {
@@ -596,12 +616,19 @@ void App::tryConnect()
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) { if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n", VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
attempt, cookieConfig.hush_dir.c_str()); attempt, cookieConfig.hush_dir.c_str());
worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb { worker_->post([this, cookieConfig, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port, bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
cookieConfig.rpcuser, cookieConfig.rpcpassword, cookieConfig.rpcuser, cookieConfig.rpcpassword,
cookieConfig.use_tls); cookieConfig.use_tls);
return [this, cookieConfig, ok, attempt]() { return [this, cookieConfig, ok, attempt, connectSession]() {
connection_in_progress_ = false; connection_in_progress_ = false;
if (ok && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
if (rpc_) {
rpc_->requestAbort();
rpc_->disconnect();
}
return; // stale: a teardown raced the probe
}
if (ok) { if (ok) {
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt); VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
saved_config_ = cookieConfig; saved_config_ = cookieConfig;
@@ -832,6 +859,7 @@ void App::onConnected()
fast_worker_->start(); fast_worker_->start();
} }
// Connect on the fast worker's own thread (non-blocking to main) // Connect on the fast worker's own thread (non-blocking to main)
if (fast_rpc_ && !shutting_down_.load()) fast_rpc_->clearAbort(); // open() no longer clears a prior teardown's abort
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb { fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port, bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
saved_config_.rpcuser, saved_config_.rpcpassword, saved_config_.rpcuser, saved_config_.rpcpassword,
@@ -891,8 +919,10 @@ void App::onDisconnected(const std::string& reason)
pending_opids_.clear(); pending_opids_.clear();
pending_send_info_.clear(); pending_send_info_.clear();
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect. // Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
// Each callback here exists only because z_sendmany already returned an opid, so the op may still
// commit (or have committed) while we lost the connection: report "outcome unknown", not a failure.
for (auto& entry : pending_send_callbacks_) { for (auto& entry : pending_send_callbacks_) {
if (entry.second) entry.second(false, reason); if (entry.second) entry.second(false, rpc::markSendMaybeInFlight(reason));
} }
pending_send_callbacks_.clear(); pending_send_callbacks_.clear();
consecutive_core_failures_ = 0; consecutive_core_failures_ = 0;
@@ -950,6 +980,13 @@ void App::onDisconnected(const std::string& reason)
// the fast lane dead for the rest of the session — which silently stalls the opid poll // the fast lane dead for the rest of the session — which silently stalls the opid poll
// (its post() never runs, so a completed send spins on "Waiting for operation"). // (its post() never runs, so a completed send spins on "Waiting for operation").
fast_worker_.reset(); fast_worker_.reset();
// stop() discards undrained MainCbs, and these in-progress flags are cleared only inside
// those callbacks (unlock/lock/PIN/encrypt are posted on the fast lane when it is running).
// Release them here so the lock screen cannot show "Unlocking..." forever; the user can retry
// after reconnect, and the real encryption state is re-read on connect.
lock_unlock_in_progress_ = false;
pin_in_progress_ = false;
encrypt_in_progress_ = false;
} }
if (fast_rpc_) { if (fast_rpc_) {
fast_rpc_->disconnect(); fast_rpc_->disconnect();
@@ -1111,7 +1148,10 @@ void App::abortMaintenanceCalls()
{ {
// Both stick until the next connect, which is fine here: every caller disconnects next. // Both stick until the next connect, which is fine here: every caller disconnects next.
maint_abort_.store(true); // refuses maintenance calls still queued on the worker maint_abort_.store(true); // refuses maintenance calls still queued on the worker
if (rpc_ && maint_calls_.load() > 0) rpc_->requestAbort(); // cuts off the one in flight // Unconditional: an ordinary in-flight refresh/scan also holds curl_mutex_ for its whole
// curl_easy_perform, and every caller disconnect()s next. The abort is cleared by the next
// tryConnect() (rpc_) / onConnected() (fast_rpc_) before they reconnect.
if (rpc_) rpc_->requestAbort(); // cuts off whatever call is in flight
} }
bool App::walletScanReady() const bool App::walletScanReady() const
@@ -3772,7 +3812,7 @@ void App::banPeer(const std::string& ip, int duration_seconds)
} }
return [this, err]() { return [this, err]() {
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_ban_failed")) + util::friendlyRpcError(err)); if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_ban_failed")) + util::friendlyRpcError(err));
else refreshPeerInfo(); else { ui::Notifications::instance().success(TR("toast_peer_banned")); refreshPeerInfo(); }
}; };
}); });
} }
@@ -3790,7 +3830,7 @@ void App::unbanPeer(const std::string& ip)
} }
return [this, err]() { return [this, err]() {
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_unban_failed")) + util::friendlyRpcError(err)); if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_unban_failed")) + util::friendlyRpcError(err));
else refreshPeerInfo(); else { ui::Notifications::instance().success(TR("toast_peer_unbanned")); refreshPeerInfo(); }
}; };
}); });
} }
@@ -3809,6 +3849,7 @@ void App::clearBans()
return [this, err]() { return [this, err]() {
if (!err.empty()) { ui::Notifications::instance().error(std::string(TR("toast_clear_bans_failed")) + util::friendlyRpcError(err)); return; } if (!err.empty()) { ui::Notifications::instance().error(std::string(TR("toast_clear_bans_failed")) + util::friendlyRpcError(err)); return; }
state_.banned_peers.clear(); state_.banned_peers.clear();
ui::Notifications::instance().success(TR("toast_bans_cleared"));
refreshPeerInfo(); refreshPeerInfo();
}; };
}); });
@@ -3916,15 +3957,32 @@ void App::createNewAddress(bool shielded, std::function<void(const std::string&)
const char* traceLabel = shielded ? "Receive tab / New shielded address" const char* traceLabel = shielded ? "Receive tab / New shielded address"
: "Receive tab / New transparent address"; : "Receive tab / New transparent address";
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb { worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
std::string addr; std::string addr, genErr;
try { try {
rpc::RPCClient::TraceScope trace(traceLabel); rpc::RPCClient::TraceScope trace(traceLabel);
json result = rpc_->call(rpcMethod); json result = rpc_->call(rpcMethod);
addr = result.get<std::string>(); addr = result.get<std::string>();
} catch (const std::exception& e) { } catch (const std::exception& e) {
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what()); DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
genErr = e.what(); // keep the real reason so the UI can show it instead of a generic toast
} }
return [this, callback, addr, shielded, typeStr]() { return [this, callback, addr, genErr, shielded, typeStr]() {
if (addr.empty()) {
// Surface the daemon's actual reason (friendly-mapped) rather than a generic "couldn't create".
last_address_error_ = genErr.empty() ? std::string() : util::friendlyRpcError(genErr);
// "HD seed not found" means a legacy / degraded wallet can't derive new HD keys. Flip degraded
// mode here so the UI explains it even for an EXTERNAL daemon — detectWalletDegraded() only
// reads the embedded daemon's startup log and would otherwise miss this.
if (shielded && genErr.find("HD seed not found") != std::string::npos) {
wallet_degraded_ = true;
if (!wallet_degraded_warned_) {
wallet_degraded_warned_ = true;
ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f);
}
}
} else {
last_address_error_.clear();
}
if (!addr.empty()) { if (!addr.empty()) {
// Inject immediately so UI can select the address next frame // Inject immediately so UI can select the address next frame
AddressInfo info; AddressInfo info;
@@ -5398,6 +5456,7 @@ bool App::lockLiteWallet()
if (!lite_wallet_) return false; if (!lite_wallet_) return false;
const bool ok = lite_wallet_->lockWallet(); const bool ok = lite_wallet_->lockWallet();
if (ok) { if (ok) {
wipeDisplayedSecrets(); // scrub any revealed seed/key display on lock (matches full-node)
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock` // Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node // doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key // behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
@@ -5412,6 +5471,69 @@ bool App::lockLiteWallet()
return ok; return ok;
} }
bool App::unlockLiteWallet(std::string passphrase, std::string* err)
{
const auto fail = [&](const std::string& m) { if (err) *err = m; };
const auto waitMsg = [](long long secs) {
char msg[128];
snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), (double)secs);
return std::string(msg);
};
if (!lite_wallet_ || !lite_wallet_->walletOpen() || passphrase.empty()) {
wallet::secureWipeLiteSecret(passphrase);
fail(TR("lite_unlock_failed"));
return false; // nothing to attempt: not a wrong-credential failure, not counted
}
// Restore the persisted attempt count once per session (the full-node lock screen does the same).
if (!lock_state_restored_) {
lock_state_restored_ = true;
restoreLockoutState();
}
// Enforce the lockout from the persisted wall-clock expiry: lite has no per-frame lockout timer.
if (settings_ && settings_->getLockStateWallet() == settings_->getActiveWalletFile()) {
const long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
if (remaining > 0) {
wallet::secureWipeLiteSecret(passphrase);
fail(waitMsg(remaining));
return false;
}
}
lock_lockout_timer_ = 0.0f; // expired: don't let a stale in-memory value re-arm the curve
const bool ok = lite_wallet_->unlockWallet(std::move(passphrase)); // wipes the passphrase
if (!ok) {
const auto st = lite_wallet_->encryptionStatus();
if (st.ok && !(st.encrypted && st.locked)) {
// Not a wrong credential: already unlocked (e.g. a racing prompt) or not encrypted. Don't count it.
state_.encrypted = st.encrypted;
state_.locked = false;
if (st.encrypted) { idle_.noteActivity(); return true; }
fail(TR("lite_unlock_failed"));
return false;
}
applyUnlockFailure(TR("lite_unlock_failed")); // bump counter, arm lockout curve, persist
fail(lock_lockout_timer_ > 0.0f ? waitMsg((long long)std::ceil(lock_lockout_timer_))
: std::string(TR("lite_unlock_failed")));
return false;
}
// Success: clear the attempt state (and its persisted copy) and reflect the unlock NOW — the next poll lags.
lock_unlock_in_progress_ = false;
lock_error_msg_.clear();
lock_attempts_ = 0;
lock_lockout_timer_ = 0.0f;
if (settings_ && settings_->getLockFailedAttempts() != 0) {
settings_->setLockFailedAttempts(0);
settings_->setLockoutUntilEpoch(0);
settings_->save();
}
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
idle_.noteActivity();
state_.encrypted = true;
state_.locked = false;
return true;
}
bool App::seedChatDemoData() bool App::seedChatDemoData()
{ {
if (!chat::hushChatFeatureEnabledAtBuild()) return false; if (!chat::hushChatFeatureEnabledAtBuild()) return false;
@@ -5624,6 +5746,48 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
} }
// Import a whole wallet-key dump file (the output of the OLD wallet's z_exportwallet / dumpwallet, or
// SilentDragon's "export all private keys") via z_importwallet: the daemon reads every key from the
// file and does ONE rescan — the right tool for recovering a legacy wallet.dat the new client can't open,
// and far faster than importing keys one by one. Only the PATH touches our memory, never the keys. Runs on
// the long-call lane (no client timeout: the rescan can take a long time). Full-node only.
void App::importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback)
{
if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback(false, TR("import_key_need_node"));
return;
}
std::string p(path);
while (!p.empty() && (p.front()==' '||p.front()=='\t'||p.front()=='\n'||p.front()=='\r')) p.erase(p.begin());
while (!p.empty() && (p.back()==' '||p.back()=='\t'||p.back()=='\n'||p.back()=='\r')) p.pop_back();
if (p.empty()) { if (callback) callback(false, TR("import_wallet_file_need_path")); return; }
{
std::string why; const char* trKey = nullptr;
if (longWalletOpRefused(&why, &trKey)) { if (callback) callback(false, TR(trKey)); return; }
}
const bool started = runLongWalletTask([this, p, callback](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
std::string err;
try {
rpc::RPCClient::TraceScope trace("Settings / Import wallet file");
c.call("z_importwallet", {p}, 0L); // reads the dump + one rescan; 0 = no client timeout
} catch (const std::exception& e) {
err = e.what();
if (!err.empty() && err.find("Cannot open") != std::string::npos)
err = TR("import_wallet_file_not_found"); // the most common cause: wrong/missing path
} catch (...) {
err = "Import failed (unknown error)";
}
return [this, err, callback]() {
if (!err.empty()) { if (callback) callback(false, err); return; }
invalidateAddressValidationCache();
refreshAddresses();
markWalletMutated(/*forceBalance=*/true); // keys added: balance changes with no tip/mempool signal
if (callback) callback(true, "");
};
});
if (!started && callback) callback(false, TR("appx_wallet_op_running"));
}
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no // Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the // address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in // key's funds (balance − fee) to a destination the user already controls. The imported key is left in
@@ -5634,7 +5798,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
const std::string& destExisting) const std::string& destExisting)
{ {
if (!state_.connected || !rpc_ || !worker_) { if (!state_.connected || !rpc_ || !worker_) {
sweep_status_ = "Not connected to the daemon."; sweep_status_ = TR("err_not_connected_daemon");
sweep_step_ = SweepStep::Error; sweep_step_ = SweepStep::Error;
return; return;
} }
@@ -5646,7 +5810,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key // Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
// can't sign, and this must never accidentally route to z_importviewingkey. // can't sign, and this must never accidentally route to z_importviewingkey.
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) { if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
sweep_status_ = "Enter a spending key (a viewing key can't move funds)."; sweep_status_ = TR("sweep_err_need_spending_key");
sweep_step_ = SweepStep::Error; sweep_step_ = SweepStep::Error;
return; return;
} }
@@ -5664,7 +5828,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
} }
sweep_step_ = SweepStep::Running; sweep_step_ = SweepStep::Running;
sweep_status_ = "Importing key & rescanning…"; sweep_status_ = TR("sweep_status_importing");
sweep_txid_.clear(); sweep_txid_.clear();
sweep_dest_shown_.clear(); sweep_dest_shown_.clear();
@@ -5725,12 +5889,12 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address."); if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs. // 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
// Let an RPC failure PROPAGATE to the outer catch (→ a real error) instead of swallowing it as
// 0.0, which would misclassify a funded key as "__NOFUNDS__" after a transient timeout/warmup.
auto balAt = [&](int minconf) -> double { auto balAt = [&](int minconf) -> double {
try { nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf}); return b.is_number() ? b.get<double>()
return b.is_number() ? b.get<double>() : b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
} catch (...) { return 0.0; }
}; };
double bal = balAt(1); double bal = balAt(1);
if (bal <= 0.0) { if (bal <= 0.0) {
@@ -5758,7 +5922,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
} catch (const std::exception& e) { } catch (const std::exception& e) {
err = e.what(); err = e.what();
} catch (...) { } catch (...) {
err = "Sweep failed (unknown error)"; err = TR("sweep_err_unknown");
} }
// Scrub the worker's copy of the spending key now that the request has been sent (all paths). // Scrub the worker's copy of the spending key now that the request has been sent (all paths).
if (!key.empty()) sodium_memzero(&key[0], key.size()); if (!key.empty()) sodium_memzero(&key[0], key.size());
@@ -5766,16 +5930,16 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
invalidateAddressValidationCache(); invalidateAddressValidationCache();
refreshAddresses(); refreshAddresses();
if (!err.empty()) { if (!err.empty()) {
if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep."; if (err == "__NOFUNDS__") sweep_status_ = TR("sweep_err_nofunds");
else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly."; else if (err == "__UNCONFIRMED__") sweep_status_ = TR("sweep_err_unconfirmed");
else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds."; else if (err == "__ALREADY__") sweep_status_ = TR("sweep_err_already");
else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee."; else if (err == "__DUST__") sweep_status_ = TR("sweep_err_dust");
else sweep_status_ = err; else sweep_status_ = err;
sweep_step_ = SweepStep::Error; sweep_step_ = SweepStep::Error;
return; return;
} }
sweep_dest_shown_ = dest; sweep_dest_shown_ = dest;
sweep_status_ = "Sweeping funds to your address…"; sweep_status_ = TR("sweep_status_sweeping");
// Send all funds from the swept address to the destination through the tested z_sendmany // Send all funds from the swept address to the destination through the tested z_sendmany
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one // wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping. // transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
@@ -6282,7 +6446,7 @@ void App::showSeedMigrationDialog()
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true); pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
trackOperation(sweepOpid); trackOperation(sweepOpid);
seed_migration_step_ = SeedMigrationStep::Sweeping; seed_migration_step_ = SeedMigrationStep::Sweeping;
seed_migration_status_ = "Checking on the previous sweep…"; seed_migration_status_ = TR("migrate_status_checking_sweep");
break; break;
case MigrationResume::SweepGate: case MigrationResume::SweepGate:
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in // No txid, and either no opid or not connected to re-track it (a persisted opid is left in
@@ -6360,15 +6524,15 @@ void App::beginSweepToSeedWallet()
return; return;
} }
if (!rpc_ || !worker_ || !state_.connected) { if (!rpc_ || !worker_ || !state_.connected) {
seed_migration_status_ = "Not connected to the daemon."; seed_migration_status_ = TR("err_not_connected_daemon");
seed_migration_step_ = SeedMigrationStep::Error; return; seed_migration_step_ = SeedMigrationStep::Error; return;
} }
if (seed_migration_dest_.empty()) { if (seed_migration_dest_.empty()) {
seed_migration_status_ = "No destination address for the sweep."; seed_migration_status_ = TR("migrate_err_no_dest");
seed_migration_step_ = SeedMigrationStep::Error; return; seed_migration_step_ = SeedMigrationStep::Error; return;
} }
seed_migration_step_ = SeedMigrationStep::Sweeping; seed_migration_step_ = SeedMigrationStep::Sweeping;
seed_migration_status_ = "Building the sweep transaction…"; seed_migration_status_ = TR("migrate_status_building");
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon // Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed // then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for // migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
@@ -6384,7 +6548,27 @@ void App::beginSweepToSeedWallet()
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0}); auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
opid = res.value("opid", std::string()); opid = res.value("opid", std::string());
if (opid.empty()) err = "The daemon returned no operation id for the sweep."; if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
} catch (const std::exception& e) { err = e.what(); } } catch (const std::exception& e) {
// z_mergetoaddress deliberately ignores coinbase UTXOs, so a miner whose legacy balance is all
// mined coinbase gets "Could not find any funds to merge". Fall back to z_shieldcoinbase, which
// DOES move coinbase, to the same shielded destination. It returns an opid exactly like the merge,
// so the adopt gate (one opid -> txid -> confirm -> balance ~0 -> adopt) is unchanged; a batch
// limit or a mixed coinbase+other wallet is drained by the existing "Sweep remaining" re-sweep.
const std::string mergeErr = e.what();
if (mergeErr.find("find any funds to merge") != std::string::npos) {
try {
// Fixed-decimal fee string (ParseFixedPoint rejects a double that serializes as "5e-05").
const std::string feeStr = util::formatAmountFixed(DRAGONX_DEFAULT_FEE);
auto sres = rpc_->call("z_shieldcoinbase", {std::string("*"), dest, feeStr, 50});
opid = sres.value("opid", std::string());
if (opid.empty()) err = "The daemon returned no operation id for the coinbase shield.";
} catch (const std::exception& e2) {
err = e2.what(); // e.g. "Could not find any coinbase funds to shield" = genuinely empty
}
} else {
err = mergeErr;
}
}
return [this, opid, err]() { return [this, opid, err]() {
if (!err.empty() || opid.empty()) { if (!err.empty() || opid.empty()) {
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker. // Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
@@ -6408,7 +6592,7 @@ void App::beginSweepToSeedWallet()
} }
pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false); pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
trackOperation(opid); trackOperation(opid);
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…"; seed_migration_status_ = TR("migrate_status_waiting_accept");
}; };
}); });
} }
@@ -6560,6 +6744,57 @@ void App::locateSweepTxid()
}); });
} }
// Final gate before the swap: on the RPC worker, RE-READ the sweep tx's confirmations (when a sweep txid
// exists) and z_gettotalbalance with minconf 0 (includes unconfirmed, unlike the default minconf 1), then
// - only if both are reorg-safe, the node is not syncing and the legacy balance is ~0 - call
// beginAdoptSeedWallet() on the main thread, so the disconnect + daemon stop follow these reads at once.
// Read-only RPCs; any failure blocks adopt (nothing is stopped or swapped) and the user may retry.
void App::verifyThenAdoptSeedWallet()
{
if (capture_mode_) { beginAdoptSeedWallet(); return; } // UI sweep: unchanged behaviour
if (seed_migration_confirm_in_flight_) return;
if (!rpc_ || !worker_ || !state_.connected) {
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"));
return;
}
seed_migration_confirm_in_flight_ = true;
const std::string txid = seed_migration_sweep_txid_;
const bool needTx = !txid.empty();
const bool syncing = effectivelySyncing(); // main-thread snapshot for the worker
worker_->post([this, txid, needTx, syncing]() -> rpc::RPCWorker::MainCb {
int confs = 0; double total = -1.0; bool ok = false;
rpc::RPCClient::TraceScope trace("Migrate / pre-adopt verify");
try {
if (needTx) {
auto tx = rpc_->call("gettransaction", {txid});
if (!tx.contains("confirmations") || !tx["confirmations"].is_number()) throw std::runtime_error("no confirmations");
confs = tx["confirmations"].get<int>();
}
auto bal = rpc_->call("z_gettotalbalance", nlohmann::json::array({0})); // minconf 0
if (bal.contains("total") && bal["total"].is_string()) {
total = std::stod(bal["total"].get<std::string>());
ok = true;
}
} catch (...) { ok = false; }
return [this, needTx, syncing, confs, total, ok]() {
seed_migration_confirm_in_flight_ = false;
if (ok) {
if (needTx) seed_migration_sweep_confs_ = confs;
seed_migration_legacy_remaining_ = total;
}
// Only act while still on a step that offers Adopt (the user may have closed/changed it).
const bool onAdoptStep = seed_migration_step_ == SeedMigrationStep::Confirming ||
seed_migration_step_ == SeedMigrationStep::Sweep;
if (ok && onAdoptStep &&
dragonx::adoptReverifyPasses(needTx, confs, total, syncing, DRAGONX_DEFAULT_FEE)) {
beginAdoptSeedWallet(); // unchanged: stop daemon -> swap -> restart with -rescan
} else if (onAdoptStep) {
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"), 12.0f);
}
};
});
}
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the // Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and // legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread. // restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
@@ -6567,7 +6802,7 @@ void App::beginAdoptSeedWallet()
{ {
if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free
seed_migration_step_ = SeedMigrationStep::Adopting; seed_migration_step_ = SeedMigrationStep::Adopting;
seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…"; seed_migration_status_ = TR("migrate_status_restarting");
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; } { std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat. // Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
daemon_restarting_ = true; daemon_restarting_ = true;
@@ -6621,21 +6856,53 @@ void App::beginAdoptSeedWallet()
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak"; const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
if (!fs::exists(newWallet)) { if (!fs::exists(newWallet)) {
err = "The new wallet file is missing; your wallet was not changed."; err = "The new wallet file is missing; your wallet was not changed.";
} else if (!util::probeWalletFile(newWallet).isBerkeleyDB) {
err = "The new wallet file is not a valid wallet; your wallet was not changed.";
} else { } else {
bool movedLegacy = false; // Crash-safe order: wallet.dat is only ever REPLACED by one atomic rename of a complete,
if (fs::exists(legacy)) { // size-verified staged copy, and only AFTER a complete size-verified .bak of the legacy
fs::rename(legacy, backup, ec); // wallet exists. A hard kill at any point leaves wallet.dat as either the whole old or the
if (ec) err = "Could not back up your current wallet; nothing was changed."; // whole new wallet - never missing or half-written. Nothing of the user's is deleted.
else movedLegacy = true; const std::string staged = legacy + ".adopt-tmp";
} auto sameSize = [](const std::string& a, const std::string& b) {
if (err.empty()) { std::error_code e1, e2;
fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec); const auto sa = fs::file_size(a, e1);
if (ec) { const auto sb = fs::file_size(b, e2);
if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); } return !e1 && !e2 && sa == sb && sa > 0;
err = "Could not install the new wallet; your original wallet was restored."; };
} else { #ifndef _WIN32
swapDone = true; auto syncFile = [](const std::string& p) { // best-effort flush to disk
int fd = ::open(p.c_str(), O_RDONLY);
if (fd >= 0) { ::fsync(fd); ::close(fd); }
};
#else
auto syncFile = [](const std::string&) {};
#endif
bool backupCreated = false;
// (a) Stage a full copy of the new wallet beside wallet.dat (same dir => atomic rename).
fs::copy_file(newWallet, staged, fs::copy_options::overwrite_existing, ec);
if (ec || !sameSize(newWallet, staged)) {
err = "Could not stage the new wallet; nothing was changed.";
} else {
syncFile(staged);
// (b) COPY (not rename) the legacy wallet to the timestamped .bak and verify it.
if (fs::exists(legacy)) {
backupCreated = !fs::exists(backup);
fs::copy_file(legacy, backup, fs::copy_options::none, ec); // never overwrites an existing .bak
if (ec || !sameSize(legacy, backup)) err = "Could not back up your current wallet; nothing was changed.";
else syncFile(backup);
} }
// (c) Atomically replace wallet.dat with the staged wallet.
if (err.empty()) {
fs::rename(staged, legacy, ec);
if (ec) err = "Could not install the new wallet; your original wallet was left unchanged.";
else swapDone = true;
}
}
if (!err.empty()) { // failed before the replace: original untouched; drop only OUR scratch files
std::error_code e3;
fs::remove(staged, e3);
if (backupCreated) fs::remove(backup, e3);
} }
} }
} }
@@ -6916,9 +7183,21 @@ void App::rebuildWalletDatabase()
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless // 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
// (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the // (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
// helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed. // helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\""; // runHiddenCapture runs this via popen("sh -c …") on POSIX, where $( ), backticks and an
// embedded \" still expand inside the double-quotes — so a crafted wallet filename (src is
// datadir + the activeWalletName setting) could inject a shell command. Refuse any path
// carrying a shell metacharacter; rc stays -1 and the verify-before-swap below fails safe.
auto shellSafe = [](const std::string& p) {
return p.find_first_of("\"'`$;&|<>^\n\r") == std::string::npos;
};
int rc = -1; int rc = -1;
const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc); std::string jout;
if (shellSafe(helper) && shellSafe(src) && shellSafe(tmpOut)) {
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
} else {
DEBUG_LOGF("[App] wallet-rebuild refused: shell metacharacter in a path\n");
}
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str()); DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys. // 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
@@ -7104,7 +7383,14 @@ void App::sendTransaction(const std::string& from, const std::string& to,
wallet::LiteSendRequest req; wallet::LiteSendRequest req;
wallet::LiteSendRecipient recipient; wallet::LiteSendRecipient recipient;
recipient.address = to; recipient.address = to;
recipient.amountZatoshis = static_cast<std::uint64_t>(std::llround(amount * 100000000.0)); // Guard the double→zatoshi conversion: a negative/NaN amount would wrap to a huge uint64, and a
// sub-zatoshi amount rounds to 0. Reject rather than broadcast a bogus value.
std::uint64_t zat = 0;
if (!util::coinsToZatoshis(amount, zat)) {
if (callback) callback(false, "Invalid send amount");
return;
}
recipient.amountZatoshis = zat;
recipient.memo = memo; recipient.memo = memo;
req.recipients.push_back(std::move(recipient)); req.recipients.push_back(std::move(recipient));
if (!lite_wallet_->sendTransaction(req)) { if (!lite_wallet_->sendTransaction(req)) {
@@ -7188,6 +7474,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel, worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb { markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
bool ok = false; bool ok = false;
bool maybeInFlight = false; // reply lost after the request left: the node may already hold the op
std::string result_str; std::string result_str;
try { try {
rpc::RPCClient::TraceScope trace(traceLabel); rpc::RPCClient::TraceScope trace(traceLabel);
@@ -7196,12 +7483,13 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
ok = true; ok = true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
result_str = e.what(); result_str = e.what();
maybeInFlight = rpc::sendReplyMayBeLost(e);
} catch (...) { } catch (...) {
// Non-std throw must not escape into the worker — the main-thread callback // Non-std throw must not escape into the worker — the main-thread callback
// below would never run, leaving a stuck "Sending…" spinner. // below would never run, leaving a stuck "Sending…" spinner.
result_str = "Send failed (unknown error)"; result_str = "Send failed (unknown error)";
} }
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() { return [this, callback, ok, maybeInFlight, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_; if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) { if (ok) {
// z_sendmany only returned an opid: the transaction is built/signed/ // z_sendmany only returned an opid: the transaction is built/signed/
@@ -7224,7 +7512,17 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
} }
} else { } else {
if (!background) send_progress_active_ = false; if (!background) send_progress_active_ = false;
if (callback) callback(false, result_str); if (maybeInFlight && !background) {
// Ambiguous: z_sendmany's reply never arrived, so the op may be running. Surface the
// distinct "check History before resending" outcome and re-read the wallet so a
// committed send shows up promptly. Definitive rejections stay plain failures below.
DEBUG_LOGF("[send] z_sendmany reply lost (%s): outcome unknown, not a definitive failure\n",
result_str.c_str());
markWalletMutated(/*forceBalance=*/true);
if (callback) callback(false, rpc::markSendMaybeInFlight(result_str));
} else if (callback) {
callback(false, result_str);
}
} }
}; };
}); });
@@ -7409,7 +7707,7 @@ bool App::runtimeRescan(int startHeight)
rescan_confirmed_active_ = true; rescan_confirmed_active_ = true;
user_initiated_rescan_ = true; // user clicked Rescan → completion should toast user_initiated_rescan_ = true; // user clicked Rescan → completion should toast
state_.sync.rescan_progress = 0.0f; state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "..."; { char rb[64]; snprintf(rb, sizeof(rb), TR("rescan_status_from_block"), startHeight); state_.sync.rescan_status = rb; }
markTransactionsDirty(); markTransactionsDirty();
last_tx_block_height_ = -1; last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true); invalidateShieldedHistoryScanProgress(true);
+24 -15
View File
@@ -548,6 +548,15 @@ void App::applyUnlockNotCounted(const std::string& msg) {
} }
void App::unlockWallet(const std::string& passphrase, int timeout) { void App::unlockWallet(const std::string& passphrase, int timeout) {
if (lite_wallet_) { // lite: backend unlock with the shared attempt-lockout (no RPC/daemon)
std::string liteErr;
if (!unlockLiteWallet(passphrase, &liteErr)) {
lock_error_msg_ = liteErr;
lock_error_timer_ = 3.0f;
lock_timer_armed_at_ = std::chrono::steady_clock::now();
}
return;
}
if (!rpc_ || !rpc_->isConnected() || !worker_) return; if (!rpc_ || !rpc_->isConnected() || !worker_) return;
lock_unlock_in_progress_ = true; lock_unlock_in_progress_ = true;
@@ -598,6 +607,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
void App::lockWallet() { void App::lockWallet() {
if (!rpc_ || !rpc_->isConnected() || !worker_) return; if (!rpc_ || !rpc_->isConnected() || !worker_) return;
if (lock_unlock_in_progress_) return; // Prevent duplicate async calls if (lock_unlock_in_progress_) return; // Prevent duplicate async calls
wipeDisplayedSecrets(); // hide any revealed seed/key immediately, before the async lock round-trip
lock_unlock_in_progress_ = true; lock_unlock_in_progress_ = true;
// Use fast-lane worker to avoid blocking behind refreshData. // Use fast-lane worker to avoid blocking behind refreshData.
@@ -790,12 +800,11 @@ void App::checkAutoLock() {
if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
if (!state_.isEncrypted() || state_.isLocked()) return; if (!state_.isEncrypted() || state_.isLocked()) return;
// Don't auto-lock while mining — mining is a long-running intentional // Don't auto-lock while SOLO mining — it runs in the daemon and locking the wallet there would stop
// operation and locking the wallet on the daemon side stops solo mining. // it (and it shields matured rewards, which needs the wallet). Pool mining pays an external address
bool miningActive = state_.mining.generate // and needs no unlocked wallet, so it must NOT hold the wallet open indefinitely.
|| (xmrig_manager_ && xmrig_manager_->isRunning()); if (state_.mining.generate) {
if (miningActive) { // Keep resetting the idle timer so we don't lock the instant solo mining stops.
// Keep resetting the idle timer so we don't lock the instant mining stops
idle_.noteActivity(); idle_.noteActivity();
return; return;
} }
@@ -1547,7 +1556,7 @@ void App::renderEncryptWalletDialog() {
} }
if (!encrypt_status_.empty()) { if (!encrypt_status_.empty()) {
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", encrypt_status_.c_str()); ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
} }
ImGui::Spacing(); ImGui::Spacing();
@@ -1558,7 +1567,7 @@ void App::renderEncryptWalletDialog() {
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) { if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
const char* why = (encPassLen < 8) ? TR("enc_min_len") const char* why = (encPassLen < 8) ? TR("enc_min_len")
: (!encMatch ? TR("enc_mismatch") : ""); : (!encMatch ? TR("enc_mismatch") : "");
if (*why) ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", why); if (*why) ImGui::TextColored(ui::material::WarningVec4(), "%s", why);
} }
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
@@ -1642,7 +1651,7 @@ void App::renderEncryptWalletDialog() {
ImGui::PopItemWidth(); ImGui::PopItemWidth();
if (!enc_dlg_pin_status_.empty()) { if (!enc_dlg_pin_status_.empty()) {
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", enc_dlg_pin_status_.c_str()); ImGui::TextColored(ui::material::WarningVec4(), "%s", enc_dlg_pin_status_.c_str());
} }
ImGui::Spacing(); ImGui::Spacing();
@@ -1760,7 +1769,7 @@ void App::renderEncryptWalletDialog() {
ImGui::PopItemWidth(); ImGui::PopItemWidth();
if (!encrypt_status_.empty()) { if (!encrypt_status_.empty()) {
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", encrypt_status_.c_str()); ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
} }
ImGui::Spacing(); ImGui::Spacing();
@@ -1841,7 +1850,7 @@ void App::renderDecryptWalletDialog() {
ImGui::PopItemWidth(); ImGui::PopItemWidth();
if (!decryptState.status.empty()) { if (!decryptState.status.empty()) {
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decryptState.status.c_str()); ImGui::TextColored(ui::material::ErrorVec4(), "%s", decryptState.status.c_str());
} }
ImGui::Spacing(); ImGui::Spacing();
@@ -2089,7 +2098,7 @@ void App::renderDecryptWalletDialog() {
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step, if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
services::WalletSecurityWorkflow::stepFromIndex(i))) { services::WalletSecurityWorkflow::stepFromIndex(i))) {
// Completed // Completed
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.4f, 1.0f), ICON_MD_CHECK_CIRCLE); ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) { } else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
// In progress - animate // In progress - animate
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f); float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
@@ -2248,7 +2257,7 @@ void App::renderPinDialogs() {
ImGui::PopItemWidth(); ImGui::PopItemWidth();
if (!pin_status_.empty()) { if (!pin_status_.empty()) {
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str()); ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
} }
ImGui::Spacing(); ImGui::Spacing();
@@ -2378,7 +2387,7 @@ void App::renderPinDialogs() {
ImGui::PopItemWidth(); ImGui::PopItemWidth();
if (!pin_status_.empty()) { if (!pin_status_.empty()) {
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str()); ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
} }
ImGui::Spacing(); ImGui::Spacing();
@@ -2447,7 +2456,7 @@ void App::renderPinDialogs() {
ImGui::PopItemWidth(); ImGui::PopItemWidth();
if (!pin_status_.empty()) { if (!pin_status_.empty()) {
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str()); ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
} }
ImGui::Spacing(); ImGui::Spacing();
-2
View File
@@ -570,8 +570,6 @@ void App::buildSweepCatalog()
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); }); [](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
add("modal-about", ui::NavPage::Overview, add("modal-about", ui::NavPage::Overview,
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; }); [](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
add("modal-settings", ui::NavPage::Settings,
[](App& a) { a.show_settings_ = true; }, [](App& a) { a.show_settings_ = false; });
// Seed-backup: pre-set fetch + a demo phrase so it renders the word grid without any RPC. // Seed-backup: pre-set fetch + a demo phrase so it renders the word grid without any RPC.
add("modal-seed-backup", ui::NavPage::Overview, add("modal-seed-backup", ui::NavPage::Overview,
+2 -1
View File
@@ -1456,7 +1456,8 @@ void App::renderFirstRunWizard() {
wizard_phase_ = WizardPhase::Done; wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true); settings_->setWizardCompleted(true);
settings_->save(); settings_->save();
ui::Notifications::instance().info(TR("wiz_encrypt_bg")); // The handoff toast is now emitted once at the completion point in app.cpp
// (wiz_handoff_encrypting), so don't also toast here.
} }
ImGui::EndDisabled(); ImGui::EndDisabled();
ImGui::PopStyleVar(); ImGui::PopStyleVar();
+23 -5
View File
@@ -108,8 +108,18 @@ bool ChatDatabase::append(const ChatMessage& message)
std::vector<unsigned char> nonce; std::vector<unsigned char> nonce;
std::vector<unsigned char> cipher; std::vector<unsigned char> cipher;
std::string plain = serialize(message); // full plaintext (decrypted body + metadata) std::string plain;
const bool encrypted = encrypt(plain, nonce, cipher); bool encrypted = false;
try {
// serialize() dumps strictly, so an invalid-UTF-8 (attacker-influenceable) body THROWS here;
// skip the row instead of letting it escape the write path and crash (appendBatch already does).
plain = serialize(message);
encrypted = encrypt(plain, nonce, cipher);
} catch (const std::exception& e) {
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
DEBUG_LOGF("[chat] append skipped a malformed message: %s\n", e.what());
return false;
}
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
if (!encrypted) return false; if (!encrypted) return false;
@@ -187,8 +197,16 @@ bool ChatDatabase::upsert(const ChatMessage& message)
std::vector<unsigned char> nonce; std::vector<unsigned char> nonce;
std::vector<unsigned char> cipher; std::vector<unsigned char> cipher;
std::string plain = serialize(message); std::string plain;
const bool encrypted = encrypt(plain, nonce, cipher); bool encrypted = false;
try {
plain = serialize(message); // strict dump → a malformed body throws; skip rather than crash
encrypted = encrypt(plain, nonce, cipher);
} catch (const std::exception& e) {
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
DEBUG_LOGF("[chat] upsert skipped a malformed message: %s\n", e.what());
return false;
}
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
if (!encrypted) return false; if (!encrypted) return false;
@@ -416,7 +434,7 @@ std::string ChatDatabase::serialize(const ChatMessage& message) const
json["ts"] = message.timestamp; json["ts"] = message.timestamp;
json["pos"] = static_cast<std::uint64_t>(message.payload_position); json["pos"] = static_cast<std::uint64_t>(message.payload_position);
json["dl"] = static_cast<int>(message.delivery); json["dl"] = static_cast<int>(message.delivery);
return json.dump(); return json.dump(); // strict: an invalid-UTF-8 body throws and is DROPPED by the callers (contract)
} }
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
+5
View File
@@ -3,6 +3,7 @@
#include "chat_store.h" #include "chat_store.h"
#include <algorithm> #include <algorithm>
#include <sodium.h>
namespace dragonx::chat { namespace dragonx::chat {
@@ -112,6 +113,10 @@ std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& convers
} }
void ChatStore::clear() { void ChatStore::clear() {
// Zero decrypted plaintext (message bodies + peer address) before dropping them — clear() runs on
// wallet lock / switch / teardown, and the freed std::strings would otherwise linger in the heap.
auto wipe = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
for (auto& m : messages_) { wipe(m.body); wipe(m.fail_reason); wipe(m.peer_zaddr); }
messages_.clear(); messages_.clear();
seen_.clear(); seen_.clear();
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo) txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
+2
View File
@@ -271,6 +271,7 @@ bool Settings::load(const std::string& path)
} }
loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
loadScalar(j, "chat_intro_dismissed", chat_intro_dismissed_);
loadScalar(j, "lock_failed_attempts", lock_failed_attempts_); loadScalar(j, "lock_failed_attempts", lock_failed_attempts_);
loadScalar(j, "lockout_until_epoch", lockout_until_epoch_); loadScalar(j, "lockout_until_epoch", lockout_until_epoch_);
loadScalar(j, "lock_state_wallet", lock_state_wallet_); loadScalar(j, "lock_state_wallet", lock_state_wallet_);
@@ -599,6 +600,7 @@ bool Settings::save(const std::string& path)
} }
j["wizard_completed"] = wizard_completed_; j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_; j["seed_backup_reminded"] = seed_backup_reminded_;
j["chat_intro_dismissed"] = chat_intro_dismissed_;
j["lock_failed_attempts"] = lock_failed_attempts_; j["lock_failed_attempts"] = lock_failed_attempts_;
j["lockout_until_epoch"] = lockout_until_epoch_; j["lockout_until_epoch"] = lockout_until_epoch_;
j["lock_state_wallet"] = lock_state_wallet_; j["lock_state_wallet"] = lock_state_wallet_;
+3
View File
@@ -392,6 +392,8 @@ public:
// Whether the one-time "back up your seed phrase" reminder has already been shown. // Whether the one-time "back up your seed phrase" reminder has already been shown.
bool getSeedBackupReminded() const { return seed_backup_reminded_; } bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
bool getChatIntroDismissed() const { return chat_intro_dismissed_; }
void setChatIntroDismissed(bool v) { chat_intro_dismissed_ = v; durable_next_save_ = true; }
// Unlock lockout state, persisted across restarts so the failed-attempt curve can't be reset by // Unlock lockout state, persisted across restarts so the failed-attempt curve can't be reset by
// killing the app. Keyed by the wallet file it applies to; the PIN/passphrase is never stored. // killing the app. Keyed by the wallet file it applies to; the PIN/passphrase is never stored.
@@ -745,6 +747,7 @@ private:
std::map<std::string, AddressMeta> address_meta_; std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false; bool wizard_completed_ = false;
bool seed_backup_reminded_ = false; bool seed_backup_reminded_ = false;
bool chat_intro_dismissed_ = false; // one-time chat "how it works" intro card
int lock_failed_attempts_ = 0; // persisted unlock-failure count (lockout curve) int lock_failed_attempts_ = 0; // persisted unlock-failure count (lockout curve)
long long lockout_until_epoch_ = 0; // unix time the current lockout expires (0 = none) long long lockout_until_epoch_ = 0; // unix time the current lockout expires (0 = none)
std::string lock_state_wallet_; // wallet file the above two apply to std::string lock_state_wallet_; // wallet file the above two apply to
+8 -8
View File
@@ -703,8 +703,8 @@ bool EmbeddedDaemon::start(const std::string& binary_path, int lockWaitPolls)
// ... not executable or wrong architecture"). Surface THAT via setState — which also // ... not executable or wrong architecture"). Surface THAT via setState — which also
// stores the Error message into last_error_ — instead of clobbering it with a generic // stores the Error message into last_error_ — instead of clobbering it with a generic
// string that would then be all getLastError()/the UI ever sees. // string that would then be all getLastError()/the UI ever sees.
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process") std::string detail = getLastError();
: last_error_; if (detail.empty()) detail = "Failed to start dragonxd process";
DEBUG_LOGF("[ERROR] %s\n", detail.c_str()); DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
setState(State::Error, detail); setState(State::Error, detail);
return false; return false;
@@ -822,7 +822,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
char errBuf[256] = {0}; char errBuf[256] = {0};
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, err, 0, errBuf, sizeof(errBuf), NULL); NULL, err, 0, errBuf, sizeof(errBuf), NULL);
last_error_ = "CreateProcess failed with error " + std::to_string(err) + ": " + errBuf; setLastError("CreateProcess failed with error " + std::to_string(err) + ": " + errBuf);
DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str()); DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str());
return false; return false;
} }
@@ -1232,7 +1232,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
// Create pipe for stdout/stderr // Create pipe for stdout/stderr
int pipefd[2]; int pipefd[2];
if (pipe(pipefd) == -1) { if (pipe(pipefd) == -1) {
last_error_ = "Failed to create pipe: " + std::string(strerror(errno)); setLastError("Failed to create pipe: " + std::string(strerror(errno)));
return false; return false;
} }
@@ -1245,7 +1245,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
// macOS, which has no pipe2(). // macOS, which has no pipe2().
int execpipe[2]; int execpipe[2];
if (pipe(execpipe) == -1) { if (pipe(execpipe) == -1) {
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno)); setLastError("Failed to create exec-status pipe: " + std::string(strerror(errno)));
close(pipefd[0]); close(pipefd[0]);
close(pipefd[1]); close(pipefd[1]);
return false; return false;
@@ -1255,7 +1255,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
pid_t pid = fork(); pid_t pid = fork();
if (pid == -1) { if (pid == -1) {
last_error_ = "Fork failed: " + std::string(strerror(errno)); setLastError("Fork failed: " + std::string(strerror(errno)));
close(pipefd[0]); close(pipefd[0]);
close(pipefd[1]); close(pipefd[1]);
close(execpipe[0]); close(execpipe[0]);
@@ -1370,8 +1370,8 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
close(pipefd[0]); close(pipefd[0]);
int status; int status;
waitpid(pid, &status, 0); waitpid(pid, &status, 0);
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) + setLastError("dragonxd could not be executed: " + std::string(strerror(child_errno)) +
" — not executable or wrong architecture"; " — not executable or wrong architecture");
return false; return false;
} }
+5 -3
View File
@@ -504,6 +504,8 @@ private:
std::atomic<bool> external_daemon_detected_{false}; std::atomic<bool> external_daemon_detected_{false};
std::string last_error_; std::string last_error_;
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads) mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
// Locked writer for last_error_ (startProcess() runs on the caller thread while the monitor may call setState()).
void setLastError(std::string message) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(message); }
mutable std::mutex output_mutex_; // protects process_output_ mutable std::mutex output_mutex_; // protects process_output_
std::string process_output_; std::string process_output_;
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_) size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
@@ -511,14 +513,14 @@ private:
// Process handle // Process handle
#ifdef _WIN32 #ifdef _WIN32
HANDLE process_handle_ = nullptr; std::atomic<HANDLE> process_handle_{nullptr}; // written by start/stop/monitor threads, read by accessors
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log) HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics) std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
std::string launch_binary_; // binary path used (for diagnostics) std::string launch_binary_; // binary path used (for diagnostics)
std::string launch_workdir_; // working directory used (for diagnostics) std::string launch_workdir_; // working directory used (for diagnostics)
#else #else
pid_t process_pid_ = 0; std::atomic<pid_t> process_pid_{0}; // written by start/stop paths and the monitor (reap), read by accessors
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log) std::atomic<int> stdout_fd_{-1}; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
void tailDebugLog(); void tailDebugLog();
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid // Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
+20
View File
@@ -42,4 +42,24 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
// wallet.dat on an unconfirmed sweep. (fund-safety: audit) // wallet.dat on an unconfirmed sweep. (fund-safety: audit)
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; } inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
// Reorg-safe depth before the adopt swap is enabled. Matches the History "confirmed" threshold
// (>= 10, data/transaction_history_store_policy.h): a 1-block reorg must not be able to un-mine the
// sweep after wallet.dat has been moved aside, which would strand the funds in the .bak.
inline constexpr int kSweepAdoptMinConfs = 10;
inline bool sweepConfsAreReorgSafe(int confs) { return confs >= kSweepAdoptMinConfs; }
// Final pre-stop re-verification, run on fresh RPC reads immediately before the daemon is stopped.
// needSweepTx : a sweep txid exists, so its confirmations must be reorg-safe (false = genuinely
// empty wallet, no sweep ever submitted).
// confs : fresh gettransaction confirmations (negative = conflicted).
// balanceMinconf0 : fresh z_gettotalbalance with minconf 0 (INCLUDES unconfirmed), < 0 = unknown.
// syncing : the node is still catching up, so balance reads are not trustworthy.
inline bool adoptReverifyPasses(bool needSweepTx, int confs, double balanceMinconf0,
bool syncing, double dust)
{
if (syncing) return false;
if (needSweepTx && !sweepConfsAreReorgSafe(confs)) return false;
return balanceMinconf0 >= 0.0 && balanceMinconf0 <= dust;
}
} // namespace dragonx } // namespace dragonx
+32 -2
View File
@@ -75,6 +75,13 @@ struct TransactionInfo {
std::string getTypeDisplay() const; std::string getTypeDisplay() const;
bool isConfirmed() const { return confirmations >= 1; } bool isConfirmed() const { return confirmations >= 1; }
bool isMature() const { return confirmations >= 100; } bool isMature() const { return confirmations >= 100; }
// gettransaction reports confirmations < 0 for a tx conflicted with the chain (double-spent / reorged out).
bool isConflicted() const { return confirmations < 0; }
// Unconfirmed for longer than maxAgeSec since the wallet first saw it: most likely dropped from the
// mempool or expired. A display heuristic only (timestamp 0 = unknown -> never flagged).
bool isStalePending(int64_t now, int64_t maxAgeSec = 3600) const {
return confirmations == 0 && timestamp > 0 && now - timestamp > maxAgeSec;
}
}; };
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared // Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
@@ -86,6 +93,15 @@ inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo&
a.txid == b.txid && a.type == b.type && a.address == b.address && a.txid == b.txid && a.type == b.type && a.address == b.address &&
a.from_address == b.from_address && a.memo == b.memo; a.from_address == b.from_address && a.memo == b.memo;
} }
// Same row except the confirmation COUNT: the pending (confirmations==0) state must still match because it drives the
// History tab's pending-first sort and shield-leg merge. A delta for which every row passes this changes nothing the
// tab's merged/sorted display list is built from, so it can patch confirmations in place instead of rebuilding.
inline bool sameTransactionLayout(const TransactionInfo& a, const TransactionInfo& b) {
return a.timestamp == b.timestamp && (a.confirmations == 0) == (b.confirmations == 0) &&
std::memcmp(&a.amount, &b.amount, sizeof(double)) == 0 &&
a.txid == b.txid && a.type == b.type && a.address == b.address &&
a.from_address == b.from_address && a.memo == b.memo;
}
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) { inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
if (a.size() != b.size()) return false; if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i) for (std::size_t i = 0; i < a.size(); ++i)
@@ -189,6 +205,11 @@ struct SyncInfo {
bool tip_known = false; bool tip_known = false;
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; } bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
// Send precondition: the wallet is caught up to a KNOWN peer-derived tip. A fresh (re)connect or a
// peerless node has tip_known == false, so its local height can't be trusted as the network tip.
// The lite backend never populates tip_known, so lite is exempt (it has its own sync gate).
bool sendTipReady(bool liteBuild) const { return liteBuild || (tip_known && !syncing && blocks > 0); }
}; };
/** /**
@@ -370,12 +391,21 @@ struct WalletState {
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a // touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag). // wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
std::uint64_t tx_revision = 1; std::uint64_t tx_revision = 1;
void touchTransactions() { ++tx_revision; } // Bumps with tx_revision EXCEPT for a pure confirmation-count change (see sameTransactionLayout): the History tab's
// O(N) merge+sort cache keys on this and just patches confirmations when only tx_revision moved. Every writer that
// is not setTransactionsIfChanged goes through touchTransactions()/replaceTransactions() and bumps both (safe default).
std::uint64_t tx_structure_revision = 1;
void touchTransactions() { ++tx_revision; ++tx_structure_revision; }
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); } void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical. // Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) { bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
if (sameTransactionList(transactions, next)) return false; if (sameTransactionList(transactions, next)) return false;
replaceTransactions(std::move(next)); bool layoutSame = transactions.size() == next.size();
for (std::size_t i = 0; layoutSame && i < next.size(); ++i)
layoutSame = sameTransactionLayout(transactions[i], next[i]);
transactions = std::move(next);
++tx_revision;
if (!layoutSame) ++tx_structure_revision;
return true; return true;
} }
int64_t last_peer_update = 0; int64_t last_peer_update = 0;
+9 -18
View File
@@ -460,15 +460,14 @@ bool Connection::createDefaultConfig(const std::string& path)
return false; return false;
} }
std::ofstream file(path); // Build the content in memory, then write it 0600 from the start (writeFileAtomically with
if (!file.is_open()) { // restrictPermissions) — the old ofstream+chmod left a brief world-readable window during which
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str()); // another local user could read the freshly-generated rpcuser/rpcpassword.
return false; std::ostringstream file;
}
// Get the data directory for exportdir // Get the data directory for exportdir
std::string dataDir = getDefaultDataDir(); std::string dataDir = getDefaultDataDir();
file << "# DragonX configuration file\n"; file << "# DragonX configuration file\n";
file << "# Auto-generated by DragonX Wallet\n"; file << "# Auto-generated by DragonX Wallet\n";
file << "\n"; file << "\n";
@@ -485,18 +484,10 @@ bool Connection::createDefaultConfig(const std::string& path)
file << "addnode=seed.dragonx.is\n"; file << "addnode=seed.dragonx.is\n";
file << "addnode=node1.dragonx.is\n"; file << "addnode=node1.dragonx.is\n";
file << "addnode=node5.dragonx.is\n"; file << "addnode=node5.dragonx.is\n";
file.close();
// The file holds the freshly-generated rpcuser/rpcpassword in plaintext. ofstream creates it if (!util::Platform::writeFileAtomically(path, file.str(), /*restrictPermissions=*/true)) {
// with the process umask (typically world-readable 0644), so restrict it to owner read/write DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
// before another local user can read the credentials. return false;
{
namespace fs = std::filesystem;
std::error_code ec;
fs::permissions(path, fs::perms::owner_read | fs::perms::owner_write,
fs::perm_options::replace, ec);
if (ec) DEBUG_LOGF("Could not restrict config permissions on %s: %s\n", path.c_str(), ec.message().c_str());
} }
DEBUG_LOGF("Created default config file: %s\n", path.c_str()); DEBUG_LOGF("Created default config file: %s\n", path.c_str());
+16 -1
View File
@@ -216,7 +216,8 @@ bool RPCClient::open(const std::string& host, const std::string& port,
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers); curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback); curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform. // Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
clearAbort(); // a fresh connection must not start in the aborted state // NOTE: abort is deliberately NOT cleared here: a requestAbort() issued while this connect was queued
// must still cut it short. Reconnect callers (App::tryConnect / onConnected) call clearAbort() first.
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L); curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback); curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this); curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
@@ -370,6 +371,20 @@ std::string RPCClient::performCall(const std::string& method, const json& params
json payload = makePayload(method, params); json payload = makePayload(method, params);
std::string body = payload.dump(); std::string body = payload.dump();
// Request-side secret scrub (responses are already scrubbed): the body + payload tree can carry
// walletpassphrase / encryptwallet / *_importkey args. Wipe them when this scope ends (all return
// and throw paths) and detach CURLOPT_POSTFIELDS so the reused handle holds no dangling pointer.
struct RequestScrubGuard {
CURL* curl; std::string& body; json& payload;
~RequestScrubGuard() {
if (!body.empty()) sodium_memzero(&body[0], body.size());
scrubJsonSecrets(payload);
if (curl) {
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, nullptr);
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, 0L);
}
}
} requestScrubGuard{impl_->curl, body, payload};
std::string response_data; std::string response_data;
// Set POST data // Set POST data
+49
View File
@@ -10,6 +10,7 @@
#include <curl/curl.h> #include <curl/curl.h>
#include <cctype>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
@@ -152,6 +153,54 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
return RpcFailureKind::Other; return RpcFailureKind::Other;
} }
// Did a failed z_sendmany (or any submit call) possibly reach the node and take effect? True only for a
// transport failure AFTER the request left us with no HTTP status back (reply timeout, connection cut
// mid-reply, our own abort): the daemon may already hold the operation, so the caller must not invite an
// immediate resend. Definitive answers/rejections (AppError, QueueFull, Auth, Warmup, Deferred,
// NotListening, ConnectTimeout, any HTTP status) mean nothing was queued and stay plain failures.
inline bool sendReplyMayBeLost(const std::exception& e)
{
const auto* t = dynamic_cast<const RpcTransportError*>(&e);
return t && t->requestSent && t->httpStatus == 0 && t->curlCode != CURLE_OK;
}
// Lite broadcast errors are plain backend text (tonic/gRPC). Ambiguous = a transport-style failure while
// talking to the lightwalletd server (the server may have accepted the tx before the reply was lost).
// Connect-phase failures and an explicit server rejection ("error_code") are definitive. Heuristic by
// design: a false positive only shows a more cautious message, never changes the send.
inline bool liteSendErrorMayBeInFlight(const std::string& err)
{
std::string lc = err;
for (char& c : lc) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
auto has = [&](const char* n) { return lc.find(n) != std::string::npos; };
if (has("error_code") || has("error trying to connect") || has("connection refused") ||
has("dns error") || has("insufficient") || has("locked"))
return false;
return has("timed out") || has("timeout") || has("deadline") || has("transport error") ||
has("connection reset") || has("broken pipe") || has("h2 protocol error") ||
has("unexpected eof") || has("status: unknown") || has("status: cancelled");
}
// Tag carried in the failure text of a send callback (bool, string) so the Send tab can show the distinct
// "may have been broadcast - check History before resending" outcome without changing the callback type.
// The prefix is readable English so callers that just print the text still say something accurate.
inline const char* sendMaybeInFlightPrefix()
{
return "Send status unknown - it may already have been broadcast: ";
}
inline bool isSendMaybeInFlight(const std::string& s)
{
return s.rfind(sendMaybeInFlightPrefix(), 0) == 0;
}
inline std::string markSendMaybeInFlight(const std::string& detail)
{
return isSendMaybeInFlight(detail) ? detail : std::string(sendMaybeInFlightPrefix()) + detail;
}
inline std::string stripSendMaybeInFlight(const std::string& s)
{
return isSendMaybeInFlight(s) ? s.substr(std::string(sendMaybeInFlightPrefix()).size()) : s;
}
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout; // What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess. // a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other }; enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
+33 -11
View File
@@ -3,6 +3,7 @@
#include "rpc/rpc_busy_gate.h" #include "rpc/rpc_busy_gate.h"
#include "../util/logger.h" #include "../util/logger.h"
#include "../util/i18n.h" // util::tr for the price-fetch-failed default #include "../util/i18n.h" // util::tr for the price-fetch-failed default
#include "../util/amount_format.h" // util::toSats: integer-satoshi accumulation
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -34,7 +35,20 @@ std::optional<T> readOptional(const json& source, const char* key)
std::optional<double> readBalanceString(const json& source, const char* key) std::optional<double> readBalanceString(const json& source, const char* key)
{ {
try { try {
if (source.contains(key)) return std::stod(source[key].get<std::string>()); if (source.contains(key)) {
const std::string text = source[key].get<std::string>();
// Strict: plain decimal only (digits, one '.', optional leading '-'). std::stod would accept
// '1.5abc', whitespace, '+', hex, exponents and nan/inf, silently.
if (text.empty()) return std::nullopt;
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
if (!((c >= '0' && c <= '9') || c == '.' || (c == '-' && i == 0))) return std::nullopt;
}
char* end = nullptr;
const double value = std::strtod(text.c_str(), &end);
if (end != text.c_str() + text.size() || !std::isfinite(value)) return std::nullopt;
return value;
}
} catch (...) {} } catch (...) {}
return std::nullopt; return std::nullopt;
} }
@@ -52,15 +66,17 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes // Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at // the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both. // minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
std::map<std::string, double> total; // Accumulated in integer satoshis (not doubles) so many notes can't drift by an ulp per addition.
std::map<std::string, double> spendable; std::map<std::string, std::int64_t> total;
std::map<std::string, std::int64_t> spendable;
for (const auto& output : unspent) { for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address"); auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount"); auto amount = readOptional<double>(output, "amount");
if (!address || !amount) continue; if (!address || !amount || !std::isfinite(*amount)) continue;
total[*address] += *amount; const std::int64_t sats = util::toSats(*amount);
total[*address] += sats;
auto conf = readOptional<int>(output, "confirmations"); auto conf = readOptional<int>(output, "confirmations");
if (conf && *conf >= 1) spendable[*address] += *amount; if (conf && *conf >= 1) spendable[*address] += sats;
} }
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no // The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
@@ -68,8 +84,8 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
for (auto& info : addresses) { for (auto& info : addresses) {
auto t = total.find(info.address); auto t = total.find(info.address);
auto s = spendable.find(info.address); auto s = spendable.find(info.address);
info.balance = (t != total.end()) ? t->second : 0.0; info.balance = (t != total.end()) ? static_cast<double>(t->second) / 1e8 : 0.0;
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0; info.spendableBalance = (s != spendable.end()) ? static_cast<double>(s->second) / 1e8 : 0.0;
} }
} }
@@ -1040,8 +1056,13 @@ NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseVie
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions, void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
const std::string& txid, const std::string& txid,
const TransactionViewCacheEntry& entry) const TransactionViewCacheEntry& entry,
int currentHeight)
{ {
// A mined entry's confirmations grow by one per block since it was sampled; never go backwards.
int entryConfirmations = entry.confirmations;
if (entry.confirmations >= 1 && entry.observed_height >= 0 && currentHeight > entry.observed_height)
entryConfirmations += currentHeight - entry.observed_height;
for (const auto& out : entry.outgoing_outputs) { for (const auto& out : entry.outgoing_outputs) {
bool alreadyTracked = false; bool alreadyTracked = false;
for (const auto& existing : transactions) { for (const auto& existing : transactions) {
@@ -1073,7 +1094,7 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
} }
} }
if (!foundExistingMetadata) { if (!foundExistingMetadata) {
info.confirmations = entry.confirmations; info.confirmations = entryConfirmations;
info.timestamp = entry.timestamp; info.timestamp = entry.timestamp;
} }
transactions.push_back(std::move(info)); transactions.push_back(std::move(info));
@@ -1460,7 +1481,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
auto cached = snapshot.viewTxCache.find(txid); auto cached = snapshot.viewTxCache.find(txid);
if (cached != snapshot.viewTxCache.end()) { if (cached != snapshot.viewTxCache.end()) {
if (!trackedSend || !cached->second.outgoing_outputs.empty()) { if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
appendViewTransactionOutputs(result.transactions, txid, cached->second); appendViewTransactionOutputs(result.transactions, txid, cached->second, currentBlockHeight);
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks // NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
// everything Incoming, so that duplicates every sent message as a phantom "from peer" // everything Incoming, so that duplicates every sent message as a phantom "from peer"
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are // entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
@@ -1493,6 +1514,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
} }
auto entry = parseViewTransactionCacheEntry(viewTransaction); auto entry = parseViewTransactionCacheEntry(viewTransaction);
entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip
const bool storedRow = rowBacklog.count(txid) > 0; const bool storedRow = rowBacklog.count(txid) > 0;
if (storedRow && entry.timestamp == 0) { if (storedRow && entry.timestamp == 0) {
const auto settled = settledStoredMeta.find(txid); const auto settled = settledStoredMeta.find(txid);
+5 -1
View File
@@ -224,6 +224,7 @@ public:
std::string from_address; std::string from_address;
std::int64_t timestamp = 0; std::int64_t timestamp = 0;
int confirmations = 0; int confirmations = 0;
int observed_height = -1; // chain tip when `confirmations` was read (-1 unknown): lets a cached entry advance
struct Output { struct Output {
std::string address; std::string address;
double value = 0.0; double value = 0.0;
@@ -427,9 +428,12 @@ public:
const std::set<std::string>& miningAddresses = {}, const std::set<std::string>& miningAddresses = {},
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr); std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction); static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
// currentHeight (>=0): the present chain tip; a cached entry that was already mined then reports
// confirmations advanced by the blocks added since it was observed (display only).
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions, static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
const std::string& txid, const std::string& txid,
const TransactionViewCacheEntry& entry); const TransactionViewCacheEntry& entry,
int currentHeight = -1);
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent // Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by // (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends). // txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
+22 -1
View File
@@ -123,6 +123,7 @@ bool ExplorerBlockCache::ensureOpen()
} }
db_ = openedDb; db_ = openedDb;
++revision_;
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of // UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
// freezing the frame for 2 s. // freezing the frame for 2 s.
sqlite3_busy_timeout(db_, 250); sqlite3_busy_timeout(db_, 250);
@@ -176,6 +177,20 @@ std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight,
return blocks; return blocks;
} }
const std::map<int, ExplorerBlockSummary>& ExplorerBlockCache::loadRangeCached(int minHeight, int maxHeight)
{
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
if (range_cache_.size() >= 16) range_cache_.clear();
const std::pair<int, int> key(minHeight, maxHeight);
auto it = range_cache_.find(key);
if (it == range_cache_.end()) {
auto loaded = loadRange(minHeight, maxHeight); // may open the DB (bumps revision_)
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
it = range_cache_.emplace(key, std::move(loaded)).first;
}
return it->second;
}
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit) std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
{ {
std::vector<ExplorerBlockSummary> results; std::vector<ExplorerBlockSummary> results;
@@ -256,6 +271,7 @@ bool ExplorerBlockCache::storeBlockRow(const ExplorerBlockSummary& block)
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block) bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
{ {
if (block.height < 1 || block.hash.empty()) return false; if (block.height < 1 || block.hash.empty()) return false;
++revision_;
const bool stored = storeBlockRow(block); const bool stored = storeBlockRow(block);
if (stored) { if (stored) {
spill_.erase(block.height); spill_.erase(block.height);
@@ -297,6 +313,7 @@ int ExplorerBlockCache::cachedBlockCount()
void ExplorerBlockCache::clearBlocks() void ExplorerBlockCache::clearBlocks()
{ {
++revision_;
spill_.clear(); spill_.clear();
if (!ensureOpen()) return; if (!ensureOpen()) return;
exec("DELETE FROM explorer_blocks"); exec("DELETE FROM explorer_blocks");
@@ -305,6 +322,7 @@ void ExplorerBlockCache::clearBlocks()
void ExplorerBlockCache::pruneTo(int maxRows) void ExplorerBlockCache::pruneTo(int maxRows)
{ {
if (maxRows < 1 || !db_) return; if (maxRows < 1 || !db_) return;
++revision_;
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the // Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water // subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases). // mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
@@ -333,8 +351,10 @@ ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height). // No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
if (!spill_.empty() && !currentBestHash.empty() && if (!spill_.empty() && !currentBestHash.empty() &&
(currentHeight < spill_tip_height_ || (currentHeight < spill_tip_height_ ||
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_))) (currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_))) {
spill_.clear(); spill_.clear();
++revision_;
}
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; } if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
return validation; return validation;
} }
@@ -526,6 +546,7 @@ void ExplorerBlockCache::close()
{ {
tip_known_ = false; tip_known_ = false;
if (!db_) return; if (!db_) return;
++revision_;
sqlite3_close(db_); sqlite3_close(db_);
db_ = nullptr; db_ = nullptr;
} }
+11
View File
@@ -5,6 +5,7 @@
#include <cstdint> #include <cstdint>
#include <map> #include <map>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
struct sqlite3; struct sqlite3;
@@ -44,6 +45,11 @@ public:
const std::string& databasePath() const { return database_path_; } const std::string& databasePath() const { return database_path_; }
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight); std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
// loadRange memoized per (min,max) and invalidated by revision() so the per-frame Explorer list only hits SQLite
// after the cache actually changed (store / clear / prune / spill reset / open / close). The reference is valid
// until the next call; copy it if you call again before using it.
const std::map<int, ExplorerBlockSummary>& loadRangeCached(int minHeight, int maxHeight);
std::uint64_t revision() const { return revision_; }
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text) // Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`. // or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit); std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
@@ -102,6 +108,11 @@ private:
int open_attempts_ = 0; int open_attempts_ = 0;
int stores_since_prune_ = 0; int stores_since_prune_ = 0;
// Bumped by every mutation that can change what loadRange returns; keys the loadRangeCached memo.
std::uint64_t revision_ = 1;
std::uint64_t range_cache_revision_ = 0;
std::map<std::pair<int, int>, std::map<int, ExplorerBlockSummary>> range_cache_;
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height // Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses. // (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
static constexpr std::size_t kMaxSpillBlocks = 512; static constexpr std::size_t kMaxSpillBlocks = 512;
+53
View File
@@ -0,0 +1,53 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Best-effort scrub of ImGui's internal InputText buffers, which retain un-zeroed copies of typed
// secrets (wallet passphrase / PIN / seed / private key) after a field is edited — the app's own
// char[] buffers are memzeroed, but ImGui also copies the text into its global edit/undo/deactivated
// state, which it only frees (not zeroes).
//
// This reaches into vendored imgui_internal state, so it is PINNED to the vendored ImGui version: a
// bump trips the #error below and forces a re-audit of the member names. It is BEST-EFFORT only —
// std::-style buffer reallocation, the system clipboard, and OS/IME layers can still hold copies, so
// this shrinks the exposure window but does not guarantee absence of secret material in a core dump.
#include "imgui.h"
#include "imgui_internal.h"
#include <sodium.h>
#include <cstddef>
#if IMGUI_VERSION_NUM != 19259
#error "imgui_secret_scrub.h is pinned to ImGui 1.92.6 (IMGUI_VERSION_NUM 19259). After bumping libs/imgui, re-audit ImGuiInputTextState / ImGuiInputTextDeactivatedState / ImGuiContext member names below, then update this guard."
#endif
namespace dragonx {
namespace ui {
// Zero ImGui's retained InputText buffers. Safe to call at a lock transition / when a secret dialog
// closes (it defocuses the active field). Do NOT call while a secret field is actively being typed.
inline void scrubImGuiInputTextState() {
ImGuiContext* ctx = ImGui::GetCurrentContext();
if (!ctx) return;
ImGuiInputTextState& st = ctx->InputTextState;
// Defocus first so the next frame can't re-read the buffer we're about to zero (ClearActiveID also
// refreshes the deactivated-state copy, which we then zero below).
if (st.ID != 0 && ctx->ActiveId == st.ID) ImGui::ClearActiveID();
auto zeroVec = [](ImVector<char>& v) {
if (v.Data && v.Size > 0) sodium_memzero(v.Data, static_cast<size_t>(v.Size));
};
zeroVec(st.TextA); // the live edit buffer
zeroVec(st.TextToRevertTo); // Escape-to-revert backup
zeroVec(st.CallbackTextBackup); // callback reconcile scratch
st.TextLen = 0;
// NOTE: st.Stb (the stb_textedit undo/redo char buffers) is deliberately NOT zeroed here — its type
// ImStbTexteditState is incomplete in imgui_internal.h (fully defined only inside ImGui's .cpp), so we
// can't sizeof it from a header. The primary copies above cover the committed text; the undo scratch
// is a residual gap in this best-effort scrub.
zeroVec(ctx->InputTextDeactivatedState.TextA); // the just-deactivated copy
ctx->InputTextDeactivatedState.ID = 0;
}
} // namespace ui
} // namespace dragonx
+18
View File
@@ -23,6 +23,7 @@ enum class NodeBannerAction {
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect) Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon) RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login
RecoverLegacyFunds, // a degraded legacy wallet with stuck transparent funds: open migrate-to-seed
}; };
// Why the banner is up. App maps this to a translated headline; `detail` carries the live, // Why the banner is up. App maps this to a translated headline; `detail` carries the live,
@@ -35,6 +36,8 @@ enum class NodeBannerReason {
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering) NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf) AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf)
LegacyFundsStuck, // a DEGRADED legacy wallet (can't derive a z-address) holding transparent funds it
// therefore can't shield/spend — offer the migrate-to-seed recovery
}; };
struct NodeBannerState { struct NodeBannerState {
@@ -75,6 +78,11 @@ struct NodeBannerInputs {
bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword) bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword)
// The wallet opened DEGRADED (no usable HD seed, so z_getnewaddress / shielding fail) AND it holds
// transparent funds that are therefore stuck. App sets this ONLY from wallet_degraded_ + a transparent
// balance — never from merely-non-mnemonic status, which is a functional wallet and must not be flagged.
bool legacy_funds_stuck = false;
std::string connection_status; // human-readable status line (already translated) std::string connection_status; // human-readable status line (already translated)
std::string daemon_last_error; // DaemonController::lastError() (may be empty) std::string daemon_last_error; // DaemonController::lastError() (may be empty)
std::string lite_open_error; // lite: last wallet-open failure reason std::string lite_open_error; // lite: last wallet-open failure reason
@@ -134,6 +142,16 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
? NodeBannerAction::RestartNode ? NodeBannerAction::RestartNode
: NodeBannerAction::None; : NodeBannerAction::None;
s.detail.clear(); s.detail.clear();
return s;
}
// Connected and synced, but a degraded legacy wallet can't shield its transparent funds. Offer the
// recovery flow. Checked after NoPeers (can't recover until the node can see the chain anyway).
if (in.legacy_funds_stuck) {
s.show = true;
s.severity = NodeBannerSeverity::Warning;
s.reason = NodeBannerReason::LegacyFundsStuck;
s.action = NodeBannerAction::RecoverLegacyFunds;
s.detail.clear();
} }
return s; return s;
} }
+26 -2
View File
@@ -100,6 +100,15 @@ void Notifications::render()
float iconGap = 4.0f * dp; float iconGap = 4.0f * dp;
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f; float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX; float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
// "+N more" badge when several live toasts are queued (render shows only the newest).
const size_t moreCount = hiddenLiveCount();
std::string badge = moreCount ? ("+" + std::to_string(moreCount)) : std::string();
float badgeW = 0.0f, badgeGap = 0.0f;
if (!badge.empty() && textFont) {
badgeW = textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, badge.c_str()).x + 10.0f * dp;
badgeGap = 6.0f * dp;
pillWidth += badgeW + badgeGap;
}
// Clamp to reasonable bounds // Clamp to reasonable bounds
float maxPillW = viewport->WorkSize.x * 0.5f; float maxPillW = viewport->WorkSize.x * 0.5f;
if (pillWidth > maxPillW) pillWidth = maxPillW; if (pillWidth > maxPillW) pillWidth = maxPillW;
@@ -163,7 +172,7 @@ void Notifications::render()
bool clipped = false; bool clipped = false;
if (textFont) { if (textFont) {
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color); ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color);
const float availTextW = (pMax.x - pillPadX) - cursorX; const float availTextW = (pMax.x - pillPadX) - cursorX - (badge.empty() ? 0.0f : (badgeW + badgeGap));
std::string shown = notif.message; std::string shown = notif.message;
if (availTextW > 0.0f && if (availTextW > 0.0f &&
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) { textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
@@ -183,8 +192,23 @@ void Notifications::render()
dl->PopClipRect(); dl->PopClipRect();
} }
// Hover: pause the auto-dismiss (handled next frame) and reveal the full message if it was clipped. // "+N more" badge, right-aligned inside the pill.
if (!badge.empty() && textFont) {
float badgeX = pMax.x - pillPadX - badgeW;
ImVec4 bf = accent_color; bf.w *= nde("badge-fill-alpha", 0.35f);
dl->AddRectFilled(ImVec2(badgeX, pillY + 3.0f * dp), ImVec2(badgeX + badgeW, pMax.y - 3.0f * dp),
ImGui::ColorConvertFloat4ToU32(bf), (pillHeight - 6.0f * dp) * 0.5f);
dl->AddText(textFont, textFont->LegacySize, ImVec2(badgeX + 5.0f * dp, contentY),
ImGui::ColorConvertFloat4ToU32(text_color), badge.c_str());
}
// Hover: pause the auto-dismiss (next frame), reveal the full message if clipped, and allow a click to
// open the notification history (the status bar consumes the request).
toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax); toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax);
if (toast_hovered_) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory();
}
if (toast_hovered_ && clipped) if (toast_hovered_ && clipped)
ImGui::SetTooltip("%s", notif.message.c_str()); ImGui::SetTooltip("%s", notif.message.c_str());
} }
+13
View File
@@ -169,8 +169,20 @@ public:
void clear() { void clear() {
notifications_.clear(); notifications_.clear();
history_requested_ = false;
} }
/// Number of currently-live toasts beyond the one the pill shows (for a "+N more" badge).
size_t hiddenLiveCount() const {
const auto now = std::chrono::steady_clock::now();
size_t live = 0;
for (const auto& n : notifications_) if (!n.isExpiredAt(now)) ++live;
return live > 1 ? live - 1 : 0;
}
/// render() sets this when the toast is clicked; the status bar consumes it to open the history panel.
void requestHistory() { history_requested_ = true; }
bool consumeHistoryRequest() { bool r = history_requested_; history_requested_ = false; return r; }
void setMaxNotifications(size_t max) { void setMaxNotifications(size_t max) {
max_notifications_ = max; max_notifications_ = max;
} }
@@ -191,6 +203,7 @@ private:
Notifications& operator=(const Notifications&) = delete; Notifications& operator=(const Notifications&) = delete;
std::deque<Notification> notifications_; std::deque<Notification> notifications_;
bool history_requested_ = false; // set on toast click, consumed by the status bar to open history
size_t max_notifications_ = 5; size_t max_notifications_ = 5;
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
std::chrono::steady_clock::time_point last_drawn_{}; std::chrono::steady_clock::time_point last_drawn_{};
+97 -51
View File
@@ -869,7 +869,7 @@ void RenderSettingsPage(App* app) {
if (skin.bundled) continue; if (skin.bundled) continue;
bool is_selected = (skin.id == skinMgr.activeSkinId()); bool is_selected = (skin.id == skinMgr.activeSkinId());
if (!skin.valid) { if (!skin.valid) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_Text, material::ErrorVec4());
ImGui::BeginDisabled(true); ImGui::BeginDisabled(true);
std::string lbl = skin.name + TR("grpa_invalid_suffix"); std::string lbl = skin.name + TR("grpa_invalid_suffix");
ImGui::Selectable(lbl.c_str(), false); ImGui::Selectable(lbl.c_str(), false);
@@ -904,7 +904,7 @@ void RenderSettingsPage(App* app) {
float cellGap = Layout::spacingLg(); float cellGap = Layout::spacingLg();
bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale) bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale)
int cols = twoCol ? 2 : 1; int cols = twoCol ? 2 : 1;
float colW = std::max(160.0f, twoCol ? (contentW - cellGap) * 0.5f : contentW); float colW = std::max(160.0f * dp, twoCol ? (contentW - cellGap) * 0.5f : contentW);
float baseX = ImGui::GetCursorScreenPos().x; float baseX = ImGui::GetCursorScreenPos().x;
const char* cellLabels[4] = { TR("theme"), TR("balance_layout"), TR("language"), TR("clock_format") }; const char* cellLabels[4] = { TR("theme"), TR("balance_layout"), TR("language"), TR("clock_format") };
@@ -1346,7 +1346,7 @@ void RenderSettingsPage(App* app) {
ImGui::SetCursorScreenPos(ImVec2(cx, last + gp)); ImGui::SetCursorScreenPos(ImVec2(cx, last + gp));
ImGui::PushTextWrapPos((cx + cw) - ImGui::GetWindowPos().x); // wrap to the card content width ImGui::PushTextWrapPos((cx + cw) - ImGui::GetWindowPos().x); // wrap to the card content width
if (app->daemonAutoShieldActive()) { if (app->daemonAutoShieldActive()) {
ImGui::TextColored(ImVec4(0.40f, 0.78f, 0.40f, 1.0f), "%s", TR("autoshield_by_node")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("autoshield_by_node"));
if (!app->daemonAutoShieldAddress().empty()) if (!app->daemonAutoShieldAddress().empty())
ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str()); ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str());
} else { } else {
@@ -1490,6 +1490,9 @@ void RenderSettingsPage(App* app) {
last = cy + bh; cy += bh + bgp; last = cy + bh; cy += bh + bgp;
if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog(); if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
last = cy + bh; cy += bh + bgp;
if (cardBtn(cx, cy, cw, "##imp_file", TR("settings_import_wallet_file"), ICON_MD_UPLOAD_FILE)) app->showImportWalletFileDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_wallet_file"));
last = cy + bh; last = cy + bh;
if (fullNode) { if (fullNode) {
cy += bh + bgp; cy += bh + bgp;
@@ -1649,20 +1652,20 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(0, Layout::spacingMd()); ImGui::SameLine(0, Layout::spacingMd());
if (isLocked) { if (isLocked) {
ImGui::PushFont(Type().iconSmall()); ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), ICON_MD_LOCK); ImGui::TextColored(material::WarningVec4(), ICON_MD_LOCK);
ImGui::PopFont(); ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs()); ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), "%s", TR("settings_locked")); ImGui::TextColored(material::WarningVec4(), "%s", TR("settings_locked"));
} else { } else {
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button"))) if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->lockWallet(); app->lockWallet();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
ImGui::SameLine(0, Layout::spacingSm()); ImGui::SameLine(0, Layout::spacingSm());
ImGui::PushFont(Type().iconSmall()); ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN); ImGui::TextColored(material::SuccessVec4(), ICON_MD_LOCK_OPEN);
ImGui::PopFont(); ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs()); ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_unlocked")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("settings_unlocked"));
} }
// Remove Encryption button — trails the row, or wraps below // Remove Encryption button — trails the row, or wraps below
// when the column is too narrow for three buttons + status. // when the column is too narrow for three buttons + status.
@@ -1708,7 +1711,11 @@ void RenderSettingsPage(App* app) {
// PIN unlock controls, trailing the auto-lock combo on the same row. // PIN unlock controls, trailing the auto-lock combo on the same row.
bool isEncryptedPIN = app->state().isEncrypted(); bool isEncryptedPIN = app->state().isEncrypted();
if (isEncryptedPIN) { // Lite has no PIN quick-unlock: its unlock paths (Settings, send-time prompt) take the passphrase
// directly and never consult a PIN vault, so these controls would be dead. Hide them.
if (app->isLiteBuild()) {
// nothing to show
} else if (isEncryptedPIN) {
bool hasPIN = app->hasPinVault(); bool hasPIN = app->hasPinVault();
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f); float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
@@ -1756,10 +1763,10 @@ void RenderSettingsPage(App* app) {
} }
} else { } else {
ImGui::PushFont(Type().iconSmall()); ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD); ImGui::TextColored(material::SuccessVec4(), ICON_MD_DIALPAD);
ImGui::PopFont(); ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs()); ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("settings_pin_active"));
} }
} }
} else { } else {
@@ -2172,9 +2179,10 @@ void RenderSettingsPage(App* app) {
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) { if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
const bool ok = app->liteWallet()->unlockWallet(s_settingsState.lite_enc_pass); std::string unlockErr;
const bool ok = app->unlockLiteWallet(std::string(s_settingsState.lite_enc_pass), &unlockErr);
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass)); sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
s_settingsState.lite_encryption_status = ok ? TR("lite_wallet_unlocked") : TR("lite_unlock_failed"); s_settingsState.lite_encryption_status = ok ? std::string(TR("lite_wallet_unlocked")) : unlockErr;
} }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock"));
} else { } else {
@@ -2331,8 +2339,10 @@ void RenderSettingsPage(App* app) {
cs.hoverBg = material::WithAlpha(OnSurface(), 30); cs.hoverBg = material::WithAlpha(OnSurface(), 30);
cs.tooltip = TR("copy"); cs.tooltip = TR("copy");
const float ih = ImGui::GetFrameHeight(); const float ih = ImGui::GetFrameHeight();
if (material::IconButton("##copydir", ICON_MD_CONTENT_COPY, Type().iconSmall(), ImVec2(ih, ih), cs)) if (material::IconButton("##copydir", ICON_MD_CONTENT_COPY, Type().iconSmall(), ImVec2(ih, ih), cs)) {
ImGui::SetClipboardText(dirPath.c_str()); ImGui::SetClipboardText(dirPath.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
} }
// Row 2: Wallet size — label left, value RIGHT-aligned. // Row 2: Wallet size — label left, value RIGHT-aligned.
@@ -2595,6 +2605,21 @@ void RenderSettingsPage(App* app) {
if (ui::DaemonUpdateDialog::consumeInstalled()) if (ui::DaemonUpdateDialog::consumeInstalled())
s_settingsState.daemon_info_loaded = false; s_settingsState.daemon_info_loaded = false;
// Persistent reminder: a newer daemon binary is installed but the running node is still
// the old one until a restart (easy to miss on the updater's Done screen).
if (app->daemonRestartPending()) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + contentW);
Type().textColored(TypeStyle::Caption, material::Warning(), TR("daemon_restart_pending"));
ImGui::PopTextWrapPos();
if (app->isUsingEmbeddedDaemon()) {
if (material::ActionButton("##drestartpend", TR("daemon_update_restart_now"),
ICON_MD_RESTART_ALT, material::ActionTier::Primary))
app->restartDaemon();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_daemon_restart_pending"));
}
}
// --- UPDATES --- // --- UPDATES ---
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label")); Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label"));
@@ -2972,7 +2997,7 @@ void RenderSettingsPage(App* app) {
s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip))) s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip)))
saveSettingsPageState(app->settings()); saveSettingsPageState(app->settings());
if (s_settingsState.stratum_allowip[0] != '\0') if (s_settingsState.stratum_allowip[0] != '\0')
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.25f, 1.0f), " %s", ImGui::TextColored(material::WarningVec4(), " %s",
TR("stratum_expose_warn")); TR("stratum_expose_warn"));
} }
} }
@@ -3001,14 +3026,23 @@ void RenderSettingsPage(App* app) {
s_settingsState.debug_gate_verifying = false; s_settingsState.debug_gate_verifying = false;
} }
} }
if (ImGui::IsItemHovered()) material::Tooltip("%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")
: (s_settingsState.debug_gate_passed ? TR("tt_debug_expand") : TR("tt_debug_expand_gated")));
ImGui::PopStyleColor(3); ImGui::PopStyleColor(3);
// Draw overline label + arrow on top of the invisible button // Draw overline label + arrow on top of the invisible button
{ {
ImFont* ovFont = Type().overline(); ImFont* ovFont = Type().overline();
float textY = headerPos.y + (ImGui::GetFrameHeight() - ovFont->LegacySize) * 0.5f; float textY = headerPos.y + (ImGui::GetFrameHeight() - ovFont->LegacySize) * 0.5f;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(headerPos.x, textY), OnSurfaceMedium(), TR("debug_logging")); float labelX = headerPos.x;
// Show a lock beside the label while the session gate hasn't been passed, so it's clear the
// section is gated rather than just a bare collapsible header.
if (!s_settingsState.debug_gate_passed) {
ImFont* lockFont = Type().iconSmall(); if (!lockFont) lockFont = ovFont;
dl->AddText(lockFont, lockFont->LegacySize, ImVec2(labelX, textY), OnSurfaceMedium(), ICON_MD_LOCK);
labelX += lockFont->CalcTextSizeA(lockFont->LegacySize, FLT_MAX, 0, ICON_MD_LOCK).x + Layout::spacingXs();
}
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, textY), OnSurfaceMedium(), TR("debug_logging"));
// Use the icon font for the expand/collapse arrow // Use the icon font for the expand/collapse arrow
ImFont* iconFont = Type().iconSmall(); ImFont* iconFont = Type().iconSmall();
if (!iconFont) iconFont = ovFont; if (!iconFont) iconFont = ovFont;
@@ -3016,6 +3050,14 @@ void RenderSettingsPage(App* app) {
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(headerPos.x + availWidth - arrowW - pad, textY), OnSurfaceMedium(), arrow); dl->AddText(iconFont, iconFont->LegacySize, ImVec2(headerPos.x + availWidth - arrowW - pad, textY), OnSurfaceMedium(), arrow);
} }
// One-line explanation under the collapsed header (what it is + that it's gated).
if (!s_settingsState.debug_expanded) {
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(),
s_settingsState.debug_gate_passed ? TR("debug_options_caption_unlocked")
: TR("debug_options_caption_gated"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}
if (s_settingsState.debug_expanded) { if (s_settingsState.debug_expanded) {
ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -3059,9 +3101,33 @@ void RenderSettingsPage(App* app) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
#endif #endif
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_debug_select")); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextColored(ImVec4(1,1,1,0.35f), "%s", TR("settings_debug_restart_note")); ImGui::TextUnformatted(TR("settings_debug_select"));
ImGui::TextWrapped("%s", TR("settings_debug_restart_note")); // was near-invisible 35% white
ImGui::PopStyleColor();
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Restart prompt moved directly under the note so the required action is prominent (appears the
// frame after a category toggles, which sets debug_cats_dirty while rendering the grid below).
if (s_settingsState.debug_cats_dirty && app->supportsFullNodeLifecycleActions()) {
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
ImFont* iconFont = Type().iconSmall();
if (iconFont) {
ImGui::PushFont(iconFont);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(ICON_MD_INFO);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
}
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("settings_debug_changed"));
ImGui::PopStyleColor();
ImGui::SameLine();
if (TactileButton(TR("settings_restart_daemon"), ImVec2(0, 0), S.resolveFont("button"))) {
s_settingsState.confirm_restart_daemon = true;
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
// The 22 dragonxd debug categories // The 22 dragonxd debug categories
static const char* debugCats[] = { static const char* debugCats[] = {
@@ -3119,27 +3185,7 @@ void RenderSettingsPage(App* app) {
} }
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// (The "restart daemon" prompt was moved up to directly under the restart note above.)
// "Restart daemon" button — only active when categories changed
if (s_settingsState.debug_cats_dirty && app->supportsFullNodeLifecycleActions()) {
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 218, 0, 255));
ImFont* iconFont = Type().iconSmall();
if (iconFont) {
ImGui::PushFont(iconFont);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(ICON_MD_INFO);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
}
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("settings_debug_changed"));
ImGui::PopStyleColor();
ImGui::SameLine();
if (TactileButton(TR("settings_restart_daemon"), ImVec2(0, 0), S.resolveFont("button"))) {
s_settingsState.confirm_restart_daemon = true;
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
}
} }
// ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ---- // ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ----
@@ -3287,12 +3333,12 @@ void RenderSettingsPage(App* app) {
// Confirmation dialog for deleting blockchain data // Confirmation dialog for deleting blockchain data
if (s_settingsState.confirm_delete_blockchain) { if (s_settingsState.confirm_delete_blockchain) {
if (BeginOverlayDialog(TR("confirm_delete_blockchain_title"), &s_settingsState.confirm_delete_blockchain, 500.0f, 0.94f)) { if (BeginOverlayDialog(TR("confirm_delete_blockchain_title"), &s_settingsState.confirm_delete_blockchain, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); material::DialogWarningHeader(TR("warning"), material::ErrorVec4());
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_delete_blockchain_msg")); ImGui::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_delete_blockchain_safe")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_delete_blockchain_safe"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::Separator(); ImGui::Separator();
ImGui::Spacing(); ImGui::Spacing();
@@ -3334,7 +3380,7 @@ void RenderSettingsPage(App* app) {
} }
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) { if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f)); material::DialogWarningHeader(TR("warning"), material::WarningVec4());
ImGui::Spacing(); ImGui::Spacing();
@@ -3353,14 +3399,14 @@ void RenderSettingsPage(App* app) {
} else { } else {
if (s_settingsState.rescan_probe_failed) { if (s_settingsState.rescan_probe_failed) {
// A long line: TextColored does not wrap, so colour a wrapped one. // A long line: TextColored does not wrap, so colour a wrapped one.
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.8f, 0.2f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_Text, material::WarningVec4());
ImGui::TextWrapped("%s", TR("rescan_probe_failed")); ImGui::TextWrapped("%s", TR("rescan_probe_failed"));
ImGui::PopStyleColor(); ImGui::PopStyleColor();
ImGui::Spacing(); ImGui::Spacing();
} }
ImGui::TextWrapped("%s", TR("confirm_rescan_msg")); ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_rescan_safe"));
} }
ImGui::Spacing(); ImGui::Spacing();
@@ -3389,12 +3435,12 @@ void RenderSettingsPage(App* app) {
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan) // Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
if (s_settingsState.confirm_repair_wallet) { if (s_settingsState.confirm_repair_wallet) {
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) { if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f)); material::DialogWarningHeader(TR("warning"), material::WarningVec4());
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg")); ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_repair_wallet_safe")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_repair_wallet_safe"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::Separator(); ImGui::Separator();
ImGui::Spacing(); ImGui::Spacing();
@@ -3417,12 +3463,12 @@ void RenderSettingsPage(App* app) {
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart) // Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
if (s_settingsState.confirm_reinstall_daemon) { if (s_settingsState.confirm_reinstall_daemon) {
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) { if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f)); material::DialogWarningHeader(TR("warning"), material::WarningVec4());
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg")); ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_reinstall_daemon_safe")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_reinstall_daemon_safe"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::Separator(); ImGui::Separator();
ImGui::Spacing(); ImGui::Spacing();
@@ -3446,7 +3492,7 @@ void RenderSettingsPage(App* app) {
// Confirm: restart daemon (briefly drops the connection to apply changed options) // Confirm: restart daemon (briefly drops the connection to apply changed options)
if (s_settingsState.confirm_restart_daemon) { if (s_settingsState.confirm_restart_daemon) {
if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) { if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f)); material::DialogWarningHeader(TR("warning"), material::WarningVec4());
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_restart_daemon_msg")); ImGui::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
@@ -3473,12 +3519,12 @@ void RenderSettingsPage(App* app) {
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe) // Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
if (s_settingsState.confirm_lite_redownload) { if (s_settingsState.confirm_lite_redownload) {
if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) { if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f)); material::DialogWarningHeader(TR("warning"), material::WarningVec4());
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg")); ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_lite_redownload_safe")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_lite_redownload_safe"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::Separator(); ImGui::Separator();
ImGui::Spacing(); ImGui::Spacing();
+2 -2
View File
@@ -77,7 +77,7 @@ void RenderAboutDialog(App* app, bool* p_open)
#ifdef DRAGONX_DEBUG #ifdef DRAGONX_DEBUG
ImGui::Text("%s", TR("about_build_type")); ImGui::Text("%s", TR("about_build_type"));
ImGui::SameLine(valX); ImGui::SameLine(valX);
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("about_debug")); ImGui::TextColored(material::WarningVec4(), "%s", TR("about_debug"));
#else #else
ImGui::Text("%s", TR("about_build_type")); ImGui::Text("%s", TR("about_build_type"));
ImGui::SameLine(valX); ImGui::SameLine(valX);
@@ -92,7 +92,7 @@ void RenderAboutDialog(App* app, bool* p_open)
ImGui::Text("%s", TR("about_daemon")); ImGui::Text("%s", TR("about_daemon"));
ImGui::SameLine(valX); ImGui::SameLine(valX);
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("connected")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("connected"));
const auto& state = app->getWalletState(); const auto& state = app->getWalletState();
ImGui::Text("%s", TR("about_chain")); ImGui::Text("%s", TR("about_chain"));
+111 -16
View File
@@ -106,6 +106,8 @@ std::string s_delete_name; // its peer name (for the confirm copy / bl
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock) bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
bool s_show_export_confirm = false; // "export as plaintext" confirm overlay bool s_show_export_confirm = false; // "export as plaintext" confirm overlay
std::string s_export_cid; // conversation targeted by the export confirm std::string s_export_cid; // conversation targeted by the export confirm
bool s_show_fingerprint = false; // key-fingerprint verification dialog
std::string s_fingerprint_cid; // conversation targeted by the fingerprint dialog
// Export file path for a conversation (the same scheme the inline export used). // Export file path for a conversation (the same scheme the inline export used).
static std::string chatExportPathFor(const std::string& peerName) { static std::string chatExportPathFor(const std::string& peerName) {
@@ -589,6 +591,17 @@ void centeredHint(const char* text) {
} }
// Centered empty state: big muted icon + title + optional wrapped hint (V4). // Centered empty state: big muted icon + title + optional wrapped hint (V4).
// Copy the user's own chat address to the clipboard (shared by the sidebar button and the intro card).
void copyMyChatAddress(App* app) {
std::string myAddr = app->chatMyAddress();
if (myAddr.empty()) {
Notifications::instance().warning(TR("chat_no_my_address"));
} else {
ImGui::SetClipboardText(myAddr.c_str());
Notifications::instance().info(TR("address_copied"));
}
}
void centeredEmptyState(const char* icon, const char* title, const char* hint) { void centeredEmptyState(const char* icon, const char* title, const char* hint) {
const ImVec2 avail = ImGui::GetContentRegionAvail(); const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 origin = ImGui::GetCursorPos(); const ImVec2 origin = ImGui::GetCursorPos();
@@ -954,15 +967,7 @@ void RenderChatTab(App* app)
{ {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?"). // Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
if (ImGui::SmallButton(TR("chat_copy_my_address"))) { if (ImGui::SmallButton(TR("chat_copy_my_address"))) copyMyChatAddress(app);
std::string myAddr = app->chatMyAddress();
if (myAddr.empty()) {
Notifications::instance().warning(TR("chat_no_my_address"));
} else {
ImGui::SetClipboardText(myAddr.c_str());
Notifications::instance().info(TR("address_copied"));
}
}
ImGui::PopStyleColor(); ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
} }
@@ -1324,13 +1329,22 @@ void RenderChatTab(App* app)
// same row. Hidden while renaming (the field takes the whole span). // same row. Hidden while renaming (the field takes the whole span).
if (hasAddr && !renaming) { if (hasAddr && !renaming) {
float ax = textX + std::min(nameW, nameAvail) + nameSpacing; float ax = textX + std::min(nameW, nameAvail) + nameSpacing;
// Key-verify lock — we hold the peer's identity key; hover shows a comparable fingerprint. // Key-verify lock — we hold the peer's identity key; now clickable to open a dialog with the
// fingerprint for out-of-band comparison (the old hover-only lock was unexplained).
if (showLock) { if (showLock) {
const float ly = hp.y + (rowH - icoSz) * 0.5f; const float ly = hp.y + (rowH - icoSz) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(ax, ly));
const bool lockClicked = ImGui::InvisibleButton("##hdr_keylock",
ImVec2(std::max(1.0f, lockW - 5.0f * hdpi), icoSz));
const bool lockHov = ImGui::IsItemHovered();
hdl->AddText(icoFont, icoSz, ImVec2(ax, ly), hdl->AddText(icoFont, icoSz, ImVec2(ax, ly),
material::WithAlpha(material::Primary(), 220), ICON_MD_LOCK); lockHov ? material::Primary() : material::WithAlpha(material::Primary(), 220),
if (ImGui::IsMouseHoveringRect(ImVec2(ax, ly), ImVec2(ax + lockW, ly + icoSz))) ICON_MD_LOCK);
material::Tooltip("%s\n%s", TR("chat_verify_key"), keyFingerprint(sel->peerPubKey).c_str()); if (lockHov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("chat_verify_tip"));
}
if (lockClicked) { s_fingerprint_cid = sel->cid; s_show_fingerprint = true; }
ax += lockW; ax += lockW;
} }
// Click-to-copy compact address. // Click-to-copy compact address.
@@ -1724,7 +1738,10 @@ void RenderChatTab(App* app)
} }
} }
if (ImGui::BeginPopup("##msgmenu")) { if (ImGui::BeginPopup("##msgmenu")) {
if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str()); if (ImGui::MenuItem(TR("copy"))) {
ImGui::SetClipboardText(m.body.c_str());
Notifications::instance().success(TR("copied"));
}
ImGui::EndPopup(); ImGui::EndPopup();
} else if (s_msgmenu_index == static_cast<int>(mi)) { } else if (s_msgmenu_index == static_cast<int>(mi)) {
s_msgmenu_index = -1; // popup closed s_msgmenu_index = -1; // popup closed
@@ -1864,9 +1881,35 @@ void RenderChatTab(App* app)
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild). // Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
} else if (convs.empty() && app->settings() && !app->settings()->getChatIntroDismissed()) {
// First-run intro: explain how chat works + let the user copy their address, instead of a bare
// "no conversations yet" line. Dismissal is persisted.
const float idp = Layout::dpiScale();
const float availX = ImGui::GetContentRegionAvail().x;
const float cardW = std::min(availX - 40.0f * idp, 460.0f * idp);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (availX - cardW) * 0.5f));
ImGui::BeginGroup();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + cardW);
material::Type().textColored(material::TypeStyle::Subtitle1, material::OnSurface(), TR("chat_intro_title"));
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextWrapped("%s", TR("chat_intro_body"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("chat_intro_fee"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
if (material::TactileButton(TR("chat_copy_my_address"), ImVec2(cardW, 0))) copyMyChatAddress(app);
ImGui::Spacing();
if (material::TactileButton(TR("chat_intro_dismiss"), ImVec2(cardW, 0))) {
app->settings()->setChatIntroDismissed(true);
app->settings()->save();
}
ImGui::EndGroup();
} else if (convs.empty()) {
centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
} else { } else {
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start")); centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
} }
} }
ImGui::EndChild(); ImGui::EndChild();
@@ -2207,6 +2250,53 @@ void RenderChatTab(App* app)
} }
} }
// ---- Key-fingerprint verification dialog ----
if (s_show_fingerprint) {
const ConvSummary* fp = nullptr;
for (const auto& c : convs) if (c.cid == s_fingerprint_cid) { fp = &c; break; }
if (!fp || fp->peerPubKey.empty()) {
s_show_fingerprint = false;
s_fingerprint_cid.clear();
} else {
material::OverlayDialogSpec ov;
ov.title = TR("chat_verify_title");
ov.p_open = &s_show_fingerprint;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 500.0f;
ov.idSuffix = "chatfp";
if (material::BeginOverlayDialog(ov)) {
const float fdp = Layout::dpiScale();
const std::string fpStr = keyFingerprint(fp->peerPubKey);
ImGui::PushTextWrapPos(0.0f);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
ImGui::TextWrapped("%s", TR("chat_verify_body"));
ImGui::PopStyleColor();
ImGui::Spacing();
ImGui::TextWrapped("%s", fp->peerName.c_str());
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::TextWrapped("%s", fpStr.c_str());
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
const float fgap = ImGui::GetStyle().ItemSpacing.x;
float wCopy = std::max(110.0f * fdp, ImGui::CalcTextSize(TR("chat_verify_copy")).x + 24.0f * fdp);
float wClose = std::max(90.0f * fdp, ImGui::CalcTextSize(TR("chat_cancel")).x + 24.0f * fdp);
material::BeginOverlayDialogFooter(wCopy + wClose + fgap, false);
if (material::TactileButton(TR("chat_verify_copy"), ImVec2(wCopy, 0))) {
ImGui::SetClipboardText(fp->peerPubKey.c_str());
Notifications::instance().success(TR("copied"));
}
ImGui::SameLine(0, fgap);
if (material::TactileButton(TR("chat_cancel"), ImVec2(wClose, 0))) {
s_show_fingerprint = false;
s_fingerprint_cid.clear();
}
material::EndOverlayDialog();
}
}
}
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ---- // ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
if (s_show_delete_confirm) { if (s_show_delete_confirm) {
const float dp = Layout::dpiScale(); const float dp = Layout::dpiScale();
@@ -2394,11 +2484,16 @@ void ResetChatTab()
s_show_blocked = false; s_show_blocked = false;
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's // Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit). // conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
// Zero the decrypted plaintext the memo caches hold (conversation previews + full thread bodies)
// before dropping them, so the previous wallet's messages don't linger in freed heap.
auto wipeStr2 = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
for (auto& c : s_convs) wipeStr2(c.lastBody);
s_convs.clear(); s_convs.clear();
s_convsHidden = 0; s_convsHidden = 0;
s_convsKey = ~0ull; s_convsKey = ~0ull;
s_threadCid.clear(); s_threadCid.clear();
s_threadRev = ~0ull; s_threadRev = ~0ull;
for (auto& m : s_threadMsgs) { wipeStr2(m.body); wipeStr2(m.fail_reason); wipeStr2(m.peer_zaddr); }
s_threadMsgs.clear(); s_threadMsgs.clear();
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear(); s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear(); s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
+57 -2
View File
@@ -6,6 +6,7 @@
// tab completion, daemon log display, and color-coded output. // tab completion, daemon log display, and color-coded output.
#include "console_tab.h" #include "console_tab.h"
#include <sodium.h> // sodium_memzero — scrub masked secret-result plaintext
#include "console_command_executor.h" #include "console_command_executor.h"
#include "console_command_reference.h" #include "console_command_reference.h"
#include "console_input_model.h" #include "console_input_model.h"
@@ -298,7 +299,26 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
{ {
std::string result; std::string result;
bool isError = false; bool isError = false;
while (exec.pollResult(result, isError)) addFormattedResult(result, isError); while (exec.pollResult(result, isError)) {
const bool secret = secret_result_armed_ && !isError;
secret_result_armed_ = false; // the first (non-error) result consumes the arming
if (secret) {
// Hold the plaintext ONLY in secret_result_ and show a masked placeholder — it never
// enters the scrollback model, so Copy All / Ctrl+C / the filter can't leak it.
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
secret_result_ = result;
secret_result_deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(60);
addLine(TR("console_secret_result_hidden"), ConsoleChannel::Warning);
} else {
addFormattedResult(result, isError);
}
if (!result.empty()) sodium_memzero(&result[0], result.size());
}
}
// Auto-wipe a revealed secret result after its deadline so it doesn't linger for the session.
if (!secret_result_.empty() && std::chrono::steady_clock::now() >= secret_result_deadline_) {
wipeSecretResult();
addLine(TR("console_secret_result_expired"), ConsoleChannel::Info);
} }
// Drain the ingest queue into the visible model once per frame, after this frame's own // Drain the ingest queue into the visible model once per frame, after this frame's own
@@ -547,7 +567,21 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
if (ImGui::IsItemHovered()) { if (ImGui::IsItemHovered()) {
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all")); material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
} }
// When a key-revealing result is masked, offer an explicit Copy-secret (through the auto-clearing
// clipboard) and a Hide action. The secret is never in the model, so the normal Copy above is safe.
if (hasSecretResult()) {
ImGui::SameLine();
if (TactileButton(TR("console_copy_secret"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
if (copy_secret_cb_) copy_secret_cb_(secret_result_);
else ImGui::SetClipboardText(secret_result_.c_str());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_copy_secret_tip"));
ImGui::SameLine();
if (TactileButton(TR("console_hide_secret"), ImVec2(0, 0), schema::UI().resolveFont("button")))
wipeSecretResult();
}
ImGui::SameLine(); ImGui::SameLine();
// Commands reference button — shown whenever the backend offers a reference table (full-node // Commands reference button — shown whenever the backend offers a reference table (full-node
@@ -1552,6 +1586,12 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
if (n == firstLower) return cat.commands[i].destructive; if (n == firstLower) return cat.commands[i].destructive;
} }
} }
// Secret-revealing full-node commands that aren't in the reference catalog still deserve the gate
// (e.g. z_exportmnemonic dumps the seed phrase; the lite "seed" equivalent is already flagged).
if (fullNode) {
static const char* kSecretReveal[] = { "z_exportmnemonic", "z_exportviewingkey" };
for (const char* s : kSecretReveal) if (firstLower == s) return true;
}
return false; return false;
} }
} // namespace } // namespace
@@ -1559,6 +1599,7 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd) bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
{ {
if (cmd.empty()) return false; if (cmd.empty()) return false;
secret_result_armed_ = false; // cleared each submit so a no-result command can't mask the next one
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible // Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
// log and the recall history. The real `cmd` below is still executed unredacted. // log and the recall history. The real `cmd` below is still executed unredacted.
@@ -1616,6 +1657,8 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning); addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
} else { } else {
destructive_confirm_cmd_.clear(); destructive_confirm_cmd_.clear();
// Arm result-masking for key-revealing commands so their OUTPUT is hidden in the scrollback.
secret_result_armed_ = ConsoleCommandRevealsSecret(cmd, exec.hasRpcReference());
exec.submit(cmd); exec.submit(cmd);
} }
return true; return true;
@@ -1882,6 +1925,9 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
ImGui::SameLine(); ImGui::SameLine();
if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) { if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
pending_submit_ = built; pending_submit_ = built;
// The reference dialog already confirmed this run, so pre-arm the destructive gate to an exact
// match — otherwise the replayed submit re-arms it and silently does nothing until retyped.
destructive_confirm_cmd_ = built;
command_search_[0] = '\0'; command_search_[0] = '\0';
run_confirm_cmd_ = nullptr; run_confirm_cmd_ = nullptr;
show_commands_popup_ = false; show_commands_popup_ = false;
@@ -2193,8 +2239,17 @@ void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& m
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc); addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
} }
void ConsoleTab::wipeSecretResult()
{
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
secret_result_.clear();
secret_result_armed_ = false;
secret_result_deadline_ = {};
}
void ConsoleTab::clear() void ConsoleTab::clear()
{ {
wipeSecretResult(); // the clear command / toolbar button also drops any held secret result
// View-only clear (main thread). The executor keeps its own log cursors, so new output // View-only clear (main thread). The executor keeps its own log cursors, so new output
// still appends. The "cleared" line is ingested and appears on the next frame's drain. // still appends. The "cleared" line is ingested and appears on the next frame's drain.
model_.clear(); model_.clear();
+13
View File
@@ -20,6 +20,8 @@
#include <vector> #include <vector>
#include <deque> #include <deque>
#include <mutex> #include <mutex>
#include <functional>
#include <chrono>
namespace dragonx { namespace dragonx {
namespace ui { namespace ui {
@@ -66,6 +68,13 @@ public:
*/ */
void clear(); void clear();
// Masked secret-result support: the plaintext of a key-revealing command's output is held ONLY here
// (never in the scrollback model), so Copy All / Ctrl+C / the filter can't leak it. An explicit
// "Copy secret" action routes through this handler (the app wires it to the auto-clearing clipboard).
void setCopySecretHandler(std::function<void(const std::string&)> f) { copy_secret_cb_ = std::move(f); }
bool hasSecretResult() const { return !secret_result_.empty(); }
void wipeSecretResult(); // zero + drop the buffer, disarm, reset the deadline (call on lock/clear)
// Ask the console to place the keyboard focus in the command input on the next render (consumed once). // Ask the console to place the keyboard focus in the command input on the next render (consumed once).
// Called when the user switches to the Console tab, gated by the console_auto_focus setting. // Called when the user switches to the Console tab, gated by the console_auto_focus setting.
void requestInputFocus() { focus_input_pending_ = true; } void requestInputFocus() { focus_input_pending_ = true; }
@@ -162,6 +171,10 @@ private:
char input_buffer_[4096] = {0}; char input_buffer_[4096] = {0};
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop' bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
bool secret_result_armed_ = false; // the just-submitted command reveals key material in its result
std::string secret_result_; // plaintext of the masked result (member-only; never in model_)
std::chrono::steady_clock::time_point secret_result_deadline_{}; // auto-wipe the buffer at this time
std::function<void(const std::string&)> copy_secret_cb_; // routes "Copy secret" to the app
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus) bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor) // (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
+23
View File
@@ -15,6 +15,10 @@ const char* const kSecretConsoleCommands[] = {
"importprivkey", "importwallet", "importmulti", "importprivkey", "importwallet", "importmulti",
"z_importkey", "z_importviewingkey", "z_importwallet", "z_importkey", "z_importviewingkey", "z_importwallet",
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic", "signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
// Lite-wallet verbs (console_command_reference kLiteKeyCommands): these take a spending/viewing key
// or a password as their argument. Full-node has no bare import/encrypt/decrypt/unlock verb, so a
// flat match is safe.
"import", "timport", "encrypt", "decrypt", "unlock",
}; };
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) { std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
@@ -67,6 +71,25 @@ bool ConsoleCommandCarriesSecret(const std::string& cmd)
return false; return false;
} }
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode)
{
size_t end = 0;
const std::string name = firstConsoleTokenLower(cmd, end);
if (name.empty()) return false;
if (fullNode) {
static const char* const kFull[] = {
"dumpprivkey", "z_exportkey", "z_exportviewingkey",
"z_exportmnemonic", "dumpwallet", "z_exportwallet",
};
for (const char* s : kFull) if (name == s) return true;
} else {
// Lite backend verbs whose output is the seed phrase / private keys.
static const char* const kLite[] = { "seed", "export" };
for (const char* s : kLite) if (name == s) return true;
}
return false;
}
std::string RedactConsoleCommand(const std::string& cmd) std::string RedactConsoleCommand(const std::string& cmd)
{ {
size_t end = 0; size_t end = 0;
+6
View File
@@ -29,6 +29,12 @@ bool ShouldPrintDaemonErrorLine(bool wasError, bool isError,
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern. // z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
bool ConsoleCommandCarriesSecret(const std::string& cmd); bool ConsoleCommandCarriesSecret(const std::string& cmd);
// True when the command's RESULT (not its args) reveals key material — dumpprivkey / z_exportkey /
// z_exportviewingkey / z_exportmnemonic / dumpwallet / z_exportwallet on the full node, or the lite
// `seed` / `export` verbs. Used to mask the output in the console scrollback. `fullNode` selects the
// command set (the bare lite verbs `seed`/`export` must not match full-node commands).
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode);
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when // A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted. // it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
std::string RedactConsoleCommand(const std::string& cmd); std::string RedactConsoleCommand(const std::string& cmd);
+11 -1
View File
@@ -445,11 +445,20 @@ private:
static void renderDone(float, const Progress& p) { static void renderDone(float, const Progress& p) {
using namespace material; using namespace material;
if (!s_notified) { s_installed_flag = true; s_notified = true; } if (!s_notified) {
s_installed_flag = true;
s_notified = true;
// Record a persistent "restart pending" reminder so it isn't lost if the user clicks Later.
if (s_app) s_app->markDaemonRestartPending();
}
Type().textColored(TypeStyle::Subtitle2, Success(), TR("daemon_update_installed_ok")); Type().textColored(TypeStyle::Subtitle2, Success(), TR("daemon_update_installed_ok"));
ImGui::Spacing(); ImGui::Spacing();
ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str()); ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str());
ImGui::Spacing(); ImGui::Spacing();
// Lead with a warning-level headline that a restart is required, then the detail note.
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x);
Type().textColored(TypeStyle::Subtitle2, Warning(), TR("daemon_update_restart_needed"));
ImGui::PopTextWrapPos();
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_restart_note")); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_restart_note"));
ImGui::Spacing(); ImGui::Spacing();
if (s_app->isUsingEmbeddedDaemon()) { if (s_app->isUsingEmbeddedDaemon()) {
@@ -459,6 +468,7 @@ private:
} }
ImGui::Spacing(); ImGui::Spacing();
if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false; if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false;
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_later_hint"));
} else { } else {
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false; if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
} }
+17 -6
View File
@@ -269,7 +269,7 @@ static void fetchBlockDetail(App* app, int height) {
auto hashResult = rpc->call("getblockhash", {height}); auto hashResult = rpc->call("getblockhash", {height});
std::string hash = hashResult.get<std::string>(); std::string hash = hashResult.get<std::string>();
result = rpc->call("getblock", {hash}); result = rpc->call("getblock", {hash});
} catch (const std::exception& e) { error = e.what(); } } catch (const std::exception& e) { error = util::friendlyRpcError(e.what()); }
return [result, error]() { return [result, error]() {
applyBlockDetailResult(result, error); applyBlockDetailResult(result, error);
}; };
@@ -383,19 +383,27 @@ static void navigateToHash(App* app, const std::string& hash) {
worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb { worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb {
json blockResult, txResult; json blockResult, txResult;
bool gotBlock = false, gotTx = false; bool gotBlock = false, gotTx = false;
bool rpcFailed = false; std::string rpcErr;
// Distinguish a genuine "not found" (RPC -5/-8) from a transport/timeout failure, so a flaky
// connection isn't reported as "no block or transaction found".
auto noteErr = [&](const std::exception& e) {
const auto* re = dynamic_cast<const rpc::RpcError*>(&e);
if (re && (re->code == -5 || re->code == -8)) return; // not-found: a real negative answer
if (!rpcFailed) { rpcFailed = true; rpcErr = e.what(); }
};
try { try {
rpc::RPCClient::TraceScope trace("Explorer tab / Search"); rpc::RPCClient::TraceScope trace("Explorer tab / Search");
blockResult = rpc->call("getblock", {hash}); blockResult = rpc->call("getblock", {hash});
gotBlock = true; gotBlock = true;
} catch (...) {} } catch (const std::exception& e) { noteErr(e); }
if (!gotBlock) { if (!gotBlock) {
try { try {
rpc::RPCClient::TraceScope trace("Explorer tab / Search"); rpc::RPCClient::TraceScope trace("Explorer tab / Search");
txResult = rpc->call("getrawtransaction", {hash, 1}); txResult = rpc->call("getrawtransaction", {hash, 1});
gotTx = true; gotTx = true;
} catch (...) {} } catch (const std::exception& e) { noteErr(e); }
} }
return [blockResult, txResult, gotBlock, gotTx]() { return [blockResult, txResult, gotBlock, gotTx, rpcFailed, rpcErr]() {
s_search_loading = false; s_search_loading = false;
if (gotBlock) { if (gotBlock) {
int h = blockResult.value("height", -1); int h = blockResult.value("height", -1);
@@ -407,6 +415,8 @@ static void navigateToHash(App* app, const std::string& hash) {
s_tx_loading = false; s_tx_loading = false;
s_expanded_tx_idx = 0; s_expanded_tx_idx = 0;
s_show_detail_modal = false; s_show_detail_modal = false;
} else if (rpcFailed) {
s_search_error = std::string(TR("explorer_lookup_failed")) + ": " + util::friendlyRpcError(rpcErr);
} else { } else {
s_search_error = TR("explorer_hash_not_found"); s_search_error = TR("explorer_hash_not_found");
} }
@@ -836,7 +846,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
std::map<int, BlockSummary> cachedBlocks; std::map<int, BlockSummary> cachedBlocks;
if (!pageHeights.empty()) { if (!pageHeights.empty()) {
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front()); // Memoized on the cache revision: SQLite is only hit after the cache changed, not every frame.
cachedBlocks = s_recent_block_cache.loadRangeCached(pageHeights.back(), pageHeights.front());
} }
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash // Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
@@ -880,7 +891,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
} }
std::map<int, BlockSummary> nextCachedBlocks; std::map<int, BlockSummary> nextCachedBlocks;
if (!nextPageHeights.empty()) { if (!nextPageHeights.empty()) {
nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front()); nextCachedBlocks = s_recent_block_cache.loadRangeCached(nextPageHeights.back(), nextPageHeights.front());
} }
for (int height : nextPageHeights) { for (int height : nextPageHeights) {
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) { if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
+7 -3
View File
@@ -9,6 +9,7 @@
#include "../../rpc/rpc_worker.h" #include "../../rpc/rpc_worker.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../../util/platform.h" #include "../../util/platform.h"
#include "../../util/export_filename.h"
#include "../notifications.h" #include "../notifications.h"
#include "../schema/ui_schema.h" #include "../schema/ui_schema.h"
#include "../material/draw_helpers.h" #include "../material/draw_helpers.h"
@@ -117,7 +118,7 @@ void ExportAllKeysDialog::render(App* app)
} else if (!s_status.empty()) { } else if (!s_status.empty()) {
ImGui::Spacing(); ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f); ImGui::PushTextWrapPos(0.0f);
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", s_status.c_str()); ImGui::TextColored(material::ErrorVec4(), "%s", s_status.c_str());
ImGui::PopTextWrapPos(); ImGui::PopTextWrapPos();
} }
@@ -160,8 +161,11 @@ void ExportAllKeysDialog::render(App* app)
if (s_include_t) { if (s_include_t) {
for (const auto& a : state.t_addresses) t_addrs.push_back(a.address); for (const auto& a : state.t_addresses) t_addrs.push_back(a.address);
} }
std::string filename(s_filename); // Sanitize the typed filename (basename only, no traversal) and reflect it back in the
// buffer so the user sees exactly what gets written. Mirrors the CSV export dialog.
std::string filename = util::sanitizeExportFilename(s_filename, ".txt", "dragonx_keys.txt");
snprintf(s_filename, sizeof(s_filename), "%s", filename.c_str());
// Run all key exports on worker thread // Run all key exports on worker thread
if (app->worker()) { if (app->worker()) {
app->worker()->post([rpc = app->rpc(), z_addrs, t_addrs, filename]() -> rpc::RPCWorker::MainCb { app->worker()->post([rpc = app->rpc(), z_addrs, t_addrs, filename]() -> rpc::RPCWorker::MainCb {
+105 -6
View File
@@ -6,7 +6,9 @@
#include "../../app.h" #include "../../app.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../../util/platform.h" #include "../../util/platform.h"
#include "../../util/export_filename.h"
#include "../../data/transaction_csv.h" #include "../../data/transaction_csv.h"
#include "transactions_tab.h"
#include "../notifications.h" #include "../notifications.h"
#include "../schema/ui_schema.h" #include "../schema/ui_schema.h"
#include "../material/draw_helpers.h" #include "../material/draw_helpers.h"
@@ -18,6 +20,8 @@
#include <string> #include <string>
#include <ctime> #include <ctime>
#include <algorithm>
#include <cctype>
namespace dragonx { namespace dragonx {
namespace ui { namespace ui {
@@ -27,12 +31,34 @@ static bool s_open = false;
static char s_filename[256] = ""; static char s_filename[256] = "";
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
static std::string s_error; // error message on failure; empty otherwise static std::string s_error; // error message on failure; empty otherwise
static int s_scope = 0; // 0 = all, 1 = current History filter, 2 = date range
static char s_from[16] = ""; // YYYY-MM-DD (date-range scope)
static char s_to[16] = "";
// Parse a YYYY-MM-DD string to the local-time start-of-day unix seconds. Returns false on empty/invalid.
static bool parseYmd(const char* s, std::int64_t& outStartOfDay) {
if (!s || !*s) return false;
int y = 0, mo = 0, d = 0;
if (std::sscanf(s, "%d-%d-%d", &y, &mo, &d) != 3) return false;
if (y < 1970 || y > 9999 || mo < 1 || mo > 12 || d < 1 || d > 31) return false;
std::tm tm{}; tm.tm_year = y - 1900; tm.tm_mon = mo - 1; tm.tm_mday = d; tm.tm_isdst = -1;
std::time_t t = std::mktime(&tm);
if (t == (std::time_t)-1) return false;
// Reject rollover (e.g. Feb 31 → Mar) by requiring the normalized fields to round-trip.
if (tm.tm_year != y - 1900 || tm.tm_mon != mo - 1 || tm.tm_mday != d) return false;
outStartOfDay = (std::int64_t)t;
return true;
}
void ExportTransactionsDialog::show() void ExportTransactionsDialog::show()
{ {
s_open = true; s_open = true;
s_saved_path.clear(); s_saved_path.clear();
s_error.clear(); s_error.clear();
// Pre-select the current History filter only when one is actually applied.
s_scope = GetTransactionsFilterSnapshot().active ? 1 : 0;
s_from[0] = '\0';
s_to[0] = '\0';
// Generate default filename with timestamp // Generate default filename with timestamp
std::time_t now = std::time(nullptr); std::time_t now = std::time(nullptr);
@@ -74,8 +100,64 @@ void ExportTransactionsDialog::render(App* app)
if (!m::BeginOverlayDialog(ov)) return; if (!m::BeginOverlayDialog(ov)) return;
const auto& state = app->getWalletState(); const auto& state = app->getWalletState();
const TxFilterSnapshot& snap = GetTransactionsFilterSnapshot();
const bool filterUsable = snap.valid && snap.active && snap.txRevision == state.tx_revision;
ImGui::Text(TR("export_tx_count"), state.transactions.size()); // ---- Export scope ----
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("export_tx_scope"));
ImGui::RadioButton(TR("export_tx_scope_all"), &s_scope, 0);
if (!filterUsable) ImGui::BeginDisabled();
ImGui::RadioButton(TR("export_tx_scope_filter"), &s_scope, 1);
if (!filterUsable) ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !filterUsable)
m::Tooltip("%s", TR("export_tx_scope_filter_none"));
if (s_scope == 1 && filterUsable && !snap.description.empty()) {
ImGui::SameLine();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "(%s)", snap.description.c_str());
}
ImGui::RadioButton(TR("export_tx_scope_range"), &s_scope, 2);
std::int64_t fromT = 0, toT = 0; bool fromOk = false, toOk = false, rangeBad = false;
if (s_scope == 2) {
ImGui::Indent();
const float dw = 130.0f * Layout::dpiScale();
ImGui::SetNextItemWidth(dw);
ImGui::InputTextWithHint("##exfrom", TR("export_tx_range_from"), s_from, sizeof(s_from));
ImGui::SameLine();
ImGui::SetNextItemWidth(dw);
ImGui::InputTextWithHint("##exto", TR("export_tx_range_to"), s_to, sizeof(s_to));
fromOk = parseYmd(s_from, fromT);
toOk = parseYmd(s_to, toT);
if (toOk) toT += 24 * 3600 - 1; // inclusive end-of-day
if ((s_from[0] && !fromOk) || (s_to[0] && !toOk)) rangeBad = true;
if (fromOk && toOk && fromT > toT) rangeBad = true;
if (rangeBad)
ImGui::TextColored(m::ErrorVec4(), "%s", TR("export_tx_range_invalid"));
else
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("export_tx_range_pending_note"));
ImGui::Unindent();
}
// Resolve the subset for the chosen scope (scopeOk=false ⇒ can't export: stale snapshot / bad range).
std::vector<size_t> subsetIdx; bool scopeOk = true;
if (s_scope == 1) {
if (!filterUsable) scopeOk = false;
else for (size_t i : snap.indices) if (i < state.transactions.size()) subsetIdx.push_back(i);
} else if (s_scope == 2) {
if (rangeBad) scopeOk = false;
else for (size_t i = 0; i < state.transactions.size(); ++i) {
const auto& tx = state.transactions[i];
if (tx.timestamp <= 0) continue; // pending rows have no date
if (fromOk && tx.timestamp < fromT) continue;
if (toOk && tx.timestamp > toT) continue;
subsetIdx.push_back(i);
}
} else {
for (size_t i = 0; i < state.transactions.size(); ++i) subsetIdx.push_back(i);
}
ImGui::Spacing();
ImGui::Text(TR("export_tx_count"), scopeOk ? subsetIdx.size() : (size_t)0);
ImGui::Spacing(); ImGui::Spacing();
// Filename + save-location hint. // Filename + save-location hint.
@@ -94,6 +176,8 @@ void ExportTransactionsDialog::render(App* app)
ImGui::SameLine(0, 4.0f * Layout::dpiScale()); ImGui::SameLine(0, 4.0f * Layout::dpiScale());
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success")); ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
widgets::AddressCopyField("##exportedpath", s_saved_path); widgets::AddressCopyField("##exportedpath", s_saved_path);
if (m::TactileButton(TR("export_tx_open_folder"), ImVec2(0, 0)))
util::Platform::openFolder(util::Platform::getConfigDir());
} else if (!s_error.empty()) { } else if (!s_error.empty()) {
ImGui::Spacing(); ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f); ImGui::PushTextWrapPos(0.0f);
@@ -109,14 +193,29 @@ void ExportTransactionsDialog::render(App* app)
if (doExport) { if (doExport) {
s_saved_path.clear(); s_saved_path.clear();
s_error.clear(); s_error.clear();
if (state.transactions.empty()) { if (!scopeOk) {
// Stale filter snapshot (tx list changed) or an invalid date range — never silently export all.
Notifications::instance().warning(s_scope == 1 ? TR("export_tx_scope_stale") : TR("export_tx_range_invalid"));
} else if (subsetIdx.empty()) {
Notifications::instance().warning(TR("export_tx_none")); Notifications::instance().warning(TR("export_tx_none"));
} else { } else {
std::string configDir = util::Platform::getConfigDir(); std::string configDir = util::Platform::getConfigDir();
std::string filepath = configDir + "/" + s_filename; // Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
// Build in memory, then write atomically (temp + fsync + rename): a disk-full / // the config dir by a pasted path).
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast. std::string safeName = util::sanitizeExportFilename(s_filename, ".csv", "dragonx_transactions.csv");
const std::string csv = data::formatTransactionsCsv(state.transactions); snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
std::string filepath = configDir + "/" + safeName;
// Build the chosen subset (scope 0 exports the whole list with no copy), then write atomically
// (temp + fsync + rename) so a disk-full failure is reported instead of a truncated CSV.
std::string csv;
if (s_scope == 0) {
csv = data::formatTransactionsCsv(state.transactions);
} else {
std::vector<TransactionInfo> subset;
subset.reserve(subsetIdx.size());
for (size_t i : subsetIdx) subset.push_back(state.transactions[i]);
csv = data::formatTransactionsCsv(subset);
}
if (!util::Platform::writeFileAtomically(filepath, csv)) { if (!util::Platform::writeFileAtomically(filepath, csv)) {
s_error = TR("export_tx_file_fail"); s_error = TR("export_tx_file_fail");
Notifications::instance().error(TR("export_tx_file_fail")); Notifications::instance().error(TR("export_tx_file_fail"));
+14 -4
View File
@@ -121,9 +121,9 @@ void KeyExportDialog::render(App* app)
ImGui::Text("%s", key_label); ImGui::Text("%s", key_label);
if (s_fetching) { if (s_fetching) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("key_export_fetching")); ImGui::TextColored(material::WarningVec4(), "%s%s", TR("key_export_fetching_base"), material::LoadingDots());
} else if (!s_error.empty()) { } else if (!s_error.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error.c_str()); ImGui::TextColored(material::ErrorVec4(), TR("error_format"), s_error.c_str());
// Not a dead-end: clearing the error falls back through to the Reveal button next frame. // Not a dead-end: clearing the error falls back through to the Reveal button next frame.
ImGui::Spacing(); ImGui::Spacing();
if (material::TactileButton(TR("retry"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) { if (material::TactileButton(TR("retry"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
@@ -132,7 +132,17 @@ void KeyExportDialog::render(App* app)
} else if (s_key.empty() && s_address.length() < 26) { } else if (s_key.empty() && s_address.length() < 26) {
// Guard against an empty/blatantly-malformed address before dispatching a doomed reveal. // Guard against an empty/blatantly-malformed address before dispatching a doomed reveal.
// (Also avoids reading s_address[0] on an empty string in the Reveal branch below.) // (Also avoids reading s_address[0] on an empty string in the Reveal branch below.)
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("invalid_address")); ImGui::TextColored(material::ErrorVec4(), "%s", TR("invalid_address"));
} else if (s_key.empty() && app->getWalletState().isLocked()) {
// Reveal would just fail with a locked-wallet RPC error — say so and offer to unlock.
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
ImGui::TextWrapped("%s", TR("key_export_unlock_first"));
ImGui::PopStyleColor();
ImGui::Spacing();
if (material::TactileButton(TR("lite_unlock"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
if (app->liteWallet()) app->requestLiteUnlock();
else s_open = false; // full node: closing reveals the lock screen already shown while locked
}
} else if (s_key.empty()) { } else if (s_key.empty()) {
// Show button to fetch key // Show button to fetch key
if (material::TactileButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) { if (material::TactileButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
@@ -173,7 +183,7 @@ void KeyExportDialog::render(App* app)
s_key = found; s_key = found;
s_show_key = wantViewing; // viewing keys are less sensitive s_show_key = wantViewing; // viewing keys are less sensitive
} else { } else {
s_error = r.ok ? std::string(TR("grpc_key_not_available")) : r.error; s_error = r.ok ? std::string(TR("grpc_key_not_available")) : util::friendlyRpcError(r.error);
} }
wallet::secureWipeLiteSecret(found); wallet::secureWipeLiteSecret(found);
s_fetching = false; s_fetching = false;
+21 -2
View File
@@ -33,6 +33,7 @@ using namespace material;
// Track selected peer for ban action // Track selected peer for ban action
static int s_selected_peer_idx = -1; static int s_selected_peer_idx = -1;
static int s_selected_banned_idx = -1; static int s_selected_banned_idx = -1;
static bool s_confirm_clear_bans = false; // inline confirm for the bulk Clear-All-Bans action
// Helper: Extract IP without port // Helper: Extract IP without port
static std::string ExtractIP(const std::string& addr) static std::string ExtractIP(const std::string& addr)
@@ -775,9 +776,11 @@ void RenderPeersTab(App* app)
ImGui::Separator(); ImGui::Separator();
if (ImGui::MenuItem(TR("copy_address"))) { if (ImGui::MenuItem(TR("copy_address"))) {
ImGui::SetClipboardText(peer.addr.c_str()); ImGui::SetClipboardText(peer.addr.c_str());
ui::Notifications::instance().info(TR("copied_to_clipboard"));
} }
if (ImGui::MenuItem(TR("peers_copy_ip"))) { if (ImGui::MenuItem(TR("peers_copy_ip"))) {
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str()); ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
ui::Notifications::instance().info(TR("copied_to_clipboard"));
} }
ImGui::Separator(); ImGui::Separator();
if (ImGui::MenuItem(TR("peers_ban_24h"))) { if (ImGui::MenuItem(TR("peers_ban_24h"))) {
@@ -891,6 +894,7 @@ void RenderPeersTab(App* app)
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) { if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
if (ImGui::MenuItem(TR("copy_address"))) { if (ImGui::MenuItem(TR("copy_address"))) {
ImGui::SetClipboardText(banned.address.c_str()); ImGui::SetClipboardText(banned.address.c_str());
ui::Notifications::instance().info(TR("copied_to_clipboard"));
} }
if (ImGui::MenuItem(TR("peers_unban"))) { if (ImGui::MenuItem(TR("peers_unban"))) {
app->unbanPeer(banned.address); app->unbanPeer(banned.address);
@@ -931,10 +935,25 @@ void RenderPeersTab(App* app)
// ================================================================ // ================================================================
if (s_show_banned && !state.bannedPeers.empty()) { if (s_show_banned && !state.bannedPeers.empty()) {
ImGui::BeginDisabled(!app->isConnected()); ImGui::BeginDisabled(!app->isConnected());
if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button"))) { if (!s_confirm_clear_bans) {
app->clearBans(); if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button")))
s_confirm_clear_bans = true; // bulk action takes a confirming second click
} else {
char cb[96];
snprintf(cb, sizeof(cb), TR("peers_clear_bans_confirm"), (int)state.bannedPeers.size());
ImGui::TextColored(material::WarningVec4(), "%s", cb);
ImGui::SameLine();
if (TactileSmallButton(TR("peers_clear_bans_confirm_yes"), S.resolveFont("button"))) {
app->clearBans();
s_confirm_clear_bans = false;
}
ImGui::SameLine();
if (TactileSmallButton(TR("cancel"), S.resolveFont("button")))
s_confirm_clear_bans = false;
} }
ImGui::EndDisabled(); ImGui::EndDisabled();
} else {
s_confirm_clear_bans = false; // no stale confirm across tab toggles / after bans clear
} }
ImGui::EndChild(); // ##PeersScroll ImGui::EndChild(); // ##PeersScroll
+6 -4
View File
@@ -333,10 +333,11 @@ static void RenderAddressDropdown(App* app, float width) {
} else { } else {
s_generating_address = true; s_generating_address = true;
if (s_addr_type_filter != 2) { if (s_addr_type_filter != 2) {
app->createNewZAddress([](const std::string& addr) { app->createNewZAddress([app](const std::string& addr) {
s_generating_address = false; s_generating_address = false;
if (addr.empty()) if (addr.empty())
Notifications::instance().error(TR("failed_create_shielded")); Notifications::instance().error(app->lastAddressError().empty()
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
else { else {
s_pending_select_address = addr; s_pending_select_address = addr;
Notifications::instance().success(TR("new_shielded_created")); Notifications::instance().success(TR("new_shielded_created"));
@@ -400,10 +401,11 @@ static void RenderMiningAddressNotice(App* app, const WalletState& state, float
s_cached_qr_data.clear(); s_cached_qr_data.clear();
} else { } else {
s_generating_address = true; // no shielded address yet: make one, as "New" does s_generating_address = true; // no shielded address yet: make one, as "New" does
app->createNewZAddress([](const std::string& addr) { app->createNewZAddress([app](const std::string& addr) {
s_generating_address = false; s_generating_address = false;
if (addr.empty()) if (addr.empty())
Notifications::instance().error(TR("failed_create_shielded")); Notifications::instance().error(app->lastAddressError().empty()
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
else { else {
s_pending_select_address = addr; s_pending_select_address = addr;
Notifications::instance().success(TR("new_shielded_created")); Notifications::instance().success(TR("new_shielded_created"));
+3 -2
View File
@@ -119,10 +119,11 @@ void RequestPaymentDialog::render(App* app)
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) { if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
if (app->isConnected()) { if (app->isConnected()) {
s_creating_address = true; s_creating_address = true;
app->createNewZAddress([](const std::string& addr) { app->createNewZAddress([app](const std::string& addr) {
s_creating_address = false; s_creating_address = false;
if (addr.empty()) { if (addr.empty()) {
Notifications::instance().error(TR("failed_create_shielded")); Notifications::instance().error(app->lastAddressError().empty()
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
} else { } else {
Notifications::instance().success(TR("new_shielded_created")); Notifications::instance().success(TR("new_shielded_created"));
// Pre-select the new address if the dialog is still open and none chosen. // Pre-select the new address if the dialog is still open and none chosen.
+17 -2
View File
@@ -8,6 +8,7 @@
#include <cstdio> #include <cstdio>
#include <string> #include <string>
#include <vector> #include <vector>
#include <sodium.h>
#include "imgui.h" #include "imgui.h"
#include "../layout.h" // Layout::dpiScale() #include "../layout.h" // Layout::dpiScale()
@@ -15,19 +16,31 @@
namespace dragonx { namespace dragonx {
namespace ui { namespace ui {
// Best-effort zero of a seed-word vector (each word holds part of the phrase). Call after rendering.
inline void WipeSeedWords(std::vector<std::string>& words)
{
for (auto& w : words) { if (!w.empty()) sodium_memzero(&w[0], w.size()); w.clear(); }
words.clear();
}
// Split a whitespace-separated seed phrase into its individual words. // Split a whitespace-separated seed phrase into its individual words.
inline std::vector<std::string> SplitSeedWords(const std::string& phrase) inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
{ {
std::vector<std::string> words; std::vector<std::string> words;
words.reserve(32); // avoid reallocation stranding un-wiped moved-from copies in freed heap
std::string cur; std::string cur;
for (char c : phrase) { for (char c : phrase) {
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
if (!cur.empty()) { words.push_back(cur); cur.clear(); } if (!cur.empty()) {
words.push_back(cur);
sodium_memzero(&cur[0], cur.size()); // zero the scratch copy before reuse
cur.clear();
}
} else { } else {
cur.push_back(c); cur.push_back(c);
} }
} }
if (!cur.empty()) words.push_back(cur); if (!cur.empty()) { words.push_back(cur); sodium_memzero(&cur[0], cur.size()); cur.clear(); }
return words; return words;
} }
@@ -45,6 +58,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str()); std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
if (need > colStride) colStride = need; if (need > colStride) colStride = need;
sodium_memzero(cell, sizeof(cell)); // the cell held a seed word
} }
for (size_t i = 0; i < words.size(); ++i) { for (size_t i = 0; i < words.size(); ++i) {
char cell[96]; char cell[96];
@@ -52,6 +66,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
ImGui::TextUnformatted(cell); ImGui::TextUnformatted(cell);
if ((i % 4) != 3 && i + 1 < words.size()) if ((i % 4) != 3 && i + 1 < words.size())
ImGui::SameLine(((i % 4) + 1) * colStride); ImGui::SameLine(((i % 4) + 1) * colStride);
sodium_memzero(cell, sizeof(cell));
} }
} }
+26 -6
View File
@@ -14,6 +14,8 @@
#include "../../util/address_validation.h" #include "../../util/address_validation.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../../util/text_format.h" #include "../../util/text_format.h"
#include "../../util/amount_format.h" // toSats / canAfford — integer-satoshi affordability check
#include "../../rpc/rpc_failure.h" // isSendMaybeInFlight — lost-reply (outcome unknown) send tag
#include "../notifications.h" #include "../notifications.h"
#include "../layout.h" #include "../layout.h"
#include "contact_picker.h" #include "contact_picker.h"
@@ -915,8 +917,9 @@ void RenderSendConfirmPopup(App* app) {
// since Review. Don't broadcast a now-invalid transaction. // since Review. Don't broadcast a now-invalid transaction.
const auto& liveState = app->getWalletState(); const auto& liveState = app->getWalletState();
double liveAvail = GetAvailableBalance(app); double liveAvail = GetAvailableBalance(app);
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 || if (!app->isConnected() || liveState.sync.syncing ||
(s_amount + s_fee) > liveAvail) { !liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 ||
!util::canAfford(s_amount, s_fee, liveAvail)) {
s_tx_status = TR("send_cannot_send_now"); s_tx_status = TR("send_cannot_send_now");
s_status_success = false; s_status_success = false;
s_status_timestamp = ImGui::GetTime(); s_status_timestamp = ImGui::GetTime();
@@ -954,6 +957,18 @@ void RenderSendConfirmPopup(App* app) {
s_amount = 0.0; s_amount = 0.0;
s_memo[0] = '\0'; s_memo[0] = '\0';
s_send_max = false; s_send_max = false;
} else if (rpc::isSendMaybeInFlight(result)) {
// Outcome unknown: the reply was lost after the request left. Do NOT present a
// plain failure (it invites a retry/double-send). Warn, point at History, and
// clear the form so a resend has to be retyped deliberately.
s_tx_status = std::string(TR("send_maybe_sent")) + "\n\n" + rpc::stripSendMaybeInFlight(result);
s_result_txid.clear();
s_status_success = false;
Notifications::instance().warning(TR("send_maybe_sent"), 15.0f);
s_to_address[0] = '\0';
s_amount = 0.0;
s_memo[0] = '\0';
s_send_max = false;
} else { } else {
// Show a friendly cause in the toast; keep the raw backend text below it in // Show a friendly cause in the toast; keep the raw backend text below it in
// the overlay (copyable) so the detail isn't lost for diagnostics. // the overlay (copyable) so the detail isn't lost for diagnostics.
@@ -1034,10 +1049,11 @@ static void RenderActionButtons(App* app, float width, float vScale,
} }
bool can_send = app->isConnected() && bool can_send = app->isConnected() &&
!state.sync.syncing && !state.sync.syncing &&
state.sync.sendTipReady(app->isLiteBuild()) &&
is_valid_address && is_valid_address &&
s_amount > 0 && s_amount > 0 &&
s_from_address[0] != '\0' && s_from_address[0] != '\0' &&
total <= available && util::canAfford(s_amount, s_fee, available) &&
sourceSpendable && sourceSpendable &&
!s_sending; !s_sending;
@@ -1058,7 +1074,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale); float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
// Explain why Review/Send is greyed while syncing (the fields above are usable now). // Explain why Review/Send is greyed while syncing (the fields above are usable now).
if (state.sync.syncing) { if (state.sync.syncing || (app->isConnected() && !state.sync.sendTipReady(app->isLiteBuild()))) {
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced")); Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
} }
@@ -1087,6 +1103,8 @@ static void RenderActionButtons(App* app, float width, float vScale,
material::Tooltip("%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) else if (state.sync.syncing)
material::Tooltip("%s", TR("send_tooltip_syncing")); material::Tooltip("%s", TR("send_tooltip_syncing"));
else if (!state.sync.sendTipReady(app->isLiteBuild()))
material::Tooltip("%s", TR("send_tooltip_no_tip"));
else if (s_from_address[0] == '\0') else if (s_from_address[0] == '\0')
material::Tooltip("%s", TR("send_tooltip_select_source")); material::Tooltip("%s", TR("send_tooltip_select_source"));
else if (is_mining_addr) else if (is_mining_addr)
@@ -1095,7 +1113,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
material::Tooltip("%s", TR("send_tooltip_invalid_address")); material::Tooltip("%s", TR("send_tooltip_invalid_address"));
else if (s_amount <= 0) else if (s_amount <= 0)
material::Tooltip("%s", TR("send_tooltip_enter_amount")); material::Tooltip("%s", TR("send_tooltip_enter_amount"));
else if (total > available) else if (!util::canAfford(s_amount, s_fee, available))
material::Tooltip("%s", TR("send_tooltip_exceeds_balance")); material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
else if (!sourceSpendable) else if (!sourceSpendable)
material::Tooltip("%s", TR("send_tooltip_view_only")); material::Tooltip("%s", TR("send_tooltip_view_only"));
@@ -1530,6 +1548,7 @@ void RenderSendTab(App* app)
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) { if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview. // Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8; s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
} }
// Draw DRGX equivalent inside the input field (right-aligned overlay) // Draw DRGX equivalent inside the input field (right-aligned overlay)
{ {
@@ -1555,6 +1574,7 @@ void RenderSendTab(App* app)
if (s_amount < 0) s_amount = 0; if (s_amount < 0) s_amount = 0;
if (market.price_usd > 0) if (market.price_usd > 0)
s_usd_amount = s_amount * market.price_usd; s_usd_amount = s_amount * market.price_usd;
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
} }
// Draw USD equivalent inside the input field (right-aligned overlay) // Draw USD equivalent inside the input field (right-aligned overlay)
if (market.price_usd > 0 && s_amount > 0) { if (market.price_usd > 0 && s_amount > 0) {
@@ -1619,7 +1639,7 @@ void RenderSendTab(App* app)
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f"); RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
// Amount error // Amount error
if (s_amount > 0 && s_amount + s_fee > available && available > 0) { if (s_amount > 0 && !util::canAfford(s_amount, s_fee, available) && available > 0) {
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee); snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
Type().textColored(TypeStyle::Caption, Error(), buf); Type().textColored(TypeStyle::Caption, Error(), buf);
} }
-608
View File
@@ -1,608 +0,0 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "settings_window.h"
#include "../../app.h"
#include "../../config/version.h"
#include "../../config/settings.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../rpc/rpc_client.h"
#include "../theme.h"
#include "../schema/ui_schema.h"
#include "../schema/skin_manager.h"
#include "../notifications.h"
#include "../effects/imgui_acrylic.h"
#include "../material/draw_helpers.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <vector>
#include <filesystem>
// Icon text for settings UI
#define ICON_CUSTOM_THEME ICON_MD_TUNE
#define ICON_REFRESH_THEMES ICON_MD_REFRESH
namespace dragonx {
namespace ui {
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
static std::string TrId(const char* tr_key, const char* id) {
std::string s = TR(tr_key);
s += "##";
s += id;
return s;
}
// Settings state - these get loaded from Settings on window open
static bool s_initialized = false;
static int s_language_index = 0;
static bool s_save_ztxs = true;
static bool s_allow_custom_fees = false;
static bool s_auto_shield = false;
static bool s_fetch_prices = true;
static bool s_use_tor = false;
static char s_rpc_host[128] = DRAGONX_DEFAULT_RPC_HOST;
static char s_rpc_port[16] = DRAGONX_DEFAULT_RPC_PORT;
static char s_rpc_user[64] = "";
static char s_rpc_password[64] = "";
static char s_tx_explorer[256] = "https://explorer.dragonx.is/tx/";
static char s_addr_explorer[256] = "https://explorer.dragonx.is/address/";
// Acrylic settings
static bool s_acrylic_enabled = true;
static float s_blur_amount = 1.5f; // 0.0=Off, continuous blur multiplier
static float s_noise_opacity = 0.5f;
static bool s_reduced_transparency = false; // Accessibility option
static bool s_gradient_background = false; // Gradient background mode
// Saved skin ID for cancel/revert
static std::string s_saved_skin_id;
// Load current settings into UI state
static void loadSettingsToUI(config::Settings* settings) {
if (!settings) return;
s_saved_skin_id = settings->getSkinId();
s_save_ztxs = settings->getSaveZtxs();
s_allow_custom_fees = settings->getAllowCustomFees();
s_auto_shield = settings->getAutoShield();
s_fetch_prices = settings->getFetchPrices();
s_use_tor = settings->getUseTor();
strncpy(s_tx_explorer, settings->getTxExplorerUrl().c_str(), sizeof(s_tx_explorer) - 1);
strncpy(s_addr_explorer, settings->getAddressExplorerUrl().c_str(), sizeof(s_addr_explorer) - 1);
// Set language index
auto& i18n = util::I18n::instance();
const auto& languages = i18n.getAvailableLanguages();
std::string current_lang = settings->getLanguage();
if (current_lang.empty()) current_lang = "en";
s_language_index = 0;
int idx = 0;
for (const auto& lang : languages) {
if (lang.first == current_lang) {
s_language_index = idx;
break;
}
idx++;
}
s_initialized = true;
}
// Save UI state to settings
static void saveSettingsFromUI(config::Settings* settings) {
if (!settings) return;
settings->setTheme(settings->getSkinId()); // Theme now synced with skin
settings->setSaveZtxs(s_save_ztxs);
settings->setAllowCustomFees(s_allow_custom_fees);
settings->setAutoShield(s_auto_shield);
settings->setFetchPrices(s_fetch_prices);
settings->setUseTor(s_use_tor);
settings->setTxExplorerUrl(s_tx_explorer);
settings->setAddressExplorerUrl(s_addr_explorer);
// Save language
auto& i18n = util::I18n::instance();
const auto& languages = i18n.getAvailableLanguages();
auto it = languages.begin();
std::advance(it, s_language_index);
if (it != languages.end()) {
settings->setLanguage(it->first);
}
// Save acrylic / visual effects settings
settings->setAcrylicEnabled(s_acrylic_enabled);
settings->setAcrylicQuality(s_blur_amount > 0.001f ? static_cast<int>(effects::AcrylicQuality::Low) : static_cast<int>(effects::AcrylicQuality::Off));
settings->setBlurMultiplier(s_blur_amount);
settings->setReducedTransparency(s_reduced_transparency);
settings->setNoiseOpacity(s_noise_opacity);
settings->setGradientBackground(s_gradient_background);
settings->save();
}
void RenderSettingsWindow(App* app, bool* p_open)
{
// Load settings on first open
if (!s_initialized && app->settings()) {
loadSettingsToUI(app->settings());
// Initialize acrylic settings from current state
s_acrylic_enabled = effects::ImGuiAcrylic::IsEnabled();
s_blur_amount = effects::ImGuiAcrylic::GetBlurMultiplier();
s_noise_opacity = effects::ImGuiAcrylic::GetNoiseOpacity();
s_reduced_transparency = effects::ImGuiAcrylic::GetReducedTransparency();
s_gradient_background = schema::SkinManager::instance().isGradientMode();
}
auto& S = schema::UI();
auto win = S.window("dialogs.settings");
auto lbl = S.label("dialogs.settings", "label");
auto cmb = S.combo("dialogs.settings", "combo");
auto connLbl = S.label("dialogs.settings", "connection-label");
auto portInput = S.input("dialogs.settings", "port-input");
auto walletBtn = S.button("dialogs.settings", "wallet-button");
auto saveBtn = S.button("dialogs.settings", "save-button");
auto cancelBtn = S.button("dialogs.settings", "cancel-button");
if (!material::BeginOverlayDialog(TR("settings"), p_open, win.width, 0.94f)) {
return;
}
if (ImGui::BeginTabBar("SettingsTabs")) {
// General settings tab
if (ImGui::BeginTabItem(TR("general"))) {
ImGui::Spacing();
// Skin/theme selection
ImGui::Text("%s", TR("theme"));
ImGui::SameLine(lbl.position);
// Active skin combo (populated from SkinManager)
auto& skinMgr = schema::SkinManager::instance();
const auto& skins = skinMgr.available();
// Find active skin for preview text
std::string active_preview = "DragonX";
bool active_is_custom = false;
for (const auto& skin : skins) {
if (skin.id == skinMgr.activeSkinId()) {
active_preview = skin.name;
active_is_custom = !skin.bundled;
break;
}
}
ImGui::SetNextItemWidth(cmb.width);
if (ImGui::BeginCombo("##Theme", active_preview.c_str())) {
// Bundled themes header
ImGui::TextDisabled("%s", TR("settings_builtin"));
ImGui::Separator();
for (size_t i = 0; i < skins.size(); i++) {
const auto& skin = skins[i];
if (!skin.bundled) continue;
bool is_selected = (skin.id == skinMgr.activeSkinId());
if (ImGui::Selectable(skin.name.c_str(), is_selected)) {
skinMgr.setActiveSkin(skin.id);
if (app->settings()) {
app->settings()->setSkinId(skin.id);
app->settings()->save();
}
}
if (is_selected) {
ImGui::SetItemDefaultFocus();
}
}
// Custom themes (if any)
bool has_custom = false;
for (const auto& skin : skins) {
if (!skin.bundled) { has_custom = true; break; }
}
if (has_custom) {
ImGui::Spacing();
ImGui::TextDisabled("%s", TR("settings_custom"));
ImGui::Separator();
for (size_t i = 0; i < skins.size(); i++) {
const auto& skin = skins[i];
if (skin.bundled) continue;
bool is_selected = (skin.id == skinMgr.activeSkinId());
if (!skin.valid) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
ImGui::BeginDisabled(true);
std::string label = skin.name + TR("swin_invalid_suffix");
ImGui::Selectable(label.c_str(), false);
ImGui::EndDisabled();
ImGui::PopStyleColor();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
material::Tooltip("%s", skin.validationError.c_str());
}
} else {
std::string label = skin.name;
if (!skin.author.empty()) {
label += " (" + skin.author + ")";
}
if (ImGui::Selectable(label.c_str(), is_selected)) {
skinMgr.setActiveSkin(skin.id);
if (app->settings()) {
app->settings()->setSkinId(skin.id);
app->settings()->save();
}
}
if (is_selected) {
ImGui::SetItemDefaultFocus();
}
}
}
}
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_theme_hotkey"));
// Show indicator if custom theme is active
if (active_is_custom) {
ImGui::SameLine();
ImGui::PushFont(material::Type().iconSmall());
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), ICON_CUSTOM_THEME);
ImGui::PopFont();
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("tt_custom_theme"));
}
}
ImGui::SameLine();
ImGui::PushFont(material::Type().iconSmall());
if (material::StyledButton(ICON_REFRESH_THEMES, ImVec2(0, 0))) {
skinMgr.refresh();
Notifications::instance().info(TR("swin_theme_list_refreshed"));
}
ImGui::PopFont();
if (ImGui::IsItemHovered()) {
material::Tooltip(TR("tt_scan_themes"),
schema::SkinManager::getUserSkinsDirectory().c_str());
}
ImGui::Spacing();
// Language selection
ImGui::Text("%s", TR("language"));
ImGui::SameLine(lbl.position);
auto& i18n = util::I18n::instance();
const auto& languages = i18n.getAvailableLanguages();
// Build language display names array
std::vector<const char*> lang_names;
lang_names.reserve(languages.size());
for (const auto& lang : languages) {
lang_names.push_back(lang.second.c_str()); // Display name
}
ImGui::SetNextItemWidth(cmb.width);
if (ImGui::Combo("##Language", &s_language_index, lang_names.data(), static_cast<int>(lang_names.size()))) {
// Get locale code from index
auto it = languages.begin();
std::advance(it, s_language_index);
i18n.loadLanguage(it->first);
}
ImGui::TextDisabled(" %s", TR("settings_language_note"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Acrylic Effects settings
ImGui::Text("%s", TR("settings_visual_effects"));
ImGui::Spacing();
ImGui::Text("%s", TR("settings_acrylic_level"));
ImGui::SameLine(lbl.position);
ImGui::SetNextItemWidth(cmb.width);
{
char blur_fmt[16];
if (s_blur_amount < 0.01f)
snprintf(blur_fmt, sizeof(blur_fmt), "Off");
else
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_blur_amount * 25.0f);
if (ImGui::SliderFloat("##AcrylicBlur", &s_blur_amount, 0.0f, 4.0f, blur_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
if (s_blur_amount > 0.0f && s_blur_amount < 0.15f) s_blur_amount = 0.0f;
s_acrylic_enabled = (s_blur_amount > 0.001f);
effects::ImGuiAcrylic::ApplyBlurAmount(s_blur_amount);
}
}
ImGui::TextDisabled(" %s", TR("tt_blur"));
ImGui::Spacing();
ImGui::Text("%s", TR("settings_noise_opacity"));
ImGui::SameLine(lbl.position);
ImGui::SetNextItemWidth(cmb.width);
{
char noise_fmt[16];
if (s_noise_opacity < 0.01f)
snprintf(noise_fmt, sizeof(noise_fmt), "Off");
else
snprintf(noise_fmt, sizeof(noise_fmt), "%.0f%%%%", s_noise_opacity * 100.0f);
if (ImGui::SliderFloat("##NoiseOpacity", &s_noise_opacity, 0.0f, 1.0f, noise_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
effects::ImGuiAcrylic::SetNoiseOpacity(s_noise_opacity);
}
}
ImGui::TextDisabled(" %s", TR("tt_noise"));
ImGui::Spacing();
// Accessibility: Reduced transparency
if (ImGui::Checkbox(TrId("settings_reduce_transparency", "reduce_trans").c_str(), &s_reduced_transparency)) {
effects::ImGuiAcrylic::SetReducedTransparency(s_reduced_transparency);
}
ImGui::TextDisabled(" %s", TR("settings_solid_colors_desc"));
ImGui::Spacing();
if (ImGui::Checkbox(TrId("simple_background", "simple_bg").c_str(), &s_gradient_background)) {
schema::SkinManager::instance().setGradientMode(s_gradient_background);
}
ImGui::TextDisabled(" %s", TR("settings_gradient_desc"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Privacy settings
ImGui::Text("%s", TR("settings_privacy"));
ImGui::Spacing();
ImGui::Checkbox(TrId("settings_save_shielded_local", "save_ztx_w").c_str(), &s_save_ztxs);
ImGui::TextDisabled(" %s", TR("settings_save_shielded_desc"));
ImGui::Spacing();
ImGui::Checkbox(TrId("settings_auto_shield_funds", "auto_shld_w").c_str(), &s_auto_shield);
ImGui::TextDisabled(" %s", TR("settings_auto_shield_desc"));
ImGui::Spacing();
{
// Not implemented: disabled and never shown as "on", even for a stored use_tor=true.
bool torOff = false;
ImGui::BeginDisabled();
ImGui::Checkbox(TrId("settings_use_tor_network", "tor_w").c_str(), &torOff);
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
const float wrapW = S.drawElement("components.settings-page", "tor-tooltip-wrap-width")
.sizeOr(420.0f) * Layout::dpiScale();
material::BeginTooltip();
ImGui::PushTextWrapPos(wrapW);
ImGui::TextUnformatted(TR(app && app->isLiteBuild() ? "tt_tor_unavailable_lite"
: "tt_tor_unavailable"));
ImGui::PopTextWrapPos();
material::EndTooltip();
}
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Other settings
ImGui::Text("%s", TR("settings_other"));
ImGui::Spacing();
ImGui::Checkbox(TrId("custom_fees", "fees_w").c_str(), &s_allow_custom_fees);
ImGui::Checkbox(TrId("fetch_prices", "prices_w").c_str(), &s_fetch_prices);
ImGui::EndTabItem();
}
// Connection settings tab
if (ImGui::BeginTabItem(TR("settings_connection"))) {
ImGui::Spacing();
ImGui::Text("%s", TR("settings_rpc_connection"));
ImGui::TextDisabled("%s", TR("settings_configure_rpc"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
material::LabeledInputRow(TR("rpc_host"), connLbl.position, "##RPCHost",
s_rpc_host, sizeof(s_rpc_host), cmb.width);
material::LabeledInputRow(TR("rpc_port"), connLbl.position, "##RPCPort",
s_rpc_port, sizeof(s_rpc_port), portInput.width);
ImGui::Spacing();
material::LabeledInputRow(TR("rpc_user"), connLbl.position, "##RPCUser",
s_rpc_user, sizeof(s_rpc_user), cmb.width);
material::LabeledInputRow(TR("rpc_pass"), connLbl.position, "##RPCPassword",
s_rpc_password, sizeof(s_rpc_password), cmb.width, nullptr,
ImGuiInputTextFlags_Password);
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::TextDisabled("%s", TR("settings_rpc_note"));
ImGui::Spacing();
if (material::StyledButton(TR("test_connection"), ImVec2(0,0), S.resolveFont("button"))) {
if (app->rpc()) {
rpc::RPCClient::TraceScope trace("Settings / Test connection");
app->rpc()->getInfo([](const nlohmann::json& result, const std::string& error) {
if (error.empty()) {
std::string version = result.value("version", "unknown");
std::string msg = std::string(TR("swin_connection_successful")) + version;
Notifications::instance().success(msg);
} else {
Notifications::instance().error(std::string(TR("swin_connection_failed")) + error);
}
});
} else {
Notifications::instance().error(TR("swin_rpc_client_not_initialized"));
}
}
ImGui::EndTabItem();
}
// Wallet tab
if (ImGui::BeginTabItem(TR("wallet"))) {
ImGui::Spacing();
ImGui::Text("%s", TR("settings_wallet_maintenance"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
if (material::StyledButton(TR("rescan"), ImVec2(walletBtn.width, 0), S.resolveFont(walletBtn.font))) {
if (app->rpc()) {
// Start rescan from block 0
app->rpc()->rescanBlockchain(0, [](const nlohmann::json& result, const std::string& error) {
if (error.empty()) {
int start = result.value("start_height", 0);
int end = result.value("stop_height", 0);
std::string msg = std::string(TR("swin_rescan_started_from_block")) + std::to_string(start) +
TR("swin_rescan_to") + std::to_string(end);
Notifications::instance().success(msg);
} else {
Notifications::instance().error(std::string(TR("swin_rescan_failed")) + error);
}
});
} else {
Notifications::instance().error(TR("swin_rpc_client_not_initialized"));
}
}
ImGui::TextDisabled(" %s", TR("settings_rescan_desc"));
ImGui::Spacing();
static bool s_confirm_clear_ztx = false;
if (material::StyledButton(TR("settings_clear_ztx_long"), ImVec2(walletBtn.width, 0), S.resolveFont(walletBtn.font))) {
s_confirm_clear_ztx = true;
}
ImGui::TextDisabled(" %s", TR("settings_clear_ztx_desc"));
// Confirmation dialog
if (s_confirm_clear_ztx) {
// Distinct idSuffix: this confirm renders nested inside (and the same frame as) the parent
// settings dialog, so it must not share the default ##OverlayDialogContent key — otherwise it
// inherits the parent's OverlayCardState (incl. the sticky overflow flag) and collides on the
// child window id.
if (material::BeginOverlayDialog(TR("confirm_clear_ztx_title"), &s_confirm_clear_ztx, 480.0f,
0.94f, 0.85f, "settings_clearztx")) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.6f, 0.0f, 1.0f));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_clear_ztx_warning1"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_clear_ztx_warning2"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool doCancel = false, doConfirm = false;
material::DialogConfirmFooter(TR("cancel"), TrId("clear_anyway", "clear_ztx_w").c_str(),
true, doCancel, doConfirm);
if (doCancel) {
s_confirm_clear_ztx = false;
}
if (doConfirm) {
std::string ztx_file = util::Platform::getDragonXDataDir() + "ztx_history.json";
if (util::Platform::deleteFile(ztx_file)) {
Notifications::instance().success(TR("swin_ztx_history_cleared"));
} else {
Notifications::instance().info(TR("swin_no_history_file_found"));
}
s_confirm_clear_ztx = false;
}
material::EndOverlayDialog();
}
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::Text("%s", TR("settings_wallet_info"));
ImGui::Spacing();
// Get actual wallet size
std::string wallet_path = util::Platform::getDragonXDataDir() + "wallet.dat";
uint64_t wallet_size = util::Platform::getFileSize(wallet_path);
if (wallet_size > 0) {
std::string size_str = util::Platform::formatFileSize(wallet_size);
ImGui::Text(TR("settings_wallet_file_size"), size_str.c_str());
} else {
ImGui::TextDisabled("%s", TR("settings_wallet_not_found"));
}
ImGui::Text(TR("settings_wallet_location"), wallet_path.c_str());
ImGui::EndTabItem();
}
// Explorer tab
if (ImGui::BeginTabItem(TR("explorer"))) {
ImGui::Spacing();
ImGui::Text("%s", TR("settings_block_explorer_urls"));
ImGui::TextDisabled("%s", TR("settings_configure_explorer"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::Text("%s", TR("transaction_url"));
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##TxExplorer", s_tx_explorer, sizeof(s_tx_explorer));
ImGui::Text("%s", TR("address_url"));
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##AddrExplorer", s_addr_explorer, sizeof(s_addr_explorer));
ImGui::Spacing();
ImGui::TextDisabled("%s", TR("settings_explorer_hint"));
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Save/Cancel buttons
if (material::StyledButton(TR("save"), ImVec2(saveBtn.width, 0), S.resolveFont(saveBtn.font))) {
saveSettingsFromUI(app->settings());
Notifications::instance().success(TR("swin_settings_saved"));
*p_open = false;
}
ImGui::SameLine();
if (material::StyledButton(TR("cancel"), ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {
// Reload settings to revert changes
loadSettingsToUI(app->settings());
// Revert skin to what was active when settings opened
if (!s_saved_skin_id.empty()) {
schema::SkinManager::instance().setActiveSkin(s_saved_skin_id);
if (app->settings()) {
app->settings()->setSkinId(s_saved_skin_id);
app->settings()->save();
}
}
*p_open = false;
}
material::EndOverlayDialog();
}
} // namespace ui
} // namespace dragonx
-19
View File
@@ -1,19 +0,0 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
namespace dragonx {
class App;
namespace ui {
/**
* @brief Render the Settings window
* Modal dialog for application settings
*/
void RenderSettingsWindow(App* app, bool* p_open);
} // namespace ui
} // namespace dragonx
@@ -58,16 +58,16 @@ void TransactionDetailsDialog::render(App* app)
ImVec4 type_color; ImVec4 type_color;
std::string type_display; std::string type_display;
if (tx.type == "receive") { if (tx.type == "receive") {
type_color = ImVec4(0.3f, 0.8f, 0.3f, 1.0f); type_color = material::SuccessVec4();
type_display = TR("tx_received"); type_display = TR("tx_received");
} else if (tx.type == "send") { } else if (tx.type == "send") {
type_color = ImVec4(0.8f, 0.3f, 0.3f, 1.0f); type_color = material::ErrorVec4();
type_display = TR("tx_sent"); type_display = TR("tx_sent");
} else if (tx.type == "generate" || tx.type == "mined") { } else if (tx.type == "generate" || tx.type == "mined") {
type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f); type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f);
type_display = TR("tx_mined"); type_display = TR("tx_mined");
} else if (tx.type == "immature") { } else if (tx.type == "immature") {
type_color = ImVec4(0.8f, 0.8f, 0.3f, 1.0f); type_color = material::WarningVec4();
type_display = TR("tx_immature"); type_display = TR("tx_immature");
} else { } else {
type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
@@ -79,11 +79,11 @@ void TransactionDetailsDialog::render(App* app)
// Confirmations badge // Confirmations badge
if (tx.confirmations == 0) { if (tx.confirmations == 0) {
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", TR("pending")); ImGui::TextColored(material::WarningVec4(), "%s", TR("pending"));
} else if (tx.confirmations < 10) { } else if (tx.confirmations < 10) {
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), TR("tx_confirmations"), tx.confirmations); ImGui::TextColored(material::WarningVec4(), TR("tx_confirmations"), tx.confirmations);
} else { } else {
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), TR("tx_confirmations"), tx.confirmations); ImGui::TextColored(material::SuccessVec4(), TR("tx_confirmations"), tx.confirmations);
} }
ImGui::Spacing(); ImGui::Spacing();
@@ -94,9 +94,9 @@ void TransactionDetailsDialog::render(App* app)
ImGui::Text("%s", TR("amount_label")); ImGui::Text("%s", TR("amount_label"));
ImGui::SameLine(lbl.position); ImGui::SameLine(lbl.position);
if (tx.amount >= 0) { if (tx.amount >= 0) {
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "+%.8f DRGX", tx.amount); ImGui::TextColored(material::SuccessVec4(), "+%.8f DRGX", tx.amount);
} else { } else {
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%.8f DRGX", tx.amount); ImGui::TextColored(material::ErrorVec4(), "%.8f DRGX", tx.amount);
} }
// USD equivalent if price available // USD equivalent if price available
+75 -8
View File
@@ -3,6 +3,7 @@
// Released under the GPLv3 // Released under the GPLv3
#include "transactions_tab.h" #include "transactions_tab.h"
#include "../notifications.h"
#include "transaction_details_dialog.h" #include "transaction_details_dialog.h"
#include "export_transactions_dialog.h" #include "export_transactions_dialog.h"
#include "transactions_tab_helpers.h" #include "transactions_tab_helpers.h"
@@ -36,6 +37,11 @@ namespace ui {
using namespace material; using namespace material;
// Latest History-tab filter snapshot, published from the filtered-list rebuild below. Used by the
// transactions CSV export to offer "export only the current filter".
static TxFilterSnapshot s_filter_snapshot;
const TxFilterSnapshot& GetTransactionsFilterSnapshot() { return s_filter_snapshot; }
static std::string TrId(const char* key, const char* id) { static std::string TrId(const char* key, const char* id) {
return std::string(TR(key)) + "##" + id; return std::string(TR(key)) + "##" + id;
} }
@@ -213,10 +219,11 @@ void RenderTransactionsTab(App* app)
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision // txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
// (a sort-combo change re-sorts without regrouping). // (a sort-combo change re-sorts without regrouping).
static history::TxidGroups s_groups; static history::TxidGroups s_groups;
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows static std::uint64_t s_groups_key = 0; // 0 never matches: tx_structure_revision starts at 1 and only grows
if (s_groups_key != contentKey) { const std::uint64_t structKey = state.tx_structure_revision; // ignores pure confirmation-count bumps
if (s_groups_key != structKey) {
s_groups = history::groupByTxid(state.transactions); s_groups = history::groupByTxid(state.transactions);
s_groups_key = contentKey; s_groups_key = structKey;
} }
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static // Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
@@ -409,13 +416,14 @@ void RenderTransactionsTab(App* app)
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by // case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
// the refresh service, but we re-sort here to apply the "pending first" ordering. // the refresh service, but we re-sort here to apply the "pending first" ordering.
static std::vector<DisplayTx> s_display_cache; static std::vector<DisplayTx> s_display_cache;
static std::uint64_t s_display_cache_key = 0; // tx_revision the cache was built from static std::uint64_t s_display_cache_key = 0; // tx_structure_revision the cache was built from
static std::uint64_t s_display_conf_rev = 0; // tx_revision the cached confirmations were last synced to
static int s_display_cache_sort = 0; // sort mode the cache was built with static int s_display_cache_sort = 0; // sort mode the cache was built with
static bool s_display_cache_valid = false; static bool s_display_cache_valid = false;
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing. // Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
if (!s_display_cache_valid || contentKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) { if (!s_display_cache_valid || structKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
++s_display_gen; ++s_display_gen;
s_display_cache.clear(); s_display_cache.clear();
std::vector<DisplayTx>& display_txns = s_display_cache; std::vector<DisplayTx>& display_txns = s_display_cache;
@@ -518,9 +526,27 @@ void RenderTransactionsTab(App* app)
} }
}); });
s_display_cache_key = contentKey; s_display_cache_key = structKey;
s_display_conf_rev = contentKey; // freshly built from the current list
s_display_cache_sort = s_sort_mode; s_display_cache_sort = s_sort_mode;
s_display_cache_valid = true; s_display_cache_valid = true;
} else if (s_display_conf_rev != contentKey) {
// Confirmation-count-only delta (setTransactionsIfChanged kept tx_structure_revision): the merged rows, their
// order and the indices into state.transactions are unchanged, so refresh just the count (same rule as the
// build above: a shield shows the larger of its two legs). Sort order cannot change: mode 0 only reads
// conf==0, which the layout check holds constant, and the other modes ignore confirmations.
for (DisplayTx& dtx : s_display_cache) {
if (dtx.is_shield) {
dtx.confirmations = std::max(state.transactions[dtx.send_idx].confirmations,
state.transactions[dtx.recv_idx].confirmations);
} else {
dtx.confirmations = state.transactions[dtx.orig_idx].confirmations;
}
}
// The filtered-index cache keys on s_display_gen (unchanged, correct: filters ignore confirmations) and
// the snapshot's indices remain valid; only re-stamp it so 'export current filter' stays usable.
if (s_filter_snapshot.valid) s_filter_snapshot.txRevision = contentKey;
s_display_conf_rev = contentKey;
} }
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only // The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
@@ -565,6 +591,35 @@ void RenderTransactionsTab(App* app)
s_filtered_search = search_str; s_filtered_search = search_str;
s_filtered_chat_rev = chatRev; s_filtered_chat_rev = chatRev;
s_filtered_valid = true; s_filtered_valid = true;
// Publish a snapshot of the visible set for "export current filter". A merged autoshield row
// contributes BOTH original legs, so the CSV stays a per-leg record like an unfiltered export.
s_filter_snapshot.indices.clear();
s_filter_snapshot.indices.reserve(s_filtered_cache.size() * 2);
for (size_t fi : s_filtered_cache) {
const auto& dtx = display_txns[fi];
if (dtx.is_shield) {
if (dtx.send_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.send_idx));
if (dtx.recv_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.recv_idx));
} else if (dtx.orig_idx >= 0) {
s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.orig_idx));
}
}
std::sort(s_filter_snapshot.indices.begin(), s_filter_snapshot.indices.end());
s_filter_snapshot.active = (type_filter != 0 || !search_str.empty());
s_filter_snapshot.txRevision = contentKey;
s_filter_snapshot.valid = true;
{
const char* typeNames[] = { "", TR("sent_filter"), TR("received_filter"),
TR("mined_filter"), TR("chat_filter") };
std::string desc = (type_filter >= 1 && type_filter <= 4) ? typeNames[type_filter] : "";
if (!search_str.empty()) {
if (!desc.empty()) desc += ", ";
desc += "\"" + search_str + "\"";
}
if (desc.empty()) desc = TR("all_filter");
s_filter_snapshot.description = desc;
}
} }
const std::vector<size_t>& filtered_indices = s_filtered_cache; const std::vector<size_t>& filtered_indices = s_filtered_cache;
filtered_count = static_cast<int>(filtered_indices.size()); filtered_count = static_cast<int>(filtered_indices.size());
@@ -800,7 +855,12 @@ void RenderTransactionsTab(App* app)
{ {
const char* statusStr; const char* statusStr;
ImU32 statusCol; ImU32 statusCol;
if (tx.confirmations == 0) { const int64_t nowSec = static_cast<int64_t>(std::time(nullptr));
if (tx.confirmations < 0) { // gettransaction < 0: conflicted / double-spent / reorged out
statusStr = TR("tx_dropped"); statusCol = Error();
} else if (tx.confirmations == 0 && tx.timestamp > 0 && nowSec - tx.timestamp > 3600) {
statusStr = TR("tx_stale_pending"); statusCol = Warning();
} else if (tx.confirmations == 0) {
statusStr = TR("pending"); statusCol = Warning(); statusStr = TR("pending"); statusCol = Warning();
} else if (tx.confirmations < 10) { } else if (tx.confirmations < 10) {
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations); snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
@@ -871,9 +931,11 @@ void RenderTransactionsTab(App* app)
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) { if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) { if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
ImGui::SetClipboardText(tx.address.c_str()); ImGui::SetClipboardText(tx.address.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
} }
if (ImGui::MenuItem(TR("copy_txid"))) { if (ImGui::MenuItem(TR("copy_txid"))) {
ImGui::SetClipboardText(tx.txid.c_str()); ImGui::SetClipboardText(tx.txid.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
} }
ImGui::Separator(); ImGui::Separator();
if (ImGui::MenuItem(TR("view_on_explorer"))) { if (ImGui::MenuItem(TR("view_on_explorer"))) {
@@ -916,7 +978,10 @@ void RenderTransactionsTab(App* app)
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id")); Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
ImGui::TextWrapped("%s", tx.txid.c_str()); ImGui::TextWrapped("%s", tx.txid.c_str());
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsItemClicked()) ImGui::SetClipboardText(tx.txid.c_str()); if (ImGui::IsItemClicked()) {
ImGui::SetClipboardText(tx.txid.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Memo // Memo
@@ -938,10 +1003,12 @@ void RenderTransactionsTab(App* app)
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) { if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.txid.c_str()); ImGui::SetClipboardText(tx.txid.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
} }
ImGui::SameLine(); ImGui::SameLine();
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) { if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.address.c_str()); ImGui::SetClipboardText(tx.address.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
} }
ImGui::SameLine(); ImGui::SameLine();
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) { if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
+22
View File
@@ -5,12 +5,34 @@
#pragma once #pragma once
#include <string> #include <string>
#include <vector>
#include <cstdint>
#include <cstddef>
namespace dragonx { namespace dragonx {
class App; class App;
namespace ui { namespace ui {
/**
* @brief Snapshot of what the History tab currently shows, for "export current filter".
*
* `indices` are positions into WalletState::transactions of every leg currently visible (a merged
* autoshield row contributes both its send and receive legs), sorted ascending. Published only when the
* filtered list is (re)built, so it reflects the last state the History tab rendered. `txRevision` lets a
* consumer reject a snapshot that predates the current tx list.
*/
struct TxFilterSnapshot {
bool valid = false;
bool active = false; // a type filter and/or search text is applied
std::uint64_t txRevision = 0;
std::string description; // human label of the active filter
std::vector<std::size_t> indices;
};
/// The latest History-tab filter snapshot (see TxFilterSnapshot).
const TxFilterSnapshot& GetTransactionsFilterSnapshot();
/** /**
* @brief Render the Transactions tab * @brief Render the Transactions tab
* Shows transaction history * Shows transaction history
+5 -5
View File
@@ -183,7 +183,7 @@ void ValidateAddressDialog::render(App* app)
if (s_validating) { if (s_validating) {
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("validating")); ImGui::TextColored(material::WarningVec4(), "%s", TR("validating"));
} }
ImGui::Spacing(); ImGui::Spacing();
@@ -194,15 +194,15 @@ void ValidateAddressDialog::render(App* app)
ImGui::Spacing(); ImGui::Spacing();
if (!s_error_message.empty()) { if (!s_error_message.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error_message.c_str()); ImGui::TextColored(material::ErrorVec4(), TR("error_format"), s_error_message.c_str());
} else { } else {
// Valid/Invalid indicator // Valid/Invalid indicator
ImGui::Text("%s", TR("validate_status")); ImGui::Text("%s", TR("validate_status"));
ImGui::SameLine(lbl.position); ImGui::SameLine(lbl.position);
if (s_is_valid) { if (s_is_valid) {
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_valid")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("validate_valid"));
} else { } else {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("validate_invalid")); ImGui::TextColored(material::ErrorVec4(), "%s", TR("validate_invalid"));
} }
if (s_is_valid) { if (s_is_valid) {
@@ -219,7 +219,7 @@ void ValidateAddressDialog::render(App* app)
ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite" ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite"
: "validate_ownership_unknown_offline")); : "validate_ownership_unknown_offline"));
} else if (s_is_mine) { } else if (s_is_mine) {
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_is_mine")); ImGui::TextColored(material::SuccessVec4(), "%s", TR("validate_is_mine"));
} else { } else {
ImGui::TextDisabled("%s", TR("validate_not_mine")); ImGui::TextDisabled("%s", TR("validate_not_mine"));
} }
+21
View File
@@ -1,11 +1,32 @@
#pragma once #pragma once
#include <string> #include <string>
#include <cstdint>
#include <cmath>
namespace dragonx { namespace dragonx {
namespace util { namespace util {
std::string formatAmountFixed(double amount, int decimals = 8); std::string formatAmountFixed(double amount, int decimals = 8);
// DRGX amounts are 8-decimal. Converting to int64 satoshis and comparing there avoids the IEEE-double
// rounding that could block a valid exact-max send (amount+fee computed in doubles landed 1 ulp over).
inline std::int64_t toSats(double coins) { return static_cast<std::int64_t>(std::llround(coins * 1e8)); }
// True when amount + fee fits within available, compared in integer satoshis (not doubles).
inline bool canAfford(double amount, double fee, double available) {
return toSats(amount) + toSats(fee) <= toSats(available);
}
// Convert a coin amount to zatoshis for a send, rejecting non-finite / non-positive / rounds-to-zero /
// over-supply values. Returns false (and leaves out untouched) on any of those.
inline bool coinsToZatoshis(double coins, std::uint64_t& out) {
if (!std::isfinite(coins) || coins <= 0.0) return false;
const std::int64_t z = toSats(coins);
if (z <= 0 || z > static_cast<std::int64_t>(21000000) * 100000000LL) return false;
out = static_cast<std::uint64_t>(z);
return true;
}
} // namespace util } // namespace util
} // namespace dragonx } // namespace dragonx
+22 -45
View File
@@ -1125,56 +1125,33 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b
std::string expectedSha = parseChecksumFile(sha256Content, 64); std::string expectedSha = parseChecksumFile(sha256Content, 64);
const bool haveSHA256 = !expectedSha.empty(); const bool haveSHA256 = !expectedSha.empty();
std::string expectedMd5; // SHA-256 is now required: the collision-prone MD5 fallback was removed. (A same-origin checksum
bool haveMD5 = false; // only protects against transport corruption, not a malicious server — true authenticity needs a
if (!haveSHA256) { // MD5 only as a fallback when no SHA-256 is published // detached ed25519 signature like the daemon/xmrig updaters; that is a separate release-coordinated
std::string md5Content = httpGetString(baseUrl + "/" + kZipName + ".md5", "[bootstrap]", opt); // change, since it requires publishing per-archive .sig files and pinning a key.)
if (cancel_requested_) return false; if (!haveSHA256) {
expectedMd5 = parseChecksumFile(md5Content, 32); DEBUG_LOGF("[Bootstrap] No SHA-256 published - refusing to install an unverified archive\n");
haveMD5 = !expectedMd5.empty(); setProgress(State::Failed, "Verification failed: no SHA-256 checksum was published for the bootstrap. "
}
if (!haveSHA256 && !haveMD5) {
DEBUG_LOGF("[Bootstrap] No checksum published - refusing to install an unverified archive\n");
setProgress(State::Failed, "Verification failed: no checksum was published for the bootstrap. "
"Refusing to install an unverified archive."); "Refusing to install an unverified archive.");
return false; // fail CLOSED; archive is KEPT return false; // fail CLOSED; archive is KEPT
} }
if (haveSHA256) { setProgress(State::Verifying, "Verifying SHA-256...", 0, 1);
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1); std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f); if (cancel_requested_) return false;
if (cancel_requested_) return false; if (actual.empty()) { setProgress(State::Failed, "SHA-256 verification error"); return false; }
if (actual.empty()) { setProgress(State::Failed, "SHA-256 verification error"); return false; } if (expectedSha != actual) {
if (expectedSha != actual) { last_verify_mismatch_ = true;
last_verify_mismatch_ = true; DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n",
DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n", expectedSha.c_str(), actual.c_str());
expectedSha.c_str(), actual.c_str()); setProgress(State::Failed,
setProgress(State::Failed, "SHA-256 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
"SHA-256 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n" "Expected: " + expectedSha.substr(0, 16) + "...\n"
"Expected: " + expectedSha.substr(0, 16) + "...\n" "Got: " + actual.substr(0, 16) + "...\n"
"Got: " + actual.substr(0, 16) + "...\n" "Try again or use a fresh download.");
"Try again or use a fresh download."); return false;
return false;
}
DEBUG_LOGF("[Bootstrap] SHA-256 verified: %s\n", actual.c_str());
} else { // haveMD5
setProgress(State::Verifying, "Verifying MD5...", 0, 1);
std::string actual = computeMD5(zipPath, 0.0f, 100.0f);
if (cancel_requested_) return false;
if (actual.empty()) { setProgress(State::Failed, "MD5 verification error"); return false; }
if (expectedMd5 != actual) {
last_verify_mismatch_ = true;
DEBUG_LOGF("[Bootstrap] MD5 MISMATCH!\n expected: %s\n actual: %s\n",
expectedMd5.c_str(), actual.c_str());
setProgress(State::Failed,
"MD5 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
"Expected: " + expectedMd5 + "\nGot: " + actual + "\n"
"Try again or use a fresh download.");
return false;
}
DEBUG_LOGF("[Bootstrap] MD5 verified: %s\n", actual.c_str());
} }
DEBUG_LOGF("[Bootstrap] SHA-256 verified: %s\n", actual.c_str());
setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0); setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0);
return true; return true;
+62
View File
@@ -0,0 +1,62 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include <string>
#include <algorithm>
#include <cctype>
namespace dragonx {
namespace util {
// Make a user-typed filename safe to write into a trusted config/export directory: basename only
// (no path traversal), illegal / control chars -> '_', no leading dots or trailing dots-or-spaces,
// Windows reserved device stems prefixed with '_', length-capped, and always ending in requiredExt
// (e.g. ".csv"). Returns `fallback` if nothing usable remains. Header-only so every export dialog
// shares one implementation.
inline std::string sanitizeExportFilename(std::string name, const char* requiredExt, const char* fallback) {
const std::size_t slash = name.find_last_of("/\\");
if (slash != std::string::npos) name = name.substr(slash + 1);
auto notSpace = [](unsigned char c) { return !std::isspace(c); };
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notSpace));
name.erase(std::find_if(name.rbegin(), name.rend(), notSpace).base(), name.end());
for (char& c : name) {
const unsigned char u = static_cast<unsigned char>(c);
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' ||
c == '?' || c == '*' || c == '/' || c == '\\')
c = '_';
}
const std::size_t firstGood = name.find_first_not_of('.');
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
if (name.size() > 120) name.resize(120);
// Windows reserved device names (case-insensitive, checked on the stem before the first dot).
{
std::string stem = name;
const std::size_t dot = stem.find('.');
if (dot != std::string::npos) stem = stem.substr(0, dot);
std::transform(stem.begin(), stem.end(), stem.begin(),
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
static const char* const kReserved[] = {
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
};
for (const char* r : kReserved) if (stem == r) { name = "_" + name; break; }
}
std::string lower = name;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
std::string lext = requiredExt ? requiredExt : "";
std::transform(lext.begin(), lext.end(), lext.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (!lext.empty() &&
(lower.size() < lext.size() || lower.compare(lower.size() - lext.size(), lext.size(), lext) != 0))
name += requiredExt;
if (name.empty() || (requiredExt && name == requiredExt)) name = fallback ? fallback : "export";
return name;
}
} // namespace util
} // namespace dragonx
+17
View File
@@ -60,6 +60,20 @@ int cancelXferCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t)
return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0; return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0;
} }
// Restrict the transfer AND any redirect to HTTPS so a redirect can't silently downgrade to http://,
// file://, ftp:// or an unexpected scheme. Every caller of these shared helpers (daemon/xmrig updaters,
// pool stats) uses https URLs. (Verify-peer/host stay on at the call sites.)
void restrictToHttps(CURL* curl)
{
#if LIBCURL_VERSION_NUM >= 0x075500 // 7.85.0: the string-form protocol setopts
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
#else
curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
#endif
}
} // namespace } // namespace
HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt) HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt)
@@ -78,6 +92,7 @@ HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetO
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects); curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
restrictToHttps(curl);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0"); curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec); curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec);
@@ -131,6 +146,8 @@ bool httpDownloadToFile(const std::string& url, const std::string& destPath,
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); // bound redirects (this path set none before)
restrictToHttps(curl);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0"); curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
+93
View File
@@ -114,6 +114,11 @@ void I18n::overlayTranslations(const nlohmann::json& translations)
bool I18n::loadLanguage(const std::string& locale) bool I18n::loadLanguage(const std::string& locale)
{ {
// Exclusive for the whole load: strings_ is cleared/rebuilt below, and translateCopy() readers on
// other threads (daemon monitor) must never observe it mid-rewrite. loadBuiltinEnglish() and
// overlayTranslations() are called only from here and do not lock (no recursion).
std::unique_lock<std::shared_mutex> lock(strings_mutex_);
// Try to load from file: CWD-relative first, then exe-relative // Try to load from file: CWD-relative first, then exe-relative
std::string rel_path = "res/lang/" + locale + ".json"; std::string rel_path = "res/lang/" + locale + ".json";
const std::string exe_dir = Platform::getExecutableDirectory(); const std::string exe_dir = Platform::getExecutableDirectory();
@@ -268,6 +273,10 @@ void I18n::loadBuiltinEnglish()
strings_["chat_retry"] = "Retry"; strings_["chat_retry"] = "Retry";
strings_["chat_jump_latest"] = "Latest"; strings_["chat_jump_latest"] = "Latest";
strings_["chat_empty_title"] = "No conversations yet"; strings_["chat_empty_title"] = "No conversations yet";
strings_["chat_intro_title"] = "How chat works";
strings_["chat_intro_body"] = "Messages are end-to-end encrypted and sent as shielded transactions, so only you and your contact can read them. Your chat identity is tied to your wallet seed, so restoring your seed restores your chat address.";
strings_["chat_intro_fee"] = "Each message costs a small network fee. Share your chat address to let people start a conversation with you.";
strings_["chat_intro_dismiss"] = "Got it";
strings_["chat_empty_start"] = "Start one with \"New conversation\"."; strings_["chat_empty_start"] = "Start one with \"New conversation\".";
strings_["chat_copy_my_address"] = "Copy my chat address"; strings_["chat_copy_my_address"] = "Copy my chat address";
strings_["chat_my_address_tip"] = "Share this address so others can message you"; strings_["chat_my_address_tip"] = "Share this address so others can message you";
@@ -315,6 +324,10 @@ void I18n::loadBuiltinEnglish()
strings_["contacts_message_needs_z"] = "Chat needs a shielded (z) address — this contact only has a transparent one"; strings_["contacts_message_needs_z"] = "Chat needs a shielded (z) address — this contact only has a transparent one";
strings_["chat_copy_address_tip"] = "Click to copy address"; strings_["chat_copy_address_tip"] = "Click to copy address";
strings_["chat_verify_key"] = "Identity key \xE2\x80\x94 compare to verify"; strings_["chat_verify_key"] = "Identity key \xE2\x80\x94 compare to verify";
strings_["chat_verify_tip"] = "End-to-end encrypted to this contact's identity key. Click to view the key fingerprint and compare it with your contact over a separate channel (call or in person) to be sure no one is impersonating them.";
strings_["chat_verify_title"] = "Verify contact key";
strings_["chat_verify_body"] = "Messages in this chat are encrypted to the identity key below, which was learned from this contact's first message. Ask your contact to read you their key fingerprint over a separate channel (a call or in person). If it matches exactly, you are talking to the right person; if not, do not trust this conversation.";
strings_["chat_verify_copy"] = "Copy key";
strings_["chat_awaiting_key"] = "Waiting for reply"; strings_["chat_awaiting_key"] = "Waiting for reply";
strings_["chat_rename"] = "Rename contact"; strings_["chat_rename"] = "Rename contact";
strings_["chat_rename_hint"] = "Contact name"; strings_["chat_rename_hint"] = "Contact name";
@@ -474,6 +487,21 @@ void I18n::loadBuiltinEnglish()
strings_["mig_receive_addr"] = "New wallet receive address:"; strings_["mig_receive_addr"] = "New wallet receive address:";
strings_["mig_copy_seed"] = "Copy seed"; strings_["mig_copy_seed"] = "Copy seed";
strings_["mig_backed_up"] = "I've written down my seed phrase"; strings_["mig_backed_up"] = "I've written down my seed phrase";
strings_["err_not_connected_daemon"] = "Not connected to the daemon.";
strings_["sweep_err_need_spending_key"] = "Enter a spending key (a viewing key can't move funds).";
strings_["sweep_status_importing"] = "Importing key & rescanning…";
strings_["sweep_err_unknown"] = "Sweep failed (unknown error)";
strings_["sweep_err_nofunds"] = "This key holds no funds to sweep.";
strings_["sweep_err_unconfirmed"] = "This key's funds are still unconfirmed — try again shortly.";
strings_["sweep_err_already"] = "This key is already in your wallet — use Send to move its funds.";
strings_["sweep_err_dust"] = "This key's balance is too small to cover the network fee.";
strings_["sweep_status_sweeping"] = "Sweeping funds to your address…";
strings_["migrate_status_checking_sweep"] = "Checking on the previous sweep…";
strings_["migrate_err_no_dest"] = "No destination address for the sweep.";
strings_["migrate_status_building"] = "Building the sweep transaction…";
strings_["migrate_status_waiting_accept"] = "Waiting for the sweep transaction to be accepted…";
strings_["migrate_status_restarting"] = "Restarting with your new wallet — this rescan can take a while…";
strings_["rescan_status_from_block"] = "Rescanning from block %d...";
strings_["mig_understood"] = "I understand that once I continue, my funds will be moved into this new wallet and can only be recovered with this seed phrase"; strings_["mig_understood"] = "I understand that once I continue, my funds will be moved into this new wallet and can only be recovered with this seed phrase";
strings_["mig_ack_required"] = "Confirm both statements above to continue"; strings_["mig_ack_required"] = "Confirm both statements above to continue";
strings_["mig_step1_done"] = "Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into this new wallet is the next step."; strings_["mig_step1_done"] = "Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into this new wallet is the next step.";
@@ -484,6 +512,8 @@ void I18n::loadBuiltinEnglish()
strings_["mig_to"] = "To: "; strings_["mig_to"] = "To: ";
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it."; strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
strings_["mig_sweep_all"] = "Sweep all funds"; strings_["mig_sweep_all"] = "Sweep all funds";
strings_["mig_unlock_prompt"] = "Your wallet is locked. Enter your passphrase to unlock it, then sweep.";
strings_["mig_unlock_btn"] = "Unlock";
strings_["mig_later"] = "Later"; strings_["mig_later"] = "Later";
strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…"; strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…";
strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved."; strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved.";
@@ -498,6 +528,8 @@ void I18n::loadBuiltinEnglish()
strings_["mig_sweep_confirm"] = "Send %.8f DRGX from your current wallet to the new seed wallet in a single transaction? This moves all of your funds."; strings_["mig_sweep_confirm"] = "Send %.8f DRGX from your current wallet to the new seed wallet in a single transaction? This moves all of your funds.";
strings_["mig_sweep_confirm_yes"] = "Yes, sweep funds"; strings_["mig_sweep_confirm_yes"] = "Yes, sweep funds";
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…"; strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
strings_["mig_waiting_depth"] = "Sweep mined. Waiting for %d confirmations before switching wallets, so a short chain reorganisation cannot undo it…";
strings_["mig_adopt_reverify_failed"] = "Could not confirm it is safe to switch wallets yet (the node may still be syncing, the sweep is not deep enough, or funds remain). Nothing was changed - wait and try again.";
strings_["mig_adopting"] = "Installing your new wallet and rescanning…"; strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase."; strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";
strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder."; strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder.";
@@ -598,6 +630,15 @@ void I18n::loadBuiltinEnglish()
strings_["settings_merge_to_address"] = "Merge to Address…"; strings_["settings_merge_to_address"] = "Merge to Address…";
strings_["settings_clear_ztx"] = "Clear Z-Tx History"; strings_["settings_clear_ztx"] = "Clear Z-Tx History";
strings_["settings_import_key"] = "Import Private Key…"; strings_["settings_import_key"] = "Import Private Key…";
strings_["settings_import_wallet_file"] = "Import From Backup File…";
strings_["import_wallet_file_title"] = "Import keys from a backup file";
strings_["import_wallet_file_note"] = "Imports every private key from an old wallet's export file (its z_exportwallet / dumpwallet output, or SilentDragon's \"Export all private keys\") and rescans once — the fastest way to recover a legacy wallet.dat this client can't open.";
strings_["import_wallet_file_path_label"] = "Path to the key dump file:";
strings_["import_wallet_file_import_btn"] = "Import and rescan";
strings_["import_wallet_file_importing"] = "Importing keys and rescanning — this can take a while";
strings_["import_wallet_file_ok"] = "Keys imported. Once the rescan finishes, shield any mined (coinbase) transparent funds from the Shield screen to make them spendable.";
strings_["import_wallet_file_need_path"] = "Enter the path to the key dump file.";
strings_["import_wallet_file_not_found"] = "Couldn't open that file — check the path is correct and the file is readable.";
strings_["settings_import_viewkey"] = "Import Viewing Key…"; strings_["settings_import_viewkey"] = "Import Viewing Key…";
strings_["settings_export_key"] = "Export Key…"; strings_["settings_export_key"] = "Export Key…";
strings_["settings_export_all"] = "Export All…"; strings_["settings_export_all"] = "Export All…";
@@ -773,6 +814,8 @@ void I18n::loadBuiltinEnglish()
strings_["wiz_pass_spaces"] = "Passphrase has leading/trailing spaces \xe2\x80\x94 remove them"; strings_["wiz_pass_spaces"] = "Passphrase has leading/trailing spaces \xe2\x80\x94 remove them";
strings_["wiz_encrypt_continue"] = "Encrypt & Continue"; strings_["wiz_encrypt_continue"] = "Encrypt & Continue";
strings_["wiz_encrypt_bg"] = "Encryption will complete in the background"; strings_["wiz_encrypt_bg"] = "Encryption will complete in the background";
strings_["wiz_handoff_starting"] = "Setting up your wallet. The node is starting and will keep syncing in the background. You can start exploring now, but balances may take a while to appear.";
strings_["wiz_handoff_encrypting"] = "Setting up your wallet. The node is starting, and encryption will finish in the background once it connects. Balances may take a while to appear.";
strings_["wiz_skip_confirm"] = "Continue WITHOUT encryption? Keys will be stored unencrypted \xe2\x80\x94 click Skip again to confirm."; strings_["wiz_skip_confirm"] = "Continue WITHOUT encryption? Keys will be stored unencrypted \xe2\x80\x94 click Skip again to confirm.";
strings_["wiz_pin_optional"] = "Quick-Unlock PIN (optional)"; strings_["wiz_pin_optional"] = "Quick-Unlock PIN (optional)";
strings_["wiz_pin_label"] = "PIN (4-8 digits):"; strings_["wiz_pin_label"] = "PIN (4-8 digits):";
@@ -1058,10 +1101,14 @@ void I18n::loadBuiltinEnglish()
strings_["tt_reset_settings"] = "Revert this page to the last saved settings (undo unsaved changes)"; strings_["tt_reset_settings"] = "Revert this page to the last saved settings (undo unsaved changes)";
strings_["tt_debug_collapse"] = "Collapse debug logging options"; strings_["tt_debug_collapse"] = "Collapse debug logging options";
strings_["tt_debug_expand"] = "Expand debug logging options"; strings_["tt_debug_expand"] = "Expand debug logging options";
strings_["debug_options_caption_gated"] = "Diagnostics and node tuning for troubleshooting. Locked: opening requires confirmation (and your PIN or passphrase if set).";
strings_["debug_options_caption_unlocked"] = "Diagnostics and node tuning for troubleshooting. Unlocked for this session.";
strings_["tt_debug_expand_gated"] = "Locked: confirm (and enter your PIN or passphrase if set) to show debug options";
strings_["tt_restart_daemon"] = "Restart the daemon to apply debug logging changes"; strings_["tt_restart_daemon"] = "Restart the daemon to apply debug logging changes";
// Settings: additional tooltips (keys/data row) // Settings: additional tooltips (keys/data row)
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet"; strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
strings_["tt_import_wallet_file"] = "Import all keys from an old wallet's exported dump file (one rescan)";
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)"; strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
strings_["tt_export_key"] = "Export the private key for the selected address"; strings_["tt_export_key"] = "Export the private key for the selected address";
strings_["tt_export_all"] = "Export all private keys to a file"; strings_["tt_export_all"] = "Export all private keys to a file";
@@ -1324,6 +1371,7 @@ void I18n::loadBuiltinEnglish()
strings_["lite_unlock_btn"] = "Unlock"; strings_["lite_unlock_btn"] = "Unlock";
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now"; strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?"; strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?";
strings_["lite_encrypt_failed_unencrypted"] = "Wallet encryption failed — your wallet may still be UNENCRYPTED. Open Settings > Security and set the passphrase again.";
// Send Tab // Send Tab
strings_["pay_from"] = "Pay From"; strings_["pay_from"] = "Pay From";
@@ -1475,6 +1523,8 @@ void I18n::loadBuiltinEnglish()
strings_["confirmations"] = "Confirmations"; strings_["confirmations"] = "Confirmations";
strings_["confirmed"] = "Confirmed"; strings_["confirmed"] = "Confirmed";
strings_["pending"] = "Pending"; strings_["pending"] = "Pending";
strings_["tx_dropped"] = "Dropped";
strings_["tx_stale_pending"] = "Expired?";
strings_["baltab_pending_chip"] = "Pending +%.4f"; strings_["baltab_pending_chip"] = "Pending +%.4f";
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed."; strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
strings_["sent"] = "sent"; strings_["sent"] = "sent";
@@ -1567,6 +1617,7 @@ void I18n::loadBuiltinEnglish()
strings_["sb_mining_hs"] = "%.1f H/s"; strings_["sb_mining_hs"] = "%.1f H/s";
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)"; strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)"; strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
strings_["sb_lite_sync_error"] = "Sync error: %.80s";
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%"; strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
strings_["sb_rescanning"] = "Rescanning"; strings_["sb_rescanning"] = "Rescanning";
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%"; strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
@@ -1600,6 +1651,9 @@ void I18n::loadBuiltinEnglish()
strings_["node_banner_open_config"] = "Open config folder"; strings_["node_banner_open_config"] = "Open config folder";
strings_["node_banner_auth_title"] = "Wallet can't sign in to the node"; strings_["node_banner_auth_title"] = "Wallet can't sign in to the node";
strings_["node_banner_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf."; strings_["node_banner_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf.";
strings_["node_banner_legacy_title"] = "Your transparent funds can't be moved yet";
strings_["node_banner_legacy_detail"] = "This wallet has no usable seed, so it can't create a shielded address to move its transparent funds. Recover them into a fresh seed-backed wallet.";
strings_["node_banner_recover"] = "Recover funds";
// Refresh-staleness badge (W6-2) on the Total Balance card. // Refresh-staleness badge (W6-2) on the Total Balance card.
strings_["data_stale_prefix"] = "Updated"; strings_["data_stale_prefix"] = "Updated";
strings_["data_stale_tooltip"] = strings_["data_stale_tooltip"] =
@@ -1991,6 +2045,9 @@ void I18n::loadBuiltinEnglish()
strings_["toast_stop_mining_failed"] = "Couldn't stop mining: "; strings_["toast_stop_mining_failed"] = "Couldn't stop mining: ";
strings_["toast_pool_start_failed"] = "Couldn't start the pool miner: "; strings_["toast_pool_start_failed"] = "Couldn't start the pool miner: ";
strings_["toast_ban_failed"] = "Couldn't ban the peer: "; strings_["toast_ban_failed"] = "Couldn't ban the peer: ";
strings_["toast_peer_banned"] = "Peer banned.";
strings_["toast_peer_unbanned"] = "Peer unbanned.";
strings_["toast_bans_cleared"] = "All bans cleared.";
strings_["toast_unban_failed"] = "Couldn't unban the peer: "; strings_["toast_unban_failed"] = "Couldn't unban the peer: ";
strings_["toast_clear_bans_failed"] = "Couldn't clear bans: "; strings_["toast_clear_bans_failed"] = "Couldn't clear bans: ";
strings_["toast_key_export_failed"] = "Key export failed: "; strings_["toast_key_export_failed"] = "Key export failed: ";
@@ -2000,6 +2057,11 @@ void I18n::loadBuiltinEnglish()
strings_["console_clear"] = "Clear"; strings_["console_clear"] = "Clear";
strings_["console_clear_console"] = "Clear Console"; strings_["console_clear_console"] = "Clear Console";
strings_["console_cleared"] = "Console cleared"; strings_["console_cleared"] = "Console cleared";
strings_["console_secret_result_hidden"] = "[result hidden — contains private key material. Use \"Copy secret\" to copy it, or it clears automatically.]";
strings_["console_secret_result_expired"] = "[hidden secret result cleared]";
strings_["console_copy_secret"] = "Copy secret";
strings_["console_copy_secret_tip"] = "Copy the hidden key/seed result to the clipboard (auto-clears after ~45s).";
strings_["console_hide_secret"] = "Hide";
strings_["console_click_commands"] = "Click commands above to insert them"; strings_["console_click_commands"] = "Click commands above to insert them";
strings_["console_click_insert"] = "Click to insert"; strings_["console_click_insert"] = "Click to insert";
strings_["console_click_insert_params"] = "Click to insert with parameters"; strings_["console_click_insert_params"] = "Click to insert with parameters";
@@ -2114,6 +2176,17 @@ void I18n::loadBuiltinEnglish()
strings_["export_tx_file_fail"] = "Failed to create CSV file"; strings_["export_tx_file_fail"] = "Failed to create CSV file";
strings_["export_tx_none"] = "No transactions to export"; strings_["export_tx_none"] = "No transactions to export";
strings_["export_tx_success"] = "Transactions exported successfully"; strings_["export_tx_success"] = "Transactions exported successfully";
strings_["export_tx_open_folder"] = "Open folder";
strings_["export_tx_scope"] = "Export scope";
strings_["export_tx_scope_all"] = "All transactions";
strings_["export_tx_scope_filter"] = "Current History filter";
strings_["export_tx_scope_range"] = "Date range";
strings_["export_tx_scope_filter_none"] = "No filter is active on the History tab";
strings_["export_tx_scope_stale"] = "The History filter changed while loading. Close and reopen this dialog.";
strings_["export_tx_range_from"] = "From (YYYY-MM-DD)";
strings_["export_tx_range_to"] = "To (YYYY-MM-DD)";
strings_["export_tx_range_invalid"] = "Enter valid dates as YYYY-MM-DD, with From on or before To";
strings_["export_tx_range_pending_note"] = "Pending transactions have no date and are not included in a date range.";
strings_["export_tx_title"] = "Export Transactions to CSV"; strings_["export_tx_title"] = "Export Transactions to CSV";
// --- Import Key Dialog --- // --- Import Key Dialog ---
@@ -2165,6 +2238,8 @@ void I18n::loadBuiltinEnglish()
// --- Key Export Dialog --- // --- Key Export Dialog ---
strings_["key_export_fetching"] = "Fetching key from wallet..."; strings_["key_export_fetching"] = "Fetching key from wallet...";
strings_["key_export_fetching_base"] = "Fetching key from wallet";
strings_["key_export_unlock_first"] = "Unlock your wallet first, then reveal the key.";
strings_["key_export_failed"] = "Couldn't export the key \xE2\x80\x94 unlock the wallet (if encrypted) and try again."; strings_["key_export_failed"] = "Couldn't export the key \xE2\x80\x94 unlock the wallet (if encrypted) and try again.";
strings_["key_export_private_key"] = "Private Key:"; strings_["key_export_private_key"] = "Private Key:";
strings_["key_export_private_warning"] = "Keep this key SECRET! Anyone with this key can spend your funds. Never share it online or with untrusted parties."; strings_["key_export_private_warning"] = "Keep this key SECRET! Anyone with this key can spend your funds. Never share it online or with untrusted parties.";
@@ -2482,6 +2557,10 @@ void I18n::loadBuiltinEnglish()
strings_["daemon_update_installed_ok"] = "Node installed"; 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_note"] = "Restart the daemon to start running the new version.";
strings_["daemon_update_restart_now"] = "Restart daemon now"; strings_["daemon_update_restart_now"] = "Restart daemon now";
strings_["daemon_update_restart_needed"] = "Restart required to use the new version";
strings_["daemon_update_later_hint"] = "A reminder stays in Settings until you restart the daemon.";
strings_["daemon_restart_pending"] = "A new daemon version is installed but not running yet. Restart the daemon to use it.";
strings_["tt_daemon_restart_pending"] = "Restarts the embedded daemon so the newly installed version takes effect.";
strings_["daemon_update_later"] = "Later"; strings_["daemon_update_later"] = "Later";
strings_["daemon_update_failed"] = "Update failed"; strings_["daemon_update_failed"] = "Update failed";
strings_["daemon_update_unknown_error"] = "Unknown error."; strings_["daemon_update_unknown_error"] = "Unknown error.";
@@ -2538,6 +2617,8 @@ void I18n::loadBuiltinEnglish()
strings_["peers_blocks"] = "Blocks"; strings_["peers_blocks"] = "Blocks";
strings_["peers_blocks_left"] = "%d blocks left"; strings_["peers_blocks_left"] = "%d blocks left";
strings_["peers_clear_all_bans"] = "Clear All Bans"; strings_["peers_clear_all_bans"] = "Clear All Bans";
strings_["peers_clear_bans_confirm"] = "Clear all %d bans?";
strings_["peers_clear_bans_confirm_yes"] = "Clear all";
strings_["peers_click_copy"] = "Click to copy"; strings_["peers_click_copy"] = "Click to copy";
strings_["peers_connected"] = "Connected"; strings_["peers_connected"] = "Connected";
strings_["peers_connected_count"] = "Connected: %d"; strings_["peers_connected_count"] = "Connected: %d";
@@ -2639,10 +2720,12 @@ void I18n::loadBuiltinEnglish()
strings_["send_tooltip_not_connected"] = "Not connected to daemon"; strings_["send_tooltip_not_connected"] = "Not connected to daemon";
strings_["send_tooltip_select_source"] = "Select a source address first"; strings_["send_tooltip_select_source"] = "Select a source address first";
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync"; strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
strings_["send_tooltip_no_tip"] = "Waiting for peers to report the network height. Sending is disabled until the wallet knows it is caught up.";
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced."; strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send"; strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
strings_["send_total"] = "Total"; strings_["send_total"] = "Total";
strings_["send_tx_failed"] = "Transaction failed"; strings_["send_tx_failed"] = "Transaction failed";
strings_["send_maybe_sent"] = "This transaction may already have been broadcast (the reply was lost). Do NOT send again yet - check History first.";
strings_["send_tx_sent"] = "Transaction sent!"; strings_["send_tx_sent"] = "Transaction sent!";
strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance."; strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
strings_["send_tx_success"] = "Transaction sent successfully!"; strings_["send_tx_success"] = "Transaction sent successfully!";
@@ -2828,6 +2911,7 @@ void I18n::loadBuiltinEnglish()
strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash"; strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash";
strings_["explorer_invalid_response"] = "Invalid response from daemon"; strings_["explorer_invalid_response"] = "Invalid response from daemon";
strings_["explorer_hash_not_found"] = "No block or transaction found for this hash"; strings_["explorer_hash_not_found"] = "No block or transaction found for this hash";
strings_["explorer_lookup_failed"] = "Lookup failed";
strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash"; strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash";
strings_["explorer_no_results"] = "No matching cached blocks"; strings_["explorer_no_results"] = "No matching cached blocks";
strings_["explorer_beyond_tip"] = "Block %d is beyond the chain tip (%d)"; strings_["explorer_beyond_tip"] = "Block %d is beyond the chain tip (%d)";
@@ -3055,6 +3139,15 @@ const char* I18n::translate(const char* key) const
return key; return key;
} }
std::string I18n::translateCopy(const char* key) const
{
if (!key) return std::string();
std::shared_lock<std::shared_mutex> lock(strings_mutex_);
auto it = strings_.find(key);
if (it != strings_.end()) return it->second;
return std::string(key);
}
void I18n::registerLanguage(const std::string& code, const std::string& name) void I18n::registerLanguage(const std::string& code, const std::string& name)
{ {
// Check if already registered // Check if already registered
+22 -1
View File
@@ -4,6 +4,8 @@
#pragma once #pragma once
#include <mutex>
#include <shared_mutex>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
@@ -38,7 +40,16 @@ public:
* @return Translated string, or key if not found * @return Translated string, or key if not found
*/ */
const char* translate(const char* key) const; const char* translate(const char* key) const;
/**
* @brief Thread-safe translate: returns a COPY under a read lock.
*
* translate() hands back a pointer into the string table, which loadLanguage() rewrites on the
* main thread. Any caller off the main thread (e.g. the EmbeddedDaemon monitor thread) MUST use
* this instead; main-thread UI code keeps using translate()/TR().
*/
std::string translateCopy(const char* key) const;
/** /**
* @brief Get current locale * @brief Get current locale
*/ */
@@ -66,6 +77,9 @@ private:
void overlayTranslations(const nlohmann::json& translations); void overlayTranslations(const nlohmann::json& translations);
std::string current_locale_ = "en"; std::string current_locale_ = "en";
// Guards strings_/current_locale_ against loadLanguage() (main thread, writer) racing
// translateCopy() (off-thread readers). The lock-free translate() is main-thread only.
mutable std::shared_mutex strings_mutex_;
std::unordered_map<std::string, std::string> strings_; std::unordered_map<std::string, std::string> strings_;
std::vector<std::pair<std::string, std::string>> available_languages_; std::vector<std::pair<std::string, std::string>> available_languages_;
}; };
@@ -79,6 +93,13 @@ inline const char* tr(const char* key) {
return I18n::instance().translate(key); return I18n::instance().translate(key);
} }
/**
* @brief Thread-safe translation returning an owned copy. Use from any non-main thread.
*/
inline std::string trCopy(const char* key) {
return I18n::instance().translateCopy(key);
}
/** /**
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation. * @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
* *
+30 -5
View File
@@ -10,6 +10,29 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstdio> #include <cstdio>
#include <cmath>
namespace {
// Strictly parse a decimal DRGX amount from an (attacker-influenceable) payment URI / QR: digits with at
// most one '.', at most 8 fractional digits, in [0, 21000000]. Rejects empty, NaN/inf, signs, whitespace
// and trailing garbage that std::stod would otherwise accept — so a crafted URI can't inject a bogus
// amount into the Send form.
bool parseStrictAmount(const std::string& s, double& out) {
if (s.empty() || s.size() > 32) return false;
int dots = 0, digits = 0, frac = 0; bool afterDot = false;
for (char c : s) {
if (c == '.') { if (++dots > 1) return false; afterDot = true; }
else if (c >= '0' && c <= '9') { ++digits; if (afterDot && ++frac > 8) return false; }
else return false;
}
if (digits == 0) return false;
double v = 0.0;
try { v = std::stod(s); } catch (...) { return false; }
if (!std::isfinite(v) || v < 0.0 || v > 21000000.0) return false;
out = v;
return true;
}
} // namespace
namespace dragonx { namespace dragonx {
namespace util { namespace util {
@@ -141,11 +164,13 @@ PaymentURI parsePaymentURI(const std::string& uri)
std::transform(key.begin(), key.end(), key.begin(), ::tolower); std::transform(key.begin(), key.end(), key.begin(), ::tolower);
if (key == "amount" || key == "amt") { if (key == "amount" || key == "amt") {
try { double a = 0.0;
result.amount = std::stod(value); if (!parseStrictAmount(value, a)) {
} catch (...) { result.error = "Invalid amount";
// Invalid amount, keep as 0 result.valid = false;
return result; // reject the whole URI rather than silently treating it as 0
} }
result.amount = a;
} else if (key == "label") { } else if (key == "label") {
result.label = value; result.label = value;
} else if (key == "memo") { } else if (key == "memo") {
@@ -174,7 +199,7 @@ PaymentURI parsePaymentURI(const std::string& uri)
} }
// Validate amount if present // Validate amount if present
if (result.amount < 0) { if (!std::isfinite(result.amount) || result.amount < 0 || result.amount > 21000000.0) {
result.error = "Invalid negative amount"; result.error = "Invalid negative amount";
return result; return result;
} }
+46 -3
View File
@@ -25,6 +25,7 @@
#include <shlobj.h> #include <shlobj.h>
#include <psapi.h> #include <psapi.h>
#include <tlhelp32.h> #include <tlhelp32.h>
#include <aclapi.h> // SetEntriesInAcl / EXPLICIT_ACCESS — owner-only DACL for restricted writes
#else #else
#include <unistd.h> #include <unistd.h>
#include <fcntl.h> #include <fcntl.h>
@@ -361,15 +362,54 @@ bool Platform::writeFileAtomically(const std::string& path,
const std::string tmp = path + ".tmp"; const std::string tmp = path + ".tmp";
#ifdef _WIN32 #ifdef _WIN32
// When restrictPermissions, build a PROTECTED owner-only DACL so the secret file (exported keys,
// seed backup, vault, chat export) is readable only by the current user — not whatever the parent
// dir's inherited ACL grants. Best-effort: on any failure fall back to default ACLs (the per-user
// profile dir still protects it). The ACL is set on the temp file; MoveFileEx is a same-volume
// rename, so the explicit (non-inherited) DACL carries to the final path.
PACL dacl = nullptr;
PTOKEN_USER tokenUser = nullptr;
SECURITY_DESCRIPTOR sd;
SECURITY_ATTRIBUTES saOwner{};
SECURITY_ATTRIBUTES* psa = nullptr;
if (restrictPermissions) {
HANDLE tok = nullptr;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) {
DWORD need = 0;
GetTokenInformation(tok, TokenUser, nullptr, 0, &need);
if (need > 0) {
tokenUser = static_cast<PTOKEN_USER>(LocalAlloc(LPTR, need));
if (tokenUser && GetTokenInformation(tok, TokenUser, tokenUser, need, &need)) {
EXPLICIT_ACCESSA ea{};
ea.grfAccessPermissions = GENERIC_ALL;
ea.grfAccessMode = SET_ACCESS;
ea.grfInheritance = NO_INHERITANCE;
ea.Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea.Trustee.TrusteeType = TRUSTEE_IS_USER;
ea.Trustee.ptstrName = static_cast<LPSTR>(tokenUser->User.Sid);
if (SetEntriesInAclA(1, &ea, nullptr, &dacl) == ERROR_SUCCESS &&
InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION) &&
SetSecurityDescriptorControl(&sd, SE_DACL_PROTECTED, SE_DACL_PROTECTED) &&
SetSecurityDescriptorDacl(&sd, TRUE, dacl, FALSE)) {
saOwner.nLength = sizeof(saOwner);
saOwner.lpSecurityDescriptor = &sd;
saOwner.bInheritHandle = FALSE;
psa = &saOwner;
}
}
}
CloseHandle(tok);
}
}
{ {
// One handle for write + flush. CreateFileA with no SECURITY_ATTRIBUTES is NOT inheritable: the old // One handle for write + flush. CreateFileA with no SECURITY_ATTRIBUTES is NOT inheritable: the old
// std::ofstream (CRT) handle was, so a child spawned with bInheritHandles=TRUE at that moment (xmrig, // std::ofstream (CRT) handle was, so a child spawned with bInheritHandles=TRUE at that moment (xmrig,
// runHiddenCapture) kept "<path>.tmp" open without FILE_SHARE_DELETE and every later MoveFileEx // runHiddenCapture) kept "<path>.tmp" open without FILE_SHARE_DELETE and every later MoveFileEx
// failed while it ran. Also no second share-mode-0 open, which silently skipped the flush whenever // failed while it ran. Also no second share-mode-0 open, which silently skipped the flush whenever
// AV / the indexer had the new file open. // AV / the indexer had the new file open.
HANDLE h = CreateFileA(tmp.c_str(), GENERIC_WRITE, 0, nullptr, HANDLE h = CreateFileA(tmp.c_str(), GENERIC_WRITE, 0, psa,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) return false; if (h == INVALID_HANDLE_VALUE) { if (dacl) LocalFree(dacl); if (tokenUser) LocalFree(tokenUser); return false; }
bool ok = true; bool ok = true;
size_t written = 0; size_t written = 0;
while (written < content.size()) { while (written < content.size()) {
@@ -382,9 +422,12 @@ bool Platform::writeFileAtomically(const std::string& path,
// Flush the temp file's contents to stable storage before the rename. // Flush the temp file's contents to stable storage before the rename.
if (ok && !FlushFileBuffers(h)) ok = false; if (ok && !FlushFileBuffers(h)) ok = false;
CloseHandle(h); CloseHandle(h);
// The DACL is baked into the temp file's security descriptor at CreateFileA time, so release the
// builders now; every path below (including the early returns) is past this point.
if (dacl) LocalFree(dacl);
if (tokenUser) LocalFree(tokenUser);
if (!ok) { DeleteFileA(tmp.c_str()); return false; } if (!ok) { DeleteFileA(tmp.c_str()); return false; }
} }
(void)restrictPermissions; // owner-only ACLs rely on the per-user profile dir on Windows
// MoveFileEx with REPLACE_EXISTING is atomic on NTFS; WRITE_THROUGH makes it durable. AV / the search // MoveFileEx with REPLACE_EXISTING is atomic on NTFS; WRITE_THROUGH makes it durable. AV / the search
// indexer can hold the destination briefly (ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION): retry // indexer can hold the destination briefly (ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION): retry
// a few times (20+40+80+160 ms worst case, only on a write that used to just fail) instead of dropping it. // a few times (20+40+80+160 ms worst case, only on a write that used to just fail) instead of dropping it.
+14 -3
View File
@@ -48,7 +48,11 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the // any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
// RAW filename — the hash disambiguates so two distinct files can never share a vault even if their // RAW filename — the hash disambiguates so two distinct files can never share a vault even if their
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat"). // sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
if (walletFile.empty() || walletFile == "wallet.dat") { scope_.clear(); return; } if (walletFile.empty() || walletFile == "wallet.dat") {
std::lock_guard<std::mutex> lk(scopeMutex_);
scope_.clear();
return;
}
std::string s; std::string s;
s.reserve(walletFile.size()); s.reserve(walletFile.size());
for (unsigned char c : walletFile) for (unsigned char c : walletFile)
@@ -58,11 +62,18 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
char suffix[9]; char suffix[9];
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu)); std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
if (s.size() > 48) s.resize(48); if (s.size() > 48) s.resize(48);
scope_ = s + "-" + suffix; std::string newScope = s + "-" + suffix;
std::lock_guard<std::mutex> lk(scopeMutex_);
scope_ = std::move(newScope);
} }
std::string SecureVault::getVaultPath() const { std::string SecureVault::getVaultPath() const {
const std::string name = scope_.empty() ? std::string("vault.dat") : ("vault-" + scope_ + ".dat"); std::string scope;
{
std::lock_guard<std::mutex> lk(scopeMutex_);
scope = scope_;
}
const std::string name = scope.empty() ? std::string("vault.dat") : ("vault-" + scope + ".dat");
return (fs::path(Platform::getConfigDir()) / name).string(); return (fs::path(Platform::getConfigDir()) / name).string();
} }
+4
View File
@@ -7,6 +7,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <cstdint> #include <cstdint>
#include <mutex>
namespace dragonx { namespace dragonx {
namespace util { namespace util {
@@ -101,6 +102,9 @@ private:
const uint8_t* salt, size_t saltLen, const uint8_t* salt, size_t saltLen,
uint8_t* key, size_t keyLen) const; uint8_t* key, size_t keyLen) const;
// Guards scope_: setWalletScope() runs on the main thread (wallet switch) while retrieve()/hasVault()/
// changePin() run on RPC worker threads and read it via getVaultPath().
mutable std::mutex scopeMutex_;
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
}; };
+16 -6
View File
@@ -94,11 +94,11 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
: api_(other.api_), : api_(other.api_),
unavailableReason_(std::move(other.unavailableReason_)), unavailableReason_(std::move(other.unavailableReason_)),
shutdownCalled_(other.shutdownCalled_), shutdownCalled_(other.shutdownCalled_.load(std::memory_order_acquire)),
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire)) mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
{ {
other.api_ = {}; other.api_ = {};
other.shutdownCalled_ = true; other.shutdownCalled_.store(true, std::memory_order_release);
} }
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
@@ -107,11 +107,11 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
shutdown(); shutdown();
api_ = other.api_; api_ = other.api_;
unavailableReason_ = std::move(other.unavailableReason_); unavailableReason_ = std::move(other.unavailableReason_);
shutdownCalled_ = other.shutdownCalled_; shutdownCalled_.store(other.shutdownCalled_.load(std::memory_order_acquire), std::memory_order_release);
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire), mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
std::memory_order_release); std::memory_order_release);
other.api_ = {}; other.api_ = {};
other.shutdownCalled_ = true; other.shutdownCalled_.store(true, std::memory_order_release);
return *this; return *this;
} }
@@ -140,6 +140,7 @@ bool LiteClientBridge::checkServerOnline(const std::string& server) const
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server) LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
{ {
if (!available()) return unavailableResult(); if (!available()) return unavailableResult();
if (isShutdown()) return shutDownResult();
return takeOwnedString(api_.initializeNew(dangerous, server.c_str())); return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
} }
@@ -151,6 +152,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
bool overwrite) bool overwrite)
{ {
if (!available()) return unavailableResult(); if (!available()) return unavailableResult();
if (isShutdown()) return shutDownResult();
return takeOwnedString(api_.initializeNewFromPhrase( return takeOwnedString(api_.initializeNewFromPhrase(
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite)); dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
} }
@@ -158,6 +160,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server) LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
{ {
if (!available()) return unavailableResult(); if (!available()) return unavailableResult();
if (isShutdown()) return shutDownResult();
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str())); return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
} }
@@ -180,6 +183,9 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
{ {
if (!available()) return unavailableResult(); if (!available()) return unavailableResult();
if (command.empty()) return {false, {}, "lite command is empty"}; if (command.empty()) return {false, {}, "lite command is empty"};
// A retired controller's bridge is shut down; litelib_execute would run on the NEW global client
// (a stale send/save/sync/console command). Refuse it here, once, for every detached thread.
if (isShutdown()) return shutDownResult();
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str())); auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative) if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel); mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
@@ -188,11 +194,15 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
void LiteClientBridge::shutdown() void LiteClientBridge::shutdown()
{ {
if (shutdownCalled_) return; if (shutdownCalled_.exchange(true, std::memory_order_acq_rel)) return; // exactly one caller shuts the client down
shutdownCalled_ = true;
if (available()) api_.shutdown(); if (available()) api_.shutdown();
} }
LiteBridgeStringResult LiteClientBridge::shutDownResult()
{
return {false, {}, "lite client was shut down"};
}
LiteBridgeStringResult LiteClientBridge::unavailableResult() const LiteBridgeStringResult LiteClientBridge::unavailableResult() const
{ {
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_}; return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
+6 -1
View File
@@ -70,6 +70,10 @@ public:
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server); LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
LiteBridgeStringResult execute(const std::string& command, const std::string& args); LiteBridgeStringResult execute(const std::string& command, const std::string& args);
void shutdown(); void shutdown();
// True once shutdown() ran (retire() / destruction / move-from). After this every backend call
// (execute / initialize*) is refused: litelib_* acts on the process-global client, which may by
// then belong to a NEW controller.
bool isShutdown() const { return shutdownCalled_.load(std::memory_order_acquire); }
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible // Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
// data (anything not on the read-only allow-list). The refresh worker samples this to detect // data (anything not on the read-only allow-list). The refresh worker samples this to detect
@@ -82,12 +86,13 @@ private:
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason); LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
LiteBridgeStringResult unavailableResult() const; LiteBridgeStringResult unavailableResult() const;
static LiteBridgeStringResult shutDownResult();
LiteBridgeStringResult takeOwnedString(char* rawValue) const; LiteBridgeStringResult takeOwnedString(char* rawValue) const;
static bool commandIsReadOnly(const std::string& command); static bool commandIsReadOnly(const std::string& command);
LiteClientBridgeApi api_; LiteClientBridgeApi api_;
std::string unavailableReason_; std::string unavailableReason_;
bool shutdownCalled_ = false; std::atomic<bool> shutdownCalled_{false}; // read by detached threads in execute() while retire() sets it
std::atomic<std::uint64_t> mutationEpoch_{0}; std::atomic<std::uint64_t> mutationEpoch_{0};
}; };
+9 -4
View File
@@ -40,7 +40,8 @@ LiteBridgeOwnedString::LiteBridgeOwnedString(LiteBridgeOwnedString&& other) noex
LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&& other) noexcept LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&& other) noexcept
{ {
if (this == &other) return *this; if (this == &other) return *this;
copyValueBeforeFree(); // Free only — do NOT copy the raw value here: nothing reads copiedValue_ after this, and the copy
// (std::string assignment) can throw bad_alloc, which from this noexcept path would call std::terminate.
releaseRawValue(); releaseRawValue();
wipeString(copiedValue_); wipeString(copiedValue_);
moveFrom(std::move(other)); moveFrom(std::move(other));
@@ -49,7 +50,8 @@ LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&&
LiteBridgeOwnedString::~LiteBridgeOwnedString() LiteBridgeOwnedString::~LiteBridgeOwnedString()
{ {
copyValueBeforeFree(); // Free only (see operator= note): the copy is unnecessary when the object is dying and must never
// throw out of a destructor. The copy happens solely in intoResult(), the one path that reads it.
releaseRawValue(); releaseRawValue();
wipeString(copiedValue_); wipeString(copiedValue_);
} }
@@ -66,13 +68,16 @@ LiteBridgeStringResult LiteBridgeOwnedString::intoResult()
return {false, {}, "lite bridge freeString function is unavailable"}; return {false, {}, "lite bridge freeString function is unavailable"};
} }
// copyValueBeforeFree() can throw bad_alloc; if it does, rawValue_ is still set and the destructor
// (now free-only) releases it — no leak, no re-copy, no terminate.
copyValueBeforeFree(); copyValueBeforeFree();
releaseRawValue(); releaseRawValue();
const std::string resultValue = copiedValue_; // Move the copy into the result instead of making a second const copy that would linger unwiped.
std::string resultValue = std::move(copiedValue_);
wipeString(copiedValue_); wipeString(copiedValue_);
if (looksLikeError(resultValue)) return {false, {}, resultValue}; if (looksLikeError(resultValue)) return {false, {}, resultValue};
return {true, resultValue, {}}; return {true, std::move(resultValue), {}};
} }
void LiteBridgeOwnedString::copyValueBeforeFree() void LiteBridgeOwnedString::copyValueBeforeFree()
+53 -3
View File
@@ -79,6 +79,9 @@ bool requireArray(const json& value, const std::string& path, Result& result)
return true; return true;
} }
// 21,000,000 DRGX in zatoshi: no single backend-reported amount can legitimately exceed the supply.
constexpr std::int64_t kMaxMoneyZat = static_cast<std::int64_t>(21000000) * 100000000LL;
bool jsonToUnsigned(const json& value, std::uint64_t& output) bool jsonToUnsigned(const json& value, std::uint64_t& output)
{ {
try { try {
@@ -95,6 +98,9 @@ bool jsonToUnsigned(const json& value, std::uint64_t& output)
if (value.is_string()) { if (value.is_string()) {
const auto text = value.get<std::string>(); const auto text = value.get<std::string>();
if (text.empty()) return false; if (text.empty()) return false;
// stoull follows strtoull: it accepts a leading '-' (wrapping to a huge value), '+' and
// whitespace. Require a leading digit so a hostile/buggy backend can't forge a balance.
if (!std::isdigit(static_cast<unsigned char>(text[0]))) return false;
std::size_t parsedSize = 0; std::size_t parsedSize = 0;
const auto parsed = std::stoull(text, &parsedSize, 10); const auto parsed = std::stoull(text, &parsedSize, 10);
if (parsedSize != text.size()) return false; if (parsedSize != text.size()) return false;
@@ -125,6 +131,8 @@ bool jsonToSigned(const json& value, std::int64_t& output)
if (value.is_string()) { if (value.is_string()) {
const auto text = value.get<std::string>(); const auto text = value.get<std::string>();
if (text.empty()) return false; if (text.empty()) return false;
// Reject leading whitespace / '+' that strtoll would otherwise accept (a '-' is valid here).
if (text[0] != '-' && !std::isdigit(static_cast<unsigned char>(text[0]))) return false;
std::size_t parsedSize = 0; std::size_t parsedSize = 0;
const auto parsed = std::stoll(text, &parsedSize, 10); const auto parsed = std::stoll(text, &parsedSize, 10);
if (parsedSize != text.size()) return false; if (parsedSize != text.size()) return false;
@@ -277,9 +285,20 @@ bool parseSpendableOutput(const json& value,
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false; if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false; if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false; if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
if (output.value > static_cast<std::uint64_t>(kMaxMoneyZat)) {
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".value", "note value exceeds the maximum supply");
}
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false; if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false; if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent; output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
// The backend's per-note 'spendable' (we hold the spending key AND the note is at/below the anchor
// height) is authoritative when present (Sapling notes only; transparent utxos carry no such field,
// so they keep the confirmed/unspent rule above). It can only narrow, never widen, spendability.
bool backendSpendable = true;
if (!readOptionalBoolField(value, "spendable", backendSpendable, result)) return false;
if (value.contains("spendable") && value.at("spendable").is_boolean()) {
output.spendable = output.spendable && backendSpendable;
}
return true; return true;
} }
@@ -337,15 +356,37 @@ bool parseTransactionRecord(const json& value,
for (std::size_t index = 0; index < metadata.size(); ++index) { for (std::size_t index = 0; index < metadata.size(); ++index) {
LiteTransactionOutput parsedOutput; LiteTransactionOutput parsedOutput;
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false; if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
// Reject negative values and guard the running sum: signed overflow is UB, and a hostile
// backend could otherwise overflow the total into a bogus (even negative) amount.
const std::string outputPath = path + ".outgoing_metadata[" + std::to_string(index) + "]";
if (parsedOutput.value < 0 || parsedOutput.value > kMaxMoneyZat) {
return fail(result, LiteResultParserError::InvalidFieldValue, outputPath + ".value", "outgoing output value is negative or exceeds the maximum supply");
}
if (output.amount > kMaxMoneyZat - parsedOutput.value) {
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".outgoing_metadata", "outgoing output total exceeds the maximum supply");
}
output.amount += parsedOutput.value; output.amount += parsedOutput.value;
output.outgoingMetadata.push_back(std::move(parsedOutput)); output.outgoingMetadata.push_back(std::move(parsedOutput));
} }
// A send this wallet did not author (seed restore / other device) has empty outgoing_metadata,
// so the sum is 0. Fall back to the record's net 'amount' magnitude (includes the fee) so Sent
// totals aren't understated. Display-only; tolerant of a missing/odd field.
if (output.amount == 0 && value.contains("amount") && !value.at("amount").is_null()) {
std::int64_t net = 0;
if (jsonToSigned(value.at("amount"), net) && net != std::numeric_limits<std::int64_t>::min()
&& (net < 0 ? -net : net) <= kMaxMoneyZat) {
output.amount = net < 0 ? -net : net;
}
}
return true; return true;
} }
output.direction = LiteTransactionDirection::Receive; output.direction = LiteTransactionDirection::Receive;
if (!readRequiredStringField(value, "address", output.address, result)) return false; if (!readRequiredStringField(value, "address", output.address, result)) return false;
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false; if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
if (output.amount < 0 || output.amount > kMaxMoneyZat) {
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".amount", "received amount is negative or exceeds the maximum supply");
}
if (!readOptionalStringField(value, "memo", output.memo, result)) return false; if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
if (!readOptionalSignedField(value, "position", output.position, result)) return false; if (!readOptionalSignedField(value, "position", output.position, result)) return false;
return true; return true;
@@ -561,11 +602,20 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value)
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks // The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}. // are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
std::string syncing = "false"; std::optional<std::string> syncingField;
if (!readOptionalStringField(value, "syncing", syncing, result)) return result; if (!readOptionalStringField(value, "syncing", syncingField, result)) return result;
const std::string syncing = syncingField.value_or(std::string());
const bool isSyncing = (syncing == "true" || syncing == "1"); const bool isSyncing = (syncing == "true" || syncing == "1");
const bool isIdle = (syncing == "false" || syncing == "0");
if (isSyncing) { if (!isSyncing && !isIdle) {
// Missing/odd "syncing": we do not KNOW the wallet is caught up, so never default to synced.
result.syncStatus.syncedBlocks = 0;
result.syncStatus.totalBlocks = 0;
result.syncStatus.complete = false;
result.syncStatus.indeterminate = true;
result.syncStatus.progress = 0.0;
} else if (isSyncing) {
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result; if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result; if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
result.syncStatus.complete = false; // actively syncing result.syncStatus.complete = false; // actively syncing
+1
View File
@@ -130,6 +130,7 @@ struct LiteSyncStatusResponse {
std::uint64_t totalBlocks = 0; std::uint64_t totalBlocks = 0;
double progress = 0.0; double progress = 0.0;
bool complete = false; bool complete = false;
bool indeterminate = false; // "syncing" missing/unrecognised: state unknown, NOT assumed synced
}; };
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; }; struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
+147 -19
View File
@@ -110,6 +110,21 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
return false; return false;
} }
// True when the wallet is encrypted AND currently unlocked. The backend's `save` re-locks such a wallet (do_save),
// so a background save must not run under a user who just unlocked. Fail-open: any error reads as "not unlocked" and
// the save proceeds as it always did.
bool liteWalletEncryptedAndUnlocked(LiteClientBridge& bridge)
{
const auto r = bridge.execute("encryptionstatus", "");
if (!r.ok) return false;
try {
const auto j = nlohmann::json::parse(r.value);
return j.is_object() && !j.contains("error") && j.value("encrypted", false) && !j.value("locked", false);
} catch (...) {
return false;
}
}
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has // The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after // already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
// every attempt. Captures shared state only. // every attempt. Captures shared state only.
@@ -129,7 +144,12 @@ void runLiteScanThread(const std::shared_ptr<LiteClientBridge>& bridge,
op.error = call.error.empty() ? "sync failed" : call.error; op.error = call.error.empty() ? "sync failed" : call.error;
} }
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed. // The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
if (!bridge->execute("save", "").ok) // W5-2 // Not while the user has the wallet unlocked: the backend save re-locks an encrypted+unlocked wallet, which
// would yank the unlock out from under a spend/unlock in progress. The progress is saved by the next scan
// once locked (and by the shutdown save); a crash in between only costs a re-scan from the checkpoint.
if (liteWalletEncryptedAndUnlocked(*bridge))
liteLog("sync save deferred: wallet is unlocked (a save would re-lock it)");
else if (!bridge->execute("save", "").ok) // W5-2
liteLog("save failed after sync — the next launch will re-scan from the checkpoint"); liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
if (op.ok) { if (op.ok) {
scan->setError(std::string()); scan->setError(std::string());
@@ -358,13 +378,38 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin; state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin; state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin; state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin; // Full node defines unconfirmedBalance as total - spendable (the pending portion; balance_tab's "pending"
// Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed // chip and the fast-poll hint read it that way). Lite: the not-yet-spendable shielded part (unverified /
// (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend // unconfirmed notes) is that portion; the backend's own `unconfirmed` is mempool-incoming. Take the larger so
// validation isn't zeroed on lite (the actual lite spend gate is per-address, set below). // neither is hidden and a note counted in both is not double-reported. Display-only; never feeds a spend gate.
state.spendablePrivateBalance = state.privateBalance; {
state.spendableTransparentBalance = state.transparentBalance; const std::uint64_t notYetSpendable = model.balance.shieldedZatoshis -
state.spendableTotalBalance = state.totalBalance; std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis);
state.unconfirmedBalance = static_cast<double>(
std::max(model.balance.unconfirmedZatoshis, notYetSpendable)) / kZatoshisPerCoin;
}
// Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the
// gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The
// spendable_zbalance field is required by the parser, so it's always present when hasBalance is
// true; clamp to the shielded total defensively. Transparent is already confirmed on lite.
const double spendableShielded = static_cast<double>(
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
state.spendablePrivateBalance = spendableShielded;
// Backend tbalance sums every utxo with spent==None (lightwallet.rs get_utxos), so a t-UTXO already spent by
// an unconfirmed tx of ours is still in transparentBalance. Subtract those from the SPENDABLE figure only
// (clamped; gross transparentBalance display unchanged). Needs this cycle's utxo list: if the notes call
// failed, nothing is subtracted (same as before).
std::uint64_t unconfirmedSpentTransparent = 0;
if (model.hasSpendableOutputs) {
for (const auto& output : model.spendableOutputs) {
if (output.kind == LiteSpendableOutputKind::Utxo && output.unconfirmedSpent && !output.spent)
unconfirmedSpentTransparent += output.valueZatoshis;
}
}
const std::uint64_t spendableTransparentZat = model.balance.transparentZatoshis -
std::min(unconfirmedSpentTransparent, model.balance.transparentZatoshis);
state.spendableTransparentBalance = static_cast<double>(spendableTransparentZat) / kZatoshisPerCoin;
state.spendableTotalBalance = spendableShielded + state.spendableTransparentBalance;
} }
if (model.hasAddresses) { if (model.hasAddresses) {
@@ -373,10 +418,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so // unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
// the per-address figures match the confirmed balance the wallet treats as available. // the per-address figures match the confirmed balance the wallet treats as available.
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis; std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
std::unordered_map<std::string, std::uint64_t> perAddressSpendableZatoshis; // backend 'spendable' only
if (model.hasSpendableOutputs) { if (model.hasSpendableOutputs) {
for (const auto& output : model.spendableOutputs) { for (const auto& output : model.spendableOutputs) {
if (output.spent || output.unconfirmedSpent || output.pending) continue; if (output.spent || output.unconfirmedSpent || output.pending) continue;
perAddressZatoshis[output.address] += output.valueZatoshis; perAddressZatoshis[output.address] += output.valueZatoshis;
if (output.spendable) perAddressSpendableZatoshis[output.address] += output.valueZatoshis;
} }
} }
@@ -385,8 +432,14 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks // of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
// like fund loss and breaks "send from this address". // like fund loss and breaks "send from this address".
std::unordered_map<std::string, double> priorBalances; std::unordered_map<std::string, double> priorBalances;
std::unordered_map<std::string, double> priorSpendable;
std::unordered_map<std::string, bool> priorHasKey;
if (!model.hasSpendableOutputs) { if (!model.hasSpendableOutputs) {
for (const auto& a : state.addresses) priorBalances[a.address] = a.balance; for (const auto& a : state.addresses) {
priorBalances[a.address] = a.balance;
priorSpendable[a.address] = a.spendableBalance;
priorHasKey[a.address] = a.has_spending_key;
}
} }
state.addresses.clear(); state.addresses.clear();
@@ -404,9 +457,22 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
} else { } else {
info.balance = 0.0; // notes succeeded and address has no spendable outputs info.balance = 0.0; // notes succeeded and address has no spendable outputs
} }
info.spendableBalance = info.balance; // lite per-address balance is already confirmed/spendable // balance stays the gross confirmed-unspent sum; spendableBalance counts only outputs the backend
// marks spendable (spending key + anchor depth). Notes failed this cycle -> keep last-known.
if (model.hasSpendableOutputs) {
const auto sit = perAddressSpendableZatoshis.find(addr.address);
info.spendableBalance = sit != perAddressSpendableZatoshis.end()
? static_cast<double>(sit->second) / kZatoshisPerCoin : 0.0;
} else {
const auto psit = priorSpendable.find(addr.address);
info.spendableBalance = psit != priorSpendable.end() ? psit->second : 0.0;
}
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent"; info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true; info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
if (!addr.spendabilityKnown && !model.hasSpendableOutputs) {
const auto pkit = priorHasKey.find(addr.address); // keep last-known view-only verdict
if (pkit != priorHasKey.end()) info.has_spending_key = pkit->second;
}
if (addr.kind == LiteWalletAppAddressKind::Shielded) { if (addr.kind == LiteWalletAppAddressKind::Shielded) {
state.z_addresses.push_back(info); state.z_addresses.push_back(info);
} else { } else {
@@ -432,7 +498,9 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
} else { } else {
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive"; tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
} }
tx.amount = static_cast<double>(record.amountZatoshis) / kZatoshisPerCoin; // Signed like the full node: a send is a negative debit, a receive positive. Consumers
// (History/Details/CSV) pick sign + colour from this; the summary cards use std::abs.
tx.amount = static_cast<double>(record.signedAmountZatoshis) / kZatoshisPerCoin;
tx.timestamp = record.timestamp; tx.timestamp = record.timestamp;
tx.address = record.address; tx.address = record.address;
tx.memo = record.memo; tx.memo = record.memo;
@@ -912,9 +980,11 @@ void LiteWalletController::applyPendingLifecycleSecurity()
if (op == PendingSecurityOp::Encrypt) { if (op == PendingSecurityOp::Encrypt) {
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves. // encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
const auto enc = encryptWallet(pendingLifecyclePassphrase_); const auto enc = encryptWallet(pendingLifecyclePassphrase_);
if (!enc.ok) if (!enc.ok) {
lifecycleEncryptFailed_ = true; // surfaced to the user by the App (takeEncryptionFailureNotice)
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " + liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
enc.error); enc.error);
}
} else if (op == PendingSecurityOp::Unlock) { } else if (op == PendingSecurityOp::Unlock) {
if (!unlockWallet(pendingLifecyclePassphrase_)) if (!unlockWallet(pendingLifecyclePassphrase_))
liteLog("wallet opened but unlock failed (wrong passphrase?)"); liteLog("wallet opened but unlock failed (wrong passphrase?)");
@@ -1044,7 +1114,12 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed // returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and // "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
// surface it as the synced height/tip so the block count is correct at rest. // surface it as the synced height/tip so the block count is correct at rest.
if (bridge_ && (!honorStop || running_.load())) { // Do not force "synced" from the height read while syncstatus says the backend is still scanning (work remaining)
// or could not be interpreted: the height only proves the last scanned block, not that we reached the tip.
const bool backendBusy = syncResult.ok && !syncResult.syncStatus.complete &&
(syncResult.syncStatus.indeterminate ||
syncResult.syncStatus.syncedBlocks < syncResult.syncStatus.totalBlocks);
if (bridge_ && !backendBusy && (!honorStop || running_.load())) {
const auto h = bridge_->execute("height", ""); const auto h = bridge_->execute("height", "");
if (h.ok) { if (h.ok) {
const auto parsed = parseLiteHeightResponse(h.value); const auto parsed = parseLiteHeightResponse(h.value);
@@ -1068,7 +1143,14 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
model.sync.progress = std::min(model.sync.progress, 0.999); model.sync.progress = std::min(model.sync.progress, 0.999);
} }
} }
// A server-bound read failed this cycle (server lost after catch-up): not synced, and say why. Without this the
// height override above kept reporting Synced because only a failed *scan* (scan_->failed) demoted it.
if (refreshResult.syncReadFailed && model.hasSyncStatus) {
model.sync.complete = false;
model.sync.progress = std::min(model.sync.progress, 0.999);
}
model.syncError = scan_->errorCopy(); model.syncError = scan_->errorCopy();
if (model.syncError.empty() && refreshResult.syncReadFailed) model.syncError = "server unreachable";
return model; return model;
} }
@@ -1235,6 +1317,14 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe
return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key
} }
// Recursively zero every string node of a parsed secret JSON tree (seed / private keys) so the parse
// copies don't linger in freed heap. Best-effort (nlohmann's own lexer scratch isn't reachable).
static void wipeJsonStrings(nlohmann::json& j)
{
if (j.is_string()) { secureWipeLiteSecret(j.get_ref<nlohmann::json::string_t&>()); }
else if (j.is_object() || j.is_array()) { for (auto& el : j) wipeJsonStrings(el); }
}
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress) LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
{ {
LiteExportResult out; LiteExportResult out;
@@ -1243,13 +1333,18 @@ LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& opti
return out; return out;
} }
// Empty address -> export keys for all addresses; otherwise just the given address. // Empty address -> export keys for all addresses; otherwise just the given address.
const auto result = bridge_->execute("export", optionalAddress); auto result = bridge_->execute("export", optionalAddress); // non-const: result.value is a secret
if (!result.ok) { if (!result.ok) {
out.error = result.error.empty() ? "export failed" : result.error; out.error = result.error.empty() ? "export failed" : result.error;
return out; return out;
} }
nlohmann::json j;
// result.value + the parse tree hold the private keys; wipe them on every return path. out already
// has its own copy (out.privateKeysJson) which the caller wipes after use.
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
scrub{result.value, j};
try { try {
const auto j = nlohmann::json::parse(result.value); j = nlohmann::json::parse(result.value);
if (j.is_object() && j.contains("error")) { if (j.is_object() && j.contains("error")) {
out.error = extractJsonError(j); out.error = extractJsonError(j);
return out; return out;
@@ -1270,13 +1365,18 @@ LiteSeedResult LiteWalletController::exportSeed()
out.error = "no wallet is open"; out.error = "no wallet is open";
return out; return out;
} }
const auto result = bridge_->execute("seed", ""); auto result = bridge_->execute("seed", ""); // non-const: result.value holds the seed
if (!result.ok) { if (!result.ok) {
out.error = result.error.empty() ? "seed export failed" : result.error; out.error = result.error.empty() ? "seed export failed" : result.error;
return out; return out;
} }
nlohmann::json j;
// result.value + the parse tree contain the seed phrase; wipe them on every return path (out.seedPhrase
// is extracted separately and returned to the caller).
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
scrub{result.value, j};
try { try {
const auto j = nlohmann::json::parse(result.value); j = nlohmann::json::parse(result.value);
if (j.is_object()) { if (j.is_object()) {
if (j.contains("error")) { if (j.contains("error")) {
out.error = extractJsonError(j); out.error = extractJsonError(j);
@@ -1541,6 +1641,13 @@ bool LiteWalletController::unlockWallet(std::string passphrase)
return ok; return ok;
} }
bool LiteWalletController::takeEncryptionFailureNotice()
{
const bool failed = lifecycleEncryptFailed_;
lifecycleEncryptFailed_ = false;
return failed;
}
bool LiteWalletController::lockWallet() bool LiteWalletController::lockWallet()
{ {
if (!walletOpen_.load() || !bridge_) return false; if (!walletOpen_.load() || !bridge_) return false;
@@ -1589,6 +1696,7 @@ void LiteWalletController::workerLoop()
{ {
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
std::optional<LiteWalletAppRefreshModel> lastModel; // last published full model (to demote on a failed probe)
while (running_.load()) { while (running_.load()) {
if (walletOpen_.load()) { if (walletOpen_.load()) {
const GateDecision decision = evaluateRefreshGate(gate, failStreak); const GateDecision decision = evaluateRefreshGate(gate, failStreak);
@@ -1600,6 +1708,19 @@ void LiteWalletController::workerLoop()
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off // run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
// applies and we don't double the dead-server stall. Next cycle takes the full path. // applies and we don't double the dead-server stall. Next cycle takes the full path.
failStreak += 1; failStreak += 1;
// The probe path never re-derived sync state, so the last 'Synced' model stayed on screen while the
// server was gone. Re-publish it demoted, with the reason.
if (lastModel && running_.load()) {
auto demoted = *lastModel;
if (demoted.hasSyncStatus) {
demoted.sync.complete = false;
demoted.sync.progress = std::min(demoted.sync.progress, 0.999);
}
if (demoted.syncError.empty()) demoted.syncError = "server unreachable";
lastModel = demoted;
std::lock_guard<std::mutex> lock(modelMutex_);
pendingModel_ = std::move(demoted);
}
} else { // DoFull } else { // DoFull
// Sample the change baselines BEFORE the data commands (race rule). // Sample the change baselines BEFORE the data commands (race rule).
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0; const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
@@ -1617,6 +1738,7 @@ void LiteWalletController::workerLoop()
gate.encrypted = m.encrypted; gate.encrypted = m.encrypted;
gate.locked = m.locked; gate.locked = m.locked;
gate.fullAt = std::chrono::steady_clock::now(); gate.fullAt = std::chrono::steady_clock::now();
lastModel = m;
std::lock_guard<std::mutex> lock(modelMutex_); std::lock_guard<std::mutex> lock(modelMutex_);
pendingModel_ = std::move(model); pendingModel_ = std::move(model);
} else { } else {
@@ -1696,7 +1818,10 @@ LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateReq
// copy and wipes it. // copy and wipes it.
if (walletOpen_.load() && !request.passphrase.empty()) { if (walletOpen_.load() && !request.passphrase.empty()) {
const auto enc = encryptWallet(request.passphrase); const auto enc = encryptWallet(request.passphrase);
if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error); if (!enc.ok) {
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
liteLog("wallet created but encryption failed: " + enc.error);
}
} }
secureWipeLiteSecret(request.passphrase); secureWipeLiteSecret(request.passphrase);
return result; return result;
@@ -1726,7 +1851,10 @@ LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreR
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3). // If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
if (walletOpen_.load() && !request.passphrase.empty()) { if (walletOpen_.load() && !request.passphrase.empty()) {
const auto enc = encryptWallet(request.passphrase); const auto enc = encryptWallet(request.passphrase);
if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error); if (!enc.ok) {
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
liteLog("wallet restored but encryption failed: " + enc.error);
}
} }
secureWipeLiteSecret(request.seedPhrase); secureWipeLiteSecret(request.seedPhrase);
secureWipeLiteSecret(request.passphrase); secureWipeLiteSecret(request.passphrase);
+4
View File
@@ -363,6 +363,9 @@ public:
bool lockWallet(); bool lockWallet();
LiteEncryptionResult decryptWallet(std::string passphrase); LiteEncryptionResult decryptWallet(std::string passphrase);
LiteEncryptionStatus encryptionStatus(); LiteEncryptionStatus encryptionStatus();
// True once (then cleared) if the passphrase encryption of a just-created/restored wallet FAILED, leaving it
// unencrypted. Main thread; the App turns it into a visible error instead of a log line.
bool takeEncryptionFailureNotice();
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the // Poll sync status + fetch balance/addresses/transactions, and apply the result into the
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open. // app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
@@ -488,6 +491,7 @@ private:
enum class PendingSecurityOp { None, Encrypt, Unlock }; enum class PendingSecurityOp { None, Encrypt, Unlock };
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None; PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
bool lifecycleEncryptFailed_ = false; // main-thread only; see takeEncryptionFailureNotice()
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op); void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open) void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
+26
View File
@@ -190,6 +190,32 @@ LiteWalletStateMapResult mapLiteWalletRefreshBundle(const LiteWalletRefreshBundl
result.model.sync.complete = bundle.syncStatus.complete; result.model.sync.complete = bundle.syncStatus.complete;
} }
// Per-address spending-key evidence from the backend's per-note 'spendable' flag. Only POSITIVE evidence
// sets spendabilityKnown: a spendable note proves a key; a confirmed, long-mature (well past any anchor
// offset) unspent note that is still not spendable can only mean a view-only address. Fresh/immature
// notes, no notes, or no chain height stay unknown (the controller then keeps its spendable default).
if (result.model.hasAddresses && result.model.hasSpendableOutputs) {
constexpr std::int64_t kMatureDepth = 100;
const std::int64_t tip = result.model.hasSyncStatus
? static_cast<std::int64_t>(result.model.sync.chainHeight) : 0;
for (auto& addr : result.model.addresses) {
if (addr.kind != LiteWalletAppAddressKind::Shielded) continue;
bool anySpendable = false;
bool matureNonSpendable = false;
for (const auto& out : result.model.spendableOutputs) {
if (out.address != addr.address || out.kind != LiteSpendableOutputKind::UnspentNote) continue;
if (out.spent || out.unconfirmedSpent || out.pending) continue;
if (out.spendable) { anySpendable = true; break; }
if (tip > 0 && out.createdInBlock && *out.createdInBlock > 0 &&
tip - *out.createdInBlock >= kMatureDepth) {
matureNonSpendable = true;
}
}
if (anySpendable) { addr.spendabilityKnown = true; addr.spendable = true; }
else if (matureNonSpendable) { addr.spendabilityKnown = true; addr.spendable = false; }
}
}
if (!modelHasAnyMappedField(result.model)) { if (!modelHasAnyMappedField(result.model)) {
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields"); addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
result.error = result.issues.back().message; result.error = result.issues.back().message;
+3
View File
@@ -52,6 +52,7 @@ inline std::atomic<bool> g_liteFakeBlockBalance{false}; // "balance" blocks (a
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance" inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state) inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
inline std::atomic<bool> g_liteFakeEncryptFails{false}; // "encrypt" returns an error and changes no state
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape) inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
@@ -261,6 +262,8 @@ inline char* liteFakeExecute(const char* command, const char* args)
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}. // unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
if (std::strcmp(c, "encrypt") == 0) { if (std::strcmp(c, "encrypt") == 0) {
liteFakeWaitWhile(g_liteFakeBlockEncrypt); liteFakeWaitWhile(g_liteFakeBlockEncrypt);
if (g_liteFakeEncryptFails.load())
return liteFakeDup("{\"result\":\"error\",\"error\":\"encrypt failed\"}");
g_liteFakeEncrypted = true; g_liteFakeLocked = true; g_liteFakeEncrypted = true; g_liteFakeLocked = true;
return liteFakeDup("{\"result\":\"success\"}"); return liteFakeDup("{\"result\":\"success\"}");
} }
+572 -6
View File
@@ -819,6 +819,41 @@ void testClassifyRpcFailure()
EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull)); EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull));
} }
// Lost-reply sends: only a transport failure AFTER the request left (no HTTP status) is ambiguous.
void testSendMaybeInFlight()
{
using namespace dragonx::rpc;
using K = RpcFailureKind;
// Ambiguous: reply timeout, connection cut mid-reply, our own abort - request sent, no HTTP status.
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::ReplyTimeout, "t", CURLE_OPERATION_TIMEDOUT, 0, true)));
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::Other, "t", CURLE_GOT_NOTHING, 0, true)));
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::Aborted, "t", CURLE_ABORTED_BY_CALLBACK, 0, true)));
// Definitive: never reached the daemon, or the daemon/HTTP layer answered.
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::ConnectTimeout, "t", CURLE_OPERATION_TIMEDOUT, 0, false)));
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::NotListening, "t", CURLE_COULDNT_CONNECT, 0, false)));
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::QueueFull, "t", CURLE_OK, 500, true)));
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::ShuttingDown, "t", CURLE_OK, 503, true)));
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::Auth, "t", CURLE_OK, 401, true)));
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::Deferred, "t", CURLE_OK, 0, false)));
EXPECT_FALSE(sendReplyMayBeLost(RpcError(-6, "Insufficient funds")));
EXPECT_FALSE(sendReplyMayBeLost(RpcError(-5, "Invalid address")));
EXPECT_FALSE(sendReplyMayBeLost(std::runtime_error("plain")));
// Lite text heuristic.
EXPECT_TRUE(liteSendErrorMayBeInFlight("status: Unknown, message: \"transport error\""));
EXPECT_TRUE(liteSendErrorMayBeInFlight("operation timed out"));
EXPECT_FALSE(liteSendErrorMayBeInFlight("error trying to connect: tcp connect error"));
EXPECT_FALSE(liteSendErrorMayBeInFlight("Error: SendResponse { error_code: 1, error_message: \"bad-txns\" }"));
EXPECT_FALSE(liteSendErrorMayBeInFlight("Insufficient verified funds"));
EXPECT_FALSE(liteSendErrorMayBeInFlight("Wallet is locked"));
// Marker round trip + idempotence.
const std::string marked = markSendMaybeInFlight("RPC request failed: Timeout");
EXPECT_TRUE(isSendMaybeInFlight(marked));
EXPECT_FALSE(isSendMaybeInFlight("RPC request failed: Timeout"));
EXPECT_EQ(markSendMaybeInFlight(marked), marked);
EXPECT_EQ(stripSendMaybeInFlight(marked), std::string("RPC request failed: Timeout"));
EXPECT_EQ(stripSendMaybeInFlight("plain"), std::string("plain"));
}
void testSecureWipeAndRemove() void testSecureWipeAndRemove()
{ {
namespace fs = std::filesystem; namespace fs = std::filesystem;
@@ -1033,7 +1068,17 @@ void testPaymentUri()
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1"); auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
EXPECT_FALSE(invalid.valid); EXPECT_FALSE(invalid.valid);
EXPECT_EQ(invalid.error, std::string("Invalid negative amount")); EXPECT_EQ(invalid.error, std::string("Invalid amount")); // strict parse rejects '-' up front now
// Crafted amounts that std::stod would have accepted are now rejected before the Send form.
for (const char* bad : {"nan", "inf", "1e3", "0x10", "1.5abc", "0.000000001", "21000001", ""}) {
auto r = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=" + bad);
EXPECT_FALSE(r.valid);
}
for (const char* ok : {"1", "0.00000001", "21000000", "1.5"}) {
auto r = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=" + ok);
EXPECT_TRUE(r.valid);
}
} }
void testWalletFileProbe() void testWalletFileProbe()
@@ -1198,10 +1243,35 @@ void testWalletFileProbe()
void testAmountFormatting() void testAmountFormatting()
{ {
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000")); using namespace dragonx::util;
EXPECT_EQ(dragonx::util::formatAmountFixed(0.1), std::string("0.10000000")); EXPECT_EQ(formatAmountFixed(1.0), std::string("1.00000000"));
EXPECT_EQ(dragonx::util::formatAmountFixed(0.000000019), std::string("0.00000002")); EXPECT_EQ(formatAmountFixed(0.1), std::string("0.10000000"));
EXPECT_EQ(dragonx::util::formatAmountFixed(12.3456, 2), std::string("12.35")); EXPECT_EQ(formatAmountFixed(0.000000019), std::string("0.00000002"));
EXPECT_EQ(formatAmountFixed(12.3456, 2), std::string("12.35"));
// No scientific notation / comma decimals for tiny or large amounts (the z_sendmany string).
EXPECT_EQ(formatAmountFixed(1e-8), std::string("0.00000001"));
EXPECT_EQ(formatAmountFixed(21000000.0), std::string("21000000.00000000"));
EXPECT_TRUE(formatAmountFixed(0.00000001).find('e') == std::string::npos);
// toSats / canAfford — the exact-max send that double addition wrongly blocked (fund-correctness audit).
EXPECT_EQ(toSats(63.0410039), 6304100390LL);
EXPECT_TRUE(canAfford(63.0409039, 0.0001, 63.0410039)); // true total == available; was false in doubles
EXPECT_TRUE(canAfford(44.83756352, 0.0001, 44.83766352));
EXPECT_TRUE(canAfford(0.4999, 0.0001, 0.5));
EXPECT_TRUE(canAfford(0.5, 0.0, 0.5)); // exact, no fee
EXPECT_TRUE(!canAfford(0.5, 0.0001, 0.5)); // amount+fee over available
EXPECT_TRUE(!canAfford(0.50001, 0.0, 0.5));
// coinsToZatoshis — rejects the values that would wrap/round wrong on the lite send path.
std::uint64_t z = 0;
EXPECT_TRUE(coinsToZatoshis(1.0, z) && z == 100000000ULL);
EXPECT_TRUE(coinsToZatoshis(0.00000001, z) && z == 1ULL);
EXPECT_TRUE(!coinsToZatoshis(0.0, z));
EXPECT_TRUE(!coinsToZatoshis(-1.0, z));
EXPECT_TRUE(!coinsToZatoshis(0.000000004, z)); // sub-zatoshi rounds to 0 → rejected
EXPECT_TRUE(!coinsToZatoshis(std::numeric_limits<double>::quiet_NaN(), z));
EXPECT_TRUE(!coinsToZatoshis(std::numeric_limits<double>::infinity(), z));
EXPECT_TRUE(!coinsToZatoshis(21000001.0, z)); // over supply cap
} }
void testSpendableFiltering() void testSpendableFiltering()
@@ -2940,6 +3010,88 @@ void testNetworkRefreshResultModels()
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed")); EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1)); EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
EXPECT_EQ(confirmedBlock, 222); EXPECT_EQ(confirmedBlock, 222);
// Tier-B: per-address balances accumulate in integer satoshis (no IEEE drift over many notes).
{
std::vector<dragonx::AddressInfo> addrs(1);
addrs[0].address = "zs1drift";
json notes = json::array();
for (int i = 0; i < 10; ++i)
notes.push_back(json{{"address", "zs1drift"}, {"amount", 0.1}, {"confirmations", 3}});
Refresh::applyShieldedBalancesFromUnspent(addrs, notes);
EXPECT_TRUE(addrs[0].balance == 1.0); // exact; double summation would give 0.9999999999999999
EXPECT_TRUE(addrs[0].spendableBalance == 1.0);
}
// Tier-B: readBalanceString rejects anything but a plain decimal (std::stod accepted trailing garbage).
{
auto bad = Refresh::parseCoreRefreshResult(
json{{"private", "1.5abc"}, {"transparent", ""}, {"total", "nan"}}, json(), true, json::object(), false);
EXPECT_TRUE(!bad.shieldedBalance.has_value());
EXPECT_TRUE(!bad.transparentBalance.has_value());
EXPECT_TRUE(!bad.totalBalance.has_value());
auto bad2 = Refresh::parseCoreRefreshResult(
json{{"private", " 1.0"}, {"transparent", "0x10"}, {"total", "1e3"}}, json(), true, json::object(), false);
EXPECT_TRUE(!bad2.shieldedBalance.has_value());
EXPECT_TRUE(!bad2.transparentBalance.has_value());
EXPECT_TRUE(!bad2.totalBalance.has_value());
auto good = Refresh::parseCoreRefreshResult(
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, json(), true, json::object(), false);
EXPECT_NEAR(*good.shieldedBalance, 1.25, 1e-9);
EXPECT_NEAR(*good.totalBalance, 1.75, 1e-9);
}
}
// Resilience mediums: the send tip-gate, cached view-tx confirmations advancing, and the dropped /
// stale-pending display predicates.
void testResilienceMediums()
{
{ // 1) send tip gate
dragonx::SyncInfo s;
s.blocks = 100; s.headers = 100; s.syncing = false; s.tip_known = false;
EXPECT_FALSE(s.sendTipReady(false)); // peerless / fresh connect: tip unknown
EXPECT_TRUE(s.sendTipReady(true)); // lite exempt
s.tip_known = true;
EXPECT_TRUE(s.sendTipReady(false));
s.syncing = true;
EXPECT_FALSE(s.sendTipReady(false));
s.syncing = false; s.blocks = 0;
EXPECT_FALSE(s.sendTipReady(false));
}
{ // 2) cached view entry confirmations advance (never backwards; pending not extrapolated)
using R = dragonx::services::NetworkRefreshService;
R::TransactionViewCacheEntry e;
e.from_address = "zs-from"; e.confirmations = 3; e.timestamp = 500; e.observed_height = 100;
R::TransactionViewCacheEntry::Output o; o.address = "zs-dest"; o.value = 1.0; e.outgoing_outputs.push_back(o);
std::vector<dragonx::TransactionInfo> rows;
R::appendViewTransactionOutputs(rows, "tx1", e, 105);
EXPECT_EQ(rows.size(), static_cast<size_t>(1));
EXPECT_EQ(rows[0].confirmations, 8); // 3 + (105-100)
std::vector<dragonx::TransactionInfo> rows2;
R::appendViewTransactionOutputs(rows2, "tx1", e); // no tip -> unchanged
EXPECT_EQ(rows2[0].confirmations, 3);
std::vector<dragonx::TransactionInfo> rows3;
R::appendViewTransactionOutputs(rows3, "tx1", e, 90); // reorg/older tip: never backwards
EXPECT_EQ(rows3[0].confirmations, 3);
e.confirmations = 0; // pending entries are not extrapolated
std::vector<dragonx::TransactionInfo> rows4;
R::appendViewTransactionOutputs(rows4, "tx1", e, 110);
EXPECT_EQ(rows4[0].confirmations, 0);
}
{ // 3) dropped / stale-pending predicates
dragonx::TransactionInfo t;
t.confirmations = -1;
EXPECT_TRUE(t.isConflicted());
EXPECT_FALSE(t.isStalePending(100000));
t.confirmations = 0; t.timestamp = 1000;
EXPECT_FALSE(t.isConflicted());
EXPECT_FALSE(t.isStalePending(1000 + 3600)); // exactly the limit: still pending
EXPECT_TRUE(t.isStalePending(1000 + 3601));
t.timestamp = 0;
EXPECT_FALSE(t.isStalePending(999999)); // unknown time never flagged
t.timestamp = 1000; t.confirmations = 1;
EXPECT_FALSE(t.isStalePending(999999)); // mined
}
} }
void testOperationStatusPollParsing() void testOperationStatusPollParsing()
@@ -3656,6 +3808,33 @@ void testNodeStatusBanner()
NodeBannerInputs open; open.lite = true; open.connected = true; NodeBannerInputs open; open.lite = true; open.connected = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(open).show); EXPECT_TRUE(!evaluateNodeStatusBanner(open).show);
} }
// Legacy-funds-stuck: a connected+synced degraded wallet with stuck transparent funds offers recovery.
{
NodeBannerInputs in;
in.connected = true; in.tip_known = true; in.blocks = 100;
in.legacy_funds_stuck = true;
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.show);
EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
EXPECT_TRUE(s.reason == NodeBannerReason::LegacyFundsStuck);
EXPECT_TRUE(s.action == NodeBannerAction::RecoverLegacyFunds);
}
// A functional wallet (legacy_funds_stuck false — e.g. non-mnemonic but can still shield) is NEVER flagged.
{
NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100;
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
}
// NoPeers takes priority: can't recover before the node can see the chain.
{
NodeBannerInputs in;
in.connected = true; in.tip_known = false; in.blocks = 100; in.legacy_funds_stuck = true;
EXPECT_TRUE(evaluateNodeStatusBanner(in).reason == NodeBannerReason::NoPeers);
}
// Lite never shows it (the lite branch returns first; App also gates the input to full-node).
{
NodeBannerInputs in; in.lite = true; in.connected = true; in.legacy_funds_stuck = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
}
} }
void testStalenessBadge() void testStalenessBadge()
@@ -3776,6 +3955,24 @@ void testSeedMigrationResume()
// ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked. // ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked.
EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok
EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked
// FIX E: reorg-safe adopt depth + the final pre-stop re-verification verdict.
EXPECT_EQ(dragonx::kSweepAdoptMinConfs, 10);
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(-1));
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(0));
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(1));
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(9));
EXPECT_TRUE(dragonx::sweepConfsAreReorgSafe(10));
const double kDust = 0.0001;
EXPECT_TRUE(dragonx::adoptReverifyPasses(true, 10, 0.0, false, kDust)); // deep + empty + synced
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 9, 0.0, false, kDust)); // 1-block-reorg window
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, -1, 0.0, false, kDust)); // conflicted
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, 0.0, true, kDust)); // syncing -> balance untrusted
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, 5.0, false, kDust)); // funds remain / unconfirmed
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, -1.0, false, kDust)); // balance unknown
EXPECT_TRUE(dragonx::adoptReverifyPasses(false, 0, 0.0, false, kDust)); // genuinely empty, no sweep
EXPECT_FALSE(dragonx::adoptReverifyPasses(false, 0, 2.0, false, kDust)); // no-funds path but unconfirmed funds
EXPECT_FALSE(dragonx::adoptReverifyPasses(false, 0, 0.0, true, kDust)); // no-funds path while syncing
} }
void testLoggerFileSink() void testLoggerFileSink()
@@ -8985,6 +9182,26 @@ void testExplorerBlockCache()
EXPECT_TRUE(cache.storeBlock(block12)); EXPECT_TRUE(cache.storeBlock(block12));
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5); EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
{ // loadRangeCached memoizes on revision(): reads don't bump; a store invalidates; updates are seen.
// Isolated on its own cache so storing blocks here can't perturb the shared `cache`'s count assertions.
fs::path mdir = makeTempDir();
ExplorerBlockCache mcache((mdir / "memo.sqlite").string(), (mdir / "memo.json").string());
EXPECT_TRUE(mcache.ensureOpen());
ExplorerBlockSummary m12; m12.height = 12; m12.hash = "m-12"; m12.tx_count = 5; m12.size = 1200; m12.time = 5200; m12.difficulty = 1.4;
EXPECT_TRUE(mcache.storeBlock(m12));
const std::uint64_t rev0 = mcache.revision();
EXPECT_EQ(mcache.loadRangeCached(12, 12).size(), static_cast<size_t>(1));
EXPECT_EQ(mcache.revision(), rev0); // reading does not bump
ExplorerBlockSummary m13 = m12; m13.height = 13; m13.hash = "m-13";
EXPECT_TRUE(mcache.storeBlock(m13));
EXPECT_TRUE(mcache.revision() != rev0); // a store invalidates the memo
EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast<size_t>(2));
EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast<size_t>(2)); // memo hit, same answer
m13.tx_count = 99;
EXPECT_TRUE(mcache.storeBlock(m13));
EXPECT_EQ(mcache.loadRangeCached(13, 13).at(13).tx_count, 99); // sees the update, not a stale memo
}
{ {
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string()); ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
EXPECT_TRUE(reopened.ensureOpen()); EXPECT_TRUE(reopened.ensureOpen());
@@ -10886,6 +11103,66 @@ void testLiteWalletControllerCreateEncryptsWithPassphrase()
EXPECT_FALSE(s2.locked); EXPECT_FALSE(s2.locked);
} }
// FIX C: (e) a create-time passphrase whose encryption FAILS is surfaced once via takeEncryptionFailureNotice() and
// leaves the wallet unencrypted; (d) the background scan save is skipped while an encrypted wallet is unlocked (the
// backend save would re-lock it) and resumes once locked.
void testLiteWalletControllerLockUnlockParity()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
{ // (e)
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
dragonx::test::g_liteFakeEncryptFails = true;
auto c = std::make_unique<LiteWalletController>(
caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2hunter2";
(void)c->createWallet(req);
EXPECT_TRUE(c->takeEncryptionFailureNotice());
EXPECT_FALSE(c->takeEncryptionFailureNotice()); // one-shot
EXPECT_FALSE(c->encryptionStatus().encrypted);
dragonx::test::g_liteFakeEncryptFails = false;
}
{ // (e) success path raises no notice
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
auto c = std::make_unique<LiteWalletController>(
caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2hunter2";
(void)c->createWallet(req);
EXPECT_FALSE(c->takeEncryptionFailureNotice());
EXPECT_TRUE(c->encryptionStatus().encrypted);
}
{ // (d)
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
auto c = std::make_unique<LiteWalletController>(
caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 600 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(c->encryptWallet("pw12345678").ok);
EXPECT_TRUE(c->unlockWallet("pw12345678"));
EXPECT_FALSE(c->encryptionStatus().locked);
const long saves0 = dragonx::test::g_liteFakeSaveCount.load();
EXPECT_TRUE(c->startRescan());
for (int i = 0; i < 600 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(c->syncComplete());
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), saves0); // no background save while unlocked
EXPECT_FALSE(c->encryptionStatus().locked); // so the unlock survived the scan
EXPECT_TRUE(c->lockWallet());
EXPECT_TRUE(c->startRescan());
for (int i = 0; i < 600 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() > saves0); // saves resume once locked
}
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
}
void testLiteWalletControllerAsyncLifecycleFailover() void testLiteWalletControllerAsyncLifecycleFailover()
{ {
using namespace dragonx::wallet; using namespace dragonx::wallet;
@@ -11216,6 +11493,71 @@ void testSameTransactionList()
} }
} }
// tx_structure_revision: a pure confirmation-count bump moves tx_revision only; any other change, a pending<->confirmed
// flip, or a size change moves both; legacy writers (replace/touch) always move both.
void testTxStructureRevision()
{
auto row = [](const char* txid, const char* type, double amount, std::int64_t ts, int conf) {
dragonx::TransactionInfo t;
t.txid = txid; t.type = type; t.amount = amount; t.timestamp = ts; t.confirmations = conf;
t.address = "zs-a"; t.from_address = ""; t.memo = "m";
return t;
};
const std::vector<dragonx::TransactionInfo> base = {
row("tx-a", "receive", 1.5, 1000, 12),
row("tx-b", "send", -0.25, 1001, 3),
row("tx-c", "mined", 3.0, 1002, 120),
};
EXPECT_TRUE(dragonx::sameTransactionLayout(base[0], base[0]));
{ // confirmation count only: tx_revision +1, structure unchanged, new count stored
dragonx::WalletState state;
state.replaceTransactions(std::vector<dragonx::TransactionInfo>(base));
const std::uint64_t rev = state.tx_revision, srev = state.tx_structure_revision;
auto next = base;
next[0].confirmations += 1;
next[1].confirmations += 5;
EXPECT_TRUE(state.setTransactionsIfChanged(std::move(next)));
EXPECT_EQ(state.tx_revision, rev + 1);
EXPECT_EQ(state.tx_structure_revision, srev);
EXPECT_EQ(state.transactions[1].confirmations, 8);
}
{ // pending (0) -> confirmed (1) flips the sort state: structural
dragonx::WalletState state;
auto pend = base;
pend[1].confirmations = 0;
state.replaceTransactions(std::move(pend));
const std::uint64_t rev = state.tx_revision, srev = state.tx_structure_revision;
EXPECT_TRUE(state.setTransactionsIfChanged(std::vector<dragonx::TransactionInfo>(base)));
EXPECT_EQ(state.tx_revision, rev + 1);
EXPECT_EQ(state.tx_structure_revision, srev + 1);
}
{ // a non-confirmation field change, and a size change, are structural
dragonx::WalletState state;
state.replaceTransactions(std::vector<dragonx::TransactionInfo>(base));
std::uint64_t srev = state.tx_structure_revision;
auto next = base;
next[2].memo = "changed";
EXPECT_TRUE(state.setTransactionsIfChanged(std::move(next)));
EXPECT_EQ(state.tx_structure_revision, srev + 1);
srev = state.tx_structure_revision;
auto shorter = base;
shorter.pop_back();
EXPECT_TRUE(state.setTransactionsIfChanged(std::move(shorter)));
EXPECT_EQ(state.tx_structure_revision, srev + 1);
}
{ // identical list: neither counter moves; legacy touch moves both
dragonx::WalletState state;
state.replaceTransactions(std::vector<dragonx::TransactionInfo>(base));
const std::uint64_t rev = state.tx_revision, srev = state.tx_structure_revision;
EXPECT_FALSE(state.setTransactionsIfChanged(std::vector<dragonx::TransactionInfo>(base)));
EXPECT_EQ(state.tx_revision, rev);
EXPECT_EQ(state.tx_structure_revision, srev);
state.touchTransactions();
EXPECT_EQ(state.tx_revision, rev + 1);
EXPECT_EQ(state.tx_structure_revision, srev + 1);
}
}
void testHistoryTxidGroups() void testHistoryTxidGroups()
{ {
using dragonx::ui::history::groupByTxid; using dragonx::ui::history::groupByTxid;
@@ -11509,8 +11851,9 @@ void testLiteRefreshModelAppliesToWalletState()
bundle.hasBalance = true; bundle.hasBalance = true;
bundle.balance.shieldedBalance = 200000000; // 2 DRGX bundle.balance.shieldedBalance = 200000000; // 2 DRGX
bundle.balance.spendableShieldedBalance = 200000000; // synced: all of it verified/spendable (required field)
bundle.balance.transparentBalance = 100000000; // 1 DRGX bundle.balance.transparentBalance = 100000000; // 1 DRGX
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX (mempool-incoming)
bundle.hasAddresses = true; bundle.hasAddresses = true;
bundle.addresses.zAddresses = {"zs1shielded"}; bundle.addresses.zAddresses = {"zs1shielded"};
@@ -11666,6 +12009,120 @@ void testLiteSendShowsRecipientFromOutgoing()
EXPECT_EQ(state.transactions[0].memo, std::string("thanks")); EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
} }
// Lite sends must carry a NEGATIVE amount (full-node debit semantics) so History/Details/CSV render a
// red "-" instead of a green "+"; receives stay positive.
void testLiteSendAmountIsNegative()
{
using namespace dragonx::wallet;
LiteWalletRefreshBundle bundle;
bundle.complete = true;
bundle.hasTransactions = true;
LiteTransactionRecord snd;
snd.txid = "s"; snd.datetime = 1700000000; snd.direction = LiteTransactionDirection::Send;
LiteTransactionOutput out; out.address = "zs1r"; out.value = 75000000;
snd.outgoingMetadata.push_back(out);
snd.amount = 75000000;
LiteTransactionRecord rcv;
rcv.txid = "r"; rcv.datetime = 1700000100; rcv.direction = LiteTransactionDirection::Receive;
rcv.address = "zs1me"; rcv.amount = 50000000;
bundle.transactions.transactions.push_back(snd);
bundle.transactions.transactions.push_back(rcv);
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(bundle).model, state);
EXPECT_EQ(static_cast<int>(state.transactions.size()), 2);
for (const auto& t : state.transactions) {
if (t.txid == "s") { EXPECT_EQ(t.type, std::string("send")); EXPECT_NEAR(t.amount, -0.75, 1e-9); }
else { EXPECT_EQ(t.type, std::string("receive")); EXPECT_NEAR(t.amount, 0.5, 1e-9); }
}
}
// A send with empty outgoing_metadata (seed restore / other device) falls back to |record.amount|.
void testLiteForeignSendFallsBackToRecordAmount()
{
using namespace dragonx::wallet;
const auto parsed = parseLiteTransactionsResponse(std::string(
R"([{"txid":"f","datetime":1700000000,"outgoing_metadata":[],"amount":-123456789}])"));
EXPECT_TRUE(parsed.ok);
EXPECT_EQ(static_cast<int>(parsed.transactions.transactions.size()), 1);
EXPECT_TRUE(parsed.transactions.transactions[0].direction == LiteTransactionDirection::Send);
EXPECT_EQ(parsed.transactions.transactions[0].amount, static_cast<std::int64_t>(123456789));
// Explicit outgoing_metadata still wins over the record amount.
const auto own = parseLiteTransactionsResponse(std::string(
R"([{"txid":"o","datetime":1,"outgoing_metadata":[{"address":"zs1","value":5}],"amount":-99}])"));
EXPECT_TRUE(own.ok);
EXPECT_EQ(own.transactions.transactions[0].amount, static_cast<std::int64_t>(5));
}
// Tier-B: the parser range-checks amounts (reject negative / over-supply) and fails with a reason instead
// of a silent bare `return false` that rejected the whole list with an empty error.
void testLiteParserAmountRangeChecks()
{
using namespace dragonx::wallet;
// Negative receive amount rejected with a reason.
auto neg = parseLiteTransactionsResponse(std::string(R"([{"txid":"a","datetime":1,"address":"zs1","amount":-5}])"));
EXPECT_TRUE(!neg.ok);
EXPECT_TRUE(!neg.errorMessage.empty());
// Absurd receive amount (> 21M DRGX) rejected.
auto big = parseLiteTransactionsResponse(std::string(R"([{"txid":"b","datetime":1,"address":"zs1","amount":2100000000000001}])"));
EXPECT_TRUE(!big.ok);
// Max supply itself is accepted.
auto edge = parseLiteTransactionsResponse(std::string(R"([{"txid":"c","datetime":1,"address":"zs1","amount":2100000000000000}])"));
EXPECT_TRUE(edge.ok);
// A negative outgoing output now fails with a message instead of a silent reject.
auto sneg = parseLiteTransactionsResponse(std::string(R"([{"txid":"d","datetime":1,"outgoing_metadata":[{"address":"zs1","value":-1}]}])"));
EXPECT_TRUE(!sneg.ok);
EXPECT_TRUE(!sneg.errorMessage.empty());
// Sum of outputs over the cap is rejected.
auto sover = parseLiteTransactionsResponse(std::string(R"([{"txid":"e","datetime":1,"outgoing_metadata":[{"address":"a","value":2000000000000000},{"address":"b","value":2000000000000000}]}])"));
EXPECT_TRUE(!sover.ok);
EXPECT_TRUE(!sover.errorMessage.empty());
// Absurd note value rejected.
auto note = parseLiteNotesResponse(std::string(R"({"unspent_notes":[{"address":"zs1","created_in_txid":"t","value":9000000000000000}],"utxos":[],"pending_notes":[],"pending_utxos":[]})"));
EXPECT_TRUE(!note.ok);
}
// Lite-parity mediums: unconfirmedBalance uses the full-node pending semantics (total - spendable, or the
// backend mempool-incoming, whichever is larger), and a t-UTXO spent by a pending tx is excluded from the
// SPENDABLE transparent total (never over-reports spendable).
void testLiteUnconfirmedAndTransparentSpendable()
{
using namespace dragonx::wallet;
{
LiteWalletAppRefreshModel m;
m.hasBalance = true;
m.balance.shieldedZatoshis = 500000000; m.balance.spendableShieldedZatoshis = 300000000;
m.balance.transparentZatoshis = 100000000; m.balance.totalZatoshis = 600000000;
m.balance.unconfirmedZatoshis = 50000000;
dragonx::WalletState s;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.unconfirmedBalance, 2.0, 1e-9);
EXPECT_NEAR(s.totalBalance - s.spendableTotalBalance, 2.0, 1e-9);
m.balance.unconfirmedZatoshis = 900000000;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.unconfirmedBalance, 9.0, 1e-9);
}
{
LiteWalletAppRefreshModel m;
m.hasBalance = true;
m.balance.transparentZatoshis = 300000000; m.balance.totalZatoshis = 300000000;
m.hasSpendableOutputs = true;
LiteWalletAppSpendableOutputModel a; a.kind = LiteSpendableOutputKind::Utxo; a.address = "R1"; a.valueZatoshis = 100000000; a.unconfirmedSpent = true;
LiteWalletAppSpendableOutputModel b = a; b.unconfirmedSpent = false; b.valueZatoshis = 200000000;
m.spendableOutputs = {a, b};
dragonx::WalletState s;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.transparentBalance, 3.0, 1e-9);
EXPECT_NEAR(s.spendableTransparentBalance, 2.0, 1e-9);
EXPECT_NEAR(s.spendableTotalBalance, 2.0, 1e-9);
m.hasSpendableOutputs = false;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.spendableTransparentBalance, 3.0, 1e-9);
m.hasSpendableOutputs = true; m.spendableOutputs[0].valueZatoshis = 900000000;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.spendableTransparentBalance, 0.0, 1e-9);
}
}
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but // A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known // !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
// values so a correct total isn't accompanied by a misleading all-zero breakdown. // values so a correct total isn't accompanied by a misleading all-zero breakdown.
@@ -11702,6 +12159,67 @@ void testLitePartialRefreshKeepsPriorAddressBalances()
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
} }
// Lite per-address spendable: the backend's per-note 'spendable' flag drives spendableBalance (balance stays
// gross), a mature non-spendable note marks the address view-only, and a partial refresh keeps both.
void testLitePerAddressSpendableAndViewOnly()
{
using namespace dragonx::wallet;
const auto parsed = parseLiteNotesResponse(std::string(R"({
"unspent_notes":[
{"address":"zsKey","created_in_txid":"a","created_in_block":100,"value":100000000,"spendable":true},
{"address":"zsKey","created_in_txid":"b","created_in_block":995,"value":50000000,"spendable":false},
{"address":"zsView","created_in_txid":"c","created_in_block":100,"value":200000000,"spendable":false},
{"address":"zsFresh","created_in_txid":"d","created_in_block":999,"value":30000000,"spendable":false}],
"utxos":[{"address":"t1x","created_in_txid":"e","created_in_block":100,"value":70000000}],
"pending_notes":[],"pending_utxos":[]})"));
EXPECT_TRUE(parsed.ok);
if (!parsed.ok) return;
EXPECT_TRUE(parsed.notes.unspentNotes[0].spendable);
EXPECT_FALSE(parsed.notes.unspentNotes[1].spendable);
EXPECT_TRUE(parsed.notes.utxos[0].spendable); // no flag on utxos -> confirmed/unspent rule
LiteWalletRefreshBundle bundle;
bundle.complete = true;
bundle.hasAddresses = true;
bundle.addresses.zAddresses = {"zsKey", "zsView", "zsFresh"};
bundle.addresses.tAddresses = {"t1x"};
bundle.hasNotes = true;
bundle.notes = parsed.notes;
bundle.hasSyncStatus = true;
bundle.syncStatus.syncedBlocks = 1000;
bundle.syncStatus.totalBlocks = 1000;
bundle.syncStatus.complete = true;
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(bundle).model, state);
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 3);
if (state.z_addresses.size() != 3) return;
const auto& key = state.z_addresses[0];
const auto& view = state.z_addresses[1];
const auto& fresh = state.z_addresses[2];
EXPECT_NEAR(key.balance, 1.5, 1e-9); // gross
EXPECT_NEAR(key.spendableBalance, 1.0, 1e-9); // only the anchored note
EXPECT_TRUE(key.has_spending_key);
EXPECT_NEAR(view.balance, 2.0, 1e-9);
EXPECT_NEAR(view.spendableBalance, 0.0, 1e-9);
EXPECT_FALSE(view.has_spending_key); // mature + non-spendable => view-only
EXPECT_FALSE(view.isSpendable());
EXPECT_TRUE(fresh.has_spending_key); // immature only => unknown, not mislabelled
EXPECT_NEAR(fresh.spendableBalance, 0.0, 1e-9);
EXPECT_NEAR(state.t_addresses[0].spendableBalance, 0.7, 1e-9);
// Partial refresh (notes failed): keep last-known balance, spendable and view-only verdict.
LiteWalletRefreshBundle partial;
partial.complete = true;
partial.hasAddresses = true;
partial.addresses.zAddresses = {"zsKey", "zsView", "zsFresh"};
partial.addresses.tAddresses = {"t1x"};
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
EXPECT_NEAR(state.z_addresses[0].spendableBalance, 1.0, 1e-9);
EXPECT_FALSE(state.z_addresses[1].has_spending_key);
}
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState() // M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState. // pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
void testLiteWalletControllerRefreshPopulatesState() void testLiteWalletControllerRefreshPopulatesState()
@@ -11766,6 +12284,24 @@ void testLiteSyncStatusParserRealShapes()
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}")); const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
EXPECT_FALSE(p.ok); EXPECT_FALSE(p.ok);
} }
// Missing or unrecognised "syncing" must NOT default to fully synced.
{
const auto p = parseLiteSyncStatusResponse(std::string("{}"));
EXPECT_TRUE(p.ok);
EXPECT_FALSE(p.syncStatus.complete);
EXPECT_TRUE(p.syncStatus.indeterminate);
EXPECT_NEAR(p.syncStatus.progress, 0.0, 1e-9);
const auto q = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"maybe\"}"));
EXPECT_TRUE(q.ok);
EXPECT_FALSE(q.syncStatus.complete);
EXPECT_TRUE(q.syncStatus.indeterminate);
}
// Explicit idle is still caught-up and determinate.
{
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}"));
EXPECT_TRUE(p.syncStatus.complete);
EXPECT_FALSE(p.syncStatus.indeterminate);
}
} }
// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded) // Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
@@ -11976,6 +12512,26 @@ void testLiteClientBridgeMutationEpoch()
} }
} }
// A retired bridge is shut down; every later backend call must be refused (litelib_* would otherwise run
// on the process-global client, which may belong to a NEW controller by then). shutdown() is idempotent.
void testLiteClientBridgeRefusesAfterShutdown()
{
using namespace dragonx::wallet;
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
EXPECT_FALSE(bridge.isShutdown());
EXPECT_TRUE(bridge.execute("balance", "").ok);
bridge.shutdown();
bridge.shutdown(); // idempotent (exchange): second call is a no-op
EXPECT_TRUE(bridge.isShutdown());
const auto before = bridge.mutationEpoch();
const auto s = bridge.execute("send", "[]");
EXPECT_FALSE(s.ok);
EXPECT_FALSE(bridge.execute("save", "").ok);
EXPECT_FALSE(bridge.initializeExisting(false, "https://x").ok);
EXPECT_FALSE(bridge.initializeNew(false, "https://x").ok);
EXPECT_TRUE(bridge.mutationEpoch() == before); // a refused send never reached the backend or bumped the epoch
}
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the // M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined). // controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
void testLiteWalletControllerShutdownDoesNotHangDuringSync() void testLiteWalletControllerShutdownDoesNotHangDuringSync()
@@ -19100,6 +19656,7 @@ int main()
{ {
testConnectionConfig(); testConnectionConfig();
testClassifyRpcFailure(); testClassifyRpcFailure();
testSendMaybeInFlight();
testClassifyUnlockFailure(); testClassifyUnlockFailure();
testHttpSinkAccept(); testHttpSinkAccept();
testReadFileExact(); testReadFileExact();
@@ -19311,18 +19868,25 @@ int main()
testLiteWalletControllerM5Persistence(); testLiteWalletControllerM5Persistence();
testLiteWalletControllerEncryption(); testLiteWalletControllerEncryption();
testLiteWalletControllerCreateEncryptsWithPassphrase(); testLiteWalletControllerCreateEncryptsWithPassphrase();
testLiteWalletControllerLockUnlockParity();
testLiteWalletControllerAsyncAppliesPassphrase(); testLiteWalletControllerAsyncAppliesPassphrase();
testLiteNewPassphraseRules(); testLiteNewPassphraseRules();
testLiteChainNameMigration(); testLiteChainNameMigration();
testLiteUnconfirmedAndTransparentSpendable();
testLiteRefreshModelAppliesToWalletState(); testLiteRefreshModelAppliesToWalletState();
testLiteTransactionsAreNewestFirst(); testLiteTransactionsAreNewestFirst();
testSameTransactionList(); testSameTransactionList();
testTxStructureRevision();
testHistoryTxidGroups(); testHistoryTxidGroups();
testHistoryFilterInputs(); testHistoryFilterInputs();
testWalletStateTxRevision(); testWalletStateTxRevision();
testTransactionRefreshRevision(); testTransactionRefreshRevision();
testLiteSendShowsRecipientFromOutgoing(); testLiteSendShowsRecipientFromOutgoing();
testLiteSendAmountIsNegative();
testLiteForeignSendFallsBackToRecordAmount();
testLiteParserAmountRangeChecks();
testLitePartialRefreshKeepsPriorAddressBalances(); testLitePartialRefreshKeepsPriorAddressBalances();
testLitePerAddressSpendableAndViewOnly();
testLitePerAddressBalances(); testLitePerAddressBalances();
testLiteWalletControllerNewAddress(); testLiteWalletControllerNewAddress();
testLiteSyncStatusParserRealShapes(); testLiteSyncStatusParserRealShapes();
@@ -19331,6 +19895,7 @@ int main()
testLiteWalletControllerWorkerProducesModel(); testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerWorkerChangeGate(); testLiteWalletControllerWorkerChangeGate();
testLiteClientBridgeMutationEpoch(); testLiteClientBridgeMutationEpoch();
testLiteClientBridgeRefusesAfterShutdown();
testLiteWalletControllerShutdownDoesNotHangDuringSync(); testLiteWalletControllerShutdownDoesNotHangDuringSync();
testSerialJobLane(); testSerialJobLane();
testLiteGatewayRefreshPolicyHelpers(); testLiteGatewayRefreshPolicyHelpers();
@@ -19425,6 +19990,7 @@ int main()
testResolveExchangeSelection(); testResolveExchangeSelection();
testTransactionCsv(); testTransactionCsv();
testEarningsSeries(); testEarningsSeries();
testResilienceMediums();
if (g_failures != 0) { if (g_failures != 0) {
std::cerr << g_failures << " test assertion(s) failed\n"; std::cerr << g_failures << " test assertion(s) failed\n";
@@ -2023,9 +2023,19 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
} }
// unconfirmed_spent is left as it is: a pending send can still be mined on the // unconfirmed_spent is left as it is: a pending send can still be mined on the
// new branch. Only a block that spends the note (scan_block) or a reload clears // new branch. Only a block that spends the note (scan_block), the send expiring
// it, the same as for a send that expires unmined. // unmined (cleanup_mempool) or a reload clears it.
}) });
// Same for transparent utxos: one whose spender was reorged out is unspent again
// (else it stays spent=Some(removed txid) and drops out of the balance until a rescan).
wtx.utxos.iter_mut()
.for_each(|utxo| {
if utxo.spent.is_some() && txids_to_remove.contains(&utxo.spent.unwrap()) {
utxo.spent = None;
}
// unconfirmed_spent is left as it is, for the same reason as on the notes.
});
}) })
} }
@@ -3007,16 +3017,47 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
// 2. The Tx has been added to the wallet via a mined block // 2. The Tx has been added to the wallet via a mined block
pub fn cleanup_mempool(&self) { pub fn cleanup_mempool(&self) {
const DEFAULT_TX_EXPIRY_DELTA: i32 = 20; const DEFAULT_TX_EXPIRY_DELTA: i32 = 20;
// A send is built with Builder::new(tip) (wtx.block = tip), so its expiry_height is tip + 20 and it can
// still be mined through block tip+20 (consensus rejects it only once the height exceeds the expiry). It
// is provably dead once the chain reaches tip+21, so the pending lock on its inputs must be held until
// then — one block longer than the old drop point (tip+20), which would release the notes while the tx
// could still be mined. The point is that the lock IS released now; previously unconfirmed_spent never was.
const MEMPOOL_KEEP: i32 = DEFAULT_TX_EXPIRY_DELTA + 1;
let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0); let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0);
let mut expired: HashSet<TxId> = HashSet::new();
{ {
// Remove all expired Txns // Remove all expired Txns, remembering which ones so their inputs can be released below.
self.mempool_txs.write().unwrap().retain( | _, wtx| { self.mempool_txs.write().unwrap().retain( | txid, wtx| {
current_height < (wtx.block + DEFAULT_TX_EXPIRY_DELTA) let keep = current_height < (wtx.block + MEMPOOL_KEEP);
if !keep {
expired.insert(txid.clone());
}
keep
}); });
} }
if !expired.is_empty() {
// An expired send can never be mined: release the notes/utxos it locked (unconfirmed_spent). The
// mempool_txs lock above is released first, so mempool_txs and txs are never held together.
let mut txs = self.txs.write().unwrap();
// A txid already in txs was mined; scan_block/scan_full_tx settled its inputs, so skip it.
expired.retain(|txid| !txs.contains_key(txid));
for wtx in txs.values_mut() {
for nd in wtx.notes.iter_mut() {
if nd.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
nd.unconfirmed_spent = None;
}
}
for utxo in wtx.utxos.iter_mut() {
if utxo.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
utxo.unconfirmed_spent = None;
}
}
}
}
{ {
// Remove all txns where the txid is added to the wallet directly // Remove all txns where the txid is added to the wallet directly
self.mempool_txs.write().unwrap().retain ( |txid, _| { self.mempool_txs.write().unwrap().retain ( |txid, _| {
@@ -94,7 +94,6 @@ pub struct SaplingNoteData {
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed. pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
pub memo: Option<Memo>, pub memo: Option<Memo>,
pub is_change: bool, pub is_change: bool,
// TODO: We need to remove the unconfirmed_spent (i.e., set it to None) if the Tx has expired
} }
@@ -987,7 +987,28 @@ impl Sim {
let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect(); let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect();
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it). // cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
let rel = &self.rel; let rel = &self.rel;
self.mempool.retain(|t, b| !ids.contains(t) && !rel.contains_key(t) && h < *b + TX_EXPIRY_DELTA); // Mirror cleanup_mempool: it keeps a send for MEMPOOL_KEEP (= TX_EXPIRY_DELTA + 1, i.e. one block past the
// tx's expiry height, when it is provably dead) past the sending tip, and for a send dropped purely by
// expiry (never mined, i.e. not in `ids`/`rel`/the wallet's `txs`) it now RELEASES the pending lock on the
// inputs. So clear `pending` for those, exactly as the wallet clears `unconfirmed_spent`. The real tx
// expiry stays TX_EXPIRY_DELTA (used at verify_tx below).
const MEMPOOL_KEEP: i32 = TX_EXPIRY_DELTA + 1;
let mut expired: HashSet<TxId> = HashSet::new();
self.mempool.retain(|t, b| {
let mined = ids.contains(t) || rel.contains_key(t);
let keep = !mined && h < *b + MEMPOOL_KEEP;
if !keep && !mined {
expired.insert(*t);
}
keep
});
if !expired.is_empty() {
for n in self.notes.iter_mut() {
if n.pending.map_or(false, |p| expired.contains(&p)) {
n.pending = None;
}
}
}
let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h)) let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h))
.cloned().collect(); .cloned().collect();