140 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 abbe5d8412 feat(chat): thicker, grabbable scrollbar for the message thread
The chat message thread used the global 6px scrollbar with a ~6% grab, which is
thin and hard to grab. Push a 12px scrollbar with a clearly-visible,
theme-adaptive grab (OnSurface at 55/95/135 alpha for idle/hover/active) for the
##ChatMessages child only, popped after EndChild. No change to the global
scrollbar or other scroll regions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-27 13:35:13 -05:00
DanSandClaude Opus 4.8 00d298453e fix(wallet): don't raise the "needs a repair" dialog after a Compact
Compact runs the node with -salvagewallet to rewrite wallet.dat, which produces
the exact same salvage signature in the daemon output as a corruption recovery.
So detectWalletAutoRecovery mistook the compaction for a recovery and raised the
alarming "Your wallet file needs a quick repair" dialog — whose "Restore
original" would have UNDONE the compaction by restoring the larger pre-compact
backup.

Flag the salvage as expected when we arm it for a compaction
(compaction_salvage_expected_, set in both compactWallet and
compactViaEmbeddedSwitch, disarmed if the restart is refused). When the detector
sees the salvage it consumes the flag and shows a calm "Wallet compacted" toast
(compact_done) instead of the recovery dialog. A genuine, unexpected salvage
still surfaces the dialog as before.

English only for compact_done (8 locales fall back). Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-27 12:09:29 -05:00
DanSandClaude Opus 4.8 38461f084c i18n(wallet): translate the Compact external-daemon / switch strings
Translate the 4 remaining Compact strings (compact_needs_embedded,
confirm_compact_switch_msg, compact_switching, compact_switch_stop_failed) into
de, es, fr, ja, ko, pt, ru, zh (res/lang/*.json, 2519 → 2523 keys each), grounded
per file and keeping wallet.dat / dragonxd / -salvagewallet as-is. No new CJK
glyphs, so the subset font is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-27 11:56:55 -05:00
DanSandClaude Opus 4.8 16d60a72f2 feat(wallet): one-click "switch to the built-in node and compact"
When connected to an external/standalone dragonxd (the wallet adopted a running
node, so daemon_controller_ is null), Compact couldn't run. Add a path that does
it in one click: compactViaEmbeddedSwitch() gates the connect loop, drops the RPC
connection, and on the worker gracefully stops the external node (RPC "stop" with
our credentials, via stopDaemonForWalletSwitch) and waits for the port; back on
the main thread it switches the wallet to its built-in node and starts it with a
one-shot compact_on_embedded_start_ flag that arms -salvagewallet, so the fresh
node rewrites wallet.dat (and rescans). On stop failure nothing is changed and
the external node stays up.

The Compact button is now enabled whenever the build can run the embedded node;
its confirmation branches — direct compact when the wallet already runs the
built-in node (canCompactWallet), else a clear message (confirm_compact_switch_
msg) that it will stop the external node, switch to the built-in one, and that
this changes which node binary runs. New strings confirm_compact_switch_msg /
compact_switching / compact_switch_stop_failed.

Mirrors restartDaemon's disconnect + background stop pattern; controller creation
stays on the main thread. Can't be runtime-verified here (no live daemon). Build
+ tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-27 11:41:49 -05:00
DanSandClaude Opus 4.8 a5cd211e89 fix(wallet): correct Compact's behaviour with an external daemon
Compact rewrites wallet.dat by restarting the node with -salvagewallet, which the
wallet can only do for the EMBEDDED node it manages. When the wallet is connected
to a dragonxd the user started themselves (use-embedded off, or it attached to an
already-running node), daemon_controller_ is null — and compactWallet() fired the
misleading "unavailable in lite build" warning even on a full-node wallet.

Now: a precise App::canCompactWallet() (full-node AND embedded AND a live
controller) disables the Compact button for an external/standalone daemon with a
tooltip, and compactWallet() refuses with an accurate message (compact_needs_
embedded) that tells the user to enable "Use embedded dragonxd" or restart their
dragonxd with -salvagewallet themselves. The lite-build path keeps its own
message. New string compact_needs_embedded (English; 8 locales fall back).

Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-27 10:55:46 -05:00
DanSandClaude Opus 4.8 0ebd1ac5d5 feat(wallet): defer + retry a merge that hits a transient mempool conflict
A consolidation batch can be rejected by the daemon with
bad-txns-shielded-requirements-not-met (REJECT_DUPLICATE) when it clashes with an
earlier, still-unconfirmed batch — the batch outran its predecessors into the
mempool. That isn't a real failure: once the earlier batches confirm, the same
merge succeeds. Previously it failed the whole address.

Now a merge OP failure whose message is a mempool conflict (shielded- /
joinsplit-requirements-not-met, txn-mempool-conflict, inputs-spent — new pure
helper isDeferrableMergeOpError) HOLDS the address for kRunMempoolClearSec (2 min)
instead of failing it, then retries from where it left off; up to
kRunMaxDeferRetries consecutive defers (reset on any success), after which it is a
genuine failure. Deferrals don't feed the halt breaker, and since the address
outcome stays non-failed the run verdict reads Clean when the retry succeeds.
applyOpState takes `now` to stamp the hold; next() waits on a deferred address
until its window passes.

Unit-tested (testConsolidationDefer): the classifier; and a driven run where an
op reports the conflict → the address is deferred (not failed), held before the
window and retried after it. Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 18:26:28 -05:00
DanSandClaude Opus 4.8 94d41e2932 i18n(wallet): translate the Compact action + reworded consolidation strings
Translate the 7 new Compact strings (wallet_size_compact, tt_wallet_size_compact,
compact_started, confirm_compact_wallet_{title,msg,safe}, compact_wallet_confirm)
and re-translate the 4 corrected consolidation strings (consolidate_desc,
wallet_size_warn, wallet_size_auto_info, tt_wallet_size_warn) into de, es, fr, ja,
ko, pt, ru, zh (res/lang/*.json, 2512 → 2519 keys each), grounded per file and
keeping wallet.dat / Berkeley DB as-is. Rebuilt the CJK subset font
(2123 → 2127 glyphs) for the five new glyphs (肥 領 + ko 써 압 켤).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 17:29:33 -05:00
DanSandClaude Opus 4.8 4cd27dcd4c fix(i18n): correct the consolidation wording — it doesn't shrink the file
The note/consolidation strings promised the wallet "shrinks" / "stays small",
but consolidation can't shrink wallet.dat (Berkeley DB never shrinks in place) —
it reduces the note count so the wallet scans faster and stops growing, and the
freed space is reclaimed only by Compact. Reword consolidate_desc,
wallet_size_warn, wallet_size_auto_info and tt_wallet_size_warn to say exactly
that (the tooltip now points at Compact). English only; the 8 locales keep their
prior translations until the next pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 17:25:34 -05:00
DanSandClaude Opus 4.8 4dfdf8a36f feat(wallet): add a Compact action to reclaim space after consolidating
Consolidating reduces the note count and stops the wallet growing, but Berkeley
DB never shrinks wallet.dat in place, so the freed space isn't returned to the OS
and the displayed size doesn't drop. Add an explicit "Compact wallet…" action
(next to "Consolidate notes…" in the large-wallet area of Node & Security) that
rewrites the file into a fresh, compact one.

App::compactWallet() arms the daemon's -salvagewallet flag for the next start and
calls restartDaemon() (reusing its full-node / busy / lifecycle guards); the
daemon rewrites wallet.dat into a smaller file, keeps the old one as a timestamped
.bak, and — because -salvagewallet implies -rescan — re-scans the chain. If the
restart is refused the salvage flag is disarmed so it can't fire on a later
restart. A confirmation overlay (mirroring the repair-wallet one) explains the
restart + rescan + that funds/balance are safe and a backup is kept. New strings
wallet_size_compact / tt_wallet_size_compact / compact_started /
confirm_compact_wallet_{title,msg,safe} / compact_wallet_confirm.

Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 17:24:21 -05:00
DanSandClaude Opus 4.8 1a01de9791 fix(ui): inset the consolidate address-list text from its border
The per-address list boxes (the run-progress list and the selection list) drew
their text flush against the border. The Consolidate dialog is a floating
overlay, which pops its content padding after the content child, so the ambient
WindowPadding is 0 — a bordered child therefore got no inner padding and its
rows touched the border. Push a small inner padding (10px, DPI-scaled) and
AlwaysUseWindowPadding for both lists, reserving it in their heights, so the
rows sit comfortably inset. Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 16:33:16 -05:00
DanSandClaude Opus 4.8 3b828e8a49 fix(ui): stop the Consolidate modal from jumping around during a run
The overlay dialog is auto-height and vertically centred, so it re-centers
whenever its content height changes. During a consolidation run two lines toggle
and made it jump: the pause notice (waiting for a batch gap / sync / a send),
which flickers on and off every batch, and the "run again" leftovers hint, which
appears partway through. Reserve a fixed slot for the pause notice and, while the
run is live, reserve the leftovers hint's measured height up front, so the
content height stays constant and the modal holds still. The end-of-run
transition to the result view is unchanged. Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 15:09:27 -05:00
DanSandClaude Opus 4.8 7138f633a8 feat(wallet): enable auto-consolidation by default
Default the auto_consolidate setting to true, so a full-node wallet keeps its
note count down on its own out of the box (users can still turn it off). A
missing key in an older config loads the new default, enabling it on upgrade,
which is intended. The page-state mirror default matches.

Safety is unchanged and reinforced in comments: auto-consolidation reuses the
"keep in each address" engine — z_mergetoaddress [addr] -> addr — so funds NEVER
move between addresses; it only sheds notes within each address. It never uses
the "into one address" sweep. It also only runs when the wallet is idle AND
unlocked (a locked encrypted wallet never auto-runs) and only touches addresses
with more than kAutoConsolidateMinNotes notes, so a fresh or lean wallet stays
dormant. Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 14:56:22 -05:00
DanSandClaude Opus 4.8 fc9f8d52c3 fix(ui): clamp tooltips to a comfortable width instead of spanning the screen
Audited every tooltip. The shared helper material::Tooltip wrapped ImGui's
SetTooltip with no width limit, so a long tooltip laid out on one line and
stretched across the whole display — most visible on the newly reworded
note/consolidation tooltips. 258 call sites go through this helper, so the fix is
central:

- material::Tooltip / BeginTooltip now push a text wrap position (420 logical px,
  DPI-scaled) so long text wraps to a comfortable column; short text is
  unaffected (it's already narrower). A caller that pushes its own wrap pos nests
  cleanly. Also fixes a latent style-stack leak when ImGui::BeginTooltip returns
  false (the pushed style is now popped).

Six sites bypassed the helper with raw ImGui tooltips. The two that actually
stretched are migrated to material::Tooltip: the alert/bell toast-hover showing a
full (now ~250-char) message, and the wallets dialog's folder-path hover. The
chat over-cap tooltip is migrated too for consistency. The remaining three
(two wallet-recovery dialog tips, the chat composer's parts tooltip) already
clamp via their own PushTextWrapPos and are left as-is.

Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 14:45:39 -05:00
DanSandClaude Opus 4.8 d83169c963 feat(wallet): pause routine wallet scans during a user-run consolidation
A manual consolidation's merges compete with the GUI's routine wallet scans for
the daemon's cs_main: the run won't fire the next merge while any scan is in
flight, and because each merge mutates the wallet it re-triggers the balance /
history scans, which then delay the following merge. The WalletScanGovernor
already serializes all of these one-at-a-time under a cs_main budget, so the fix
is to pause the heavy routine streams — not every RPC — while a user run owns the
budget.

WalletScanGovernor gains per-stream routine suppression (tryAdmit returns empty
for a paused stream, which keeps waiting and resumes the instant it is unpaused;
explicit needs via beginExplicit are never paused), plus a convenience that
pauses the balance / addresses / history / recent-tx group together. App::update
pauses that group while consolidation_.active() AND the run is user-started —
an auto (background) run is left gentle and untouched, and the cheap sync / peer
/ mempool / operation-status polls keep running so sync and disconnects stay
visible. The end-of-run forced balance refresh repaints once the run finishes.

Note: a manual run is bounded (≤ maxBatches per click, ~minutes), so the balance
and recent-tx views are only briefly stale — an incoming payment shows once the
run finishes or is stopped. Unit-tested (testWalletScanGovernor): a paused stream
is not admitted but keeps waiting, explicit bypasses it, unpausing re-admits, and
chat/Adhoc are never in the paused group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 14:22:53 -05:00
DanSandClaude Opus 4.8 9335e2bffb fix(wallet): always flag a user-started consolidation as manual
startConsolidation reset the shared ConsolidationRun but never cleared
auto_consolidate_run_. In the rare window where a background auto cycle was torn
down by a disconnect (abort) before pumpAutoConsolidation cleared its flag, a
user-started run could inherit the stale flag and be treated as an auto cycle —
silent (no merge-done/failed toasts) and auto-dismissed. Clear the flag on a
successful manual start so a user run always keeps its own notifications and
lifecycle. No behavior change in the normal case. Build + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 14:05:47 -05:00
DanSandClaude Opus 4.8 112752e8af i18n(wallet): translate the note-consolidation strings (8 languages)
Translate the three new keys (auto_consolidate, tt_auto_consolidate,
wallet_size_auto_info) and re-translate the three reworded ones (wallet_size_warn,
tt_wallet_size_warn, consolidate_desc) into de, es, fr, ja, ko, pt, ru, zh
(res/lang/*.json, 2509 → 2512 keys each), so the "what are notes / why
consolidate" education and the auto-consolidation toggle read natively in every
language. Each was grounded in its file's existing terminology for consolidate /
transaction / fee / balance / sync / shielded, with a single consistent rendering
of the crypto term "note" per language (e.g. ru «заметка», zh 票据, witness data
见证数据).

Rebuilt the CJK subset font (NotoSansCJK-Subset.ttf, 2115 → 2123 glyphs) for the
eleven new glyphs the translations introduce (千堆巧幣悄枚溜財 + ko 띄슷였).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 13:34:05 -05:00
DanSandClaude Opus 4.8 045289115d feat(wallet): explain notes + why to consolidate in the large-wallet nudge
The large-wallet notification just said "This wallet file is large" — users who
don't know what a shielded "note" is had no idea what to do or why. The nudge now
teaches: its message explains that each payment is stored as a separate note,
that notes pile up and enlarge/slow the wallet, and that consolidating merges
them while leaving the balance unchanged. The text wraps in full in the bell/
alert panel and the Settings wallet-size banner (and shows on hover from the
ellipsized toast), and the hover tooltip adds the detail plus points at the new
Auto-consolidate toggle.

When Auto-consolidate is ON the nudge no longer stays silent — it shows a calm
one-time info (wallet_size_auto_info) telling the user the background
consolidator is already handling it, so the accumulating notes never look like an
unexplained problem. When it's OFF, the educational warning keeps its
"Consolidate notes…" action that opens the dialog.

The Consolidate dialog's own intro (consolidate_desc) is likewise expanded from a
one-liner into a plain-language explanation of what notes are and why consolidate.

English source updated; the reworded existing keys keep their prior (valid, if
terser) translations and the new wallet_size_auto_info falls back to English
pending a translation pass. Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 13:27:36 -05:00
DanSandClaude Opus 4.8 64294d1518 feat(wallet): optional background auto-consolidation (Settings toggle)
A mining/pool wallet can accumulate tens of thousands of shielded notes, and the
manual "Consolidate" run — capped at 20 batches per address per click — needs
many re-runs to flatten a 10k+ note address. This adds a hands-off alternative:
a Settings toggle ("Auto-consolidate notes", full-node, off by default) that
trickle-merges notes in the background while the wallet is idle, so it sheds
notes over hours/days without the user ever opening the dialog.

Deliberately gentle, per the request: a small batch (15 inputs/tx vs 50 manual)
and only 3 batches per address per cycle, with cycles ~10 min apart — each cycle
is a handful of tiny merges that barely touch the node. It only acts when the
wallet is genuinely idle (walletScanReady: connected, synced, unlocked, node not
busy, not rescanning) and no send / shield / manual run is in progress, and it
goes dormant (re-scanning hourly) once every address is tidy.

Implementation reuses the existing paced services::ConsolidationRun engine in
"keep in each address" mode, so there is NO fund-path change and the run's
pacing / failure breakers / note-locking all apply:
- App::pumpAutoConsolidation (pumped from update() after pumpConsolidation):
  finalizes a finished cycle (schedules the cooldown + dismisses the run so the
  manual dialog never shows a stale auto result), else starts a new cycle when
  idle and the cooldown has elapsed.
- postAutoConsolidationScan: a quiet z_listunspent scan (the dialog's loadScope
  minus the UI state), session-guarded; on result picks candidates and starts.
- startConsolidationQuiet: the same guards as startConsolidation but silent, with
  a gentle RunConfig; finishConsolidation stays silent for auto runs (logs only).
- services::autoConsolidateCandidates (pure, unit-tested): addresses above the
  note floor that are worth merging; plus the gentle kAutoConsolidate* constants.
- The one-time large-wallet nudge is suppressed while auto-consolidation is on.

Settings plumbing (auto_consolidate, default false) + a daemon-gated toggle in
the Wallet tab's options, with a tooltip. Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 13:18:00 -05:00
DanSandClaude Opus 4.8 54f8359bd4 i18n(chat): translate the tier-3 larger-message strings (8 languages)
Translate the five new chat strings — chat_parts_label, chat_txns_label,
chat_parts_explain, chat_parts_explain_multi, chat_receiving_large — into de, es,
fr, ja, ko, pt, ru, zh (res/lang/*.json, 2504 → 2509 keys each), additively and
matching each file's existing terminology for transaction / fee / message. The
transaction abbreviation uses each locale's natural short form (Tx / tx / 件 /
건 / транз. / 笔).

Rebuilt the CJK subset font (NotoSansCJK-Subset.ttf, 2111 → 2115 glyphs) for the
four new glyphs the translations introduce (产 割 拆 in zh, 뉩 in ko) so they
render instead of tofu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 12:53:56 -05:00
DanSandClaude Opus 4.8 dba97c8b35 feat(chat): show a progress hint while a tier-3 message is still arriving
Until now an incoming large message was invisible until every part landed.
ChatService::pendingLargeMessages() exposes the in-progress tier-3 receives
(conversation, chunks gathered / chunks needed) from the reassembly buffer, and
the chat tab renders a subtle centred line below the thread — "Receiving a large
message…  24 / 36 parts" — that updates each scan and disappears when the message
completes and surfaces as an ordinary bubble. A complete-but-withheld (sensitive
vault locked) group is not reported as incoming.

New string chat_receiving_large (English; 8 locales fall back). Unit test extended:
after the first slice the receive is reported in progress; after the whole message
arrives it is no longer pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 12:47:37 -05:00
DanSandClaude Opus 4.8 acb6446076 feat(chat): raise the composer to the tier-3 cap + show the transaction count
Front end of tier-3 larger messages. The composer body cap goes from 4000 to
16000 bytes (kHushChatMaxMessageChunks = 64 chunks ⇒ up to 4 transactions), so a
message past the single-transaction ceiling now actually composes and sends via
the tier-3 path. The buffer and static_assert grow to match.

The beside-the-gauge indicator (and its hover tooltip) now distinguish the two
tiers: while a long message still fits one transaction it shows the part count as
before; once it spills across several it shows the TRANSACTION count — the real
cost the user asked to see — and the tooltip explains it is split across several
transactions, the recipient sees it only once every part has arrived, so it takes
longer to deliver and costs a fee per transaction. New strings chat_txns_label /
chat_parts_explain_multi (English; 8 locales fall back pending a translation pass).

Tier 3 is now end to end: compose → split → broadcast → reassemble → display.
A receive-side "incoming N/M parts" progress hint remains a future refinement
(an incomplete large message simply appears once whole).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 12:41:04 -05:00
DanSandClaude Opus 4.8 e61772a825 feat(chat): broadcast a tier-3 message across its transactions under one echo
Send side of tier-3 larger messages. sendChatMessage now composes via
composeMessageParts (splitOutgoingMessage), which returns one OutgoingChatMemos
per transaction, and enqueues each on the existing note-buffer coordinator — so
the parts drain sequentially against verified notes exactly like ordinary chat
sends, with no fund-path change (each part is the same pinned 0-value
chatSendOutputs z_sendmany).

A single echo bubble represents the whole message. A new per-echo outstanding-
part counter holds it at "Sending" until the LAST part is broadcast, then flips
it to Sent; if any part hits a hard failure the remaining queued parts are purged
(a message missing a slice can never reassemble, so sending the rest only wastes
fees) and the whole message is failed once — its existing Retry recomposes and
re-splits from the body. A transient-funds deferral still retries the part in
place. The counter map is cleared alongside the send queue on a wallet reset.

Inert until the composer cap is raised (the current 4000-char cap yields a
single tier-2 transaction); that + the multi-transaction composer indicator land
next. Build + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 12:36:40 -05:00
DanSandClaude Opus 4.8 5e33bf2c41 feat(chat): tier-3 reassembly accumulator in the chat service
Second half of tier-3 larger messages: the receive side. ChatService now buffers
the slices of a multi-transaction message (keyed by the shared message id) as
they arrive — across transactions and across scans — and reassembles the whole
ciphertext once every chunk is present, then decrypts and surfaces it as one
message. The buffer is NOT persisted: a rescan re-sends every slice, so it
re-derives from chain exactly like the control reducer, and survives a restart
mid-receive (the next full scan re-feeds the siblings and completes it).

Ingest's per-message build/decrypt/guard logic is refactored into one shared
lambda so a reassembled tier-3 message passes the identical block / on-chain-hide
/ sensitive-vault / tombstone checks as a single-tx message. A completed group is
retired after it surfaces (deduped across scans by its stable canonical txid —
the smallest sibling txid); a group withheld only because the sensitive vault is
locked is kept so a later unlock + scan completes it. Our own slices (sender ==
us) are skipped — the local echo records the outgoing message. The pending map is
bounded (256 groups) against a flood of never-completing partials.

Unit-tested (testHushChatTier3Reassembly): a ~9000-char message's first slice
alone surfaces nothing; once every slice has arrived it reassembles, decrypts to
the original and surfaces once; a re-scan dedups; our own slices never ingest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 12:31:42 -05:00
DanSandClaude Opus 4.8 cc08854237 feat(chat): tier-3 larger messages — protocol + split + cross-tx reassembly core
Foundation for messages too large for a single transaction (tier 2's 16-output
ceiling, ~4000 chars). A long message is now split across SEVERAL transactions
that share a random message id; each carries a slice of the one ciphertext with
GLOBAL chunk indices, and the receiver reassembles across the siblings.

Protocol: the Message header gains an optional "mid" (message id); "n" is reused
as the TOTAL chunk count and its bound raised to kHushChatMaxMessageChunks (64 ⇒
up to ~16 KB / 4 txs, tunable, ≤256 for the 2-hex index). The grouper, when a
tx's header carries "mid", emits a PARTIAL (its global-indexed chunks + message
id + total) instead of reassembling in-place; HushChatTransactionMetadata
carries those partials through. Crypto is untouched — one secretstream session
for the whole message, chunking is transport-only.

Compose: splitOutgoingMessage() returns one OutgoingChatMemos per transaction
(one element = the classic tier-1/2 single tx, byte-identical; several = tier 3).
buildOutgoingMessage (single-tx) is unchanged. Old clients bound "n" to 16 so a
tier-3 header fails to parse and its memos are dropped — graceful degradation.

Unit-tested (testHushChatLargeMessageTier3): a ~9000-char message splits into 3
txs, every output shuffled, slices gathered across siblings, reassembled and
decrypted back to the original; short message stays single-tx (no mid); the
total-chunk cap is enforced. The stateful accumulator (persisted, confirm-gated),
sequential broadcast, and progressive/incomplete UI follow in later commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 12:20:11 -05:00
DanSandClaude Opus 4.8 1729f7dfad feat(chat): show the on-chain part count for a long message in the composer
A larger message (tier 2) is carried as one transaction split across up to 16
memo outputs, but the composer gave no sign of that. It now shows the part
count beside the byte gauge the moment the body spans more than one memo output
(computed the same way buildOutgoingMessage chunks it), and the gauge's hover
tooltip adds the count plus "sent as one transaction split into these memo
parts — it arrives as quickly as a normal message" (the honest answer: it's one
atomic 0-conf tx, not N transactions — multi-transaction splitting is the
unbuilt tier 3). New strings chat_parts_label / chat_parts_explain (English;
8 locales fall back).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 12:00:27 -05:00
DanSandClaude Opus 4.8 12c61bd796 fix(platform): correct wallet-size reporting for files over 2 GiB (Windows)
getFileSize used stat(), whose st_size is a 32-bit signed _off_t on mingw/
Windows, so a wallet.dat over 2 GiB overflowed negative and cast to a garbage
~2^64 value — the "Wallet Size: 17179869182.10 GB" (≈ 2^34 GiB) seen on a real
~2.1 GB shielded wallet, which also spuriously fired the "wallet is large"
banner. Switch to std::filesystem::file_size (portable 64-bit uintmax_t) with
the error_code overload, returning 0 on failure (missing / unreadable / a
Windows sharing violation while the daemon holds the file open) so the callers'
existing `> 0` guards fall back to "unknown". The size now reports correctly,
and the large-wallet warning fires on the real size (so a genuinely bloated
2+ GiB wallet is flagged, as intended).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 11:45:42 -05:00
DanSandClaude Opus 4.8 63c284684c test(chat): regtest harness verifying on-chain memo round-trip (larger messages + Control)
Adds a dev-only harness that closes the "needs a live daemon" gap for the new
chat wire paths, fund-safely on an isolated regtest chain:
- tools/chat_regtest_smoke.cpp (+ an EXCLUDE_FROM_ALL CMake target): a small
  driver linking the chat library — derive identity / compose a message or a
  Control memo / extract + decrypt harvested memos. Secret-safe.
- tools/chat_regtest.py: starts a throwaway regtest dragonxd (own datadir,
  -regtest, no network — nothing touches ~/.hush), funds a shielded note, then
  drives a full round-trip through the real daemon: compose -> z_sendmany the
  memo outputs -> mine -> z_listreceivedbyaddress -> feed back to the driver.

Verified on dragonxd v1.3.0: a chunked 1529-byte message (8 memo outputs in one
tx) and a self-addressed Control(del) memo both round-trip verbatim and
reassemble/extract correctly — i.e. the real node accepts a multi-output 0-value
chat tx and returns its memos intact. Paths are env-overridable
(DRAGONX_REGTEST_DAEMON_DIR / DRAGONX_CHAT_SMOKE / DRAGONX_REGTEST_RUN).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-24 14:03:45 -05:00
DanSandClaude Opus 4.8 2d63a72098 i18n(chat): translate chat_toast_too_long (8 languages)
The one remaining English-only chat string (the "message too long" toast from
the larger-messages composer cap) is now translated into de/es/fr/pt/ru/zh/ja/ko
(2503 -> 2504 keys each), consistent with each file's terminology and formality.
No new CJK glyphs — all covered by the existing subset font.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-24 12:48:06 -05:00
DanSandClaude Opus 4.8 743e7f8b65 feat(chat): raise the composer cap to the tier-2 larger-message capacity
The composer byte cap was 236 (one memo). With larger-messages tier 2 a body
can span up to 16 memo outputs in one tx, so kChatBodyMaxBytes rises to 4000
(a touch under the ~4063 protocol max so the composer never produces a body
compose would reject). The compose buffer grows to match, guarded by a
static_assert, and the emoji picker / byte gauge follow the single constant.
sendChatMessage now shows a specific "message too long" toast on a TooLong
compose instead of the generic compose-failed one (English string added; the
8 locales fall back until translated).

Broadcast is unchanged — both chatSendOutputs call sites already iterate the
N outputs. (Lite backend acceptance of >2 duplicate-address 0-value outputs is
the one bit needing a live smoke test, like the other lite broadcast paths.)

Larger-messages tiers 1-2 are now usable end to end on the full node.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-24 11:19:45 -05:00
DanSandClaude Opus 4.8 6b93a8ab60 feat(chat): larger messages — multi-output chunking in one transaction (tier 2)
A message whose ciphertext exceeds one 512-byte memo is now split across up to
kHushChatMaxMessageParts (16) payload outputs in a SINGLE transaction, lifting
the ~239-byte cap to a few KB while staying atomic, instant (0-conf), and
one-fee. The crypto is untouched (one secretstream push/pull); chunking is a
transport concern:
- Header gains an optional part count `n` (emitted only when >1, so a 1-part
  message stays byte-identical on the wire); bounded in the parser.
- Each chunk is a 2-hex part-index prefix + its ciphertext slice, so the
  receiver reorders them after the daemon's output shuffle.
- OutgoingChatMemos carries a payload vector; chatSendOutputs emits header + N
  payloads (both broadcast call sites already iterate the result).
- The grouper gains a multi-part pre-scan: a lone Message header with n>1 →
  collect the N indexed chunks, sort, strip, concat → one pair; anything else
  falls through to the classic single-pair path (older clients mis-pair and
  drop — graceful). A body beyond the cap is refused as TooLong (tier 3 later).
Round-trip (shuffled outputs), 1-part byte-identical, and cap all unit-tested.

Larger-messages tier 2; tier 3 (multi-tx) + the denser raw-memo encoding remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-24 11:16:22 -05:00
DanSandClaude Opus 4.8 19e9b62632 chore(release): bump full-node to 2.1.0 and lite to 2.0.0
Full node 2.0.2 -> 2.1.0 (minor): a large additive feature + audit-fix release
since v2.0.2 with no breaking changes and only opt-in/user-initiated migrations.
Lite 1.1.1 -> 2.0.0 (major): inherits the shared feature work and carries a
deliberate on-disk/interop format change — newly-encrypted lite wallets now use
the salted Argon2id KDF (wallet v10) that older ObsidianDragonLite / SilentDragonXLite
cannot open — plus the lite line reaching functionally-complete status this cycle.

Single source of truth: project(VERSION) + DRAGONX_LITE_VERSION in CMakeLists.txt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 20:32:22 -05:00
DanSandClaude Opus 4.8 076bb3ff06 i18n(chat): translate the sensitive-conversation strings (8 languages)
Back-fills the 29 new chat_sensitive_* keys additively into de/es/fr/pt/ru/zh/
ja/ko (2474 -> 2503 each), matching each file's established terminology and —
critically — keeping the sensitive "passphrase" worded distinctly from the
"seed phrase" (e.g. de Passphrase vs Seed-Phrase, es contraseña vs frase de
recuperación, fr phrase secrète vs phrase de récupération), translated +
reviewed per language. Rebuilds NotoSansCJK-Subset.ttf (2107 -> 2111 glyphs)
for 4 new CJK glyphs (ja 論; ko 눌/똑/론) the new strings introduced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 18:49:44 -05:00
DanSandClaude Opus 4.8 f0be72ac36 feat(chat): sensitive-conversation UI (mark/unmark, setup/unlock, lock)
Wires sensitive mode into the chat tab:
- A per-conversation lock toggle in the header (next to delete) opens a dialog
  that, by vault state, sets up / unlocks the vault and marks or unmarks the
  conversation. The mark dialog shows the honest warning (lose the passphrase =
  gone forever; sent fully protected vs received still chain-recoverable; prior
  history stays recoverable).
- A "Lock sensitive" / "Unlock sensitive (N)" opener beside Blocked/Restore
  locks or unlocks the vault; unlocking surfaces the withheld sensitive threads.
- Passphrase fields use the password flag and are memset on close; marking
  requires the vault unlocked (the App layer migrates rows + broadcasts the
  on-chain signal).

~29 new English strings (fall back to English until translated). Phase B
complete (policy level); the cryptographic re-key level remains out of scope.
Needs a live smoke test (built unverified, like the delete UI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 18:25:12 -05:00
DanSandClaude Opus 4.8 6e285bd4a4 feat(chat): App vault lifecycle for sensitive conversations
Adds the App-level glue that makes sensitive mode operable:
- Owns ChatSensitiveVault (path <configDir>/chat_sensitive.vault); provisioning
  wires the service's local sensitive predicate to Settings::isChatSensitive and
  points the vault at its path (left LOCKED until the user enters its passphrase).
- chatSensitiveVaultState() (NotConfigured/Locked/Unlocked) + create / unlock /
  lock: unlock pushes the key to chat_db_ and pulls sensitive rows into the store
  (+ re-harvest); lock drops the key and reloads so sensitive threads vanish.
- markChatConversationSensitive / unmark: migrate the thread's existing rows
  to/from the vault key via upsert (so the whole thread hides when locked) and
  broadcast the on-chain "sensitive"/"unsensitive" signal via sendChatControl.
  (wallet lock already wipes the vault key via chat_db_.lock().)

Phase B, step 5a (App lifecycle). UI next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 17:47:57 -05:00
DanSandClaude Opus 4.8 fe3d2aa935 feat(chat): service-level sensitive gating + local sensitive marks
Wires the sensitive-conversation plumbing into ChatService + Settings:
- Settings gains a persisted local sensitive set (isChatSensitive /
  setChatSensitive / sensitiveChatConversations), mirroring the blocked set, for
  INSTANT effect the moment the user marks a conversation (before the on-chain
  signal is harvested back).
- ChatService::isConversationSensitive() OR-combines that local mark with the
  on-chain reduction, and setPersistence() wires it as the DB's per-row key
  selector (sensitive rows -> vault key).
- ingest() withholds a sensitive conversation's messages while the vault key is
  absent (fail-closed: not shown, not persisted); they re-import + persist under
  the vault key once it is unlocked and a scan runs. loadFromDatabase() already
  withholds via the DB's two-key try.
Service-tested end to end against a real ChatDatabase (ingest + reload).

Phase B, step 4 (service gating). App vault lifecycle + UI next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 17:42:31 -05:00
DanSandClaude Opus 4.8 6b9bda3ee4 feat(chat): per-conversation local-key encryption in ChatDatabase
ChatDatabase gains a second AEAD key — the local vault key (setSensitiveKey /
lockSensitive) — and a sensitivity predicate. Rows of conversations the
predicate marks sensitive are encrypted under the vault key instead of the
seed-derived key, and are persisted only while the vault is unlocked (never
stored under the seed key). load() tries the seed key then the vault key, so a
sensitive row is WITHHELD whenever the vault key is absent — including on a
fresh instance that holds only the seed (the "fresh seed import can't read
sensitive conversations" guarantee); a wrong vault key withholds too, never
yields garbage. encrypt/decrypt are now key-parametrized; lock() also wipes the
vault key. Unit-tested end to end.

Phase B, step 3 (DB dual-key).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 17:36:50 -05:00
DanSandClaude Opus 4.8 84ca1a5cbe feat(chat): passphrase-wrapped local key vault for sensitive conversations
Adds ChatSensitiveVault: a per-wallet random 32-byte key wrapped under a user
passphrase (Argon2id MODERATE KEK + XSalsa20-Poly1305) in an 89-byte local file.
This key encrypts the at-rest rows of "sensitive" conversations INDEPENDENTLY of
the seed-derived chat key, so those rows can't be read by someone who has the
seed but not this passphrase — and a wallet restored from the seed alone can't
read them at all. Losing the passphrase/file makes them unrecoverable by design.
create/unlock/changePassphrase/remove, atomic write + 0600, keyOut zeroed on any
failure. Unit-tested (round-trip, wrong-passphrase zeroing, rekey, no-clobber).

Phase B, step 2 (local vault key).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 17:31:30 -05:00
DanSandClaude Opus 4.8 669b8b7ca5 feat(chat): add an independent "sensitive" axis to the control reducer
Routes control ops to one of two independent per-conversation reductions: the
hide axis (del/res) and a new sensitive axis (sensitive/unsensitive), so a
conversation can be deleted AND sensitive without the two states clobbering
each other. Unknown future ops are ignored (not stored in either axis).
Adds conversationSensitiveOnChain() + onChainSensitiveConversations(); the
reduction stays monotonic by (compose-time, txid). Reducer-tested, including
axis independence and that marking sensitive neither hides nor erases a thread.

Phase B, step 1 (reducer) of per-conversation local-key encryption (policy level).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 17:25:36 -05:00
DanSandClaude Opus 4.8 796d639ba3 i18n(chat): translate the delete-everywhere / restore strings (8 languages)
Back-fills the 14 new chat delete/restore keys additively into de/es/fr/pt/ru/
zh/ja/ko (2460 -> 2474 each), matching each file's established terminology
(conversation, Delete, Restore, sync, seed phrase, shielded/z address, scan,
device, chain, fee) — translated + reviewed per language. Rebuilds
NotoSansCJK-Subset.ttf (2104 -> 2107 glyphs) to cover 3 new CJK glyphs (微, 曾,
伝) the new zh/ja strings introduced, so they don't render as tofu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 17:02:04 -05:00
DanSandClaude Opus 4.8 daae693d1b feat(chat): Restore dialog for on-chain-deleted conversations
Adds a "Restore" opener (shown when a delete is known from chain — so it works
on a wallet freshly restored from seed — or something is locally blocked) and a
Restore dialog that: lists chain-derived deleted conversations
(onChainDeletedConversations) each with a Restore button; accepts a pasted chat
(z) address, matched against the address each delete signal carries (else
treated as a raw cid); and offers Quick scan (noteWalletMutation → re-run the
change-gated scans → re-harvest control memos). Restoring clears any local
block/hide and broadcasts Control("res") so every device un-hides; the messages
re-import from chain on the next scan.

New English strings fall back cleanly until translated (chat_restore_*).
Phase A complete: protocol -> compose -> reducer -> broadcast -> delete+restore UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-20 08:23:35 -05:00
DanSandClaude Opus 4.8 2f1b666c20 feat(chat): "Delete everywhere" action in the delete dialog (on-chain signal)
Adds a distinct, sync-gated "Delete everywhere" button to the conversation
delete dialog: it blocks the thread locally (instant + persistent, so it can't
flicker back mid-sync) AND broadcasts a self-addressed Control(del) via
sendChatControl so every other device — and a future restore from the seed —
hides it too. Carries the peer z-address as the control label so Restore can
match by address. The button is disabled until the wallet is fully synced
(lite uses its own sync-complete signal, since it never sets tip_known), with a
note explaining the on-chain signal + fee. The two local-only modes (Delete /
Delete & block) are unchanged.

New English strings fall back cleanly until translated (chat_delete_everywhere*,
chat_delete_needs_sync). Phase A, step 5 (delete UI). Restore dialog next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-20 08:19:34 -05:00
DanSandClaude Opus 4.8 5aa5fa7594 feat(chat): broadcast self-addressed Control memos (App::sendChatControl)
Adds App::sendChatControl(cid, op, label): composes a self-addressed Control
memo and hands it to the existing chat send coordinator (enqueueChatSend →
pump → the 0-value kChatMinFeeDrgx z_sendmany / lite send) exactly as a message
does — no new fund-path semantics. It carries no UI echo bubble (empty
echoLocalId; resolveOutgoing("") is a no-op), and the memo harvested back to us
is routed to the reducer, never shown as a message.

Phase A, step 4 (broadcast) of on-chain chat delete/restore. UI wiring next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-20 00:26:59 -05:00
DanSandClaude Opus 4.8 f56c94bc91 feat(chat): reduce on-chain Control memos into per-conversation hide state
ingest() now routes Control memos (self-addressed, so processed ahead of the
"skip our own memos" guard) into a per-cid reducer that keeps the latest op by
(compose-time, txid) — monotonic, so a replayed older op never flips state. A
conversation whose latest op is "del" is hidden: its messages are dropped from
ingest and erased from the store view (even rows loaded from the DB before the
signal was reduced), on every device and on a fresh seed import, until a later
"res" restores it. The DB rows are left intact so restore (or a rescan) brings
the thread back. State is re-derived from chain each session; no local
persistence. Adds conversationHiddenOnChain() + onChainDeletedConversations()
(the restore picker's source), fully reducer-tested.

Phase A, step 3 (reducer) of on-chain chat delete/restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-20 00:21:58 -05:00
DanSandClaude Opus 4.8 7320a98505 feat(chat): compose self-addressed Control memos (buildOutgoingControl)
Adds buildOutgoingControl: builds the two 0-value memo outputs for a control
signal (a "Ctrl" header carrying `op` + a plaintext label payload) addressed to
the sender's OWN z-address, so only this wallet's seed harvests it and it never
reaches the peer. buildHeaderMemo gains an optional `op` (emitted only for
Control; existing Memo/Cont headers stay byte-identical). Round-trip + guard
tested through the receive parser.

Phase A, step 2 (compose) of on-chain chat delete/restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-20 00:16:23 -05:00
DanSandClaude Opus 4.8 529286777f feat(chat): add a Control memo header type for on-chain conversation signals
Introduces HushChatHeaderType::Control — a self-addressed control signal
(delete / restore / mark-sensitive) that rides a chat tx as a header memo
carrying an `op`, paired with a plaintext label payload so it groups like a
normal header/payload pair. The op VALUE is not restricted at parse time so a
future op reaches newer clients and is ignored by older ones; unknown header
types still fail-closed (grouped as an invalid header, never surfaced, never a
crash), preserving forward-compat.

Foundation for the on-chain chat delete/restore feature (Phase A).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-20 00:12:23 -05:00
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
DanSandClaude Opus 4.8 a30a083df0 feat(security): persist unlock lockout across restarts; keep unlock errors visible
- The failed-unlock error message now stays on screen until the next attempt /
  mode switch / success, instead of fading after ~3s (the reason was often gone
  before it could be read).
- The escalating failed-attempt lockout counter + expiry are now persisted
  (keyed to the active wallet file) and restored once per lock-screen session,
  so killing and relaunching the app can no longer reset the lockout curve — a
  brute-force window. A forward clock change is capped; the PIN/passphrase is
  never stored. The verification (walletpassphrase/decrypt) path is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:20:32 -05:00
DanSandClaude Opus 4.8 89d3975eb3 feat(migration): second backup acknowledgement + confirm Discard and Sweep
Migrate-to-seed is irreversible and fund-moving, so its gates are strengthened
(UI only — the sweep/adopt/wipe logic, z_mergetoaddress args, amounts and
destination are byte-identical):
- Continue now requires a second, consequence-stating acknowledgement checkbox
  (funds move and are recoverable only via the seed phrase), not just "I wrote
  it down".
- Discard, Sweep-all and Sweep-remaining each take a second confirming click
  (stating the amount being moved) instead of firing on one click; the confirm
  body runs verbatim only after confirmation, and arming never survives a
  close/reopen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:20:32 -05:00
DanSandClaude Opus 4.8 3f1838cfdd feat(chat): confirm before exporting decrypted chat history to plaintext
The chat header's export button wrote a decrypted plain-text file immediately on
click, warning only via a hover tooltip. It now opens a confirm dialog that
states the file is unencrypted and shows the destination path before writing.
The export format, path and write call are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:04:58 -05:00
DanSandClaude Opus 4.8 dcb8e6eb25 feat(mining): persistent payout error, solo-address explainer, i18n cleanup
- The pool payout-address rule is no longer hover-only: an invalid payout
  address now outlines the field in red and shows a short reason below it (a row
  is reserved so it doesn't overlap the card beneath). The longer tooltip stays.
- The solo "Mining Addr" row gains a hover explainer and a "None yet" empty-state
  (with a tooltip pointing to Receive → New mining address) instead of a blank.
- Replaced hardcoded English in the earnings/stats cards ("(%d txn)", uptime
  "N/A"/"%lldm %llds"/"%lldh %lldm") with TR() keys.

Also carries the chat-export confirm strings used by the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:04:58 -05:00
DanSandClaude Opus 4.8 3f127f9b62 feat(request,balance): Request-Payment create-address empty-state + tx empty states
- Request-Payment no longer dead-ends with no shielded address: it shows a hint
  and a "Create a shielded address" button (auto-selecting the new address), and
  labels address entries with their contact/wallet names.
- The balance "Recent transactions" empty area uses a proper empty-state and now
  distinguishes "still loading / syncing" from "no transactions yet" instead of a
  dim one-liner that also showed mid-sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 22:50:30 -05:00
DanSandClaude Opus 4.8 f2d9590058 feat(send): usable-while-syncing form, calmer validation, honest clear/memo/source
Send-tab UX pass (presentation/validation only — the z_sendmany submit path, its
amounts, fee, recipients and memo bytes are unchanged, and the Review button plus
the confirm-time live re-check still block any send until the wallet is synced):
- The form is now editable while syncing (so the recipient/amount/memo can be
  pre-filled); only Review/Send stays gated, with an "available once synced" note.
- The recipient's red "Invalid address" no longer flashes from the first
  keystroke — it waits until the field loses focus.
- The memo field stays visible (text preserved) with a "requires a shielded
  recipient" explainer for transparent recipients, instead of vanishing + silently
  dropping what was typed.
- "Cancel" (which only cleared the form) is relabeled "Clear form", disabled when
  empty, and drops its redundant confirm (the timed Undo already covers it).
- The source picker shows address labels, and warns when the chosen source is
  transparent (non-private on this chain).

Also adds the string keys used here and by the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 22:50:30 -05:00
DanSandClaude Opus 4.8 ee6e99965c feat(balance,notifications): shielded-card tooltip + clearer toast severity
- The Shielded balance card gains the explanatory hover tooltip the mining card
  already has (suppressed while the pending-chip tooltip is showing).
- Toasts get a severity-tinted background fill and a slightly thicker accent
  border, so Success/Warning/Error/Info differ by more than hue alone (the
  per-severity leading icon already existed). Tint opacity is a ui.toml token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 18:36:55 -05:00
DanSandClaude Opus 4.8 b3e09a3314 feat(onboarding): friendlier wizard bootstrap + Done screen starts the daemon
First-run bootstrap polish:
- Failed card wraps the (often long) error instead of clipping it, and offers a
  "Try mirror" button; Retry now keeps whichever source (main/mirror) was used.
- Bootstrap offer shows an honest size/time hint and relabels "Mirror" →
  "Use mirror" with a clearer tooltip.
- Daemon status dot/label is keyed off the EmbeddedDaemon::State enum instead of
  matching the English substring "Stopping" (which broke in every other
  language), and the faint ~55%-alpha label is raised to ~86%.
- Bootstrap Done screen: for the embedded daemon it now offers "Start Daemon"
  (and says the node will start) instead of telling the user to start it by hand.

Also carries the new string-table keys for these (and the shielded-card tooltip
used by the next commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 18:36:55 -05:00
DanSandClaude Opus 4.8 2fdf33f99b feat(chat): "Request" pill on unsolicited conversations in the list
The conversation list now marks a pending stranger request with a small
"Request" pill (bottom-right of the row), so it's distinguishable before you
open it; the preview text is clipped to the pill's left so they don't overlap.
Completes the request-affordance pair with the in-thread Accept/Block card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 18:27:44 -05:00
DanSandClaude Opus 4.8 092a802959 feat(balance): pending chip in the Donut, Shield and Timeline layouts too
Extends the labeled "Pending +X" chip (with its awaiting-confirmations tooltip)
to three more layouts via a shared DrawPendingChip() helper. It draws on the
ticker line (the USD value sits on the line below in these layouts, so there's no
overlap) and is skipped if it would overflow the card. The remaining draw-list
layouts with right-aligned content (Consolidated, Minimal, the grid variants)
are left for a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 18:27:44 -05:00
DanSandClaude Opus 4.8 e9eeaf00c3 feat(chat): Accept/Block card for unsolicited contact requests
An unsolicited first message from a stranger (an incoming ContactRequest, with
nothing sent by us and the peer not in contacts) now shows an Accept / Block
card above the thread instead of looking like a normal conversation. Accept adds
the peer to the address book (which clears the request state); Block deletes and
blocks the conversation (reversible from the Blocked manager). ConvSummary gains
a derived pendingRequest flag. (The list-row "Request" pill and a header Block
button were left out to avoid the fragile hand-drawn header-toolbar geometry.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:12:57 -05:00
DanSandClaude Opus 4.8 572b98cbcf feat(shield): pick the mining address from a dropdown + show a confirm preview
Shield-coinbase replaced its free-text "*"-wildcard address box with a dropdown
of the wallet's mining (transparent) addresses and their balances ("All mining
addresses" default), and the confirm step now previews "Shield up to X DRGX from
N mining address(es)… to <label>? Network fee: Y" instead of only relabeling the
button. Also blocks submitting from an empty source or one with no funds. The
z_shieldcoinbase call (args/fee/limit/destination) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:09:41 -05:00
DanSandClaude Opus 4.8 bcfc324426 feat(balance): labeled "Pending +X" chip with tooltip in more layouts
The unconfirmed balance was shown in only one layout as an unlabeled "+X" that
read as a mystery. It's now a labeled "Pending +X" chip with an "awaiting
confirmations" tooltip — the Classic card's badge is relabeled, and the shared
compact hero (Dashboard + Two-row layouts) now draws it too. (The remaining
hand-drawn layouts can reuse the same pattern as a follow-up.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:07:43 -05:00
DanSandClaude Opus 4.8 7cea9b7339 feat(bootstrap,validate): Retry keeps the mirror; Validate works offline
- Bootstrap: Retry now reuses the source the user actually started (was always
  the default), and a "Try mirror" button offers the alternate server on
  failure (s_lastUrl remembered on start, cleared on a fresh Confirm).
- Validate Address: classification + checksum are local, so the dialog no
  longer requires a node connection — offline/lite now validate and show the
  type, with ownership reported as "Unknown — node offline / not checkable in
  the lite wallet" instead of a dead "not connected" screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:05:40 -05:00
DanSandClaude Opus 4.8 f3f0b32a48 feat(ux): robust sync ETA, persist Explorer settings, gate export-all out of lite
- Sync ETA: a new pure util::SyncRateEstimator (App-owned) replaces the
  function-local statics that went stale after a rescan/restart (producing the
  "412h" ETA). It self-resets on a height drop and on connect/disconnect/rescan/
  restart (via an atomic flag, since onConnected can run off-thread), formats
  with days, and uses the Warning() token.
- Settings: the Explorer-tab URL fields + custom-fees/fetch-prices checkboxes
  now persist on edit — they were the only controls still relying on the
  About-only Save button that a user on this tab never sees.
- Export-all-keys: hidden in the lite build (which has its own key export),
  instead of dead-ending on "Not connected to the daemon".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:02:46 -05:00
DanSandClaude Opus 4.8 8fe20f6385 feat(mining): persistent start/stop error caption + timeout-aware pool status
- A mining start/stop/pool failure now sets a persistent App::mining_last_error_
  (cleared on the next attempt), rendered as a red caption with "Press Mine to
  try again" below the Mine button (plus a Dismiss, and a Windows-Defender
  "How to fix" on an AV block), so a crashed/blocked/failed miner is no longer
  just a button that "did nothing" once its toast faded. Two more hardcoded
  English strings (pool-payout-invalid, AV-blocked) moved to i18n.
- Pool connection status is now a persistent "Status" row in the pool stats
  card (PoolMiningState.running_since / last_error): it flips from "Connecting…"
  to "Can't reach pool — check the URL" after a 30s timeout instead of sitting
  on red forever, and no longer vanishes once logs arrive. The old transient
  banner in mining_tab.cpp was removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 12:09:49 -05:00
DanSandClaude Opus 4.8 15c1181aef feat(shield): colour shield/merge status by outcome + spinner; friendly errors
The shield/merge status line was amber only while in-flight and uncoloured for
every terminal state (success, failure, uncertain). It now tracks an OpOutcome
(display-only) and colours the terminal line Success/Error/Warning accordingly,
shows a LoadingDots spinner while the 120s scan runs, and maps the raw daemon
error through friendlyRpcError. The actual shield/merge submit logic is
unchanged (no "Try again" that could double-submit a fund move).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:55:40 -05:00
DanSandClaude Opus 4.8 0101cc707c feat(network): a clear auth-failed node banner with a fix-it action
When the node rejects the wallet's RPC login (bad rpcuser/rpcpassword), the
status bar showed developer jargon ("Auth failed — check rpcuser/rpcpassword")
and the only action was Reconnect, which just repeats the failure. Now a
dedicated AuthFailed banner reason surfaces "Wallet can't sign in to the node"
with a plain-language detail and a real fix: Restart node for the embedded
daemon (regenerates its cookie creds) or Open config folder for an external one
(to fix DRAGONX.conf). Driven by a new rpc_auth_failed_ flag (set on the auth
path, cleared on each connect attempt), with evaluator unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:53:29 -05:00
DanSandClaude Opus 4.8 adf61e43d6 feat(transfer): add Send-tab guards, a correct TxID label + copy, inline reasons
The address-transfer dialog (own-address moves) enabled Confirm on amount alone,
so a disconnected / syncing / locked wallet failed only at submit. It now
mirrors the Send tab's guards (strictly more restrictive — the transfer logic is
unchanged) and re-checks live state before broadcast, shows an inline reason
when blocked (enter amount / not connected / syncing / locked), and labels the
result as a "Transaction ID" (not "Operation ID") with a copy button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:44:19 -05:00
DanSandClaude Opus 4.8 fb281714f9 feat(i18n,mining): translate warmup block/tx-type labels; fix the disconnected-solo mine tooltip
- Warmup/daemon status no longer appends a hardcoded English " (Block N)" — it
  uses a translated warmup_block_fmt key.
- Recent-transaction type labels (Sent/Received/Mined) are now translated in the
  UI consumer (recentTypeLabel in balance_recent_tx.cpp) with a generic
  "Transaction" fallback, instead of the data layer's raw English getTypeDisplay().
- Mining: a disconnected solo miner hovering the dead Mine button now gets a
  "not connected to the node" tooltip instead of a misleading "Start Mining"
  (the tooltip chain had no branch for the solo !isConnected() disable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:42:29 -05:00
DanSandClaude Opus 4.8 74c497922e fix(hidpi): scale security-dialog buttons, market caption, QR inset by dpiScale
HiDPI geometry debt from the audit: 15 security-dialog buttons used a raw 40px
height (undersized vs the scaled label at 1.5x), the market "Updated" caption
used a raw SameLine(0,12), and the QR popup's 28px overlay-inset budget wasn't
scaled (wrapping the address at HiDPI). All now go through ui::Layout::dpiScale().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:39:45 -05:00
DanSandClaude Opus 4.8 ae16c06d24 feat(contacts,chat): a "Message" action linking Contacts to Chat
- Contacts: a "Message" context-menu item for shielded (z) contacts hands the
  address to the Chat tab (new OpenChatNewConversation entry point) and switches
  there with the New-conversation dialog prefilled; disabled with a reason for
  transparent-only contacts. (Kept to the safe context-menu + picker path, not
  the hand-drawn 4-slot row action cluster, to avoid mis-wiring the destructive
  Delete slot.)
- Chat: the New-conversation picker no longer silently hides transparent
  contacts — they're shown disabled with a "needs a shielded address to chat"
  tooltip instead of vanishing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:52:21 -05:00
DanSandClaude Opus 4.8 408393a547 feat(chat): add a "Copy my chat address" button
The conversation header only copies a PEER's address, so a user had no way to
hand out their own chat address for others to message them. Add a "Copy my chat
address" link in the left pane (public App::chatMyAddress() forwards the stable
seed-derived reply z-address; copied on click only, with an "address copied"
toast and a warning if none exists yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:45:03 -05:00
DanSandClaude Opus 4.8 a80be443d1 feat(market): flag a stale price instead of showing it as a fresh green number
The aggregate price is deliberately kept on a failed or old refresh (it's never
cleared), so the hero number could be hours out of date while still drawn in the
"live" green. It now turns amber with an "as of N min/h ago" caption and a hover
tooltip carrying the friendly fetch error, when the last refresh failed or the
aggregate is >=5 min old (only when the hero is the aggregate, not an exchange
pair with its own feed). The hardcoded "Price fetch failed" default is now i18n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:42:34 -05:00
DanSandClaude Opus 4.8 a792a1ec0c feat(ux): clearer chat delivery, explorer empty/range states, honest settings label
Three next-batch UX-audit fixes:
- Chat delivery: the sending/sent glyph has higher contrast and a tooltip
  ("Sent to the network — arrives after it confirms", so a check isn't mistaken
  for "delivered"); a failed send now carries the real reason (ChatMessage::
  fail_reason, set from onChatBroadcastResult via friendlyRpcError, in-memory
  only) in the "not sent" hover tooltip.
- Explorer: a block height beyond the tip says so ("Block N is beyond the chain
  tip (M)") instead of silently snapping to the top; the empty block list shows
  Not connected / Syncing (N behind) / Loading instead of an endless "Loading".
- Settings: the misleading "Reset to Defaults" button (which only reloads the
  last-saved values) is relabeled "Discard unsaved changes" with accurate copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:20:47 -05:00
DanSandClaude Opus 4.8 a11130769d feat(send): show fee + total on the compose form; fix the stuck confirm on recheck
- The network fee and running total are now shown on the compose form (once an
  amount is entered), instead of only being revealed in the confirm popup.
- When the pre-broadcast re-validation fails (balance dropped, sync restarted,
  fee pushed total over available), the confirm popup now closes and surfaces a
  warning toast — previously the reason was drawn on the form behind the still-
  open popup, so the user was left re-clicking a button that did nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:14:44 -05:00
DanSandClaude Opus 4.8 4c6a78b3b0 fix(security): color seed-save by outcome; explain the Encrypt button; friendly key-export errors
- Seed-backup dialog: a failed "Save to file" was drawn in the success (green)
  colour; it's now red on failure, green on success (flag reset on close).
- Encrypt-wallet dialog: the disabled Encrypt button now says why once the user
  starts typing ("Use at least 8 characters." / "The passphrases don't match."),
  and a "Show passphrase" checkbox reveals both fields.
- Key-export dialog: the raw RPC exception (e.g. "...walletpassphrase first")
  is routed through util::friendlyRpcError(), so a locked wallet reads
  "Unlock your wallet first, then try again." instead of daemon jargon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:09:29 -05:00
DanSandClaude Opus 4.8 7d2972f315 feat(mining): explain solo/pool, add a payout-address create path, surface the miner download
New-user mining cluster from the UX audit:
- Solo/Pool toggle: hover tooltips on each half explaining the trade-off
  (solo = no fees but long waits on a small CPU; pool = frequent small payouts,
  needs the miner + a shielded payout address).
- Payout field: when it's empty (new wallets have no z-address, and a pool
  payout must be shielded) the bookmark button is replaced by a one-click
  "Generate a shielded payout address" button (createNewZAddress → fills the
  field, persists, toasts), instead of only a hover hint to the Receive tab.
- Miner updater pill: when no miner is installed it becomes a prominent accent
  "Download miner" call-to-action with a "pool mining needs DRG-XMRig" tooltip,
  instead of a subtle grey "Update" pill that's easy to miss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:03:24 -05:00
DanSandClaude Opus 4.8 8eee351faa fix(history): show shielding moves as neutral "Shielded", not red "Sent"
A coinbase/transparent→shielded move (often auto-shield) was drawn with a red
"Sent" label and send arrow yet a green "+X" amount — an internally
contradictory row that reads as "money leaving" to anyone who auto-shielded. It
now renders as a neutral "Shielded" row (shield icon + the shielded accent
colour for label and amount), and since a shield is your own funds going
private — not a spend — it's excluded from the Sent card total and the Sent
filter (it merges into one row visible under "All").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 08:57:38 -05:00
DanSandClaude Opus 4.8 09f39f9183 feat(i18n,errors): route node-action toasts through i18n + friendlyRpcError
Finish the error-surfacing foundation across app_network.cpp (+ one in
app_wizard.cpp): ~21 toasts that were hardcoded English — or raw daemon/RPC
error text concatenated after an English prefix — now go through TR() keys, and
every raw-error one maps the detail through util::friendlyRpcError() (warming
up / locked / method-not-found / insufficient / unreachable → plain guidance,
raw text as fallback). Covers mining start/stop, pool start, peer ban/unban/
clear, key export, rescan, wallet-switch guards, and the lite-unavailable
notices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 08:55:29 -05:00
DanSandClaude Opus 4.8 5cfa713be1 test(sweep): capture the no-peers node banner on its full-node sweep target
renderNodeStatusBanner suppresses itself during a UI sweep (the forced demo
state would otherwise paint a banner over every tab). Add a narrow opt-in
(sweep_show_node_banner_) so ONLY the overview-no-peers target shows it; every
other capture is unchanged. The target also clears connection_in_progress_ so
the evaluator classifies the state as no-peers rather than "connecting".

Verified in a full-node + DRAGONX_ENABLE_UI_SWEEP build: the banner renders as
"Connected, but no network peers — the node can't sync · Check your internet
connection and firewall, then restart the node to find peers." (Restart button
only appears with an embedded daemon, which the sweep has none of.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 23:59:03 -05:00
DanSandClaude Opus 4.8 cd9ca4cd77 test(sweep): add UI-sweep targets for the new syncing / no-peers / chat-identity states
Capture the states the earlier verification couldn't reach:
- overview-syncing: the balance "catching up — funds are safe" strip (P4).
- overview-no-peers: the node-status banner's no-peers detail (P2; full-node
  only — renders as a plain Overview in the Lite sweep binary).
- chat-identity-preparing / -unavailable: the chat empty states that used to
  all say "unlock your wallet" (P3). Setup clears the fabricated demo identity
  (clearIdentity wipes only the key; the demo message store survives);
  sweepEnsureDemoChatIdentity() re-fabricates on teardown so later targets keep
  the populated chat UI.

Also make the demo baseline a genuinely synced node (tip_known=true) so the
no-peers banner only appears on its own target, and dismiss the lite first-run
welcome prompt during the demo so it stops occluding every lite screenshot
(snapshotted + restored). Verified: all four new captures render correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 17:27:22 -05:00
DanSandClaude Opus 4.8 9db73323b6 feat(ui): friendly RPC errors + pause-on-hover/ellipsis toasts
Two of the systemic error-surfacing foundations from the UX audit:

- util::friendlyRpcError() maps the common raw daemon/JSON-RPC errors
  (warming up / -28, locked / -13, method-not-found / -32601, insufficient
  funds, unreachable node) to short translated guidance with a next step,
  falling back to the trimmed raw text. Wired into the send (money) path: the
  toast shows the friendly cause, the overlay keeps the raw text below it for
  diagnostics. The helper is reusable for the remaining raw-error sites.
- Toasts now pause their auto-dismiss while hovered and ellipsize over-long
  text with the full message in a hover tooltip, instead of hard-clipping it
  mid-word (durations were already lengthened earlier).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:53:37 -05:00
DanSandClaude Opus 4.8 4a8d07edce feat(balance): show a "catching up — funds are safe" strip while syncing
A syncing node skips the balance RPC, so the balance card paints a bare
0.00000000 / $-.-- that reads as lost funds after an update (a documented
support trap). Add one shared notice at the top of the Balance tab — above
every layout — shown only for the normal catching-up case (connected, network
tip known, still verifying, not a rescan/witness rebuild). Offline, no-peers,
a busy node and rescans stay the node-status banner's job, so the two never
stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:48:33 -05:00
DanSandClaude Opus 4.8 643ac7daef feat(seed,console): persistent re-nudging seed backup; confirm destructive console commands
- Seed backup: the one-time 12s toast that marked itself "reminded" the instant
  it fired (so a funded, unbacked wallet was never nudged again) is replaced by
  an actionable alert with a "Back up now" link that opens the backup dialog and
  persists in the bell panel. "Reminded" now means the user actually viewed the
  phrase (set from the backup dialog), so the nudge re-fires each session until
  they do, and escalates to a warning once a balance exists.
- Console: fund-moving / key-revealing commands (dumpprivkey, z_exportkey,
  z_sendmany, z_mergetoaddress, …) now require a confirming re-entry when typed
  or PASTED, honoring the command reference's `destructive` flag that was
  previously enforced only by the reference dialog's Run button. (Output
  masking of key dumps in the scrollback is still a follow-up.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:46:09 -05:00
DanSandClaude Opus 4.8 e27d88eca4 feat(chat): explain the no-identity state and why a send is stuck
- Chat tab no longer shows "Unlock your wallet" for every missing-identity
  case. A new App::chatIdentityStatus() distinguishes Locked (real),
  Preparing (unlocked, deriving the identity) and Unavailable (view-only or a
  legacy wallet with no exportable secret — correctly latched). Each gets its
  own icon + message, and the Unavailable prompt points at Back up / Migrate
  instead of telling the user to unlock an already-unlocked wallet.
- A chat message that sits queued ("Sending") because the wallet is offline or
  has no shielded funds for the fee now surfaces the reason (throttled, after a
  short dwell), wiring the ready-made chat_toast_not_connected /
  chat_toast_need_funds strings that were never shown. The send stays queued
  and goes out when the condition clears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:41:30 -05:00
DanSandClaude Opus 4.8 6efe3c0f5c feat(send,peers): show full recipient + contact label on confirm; actionable peers empty state
- send confirm: the FROM/TO cards now show the FULL address (wrapped) instead
  of a middle-truncated preview, and the saved-contact label when the address
  matches the address book ("TO  ·  Alice") — the strongest human check against
  a swapped recipient on the one screen meant to catch it. Cards auto-grow.
- peers tab: the "No connected peers" empty state now carries the same
  actionable hint as the node-status banner instead of being a bare dead-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:37:28 -05:00
DanSandClaude Opus 4.8 750771ebfb feat(network): give the "no peers" banner an honest detail and a Restart action
The no-peers banner headline ("Connected, but no network peers") was followed
by a detail line that just repeated connection_status ("Connected"/"Peers: 0"),
reading as a contradiction, and it offered no action at the exact moment the
wallet looks synced but is silently stalled at a stale height.

- NoPeers now clears the passthrough detail; App renders a translated,
  actionable hint (node_banner_no_peers_detail) for this reason instead.
- When the embedded daemon is the node, offer a Restart button (re-triggers
  peer discovery via the -addnode seeds); an external node stays action-less
  since it is the user's own to restart.
- Update + extend the evaluator unit tests (external = no action/empty detail;
  embedded = RestartNode).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:33:17 -05:00
DanSandClaude Opus 4.8 a99f71a0cd fix(ux): land quick-win UX audit fixes across settings, exports, toasts, nav
Batch of safe, isolated fixes from the 10-surface UX audit:

- settings: Auto-shield / Save-z-transactions toggles now persist on change
  (they discarded CB()'s return and relied on a Save button that only renders
  on the About tab, so the choice was silently lost on relaunch).
- exports: show the resolved Platform::getConfigDir() instead of a hardcoded
  ~/.config path (wrong on Windows/macOS) in both the all-keys and CSV
  dialogs; all-keys also reveals the file path on a partial export.
- notifications: error/warning toasts last 9s/7s (were 4s/3.5s) so failures
  are readable.
- qr popup / tx-details: copy-to-clipboard now confirms with a toast.
- about: Escape-to-close + readable heading contrast via OnSurfaceMedium().
- sidebar: suppress single-key nav (J/K/arrows/[) while an overlay dialog or
  ImGui popup is open, so it no longer switches the tab behind a modal.
- i18n: strip the stale hardcoded path out of file_save_location in i18n.cpp
  and all eight res/lang/*.json locales (additive, one key each).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:27:58 -05:00
DanSandClaude Opus 4.8 9ff64b1846 perf(history): band the full sent-sweep so it doesn't hold cs_main for minutes
A full z_listsentbyaddress holds the daemon's cs_main for the whole sweep --
~140 s on a large wallet on a daemon without the sent-history decrypt cache --
which starves block connection and makes every other RPC (getinfo, balances)
wait, so the node looks "busy". This runs the full sweep in depth bands so no
single call holds the lock for long.

- The full case now covers [0, tip] in bands: z_listsentbyaddress(z, minconf=a,
  filter-2, maxDepth=b) returns exactly depth [a, b]. Newest band first (its
  minconf=0 includes the mempool); results dedupe by txid across the overlap.
- Each band is a separate call, so cs_main is held only ~5 s at a time and the
  node stays responsive between bands (block connection isn't starved, the busy
  canary answers). Total CPU is unchanged -- just not in one long lock.
- sentSweepNextBandSize adapts the next band toward ~5 s: grows up to 4x in
  sparse depth (empty regions covered fast), shrinks to 1/2x in dense depth.
- W advances only after every band succeeds; a mid-sweep failure re-sweeps
  rather than marking a partial result. The windowed sweep is unchanged.
- Each band also fits the 120 s timeout on its own, so the seed completes even
  on a daemon where the single whole-wallet call used to time out.

This removes the ~2.5 min hang on the first-run seed, the weekly safety-net
re-sweep, and any invalidation. Multi-band + band-sizing covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 14:42:16 -05:00
DanSandClaude Opus 4.8 b47d55a3d0 feat(balance): show total period-earnings in every layout
#4 wired the total-earnings figure into the compact hero and the classic
layout only. This adds it to the remaining layouts in each one's native
style: donut / shield / timeline / minimal beside the USD line, consolidated
to the right of the USD, vertical-stack left of the Total row's amount, and
vertical-2x2 as a second line under the Total cell (dashboard and two-row
already show it via RenderCompactHero). All go through FormatTotalEarnings,
which returns nothing unless the global earnings toggle is on, so every
layout's default appearance is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 20:02:04 -05:00
DanSandClaude Opus 4.8 07437fb447 perf(history): weekly safety-net full re-sweep for sent history
Persisting the sent-sweep watermark means a relaunch runs a windowed sweep,
which can't see a change made outside the GUI (a Console z_importviewingkey,
an external same-seed restore) that adds an old send below the window. This
adds a periodic full re-sweep so that case is still caught within ~7 days.

- sentSweepSafetyNetDue(now, lastFull) = lastFull > 0 && now - lastFull > 7d
  (a wallet that has never full-swept doesn't trigger one; the seeding sweep
  records the time). lastFullTime / lastFullHeight ride in the sent_sweep
  snapshot; any successful full sweep (seed, verify-mismatch or safety-net)
  resets the clock. On load, a valid watermark without a clock starts it now,
  so even watermarks seeded before this field eventually re-sweep.
- When due, the snapshot forces a full sweep for that cycle. Gated on: a
  watermark exists, a routine (non-syncing) cycle, not the Mining page, and
  not in full-sweep backoff -- so a full sweep that can't finish on a slow
  daemon backs off instead of retrying every cycle.

Degrades gracefully: ~0.4 s on a note-cache daemon; callLong(120 s) with
backoff on an old one (the deferred banding would keep each cs_main hold
short so it fits there too). Helper + persistence covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 17:56:30 -05:00
DanSandClaude Opus 4.8 e28cacbe8e style(balance): use the shared SegmentedControl for the earnings toolbar
The Overview earnings controls (interval 1H/1D/1M/1Y, received/net mode, and the
global show-all toggle) were ad-hoc ImGui buttons. They now use
material::SegmentedControl -- the same rounded-track pill the Receive tab's
All/Shielded/Mining filter uses -- so the interval is a text segmented control,
the mode is two icon segments with per-segment tooltips, and the toggle is a
single matching segment (highlighted when on). Verified in a headless GL capture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 16:48:33 -05:00
DanSandClaude Opus 4.8 46371c69df perf(balance): stop large-wallet balance polls from saturating the node
On a wallet with a very large shielded note set, the daemon re-decrypts all
notes under cs_main on every z_gettotalbalance / z_listunspent (GetFilteredNotes),
~15-33s per pass. The GUI issued these roughly every 15s and timed them out at
the 30s RPC limit, so the node looked permanently "busy" and per-address
balances read 0 (nothing to carry on a fresh session) while the total was fine.

Two wallet-side mitigations:
- A cost-proportional floor on VISIBLE balance polls (wallet_scan_governor): the
  adaptive backoff previously applied only when the balance was off-screen. It
  now also holds a visible poll until ~3x the measured scan cost has elapsed, so
  a wallet whose sweep costs tens of seconds can't re-poll before the node
  recovers. Negligible for small wallets (cost ~ms); forced / first-read bypass it.
- The heavy balance RPCs (z_gettotalbalance, z_listunspent) now use a 120s reply
  timeout via the existing callLong path, so a slow pass completes and populates
  per-address balances instead of aborting at 30s and leaving them at 0.

The root fix is daemon-side (cache decrypted notes so GetFilteredNotes isn't
O(notes x decrypt) per call); specced separately. Adds governor test coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 16:48:24 -05:00
DanSandClaude Opus 4.8 3cda8c9e31 perf(history): persist the sent-sweep watermark (windowed relaunch)
z_listsentbyaddress re-walks the whole wallet when there's no usable
watermark, which the daemon does under cs_main for every send -- hundreds
of seconds, 400+ s and ~19 GB RSS on a large wallet. The watermark lived
only in memory, so every relaunch, reconnect or daemon restart paid for a
full sweep. This persists it so those cases run a bounded windowed sweep
([z, 0, 2, tip - W + margin]) instead (SENT-HISTORY-SWEEP Part 1).

- The watermark (W = last swept tip), its anchor (A = W - margin) and anchor
  hash (H_A = getblockhash(A)), plus the enrichment backlog and the full-
  sweep backoff, ride in the encrypted history snapshot as an optional
  `sent_sweep` object (schema stays v1; a missing/inconsistent one reads as
  "no watermark" = today's behaviour, so older builds and payloads are safe).
- Before the first windowed sweep after a load, the anchor is verified
  (getblockhash(A) == H_A). A match means any fork is inside the window; a
  mismatch (reorg deeper than the margin, different chain/datadir) or an
  inability to check drops the watermark and sweeps the whole wallet.
- The first sweep after a load uses a bigger margin (absorbs notifier lag),
  and a watermark older than 2400 blocks uses the long timeout (callLong).
- The backlog is persisted WITH W: a pure z->z send's txid lives only in a
  sweep result and would be lost if the window moved past an un-enriched one.
- Watermark changes reach disk (folded into the store signature), and every
  hard case still forces a full sweep: reorg (anchor verify), GUI rescan /
  zap / import / address change, restoreOriginalWallet, and disconnect.

Deferred (follow-ups, each only turns a correct-but-slow case faster, never a
correctness risk): the banded full sweep for seeding on a pre-note-cache
daemon, the external-import tripwire, and the weekly safety-net re-sweep.
Part 2 (the daemon's decrypt cost) is already on the daemon's
perf/sapling-note-cache branch.

Pure helpers + cache round-trip + anchor-verify/large-window covered by
tests; the getblockhash-after-sweep updates the pinned RPC sequences.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 14:47:48 -05:00
DanSandClaude Opus 4.8 064c9d1b61 feat(balance): inline period-earnings in the address list and totals
Surfaces the period-earnings aggregator in the Balance tab:
- Toolbar (shared address list): interval pills (1H/1D/1M/1Y), a received-
  vs-net mode toggle, and a global show-all toggle, placed right of "Hide 0
  Balances". The New Z-Address / New mining address buttons move to a footer
  row across from "Showing X of Y" to make room.
- Per address: the row draws its earnings on line 2, left of the $USD value
  (green positive, red negative in net mode), shown when the global toggle
  is on or the address is opted in via a new "Show/Hide earnings" right-click
  item. Works in every layout via the shared list.
- Total: FormatTotalEarnings (gated on the global toggle, so the default
  appearance is unchanged) is wired into the compact hero (dashboard/minimal)
  and the classic layout, left of / beside their USD figure. The remaining
  layouts' totals are not wired yet.
- Settings > Appearance: an "Earnings Display Format" selector (Coin /
  Coin + fiat / Fiat).

New i18n keys are translated into all eight locales; the CJK subset font is
rebuilt for the new glyphs. Builds + test suites pass on both variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 11:22:12 -05:00
DanSandClaude Opus 4.8 2ef91ea7d4 feat(settings): period-earnings preferences + per-address opt-in
Persists the Balance-tab earnings settings: interval (0=Hour 1=Day 2=Month
3=Year, default Day), mode (0=Inflows 1=Net, default Inflows), display
format (0=Coin 1=Coin+fiat 2=Fiat, default Coin+fiat), a global show-all
toggle (default off), and an opt-in set of addresses whose earnings column
is shown when show-all is off. All values are clamped on load and saved,
mirroring the existing favorite/hidden address sets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 11:22:12 -05:00
DanSandClaude Opus 4.8 19da7d1474 feat(data): period-earnings aggregation for the Balance tab
Adds src/data/earnings_series.h: a pure, header-only aggregator that sums
wallet inflows (received + mined) over the current calendar period (hour /
day / month / year), optionally net of sends, both per receiving address
and wallet-wide, at "as seen" values. Attribution is best-effort: inflows
map to the receiving address; in net mode a send debits from_address when
present (a send with no from_address still counts in the wallet total).

Includes a revision-keyed EarningsCache (mirrors ui::MiningEarningsCache)
and a 13-assertion unit test covering inflows vs net, per-address
attribution, period exclusion, period nesting, and cache invalidation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 11:22:12 -05:00
DanSandClaude Opus 4.8 faf45e60e4 fix(mining): make "Est. Daily" honest about variance and fees
The mining "Est. Daily" was a solo expected-value (your hashrate share x
blocks/day x a fixed block reward) rendered in the same green as the real
Today / Yesterday / All-Time earnings, off an instantaneous hashrate and
gross of pool fees. It read like a dependable daily income when it is a
long-run statistical average with huge (Poisson) variance.

Framing:
- The estimate is now drawn in a neutral colour, not the green "money
  earned" colour (the actual mined totals stay green).
- A sub-label in BOTH modes marks it as an average ("avg", or "avg, net"
  in pool mode when the fee is applied), replacing the pool-only
  "before pool fee" note.
- A hover tooltip over the column explains the variance ("often nothing
  for long stretches, then a lump - NOT a steady daily income") and the
  fee status (after the N% fee / before the fee when unknown / nothing for
  solo and 0%-fee pools).

Accuracy:
- Uses a trailing-average hashrate instead of the instantaneous / 10s
  snapshot: pool prefers hashrate_15m (then 60s, then 10s); solo uses the
  mean of hashrate_history once there are >=10 samples, else the instant
  rate. This stops a spike or a momentary dip from swinging the figure.
- Subtracts the known pool fee so pool-mode figures are net; synthetic or
  unmatched pools stay gross and the tooltip says the fee is unknown.

The static block reward and true pool-reported earnings are intentionally
out of scope (the latter needs a new pool-API field). New i18n keys are
translated into all eight locales; the CJK subset font is rebuilt for the
three new glyphs. Builds + test suites pass on both variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 10:56:36 -05:00
DanSandClaude Opus 4.8 2282f332a6 feat(history): view the transactions involving an address
Adds a "View transactions" action that opens the History tab pre-filtered
to a chosen address. SetTransactionsAddressFilter() stores a pending filter
that RenderTransactionsTab consumes on its next frame — pre-filling the
search box and resetting the type filter to All — then the caller navigates
to NavPage::History. This reuses the existing "prefill + navigate" pattern
(cf. SetSendFromAddress) and all of History's rendering, detail and export.

Triggers:
- Contacts: a context-menu item and a 4th per-row action icon (copy /
  view-transactions / edit / delete; the trailing width is widened to fit).
- Balance address list: a per-address context-menu item (own z/t addresses).
- Receive tab: the recent-received rows now carry their address into the
  filter instead of jumping to History unfiltered.

The History search also now matches from_address, so filtering by one of your
own addresses catches sends FROM it, not only receives INTO it.

Scoping is honest, not exhaustive: transparent is well covered, but shielded
counterparties are private and the transparent sender of received funds is
never recorded on-chain, so this shows transactions *involving* the address.
On Lite, from_address is thin, so own-address send filtering is weaker.

New i18n keys view_transactions / tt_view_transactions translated into all
eight locales (no new glyphs). Builds + test suites pass on both variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 10:13:15 -05:00
DanSandClaude Opus 4.8 5fcab14e4e feat(settings): adjustable opacity for the favorite gold outline + star
Adds a "Favorite Highlight Opacity" slider (15-100%, default 100%) to
Settings > Appearance > Scale & Effects. It scales the alpha of the four
favorite gold tokens (fill/border/icon/outline) at their resolve site in
the Balance address list via material::ScaleAlpha, so the gold row outline
and the star button fade together. The multiplier is skin-agnostic (it
scales whatever alpha the theme resolved) and touches only favorited rows;
non-favorite rows and the white selection highlight are unchanged, and the
100% default is byte-identical to the previous look.

The preference lives in Settings (favorite_opacity, clamped 0.15-1.0 in both
the setter and on load), persists across restarts, updates live, and is not
low-spec gated (it's a pure color-alpha preference, independent of the
transparency sliders). New i18n keys favorite_opacity / tt_favorite_opacity
are translated into all eight locales; the CJK subset font is rebuilt to add
the two new glyphs the zh string needs.

Verified: full + lite builds clean, full + lite test suites pass, hygiene OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 09:58:17 -05:00
128 changed files with 10167 additions and 1673 deletions
+25 -4
View File
@@ -15,7 +15,7 @@ if(APPLE)
endif()
project(ObsidianDragon
VERSION 2.0.2
VERSION 2.1.0
LANGUAGES C CXX
DESCRIPTION "DragonX Cryptocurrency Wallet"
)
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
set(DRAGONX_LITE_VERSION "1.1.1")
set(DRAGONX_LITE_VERSION "2.0.0")
set(DRAGONX_LITE_VERSION_SUFFIX "")
# C++17 standard
@@ -499,6 +499,7 @@ set(APP_SOURCES
src/chat/chat_service.cpp
src/chat/chat_database.cpp
src/chat/chat_outgoing.cpp
src/chat/chat_sensitive_vault.cpp
src/wallet/lite_owned_string.cpp
src/wallet/lite_rollout_policy.cpp
src/wallet/lite_client_bridge.cpp
@@ -553,7 +554,6 @@ set(APP_SOURCES
src/ui/windows/console_selection_controller.cpp
src/ui/windows/console_tab_helpers.cpp
src/ui/windows/console_text_layout.cpp
src/ui/windows/settings_window.cpp
src/ui/pages/settings_page.cpp
src/ui/windows/about_dialog.cpp
src/ui/windows/faq_dialog.cpp
@@ -670,6 +670,7 @@ set(APP_HEADERS
src/chat/chat_service.h
src/chat/chat_database.h
src/chat/chat_outgoing.h
src/chat/chat_sensitive_vault.h
src/config/version.h
src/data/wallet_state.h
src/data/transaction_history_cache.h
@@ -704,7 +705,6 @@ set(APP_HEADERS
src/ui/windows/console_selection_controller.h
src/ui/windows/console_tab.h
src/ui/windows/console_tab_helpers.h
src/ui/windows/settings_window.h
src/ui/windows/about_dialog.h
src/ui/windows/faq_dialog.h
src/ui/windows/faq_content.h
@@ -995,6 +995,26 @@ target_compile_definitions(HushChatFixtureCheck PRIVATE
DRAGONX_ENABLE_CHAT=0
)
# Regtest chat smoke driver (dev tool; never shipped). Links the real chat library so a runner
# script can verify a chunked message + Control memo round-trip through a live regtest dragonxd.
# cmake --build build/linux --target chat_regtest_smoke
add_executable(chat_regtest_smoke EXCLUDE_FROM_ALL
tools/chat_regtest_smoke.cpp
src/chat/chat_crypto.cpp
src/chat/chat_identity.cpp
src/chat/chat_outgoing.cpp
src/chat/chat_protocol.cpp
)
target_include_directories(chat_regtest_smoke PRIVATE
${CMAKE_SOURCE_DIR}/src
${SODIUM_INCLUDE_DIR}
)
target_link_libraries(chat_regtest_smoke PRIVATE
nlohmann_json::nlohmann_json
${SODIUM_LIBRARY}
)
target_compile_definitions(chat_regtest_smoke PRIVATE DRAGONX_ENABLE_CHAT=1)
# -----------------------------------------------------------------------------
# Copy resources
# -----------------------------------------------------------------------------
@@ -1226,6 +1246,7 @@ if(BUILD_TESTING)
src/chat/chat_service.cpp
src/chat/chat_database.cpp
src/chat/chat_outgoing.cpp
src/chat/chat_sensitive_vault.cpp
src/wallet/lite_owned_string.cpp
src/wallet/lite_rollout_policy.cpp
src/wallet/lite_client_bridge.cpp
+432
View File
@@ -0,0 +1,432 @@
# Sent-history sweep: GUI watermark fix, then daemon follow-up
**Audience:** ObsidianDragon (GUI) team and the DragonX daemon maintainer.
**Date:** 2026-10-16. **Status:** proposal.
**Code cited**
- GUI: `ObsidianDragon` @ `064c9d1` (unprefixed paths).
- Daemon: `dragonx` @ `e9247f5c6` (`perf/history-rpc-copies`), paths prefixed `dx:`. `test/v1.3.1-candidate` @ `ff28e34c8` is the same plus a crash fix; on that branch, `dx:wallet.cpp` lines past ~1263 shift by +10.
Anything not marked *(inference)* or *(estimate)* was verified in the code or measured.
---
## 1. Problem
**The wallet measured:** an isolated copy of the owner's mainnet wallet, run in a network namespace with no peers.
- 16,206 Sapling notes, all unspent.
- 5 z-addresses and 11,889 wallet txs.
- Unencrypted; `wallet.dat` is about 2.1 GB.
| Call | v1.3.0 | After daemon PR-A+B+C |
|---|---|---|
| Balance RPCs | ~14 s | ~50 ms |
| Windowed `z_listsentbyaddress` (filter type 2) | 19.5 s | 128 ms |
| **Full `z_listsentbyaddress` (no watermark)** | **424 s**; RSS 8 → 19.4 GB, never released | **113 s** |
| Full-sweep reply | 26.2 MB (all 11,889 txs) | same |
### Why every launch pays for the full sweep
- **The watermark lives only in memory.** `history_sent_scan_height_` starts at `-1` (`src/app.h:1764`).
- **Every disconnect resets it to `-1`.** That covers daemon restarts, reconnects and wallet switches. The path is `onDisconnected` → `resetTransactionHistoryCacheSession` → `invalidateShieldedHistoryScanProgress(false)` (`src/app_network.cpp:936`, `:1847`, `:1889-1895`).
- **Other resets:**
- a locked-wallet connect;
- imports (`:4013`);
- rescans (`:7279`, `:7302`; `src/app.cpp:6298`) and zap (`app.cpp:6352`);
- a new, never-scanned z-address (`app_network.cpp:2593-2597`);
- the end of a warmup during which a rescan was observed (`:2186-2195`).
- **No watermark means a full sweep.** `sentSweepIsFull` is then true (`src/services/history_scan_policy.cpp:35-38`). The GUI sends `[z,0]` via `callLong(..., kFullSentSweepTimeoutSec = 120)` (`src/services/network_refresh_service.cpp:1324-1325`, `history_scan_policy.h:31`).
- **The daemon ignores the address argument.** It walks every wallet tx under `LOCK2(cs_main, cs_wallet)` and calls `zsWalletTxJSON(wtx, ret, "*", false, 0, …)` for each one (`dx:src/wallet/rpcwallet.cpp:3719-3746`; the call is at `:3743`).
- **The full sweep barely fits, or never fits, the timeout.**
- On the fixed daemon the sweep finishes only **7 s inside** the 120 s timeout.
- On v1.3.0 it never finishes. Failures back off 10 min → 1 h → 6 h (`fullSentSweepRetryDelaySec`). That backoff is also kept only in memory (`app.h:1765-1766`).
### What the GUI uses from the reply
- **Sweep consumer** (`network_refresh_service.cpp:1328-1332`, `appendSentTransparentOutputs` ~`:1076-1123`):
- It reads only `txid`, `confirmations`, `blocktime`/`time` and `sends.transparentSends`.
- It never reads `received` (20.8 MB of the 26.2 MB) or `spends`.
- **Shielded sends come from enrichment.** Every txid goes into `knownTxids` for `z_viewtransaction` enrichment, capped at 25 per cycle (`app.h:1753`). The rest wait in the in-memory `history_enrich_backlog_`.
- **One exception:** `locateSweepTxid` (seed-migration resume) does read `sends.saplingSends` (`app_network.cpp:6394`).
---
## 2. Part 1: GUI fix (persist the watermark)
**Goal:** a relaunch, reconnect or daemon restart runs only the windowed sweep, `[z,0,2,(tip-W)+margin]`. A full sweep then happens only in three cases:
- there is no persisted state;
- the persisted state fails verification;
- the periodic safety-net re-sweep is due.
### 2.1 Where to store it
**Use the existing encrypted history snapshot.** Store an optional `sent_sweep` object in its JSON payload, not in `settings.json` or `wallets.json`. Why:
- The watermark is only valid together with the rows it produced. Both are written in one `INSERT OR REPLACE` in WAL mode (`src/data/transaction_history_cache.cpp:438-457`), so a crash leaves the previous pair intact.
- The snapshot already provides what the watermark needs:
- per-wallet keying (BLAKE2b of the sorted z+t address set, `:157-186`);
- encryption (Argon2id from the passphrase, or salted BLAKE2b when the wallet is unencrypted);
- a tip check on load (`:338-344`);
- one-wallet pruning (`pruneOtherWallets`, `:655-672`).
- **Keep `kSchemaVersion = 1`.** `load()` rejects any other version (`:351`), so a bump would throw away every user's cache.
- Unknown keys are ignored, so older builds read new payloads safely.
- A missing `sent_sweep` means `W = -1`, which is today's behaviour.
| Field | Type | Meaning |
|---|---|---|
| `height` | int | `W`: the tip used for the sweep's `maxDepth`. Read in the same worker job, right before the call |
| `anchor_height` | int | `A = W - margin`: the bottom edge of the window the sweep covered |
| `anchor_hash` | hex | `H_A = getblockhash(A)`, fetched by the worker after the sweep succeeds |
| `backlog` | [txid] | `history_enrich_backlog_`. **Required**, see 2.4 |
| `full_failures`, `full_retry_delay_s` | int | Full-sweep backoff. The delay is relative to save time, not absolute |
| `daemon_id` | string | Daemon version + start time, from `getinfo` / uptime. If it changes, the restored backoff resets (2.5) |
| `last_full_height`, `last_full_time` | int | Last successful full sweep; drives the safety net (2.6) |
| `txcount`, `k` | int | Optional tripwire (2.3). Both captured **in the sweep's worker job** |
Do **not** reuse the snapshot's `tip_height`/`tip_hash` columns. They record the tip at store time, not at sweep time (`app_network.cpp:2069-2070`).
### 2.2 Code changes
| File | Change |
|---|---|
| `src/data/transaction_history_cache.h:20-28`, `:33-48`, `:79-84` | Add a `SentSweepState` struct to `LoadResult`, `SnapshotJob` and `replace()` |
| `src/data/transaction_history_cache.cpp:412-426` (`writeSnapshot`) | Emit `sent_sweep` only if `0 < A <= W <= job.tipHeight` |
| `src/data/transaction_history_cache.cpp:351-370` (`load`) | Parse with defaults (`W=-1`, empty backlog). Drop the object if it has the wrong type or `W` is above the stored `tip_height` |
| `src/data/transaction_history_store_policy.h:35-41` / `.cpp:62-113` | Add `W`/`A`/`H_A` to the `slow` signature. They move every block, so this means a write every 600 s and always on disconnect, lock and exit. Add the backlog to `content` (written within about 20 s). Otherwise an invalidation that only changes the watermark is skipped as "unchanged". Update both callers (`app_network.cpp:2012-2014`, `:2041-2045`). The epoch counter is never persisted, so it doesn't belong in a signature |
| `src/app_network.cpp:2066-2079` (`storeTransactionHistoryCacheIfAvailable`) | Fill `job->sentSweep` from the `history_sent_*` and backlog members |
| `src/app_network.cpp:1998-2017` (`loadTransactionHistoryCacheIfAvailable`) | Restore the members. `++history_sent_epoch_`. Set `history_sent_verify_pending_ = true`. Apply any **pending rescan action** (2.3) |
| `src/app_network.cpp:2028` | The store returns early when there are no rows. On a `persistCache=true` invalidation with no rows, call `clearWallet()` so a stale W can't survive on disk |
| `src/services/network_refresh_service.h:256-266`, `:299` | New snapshot/result fields: `anchorHeight`/`anchorHash` in, the verify result and `H_A` out, and the band cursor |
| `src/services/network_refresh_service.cpp:1314-1345` | Read the tip immediately before the call. After success, fetch `getblockhash(A)`. Add the verify step (2.3) and the large-window path (2.5) |
| `src/app_network.cpp:2784-2792` | Gate both the end-of-pass sweep and the **mid-pass** windowed sweep (`allowMidPassSentSweep`) on the pending verify. Extend the retry gate at `:2792`, which today applies only when `W < 0`, to large windows |
| `src/app_network.cpp:2843-2854` | Count large-window failures toward the backoff; today only `sentSweepFull` failures count (`:2849`). Store the band cursor in `App` and reset it with `history_sent_epoch_`, like the watermark |
| `src/app.cpp:restoreOriginalWallet` | Call `invalidateShieldedHistoryScanProgress(true)` before `onDisconnected`, as `rescanBlockchain` and zap already do (`app.cpp:6298`, `:6352`) |
| `src/services/history_scan_policy.{h,cpp}` | Pure helpers: `sentSweepAnchor(W)`, `sentSweepNeedsLongCall(tip, W)`, `sentSweepMargin(firstAfterLoad)`, `nextSentSweepBand(...)` |
### 2.3 Invalidation and safety rules
**Verify at the bottom edge of the window, not at its tip.**
- **The check:** before the first windowed sweep after a load, call `getblockhash(A)` and compare it with `H_A`.
- **Match:** any fork point lies above `A`, so it is inside the window. Run the windowed sweep.
- **Mismatch:** a reorg went deeper than the margin, or this is a different chain or datadir. Drop W and run the banded full sweep (2.5).
- **Why not the tip:** checking `getblockhash(W)` would trigger a full sweep on an ordinary one-block reorg of the tip, even though the window already covers that reorg.
| Event | Action | Mechanism |
|---|---|---|
| First windowed sweep after a load | Anchor verify (above) | `getblockhash`: O(1), under `cs_main` |
| Stored `tip_height` > current tip at load (replay after an unclean stop, older datadir) | **Today:** `load()` deletes the whole snapshot (`cache.cpp:338-344`, `clearWalletByHash` at `:341`). Rows and W are lost, and a full sweep runs | Keep as the default. **Optional improvement:** have `load()` keep the rows and `sent_sweep`, flag "ahead", and defer the sent sweep until `tip >= A`, then verify `H_A`. That requires changes in `sentSweepIsFull` (`history_scan_policy.cpp:37`: `W > tip` means full today), the gate at `app_network.cpp:2789-2792`, and the test at `tests/test_phase4.cpp:15831` |
| Wallet switch, or an address added or imported while the GUI was closed | Nothing new | Different address set → different snapshot key → full sweep |
| GUI-initiated rescan, zap, import, runtime rescan | Already `invalidate(true)`; with 2.2 it now reaches disk | `app.cpp:6298`, `:6352`; `app_network.cpp:4013`, `:7279`, `:7302` |
| New never-scanned z-address during a session | Already resets W (`:2593-2597`); the reset must be persisted | Signature change in 2.2 |
| Disconnect, daemon restart, same-wallet reconnect | Keep today's in-memory reset | The disk copy is written first (`:857`) and reloaded after connect |
| **Rescan observed in the daemon log** (the daemon's catch-up "Rescanning last N blocks (from block H)", or an external `-rescan`) | **Sticky pending action** (see below) | The warmup-end invalidation at `:2186-2195` runs *before* the snapshot loads, so today it can't reach a persisted W |
| Wallet changed outside the GUI with the same address set (Console `z_importviewingkey`, external `-rescan`, same-seed restore) | Optional tripwire: after the first windowed sweep returns R rows, run the banded full sweep if `txcount − txcount_saved > \|R\| − k + slack`. `k` is the rows of the saved sweep within the same `100 + E` depth band the first window uses | `getwalletinfo.txcount` = `mapWallet.size()` (`dx:rpcwallet.cpp:2791`). The GUI already fetches it on every connect (`network_refresh_service.cpp:251-252`, `:295-298`; `app_network.cpp:2140-2143`). Measured cost: ~8-20 ms warm, ~180 ms on the first call after a start |
| Daemon version change | Keep W. Reset the persisted backoff | Same filter semantics since 2020; v1.3.0 and the candidate both report 1.3.0.50 |
**The sticky rescan signal.** Why it's needed:
- During warmup no address list is fetched (`scan_policy.h:42-44`, `:64-68`; `app_network.cpp:2492`), and `state_.clear()` has emptied the rows (`wallet_state.h:406-409`).
- So when `invalidate(true)` runs at `app_network.cpp:2195`, it finds an empty identity and returns (`:2028`, `:2032`).
- The cache then loads afterwards (`:2608`) and would restore the old W unchanged.
The fix:
1. Whenever `rescan_confirmed_active_` is set (`app.cpp:1193`, `:1324`, `:1397`), record a pending action that survives `resetTransactionHistoryCacheSession`:
- **clamp** to `H`, parsed from "Rescanning last N blocks (from block H)" or "Rescanning from height H";
- **full reset** if `H` is unknown or near genesis.
2. `loadTransactionHistoryCacheIfAvailable` applies it to the loaded W (`W = min(W, H)`, and `A` follows) and to the receive-scan heights, then clears it.
With daemon item D4 (wallet locator on clean shutdown, 3.2) shipped, the daemon's catch-up rescan line disappears from normal starts.
**Reorg overlap.**
- **Window extent.** Filter type 2 drops only txs deeper than N (`dx:rpcwallet.cpp:3740`), and `maxDepth = tip − W + margin`. So the window covers heights ≥ `W − margin + 1`, plus the mempool (depth 0 always passes).
- **Margin.**
- `MAX_REORG_LENGTH` defaults to 99 (`dx:src/main.h:79`), and a longer reorg shuts the node down (`dx:main.cpp:4314-4333`).
- A margin of 100 therefore leaves 1 block of slack, which tip drift between reading the tip and making the call can use up.
- Use **margin ≈ 120** in steady state *(recommendation)*, and read the tip in the same worker job as the call.
- **First sweep after a load.** Use margin `120 + E` with E ≈ 1000 blocks (~10 h).
- This absorbs wallet-notifier lag at save time: `ThreadNotifyWallets` applies blocks asynchronously (`dx:src/validationinterface.cpp:89-262`), and no RPC exposes the wallet's processed tip.
- E is an inferred bound, not a measurement.
- **Large `-maxreorg`.** A `-maxreorg` set above the margin is caught by the anchor check.
- **Conflicted txs.** Conflicted txs (depth −1) are dropped by both the full and the windowed sweep (`dx:rpcwallet.cpp:3725`), so nothing changes there.
**Locked-wallet rule.**
- **The rule:** never advance or persist W from a sweep dispatched or completed while the wallet is locked, and never load or store the snapshot while it is locked.
- **What already protects this today:**
- History scans don't run while locked (`src/services/scan_policy.h:48-51`).
- An encrypted wallet's snapshot opens only after unlock (`app_network.cpp:797-798`, `:1944-1962`).
- Locking keeps W and stores immediately (`:1851-1859`).
- **What breaks if it's ever violated:** a locked daemon wallet returns empty `saplingReceived` and `saplingSends`, because `GetSaplingHistoryKeys` needs spending keys (`dx:rpchushwallet.cpp:19-39`).
- The fields the main consumer reads (txid, `transparentSends`) don't depend on the lock.
- `locateSweepTxid` reads `saplingSends`, so it must never run on a locked wallet.
### 2.4 Backlog rule (required)
- **The risk.** Enrichment is capped at 25 per cycle, and the txids that miss the cap go to `history_enrich_backlog_` (`app_network.cpp:2843`).
- A pure z→z send leaves only a change note, which produces no receive row (`network_refresh_service.cpp:972`).
- So its txid exists **only** in a sweep result.
- If W is persisted while those txids sit in the backlog, the sends are lost for good once the window moves past them.
- **The rule:** persist the backlog together with W.
- **Rejected fallback:** "persist `W = -1` while the backlog is non-empty" isn't enough on its own. Enrichment retries are unbounded (`network_refresh_service.cpp:1521`), so one txid that keeps failing would stop W from ever being persisted. If that fallback is wanted as well, cap the retries per txid.
### 2.5 Timeouts, large windows, banded full sweep
**Large windows.**
- The windowed sweep uses `rpc.call`, which has curl's 30 s timeout (`src/rpc/rpc_client.cpp:223`). Only full-sweep failures count toward the backoff (`app_network.cpp:2846-2853`).
- So a watermark saved days ago produces a huge "windowed" call that times out on every block, while the daemon keeps working on each abandoned call.
- Rule: if `tip − W > 2400` (about 1 day of 36 s blocks), use `callLong` and count failures the same way as for a full sweep.
**Banded full sweep.**
- **The call:** replace the single 113 s (fixed daemon) or 424 s (v1.3.0) call with depth bands, `z_listsentbyaddress(z, minconf=a, 2, maxDepth=b)`. `minconf` (`dx:rpcwallet.cpp:3732`) and filter type 2 (`:3740`) are independent filters, so each call returns exactly the depth band `[a, b]`.
- **Order:**
- Go newest band first; the first band uses `minconf=0` so it includes the mempool.
- Overlap bands by at least 10 blocks, plus the blocks that arrive between calls, and dedupe by txid.
- Size bands adaptively, aiming for about 5 s each.
- **Scheduling:** run **one band per history cycle**, using the epoch-guarded band cursor from 2.2. All bands in one job would tie up the shared RPC worker for about 113 s, exactly as today.
- **Watermark:** take `W` from the tip at the first band, and set it only after every band has succeeded.
- **Don't page with `nCount`.** The iteration order is `nOrderPos`, not height (`:3709`, `:3746`).
- **Limits:**
- Banding shortens each `LOCK2` hold but not the total CPU.
- On v1.3.0 every call has a ~19.5 s floor, because every `CWalletTx` is deep-copied *(inference from the windowed measurement)*. There the bands need `callLong`.
**Backoff persistence.**
- Store the failure count and the relative delay (2.1), so a relaunch doesn't immediately re-run a sweep that keeps failing.
- Reset both when `daemon_id` changes. Otherwise a user who installs the fixed daemon could wait up to 6 h for a full sweep that would now succeed.
### 2.6 Safety net
- **When:** about every 7 days, run one banded full re-sweep.
- **Conditions:** only when synced, unlocked, idle (not on the Mining tab) and admitted by the governor.
- **On success**, update `last_full_*`. On failure, back off.
- **Why:** it catches what the guards can't see, such as a Console `z_importviewingkey`. That case is also missed within a session today.
### 2.7 Edge cases
| Case | Handling |
|---|---|
| Stored tip < current tip: `load()` doesn't check the hash (`cache.cpp:338-344`) | The anchor verify covers the watermark. Optionally extend `load()` too |
| Transparent send rows from `appendSentTransparentOutputs` aren't reproduced by windowed sweeps | Probably dropped on the next clean cycle (`network_refresh_service.cpp:1356-1368`, `:1528-1539`) *(inference)*. Negligible on DragonX, where consensus bans non-notary/burn t-outputs (`dx:main.cpp:1606`, `:1633`, `:1657`). Optional fix: carry over `send` rows that `listtransactions` didn't produce |
| `locateSweepTxid` runs a full sweep under the 30 s timeout (`app_network.cpp:6388`) | That times out on this wallet. Switch it to a windowed or banded call |
| Watch-only addresses aren't part of the snapshot key | Covered by the tripwire and the safety net |
| Switching between two wallets | `pruneOtherWallets` keeps one snapshot, so a switch still costs a full sweep (open question 4) |
| GUI crash mid-sweep | W advances only on `sentSweepOk`, and the write is atomic |
| Regtest or testnet | Different z-address prefixes, so a different snapshot key |
### 2.8 Test plan
**Unit tests** (`tests/test_phase4.cpp`):
- In `testTransactionHistoryCache` (`:9057`):
- every `sent_sweep` field round-trips;
- a missing key reads as −1;
- a wrong type, `W > tip_height` or `A > W` reads as −1;
- unknown keys are ignored.
- Signatures (~`:9244`): a change to W, A or `H_A` changes `slow`; a change to the backlog changes `content`.
- Policy (~`:15829`, `:16098-16213`):
- the anchor is `W − margin`;
- the first window after a load uses `120 + E`;
- `tip − W > 2400` uses `callLong` and counts failures;
- band planning: boundaries, overlap, dedupe, and cursor reset on an epoch change;
- an anchor mismatch means a full sweep;
- the pending rescan action clamps W;
- `daemon_id` changes reset the backoff.
**Integration tests** (regtest or `-testnode`):
1. Full sweep, quit, relaunch: the log shows only `[z,0,2,N]` calls.
2. `invalidateblock` one block above A while the GUI is closed: the windowed sweep runs and returns the right rows.
3. `invalidateblock` below A: the verify fails and a banded full sweep runs.
4. More than 25 z→z sends, then quit before enrichment finishes: after relaunch every send appears.
5. A daemon started with `-rescan` while the GUI was closed: the sticky action clamps or resets W.
6. `restoreOriginalWallet`: W is invalidated.
7. Locked encrypted wallet: W doesn't advance and the snapshot isn't loaded.
**Mainnet copy:** relaunch against the isolated owner copy. Expect no `[z,0]` call and a sent sweep well under 1 s.
### 2.9 Expected effect
| Scenario | Today | With Part 1 |
|---|---|---|
| Relaunch or reconnect, fixed daemon | 113 s full sweep under `LOCK2`, 7 s from the timeout | `getblockhash` plus one windowed sweep over `120 + E` + the closure gap. Steady-state windows measure ~128 ms; the first window after a load is larger *(unmeasured)* |
| Relaunch, v1.3.0 daemon | 424 s; never fits in 120 s, so it loops in backoff with RSS at 19.4 GB | ~19.5 s+ windowed once a W exists. Seeding the first W needs the banded sweep |
| Safety net | — | One banded full sweep a week, each `LOCK2` hold about 5 s |
---
## 3. Part 2: daemon follow-up
### Where the 113 s goes (owner wallet, full sweep)
| Component | Cost | Basis |
|---|---|---|
| Trial decryption of outputs that aren't wallet notes: ~172,800 outputs × 5 ivks ≈ 864k failed tries × ~0.13 ms | **~112 s (~99%)** | Output count derived from tx sizes; per-try cost fitted to the full (112.7 s) and windowed (126 ms / 740 tries) measurements *(estimate)*. Code: `OutputDecryptions::Get` (`dx:rpchushwallet.cpp:87-100`), loop at `:303-346` |
| `DecryptSent` esk-path decrypts | **0 on this wallet** | No tx spends a wallet note, so the sends helper is gated off (`:586`) |
| UniValue build + copy under `LOCK2` | ~0.28 s | Synthetic benchmark on the repo's UniValue |
| Reply serialization (after the lock is released) | ~0.31-0.34 s | Same benchmark |
### Where the foreign outputs come from
**Sietch padding.** `z_sendmany` pads its z-recipients up to `-sietch-min-zouts` (default 7, clamped to 3-50; `dx:rpcwallet.cpp:5424-5434`), with change on top.
- Recipient and dummy outputs take the sender's ovk: the from-address's `fvk.ovk`, or the seed ovk for t-address sources (`dx:asyncrpcoperation_sendmany.cpp:352-368`, applied at `:437`).
- Consolidation (`saplingconsolidation.cpp:244`), sweep (`sweep.cpp:299`), shieldcoinbase (`:239-254`) and autoshield (`:435`) also add dummies with the sender's ovk.
- So a **sending** wallet's `DecryptSent` opens every one of its own dummies. That is the ~0.36 ms esk-path decrypt each, which is D2.
- Every other wallet sees them as foreign outputs and tries them under every ivk, which is D1.
**On this wallet** most foreign outputs are **not** sietch dummies. About 158.6k of the ~174k sit in the 9,597 txs with 10 or more outputs, which are mostly multi-recipient payouts *(inference from tx sizes)*. That doesn't change D1.
### Items
| # | Item | Output | Gain (owner wallet) | Risk | GUI change |
|---|---|---|---|---|---|
| D1 | Per-output negative ivk cache, plus parallel pre-fill of the txs a call selects | Byte-identical | Warm 113 s → 0.4-1 s. First full sweep after a restart ~20-25 s (pre-fill at the measured 4.75× warm-up scaling). A windowed call stays ~100 ms *(estimate)* | Low-med | None |
| D2 | `DecryptSent` cache per (output, ovk) | Byte-identical | 0 here; ~0.36 ms per send output on sending wallets | Low | None |
| D3 | `getalldata` balance loop | Byte-identical | ~20-35 s → 10-30 ms *(estimate)* | Low | None (the GUI never calls it) |
| D4 | Write the wallet locator on clean shutdown | n/a | Start: −49-62 s (post-rescan locator write, *inferred from the log gap*) and −5-10 s of block rescan; the ~25 s witness-root pass moves to the first live block. Shutdown: +3-60 s *(inferred)* | Medium | Check the stop policy |
| D5 | JSON trims | Byte-identical | 0.2-0.4 s | Low | None |
| D6 | Sends-only `returnType` (6th parameter) | **Changes output; opt-in** | 113 s → 0.1-0.3 s; 26.2 → ~4.1 MB *(estimate)* | Low | Probe + opt-in |
| D7 | Honour the address argument | **Changes output** | None (slower) | Breaks the GUI | Not recommended |
- **D6 is the single largest win**, but it needs a GUI release.
- **D1-D5 are invisible to the GUI.**
- **With Part 1 shipped, full sweeps become rare**, so D1 mostly helps older GUIs, other clients and the safety net.
### 3.1 D1: per-output decryption cache (byte-identical)
**Shape**
- A `mutable std::vector<uint64_t>` on `CWalletTx`, with one mask per `vShieldedOutput` index.
- It is sized once, never serialized, and cleared in `Init()`.
- Precedents: `cachedPlaintext` (`dx:src/wallet/wallet.h:340-348`) and the mutable caches at `:472-490`.
- Bit k means "history key k does not decrypt this output".
- An append-only ivk → index registry on `CWallet`, under `cs_wallet`. It is resolved once per RPC into `SaplingHistoryKeys::SpendableAddress` (`dx:rpchushwallet.cpp:19-39`).
**Logic in `OutputDecryptions::Get`**
- If the bit is set, return null without decrypting.
- After a failed `Decrypt`, set the bit.
- Never cache a success: wallet notes already carry `cachedPlaintext`.
- Keys with index ≥ 64 aren't cached.
**Invariant: the result is a pure function of (txid, n, ivk).** A v4 txid commits to every output byte, so:
- nothing ever goes stale;
- a newly added key has a clear bit and is still tried;
- locking, or losing keys, only means fewer keys are consulted;
- reorgs, deletions and reloads are harmless;
- output stays byte-identical.
**Thread safety**
- The mask is mutable behind a `const CWalletTx&`, so the compiler won't enforce locking. Every mask read and write therefore happens on the RPC thread under `cs_wallet`, with an `AssertLockHeld` in the accessor.
- Pre-fill workers follow `FillSaplingNoteCache`'s rule that workers must not lock, or even assert a lock (`dx:wallet.cpp:5745-5746`). They call only `decrypt_with_note` on pre-resolved (output, ivk) pairs and write private masks, which are merged serially after the join.
- Workers must not call `GetSaplingHistoryKeys` or `IsNullifierOfAddress`, because both assert locks.
**Fill**
- Lazily, as the helpers run.
- In addition, pre-fill in parallel, but **only the txs the call has already selected**:
1. In `z_listsentbyaddress`, first collect the `wtx` pointers that pass the filters at `dx:rpcwallet.cpp:3722-3741`, honouring `nCount`.
2. Pre-fill their outputs, using the 128-item threshold and `-witnessbuildthreads` from `dx:wallet.cpp:5739-5797`.
3. Then emit.
- Pre-fill time is spent under `LOCK2`. For a full sweep that's ~20-25 s once; a windowed call stays small.
- Optionally, also record the misses `FindMySaplingNotes` already computes (`:2282-2322`).
- A startup precompute is opt-in only: ~112 CPU-s, about 24 s of wall time per start *(estimate)*.
**Also benefits:** `getalldata`'s tx list (`dx:rpcwallet.cpp:3254`) and `z_listreceivedaddress` (`:3555`).
**Tests**
- Extend the verbatim oracle `dx:src/gtest/test_wallet_history.cpp`: cold, warm and pre-filled runs must match byte for byte.
- A key added after the mask was filled, which opens a foreign output, must appear in the output.
- More than 64 keys; locked and encrypted wallets; `-witnessbuildthreads=1` vs 24; a `DEBUG_LOCKORDER` build.
- The mainnet A/B harness: `cmp` cold and warm replies against the captured 26,233,144-byte reply (`/home/d/dragonx-ab/mainnet-ab/out/B/cap/023_z_listsentbyaddress_addr0_full.json`).
**D2.** Cache `DecryptSent` results per (n, ovk index): the plaintext and address on success, or "no". That's about 650 B per entry. Key the seed ovk by value, so lock state still decides whether the cache is consulted. Use the same oracle tests as D1.
### 3.2 Smaller items
**D3: the `getalldata` balance loop** (`dx:rpcwallet.cpp:3108-3151`, under `LOCK2` at `:2998`).
- Today it copies each `SaplingNoteData` by value, witnesses included (`:3110`). For every unspent note and every address it re-derives the spending key and trial-decrypts (`:3117-3121`).
- Fix:
- build `SaplingHistoryKeys` once, and reuse it at `:3233`;
- take note data by `const&`;
- use `OutputDecryptions(wtx, op.n).Get(a.ivk)` over `keys.spendable`, moving `OutputDecryptions` out of its anonymous namespace (`rpchushwallet.cpp:56-120`) into `rpchushwallet.h`.
- Keep two quirks for byte-identity, and fix them later as a separate change with a release note:
- The no-op `txType == 3;` at `:3124` means locked notes are never counted as locked. When fixed, use a per-note local, `noteType = (txType == 0 && IsLockedNote(op)) ? 3 : txType`. `txType` is per tx (`:3043`), so assigning to it would mark every later note of the tx as locked.
- Every address whose ivk opens the note is credited, because the `continue` doesn't exit the address loop.
- Don't switch to `GetFilteredNotes`: that changes the output.
- Test: an oracle gtest with a verbatim copy of the old loop (shared-ivk, locked-note, watch-only and encrypted cases).
**D4: write the wallet locator on clean shutdown.**
- **Evidence:** the copy, taken from a clean Windows shutdown, was 12 blocks behind its chainstate. So every start rescans those blocks.
- **Cause:**
- `FlushStateToDisk` writes the locator only if `DATABASE_WRITE_INTERVAL` (3600 s, `dx:main.h:145`) has passed, even for `FLUSH_STATE_ALWAYS` (`dx:main.cpp:3838-3841`).
- `Shutdown()` calls it at `dx:init.cpp:259`.
- `pwalletMain->Flush(true)` at `:280` only flushes BDB.
- **Hook:** in `Shutdown()`, between `:259` and `:280`:
```cpp
LOCK2(cs_main, pwalletMain->cs_wallet);
const CBlockIndex* tip = pwalletMain->GetWalletTip(); // new accessor: pindexWalletTip is private (wallet.h:988-994)
if (tip && tip != lastStamped)
pwalletMain->SetBestChainINTERNAL(walletdb, chainActive.GetLocator(tip));
```
- **Don't route it through `CWallet::SetBestChain`.** That swaps in `pindexWalletTip` when set but falls back to the passed locator when it's null (`dx:wallet.cpp:829-834`). The null case is exactly "stamp the tip before the init rescan ran", which would skip the unscanned blocks for good.
- **The failure path needs the lock too.** AppInit2's failure path calls `Shutdown()` without `join_all` (`dx:bitcoind.cpp:232-243`), so the notifier can still be running. Reading the tip under `LOCK2` keeps that safe.
- **Don't drop the interval check globally.** `gettxoutsetinfo` and init also flush with ALWAYS, and each would then rewrite ~2 GB under `cs_main`.
- **Trade-off:** one locator write rewrites every note-bearing `CWalletTx`, ~2 GB here (`SetBestChainINTERNAL`, `dx:wallet.h:939-986`, WriteTx loop `:947-962`). The cost moves to shutdown rather than disappearing; see the D4 row in the table above.
- **Ship it together with a parallel witness-root check at init.**
- **Risk:** the GUI's stop policy (`src/daemon/daemon_controller.h:360`, `evaluateStopWait`) must allow about 16 s plus 3-60 s. Context: the 2026-09-30 Windows kill loop.
- **Tests:**
- Stop within the hour and restart: no "Rescanning last" line, and identical balances.
- Stop during the startup rescan: the locator does not advance.
**D5: JSON trims.**
- Use `static_cast<const CTransaction&>` at `dx:rpchushwallet.cpp:553`. Today it copies 190.6 MB of tx data per full sweep.
- Drop the `getValues` / reverse / `push_backV` copy (`dx:rpcwallet.cpp:3749-3755`). Collect the selected `wtx` pointers in the existing reverse loop, which keeps `nCount` picking the newest txs, then build `ret` oldest to newest. The same pattern is at `getalldata` `:3259-3265`.
**Also found; each needs its own design.**
- The hourly `SetBestChain` rewrites ~2 GB while `cs_main` is held (3-47 s gaps in the live log, *inferred from log gaps*).
- `FindMySaplingNotes` tries foreign outputs once per viewing key and again per address (`dx:wallet.cpp:2282-2322`). De-duplicating ivks would probably halve the per-block tries *(inference)*.
### 3.3 Options that change RPC output (need a GUI opt-in)
**D6: sends-only mode.**
- **The parameter:** add an optional 6th parameter `returnType` to `z_listsentbyaddress`, default 0 (unchanged output).
- The code's meanings are 1 = spends, 2 = received, 3 = sends. The comment at `dx:rpchushwallet.cpp:561-566` swaps 1 and 3; fix it in the same change.
- **With `3` and no address filter:**
- the spends helper still runs (`:569`), but its output isn't emitted;
- `received`, where all the trial decryption happens, is skipped (`:576-581`);
- `sends` is emitted (`:584-592`), and every tx is still pushed (`:594-598`).
- The GUI gets the same txids and the same `sends.transparentSends`.
- **Daemon changes:**
- Raise the check at `dx:rpcwallet.cpp:3606` from `> 5` to `> 6`, keeping the `== 3` rejection.
- Change `:3693` to `params.size() >= 5`. Today it reads `nCount` only when `size() == 5`, so every 6-parameter call would silently drop it.
- Read `params[5]`, reject anything outside 0-3, and document it in the help text. `dx:src/rpc/client.cpp:165` already converts index 5.
- **GUI side:** send 6 parameters. An old daemon throws its help text for more than 5, so fall back to 5 parameters and remember that per connection.
- Alternative: a new RPC name, probed through "Method not found" the same way as `z_autoshieldstatus` (`app.h:1744-1747`).
- **Owner decision:** a variant that returns only txs with wallet spends would return 0 rows here, but it changes the `knownTxids` set.
**D7: honour the address argument.**
- The GUI passes `shieldedAddresses.front()` (`network_refresh_service.cpp:1319`) and relies on the daemon using `"*"` (`dx:rpcwallet.cpp:3743`). Honouring the argument by default would silently shrink GUI sweeps to one address.
- It isn't faster either: with a filter set, `fBool=true` runs `zsTxSendsToJSON` for every tx (`:586`).
- Recommendation: document `"*"` as a compatibility contract. If honouring the argument is ever wanted, put it behind a flag.
**Additive and safe for the GUI: a wallet-tip / history fingerprint.**
- Expose `pindexWalletTip`, for example in `getwalletinfo` or a new `z_getwallethistorystate(maxheight)` returning `{count, txid-hash, wallet_tip}`.
- The GUI ignores unknown JSON fields.
- This would make Part 1's invalidation exact, replacing the E-margin guess and the `txcount` tripwire.
---
## 4. Rollout and compatibility
| Step | Ships in | Works with | Notes |
|---|---|---|---|
| Part 1: watermark + anchor verify + backlog + sticky rescan + large-window path + banding + safety net | Next GUI release | Every daemon version; it uses only existing `z_listsentbyaddress` parameters, `getblockhash`, `getwalletinfo` and `getinfo` | Old GUI with a new snapshot: the key is ignored. New GUI with an old snapshot: one full sweep, which is today's behaviour. On v1.3.0, seeding the first W needs the banded sweep |
| D1, D2, D3, D5 | Daemon v1.3.1 / v1.3.2 | Invisible to the GUI | Gate each on the byte-identical oracle and the mainnet `cmp` |
| D6 + GUI probe | Daemon first, then GUI | The GUI falls back on old daemons | Makes the safety net and any full sweep cheap |
| D4 | Together with a parallel witness-root check | Invisible apart from shutdown time | Confirm the GUI stop policy first |
---
## 5. Open questions
1. How big is the backlog after the first full sweep on the owner's wallet? Unmeasured; it sizes the persisted `backlog` field.
2. What is the full-sweep time on the owner's 24-core Windows node? The 113 s was measured on Linux/WSL, 7 s inside the 120 s timeout.
3. How long does the wallet notifier lag after a catch-up on this wallet? This sets E.
4. Should the snapshot store keep more than one wallet, so that switching wallets also benefits?
5. D6 shape: a 6th parameter or a new RPC? Should a "wallet-spends-only" variant exist?
6. Does any client besides ObsidianDragon depend on `returnType 0` output? This sets D1's priority once D6 exists.
7. Are the 5 ivks distinct? Assumed. The per-address note counts sum to exactly 16,206, which fits but doesn't prove it.
8. Does the owner want the additive wallet-tip / fingerprint RPC, which makes Part 1's invalidation exact?
Binary file not shown.
+88 -4
View File
@@ -141,6 +141,7 @@
"appx_wallet_op_running": "Ein Schlüsselimport oder Neuscan läuft noch. Versuchen Sie es erneut, wenn er abgeschlossen ist.",
"appx_wallet_op_shutting_down": "Die Wallet wird beendet.",
"appx_wallet_open_failed_prefix": "Öffnen des Wallets fehlgeschlagen: ",
"auto_consolidate": "Notes automatisch konsolidieren",
"auto_shield": "Mining automatisch abschirmen",
"autoshield_by_node": "Ihr Node schützt für Sie gemäß Coinbase automatisch.",
"autoshield_off_backup_seed": "Geschürfte Coinbase-Coins werden noch nicht automatisch geschützt. Sichern Sie Ihre Wiederherstellungsphrase (Einstellungen → Sicherung & Daten), damit Ihr Node dies sicher aktivieren kann.",
@@ -276,8 +277,12 @@
"chat_delete_body_prefix": "Ihre lokale Kopie der Unterhaltung mit ",
"chat_delete_body_suffix": " löschen?",
"chat_delete_confirm": "Löschen",
"chat_delete_everywhere": "Überall löschen",
"chat_delete_everywhere_note": "Weist auch Ihre anderen Geräte – und eine künftige Wiederherstellung aus Ihrer Seed-Phrase – an, diese Unterhaltung auszublenden. Dabei wird ein kleines On-Chain-Signal gesendet (kostet die übliche Chat-Gebühr). Sie können sie später über „Wiederherstellen“ zurückholen.",
"chat_delete_everywhere_toast": "Wird überall gelöscht – Signal gesendet",
"chat_delete_failed": "Die Unterhaltung konnte nicht gelöscht werden — es wurde nichts geändert.",
"chat_delete_local_note": "Dies betrifft nur dieses Gerät — die Nachrichten bleiben auf der Blockchain, und die andere Person behält ihre Kopie.",
"chat_delete_needs_sync": "Warten Sie, bis die Wallet vollständig synchronisiert ist, um überall löschen zu können.",
"chat_delete_revive_note": "„Löschen“ entfernt den Verlauf auf diesem Gerät. Wenn Ihnen die Person erneut schreibt, kommt die Unterhaltung zurück.",
"chat_delete_title": "Unterhaltung löschen?",
"chat_deleted_toast": "Unterhaltung gelöscht",
@@ -319,10 +324,24 @@
"chat_opt_global_clock": "Globales Uhrzeitformat",
"chat_opt_poll": "Abrufrate",
"chat_opt_timestamp": "Zeitstempel",
"chat_parts_explain": "Diese lange Nachricht wird als eine Transaktion gesendet, aufgeteilt in diese Teile – sie kommt so schnell an wie eine normale Nachricht.",
"chat_parts_explain_multi": "Diese Nachricht ist zu groß für eine Transaktion und wird daher auf mehrere aufgeteilt. Der Empfänger sieht sie erst, wenn alle Teile angekommen sind – die Zustellung dauert länger und kostet pro Transaktion eine Netzwerkgebühr.",
"chat_parts_label": "Teile",
"chat_pick_contact": "Aus Kontakten wählen…",
"chat_receiving_large": "Große Nachricht wird empfangen …",
"chat_rename": "Kontakt umbenennen",
"chat_rename_hint": "Kontaktname",
"chat_renamed": "Kontakt umbenannt",
"chat_restore_btn": "Wiederherstellen",
"chat_restore_desc": "Unterhaltungen, die Sie überall gelöscht haben, können Sie hier zurückholen – auf diesem Gerät oder nach dem Importieren Ihrer Seed-Phrase. Beim Wiederherstellen werden die Nachrichten erneut aus der Chain importiert, und Ihre anderen Geräte werden angewiesen, sie wieder anzuzeigen.",
"chat_restore_manage": "Wiederherstellen",
"chat_restore_none": "Noch keine gelöschten Unterhaltungen gefunden. Wenn Sie eine auf einem anderen Gerät gelöscht haben, versuchen Sie den Schnellscan oder fügen Sie unten ihre Chat-Adresse ein.",
"chat_restore_paste_hint": "Chat-(z-)Adresse der Unterhaltung einfügen",
"chat_restore_paste_label": "Über Chat-Adresse wiederherstellen",
"chat_restore_scan": "Schnellscan",
"chat_restore_scanning": "Die Chain wird nach gelöschten Unterhaltungen gescannt...",
"chat_restore_title": "Unterhaltungen wiederherstellen",
"chat_restore_toast": "Unterhaltung wird wiederhergestellt – sie erscheint nach dem nächsten Scan wieder",
"chat_retry": "Wiederholen",
"chat_search": "Unterhaltungen durchsuchen",
"chat_sec_appearance": "DARSTELLUNG",
@@ -331,6 +350,35 @@
"chat_send": "Senden",
"chat_send_failed": "nicht gesendet",
"chat_sending": "senden…",
"chat_sensitive_btn_mark": "Vertraulich machen",
"chat_sensitive_btn_setup": "Einrichten & vertraulich machen",
"chat_sensitive_btn_unlock": "Entsperren",
"chat_sensitive_btn_unlock_mark": "Entsperren & vertraulich machen",
"chat_sensitive_btn_unlock_unmark": "Entsperren & normal machen",
"chat_sensitive_btn_unmark": "Normal machen",
"chat_sensitive_err_create": "Der Tresor für vertrauliche Chats konnte nicht eingerichtet werden.",
"chat_sensitive_err_pass": "Die Passphrasen stimmen nicht überein oder sind leer.",
"chat_sensitive_err_unlock": "Falsche Passphrase.",
"chat_sensitive_lock_btn": "Vertrauliche sperren",
"chat_sensitive_mark_tip": "Vertraulich machen",
"chat_sensitive_mark_title": "Diese Unterhaltung vertraulich machen?",
"chat_sensitive_pass2_hint": "Passphrase bestätigen",
"chat_sensitive_pass_hint": "Passphrase",
"chat_sensitive_setup_label": "Erstellen Sie eine Passphrase für vertrauliche Unterhaltungen",
"chat_sensitive_toast_marked": "Unterhaltung ist jetzt vertraulich",
"chat_sensitive_toast_unlocked": "Vertrauliche Unterhaltungen entsperrt",
"chat_sensitive_toast_unmarked": "Unterhaltung ist nicht mehr vertraulich",
"chat_sensitive_unlock_body": "Geben Sie Ihre Passphrase für vertrauliche Chats ein, um Ihre vertraulichen Unterhaltungen wieder anzuzeigen.",
"chat_sensitive_unlock_btn": "Vertrauliche entsperren",
"chat_sensitive_unlock_label": "Geben Sie Ihre Passphrase für vertrauliche Chats ein",
"chat_sensitive_unlock_title": "Vertrauliche Unterhaltungen entsperren",
"chat_sensitive_unmark_body": "Diese Unterhaltung wird nicht mehr unter Ihrer Passphrase für vertrauliche Chats verschlüsselt. Neue Nachrichten werden wie bei jeder anderen Unterhaltung gespeichert.",
"chat_sensitive_unmark_tip": "Vertraulich – zum Ändern klicken",
"chat_sensitive_unmark_title": "Diese Unterhaltung normal machen?",
"chat_sensitive_warn_1": "Die Nachrichten darin werden auf diesem Gerät unter einer separaten Passphrase verschlüsselt – nicht unter Ihrer Seed-Phrase. Wer Ihre Wallet später allein aus Ihrem Seed wiederherstellt, sieht diese Unterhaltung nicht.",
"chat_sensitive_warn_dir": "Ihre hier gesendeten Nachrichten sind vollständig geschützt. Empfangene Nachrichten liegen weiterhin auf der Blockchain und könnten theoretisch von jemandem wiederhergestellt werden, der Ihren Seed und weitere Software besitzt – DragonX selbst zeigt sie ohne Ihre Passphrase niemals an.",
"chat_sensitive_warn_hist": "Nachrichten, die bereits in dieser Unterhaltung vorhanden sind, bleiben über die Chain wiederherstellbar; ab jetzt sind Nachrichten vollständig geschützt.",
"chat_sensitive_warn_lose": "Wenn Sie diese Passphrase verlieren, sind diese Nachrichten für immer verloren – sie können nicht aus Ihrem Seed wiederhergestellt werden.",
"chat_settings_done": "Fertig",
"chat_settings_section": "CHAT & KONTAKTE",
"chat_settings_tip": "Chat anpassen",
@@ -344,12 +392,14 @@
"chat_toast_not_connected": "Nicht verbunden – Nachricht nicht gesendet.",
"chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).",
"chat_toast_request_queued": "Kontaktanfrage in Warteschlange.",
"chat_toast_too_long": "Nachricht zu lang – kürzen Sie sie und senden Sie erneut.",
"chat_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.",
"chat_today": "Heute",
"chat_ts_12h": "12-Stunden",
"chat_ts_24h": "24-Stunden",
"chat_ts_global": "Global folgen",
"chat_ts_global_short": "Global",
"chat_txns_label": "Tx",
"chat_unblock": "Blockierung aufheben",
"chat_unblocked_toast": "Blockierung der Unterhaltung aufgehoben",
"chat_unhide": "Einblenden",
@@ -370,11 +420,20 @@
"click_to_copy": "Klicken zum Kopieren",
"clock_format": "Uhrzeitformat",
"close": "Schließen",
"compact_needs_embedded": "Das Verdichten schreibt die wallet.dat neu, was die Wallet nur mit ihrem eingebauten Node kann. Sie sind mit einem dragonxd verbunden, den Sie selbst gestartet haben – aktivieren Sie in den Einstellungen \"Eingebetteten dragonxd verwenden\" oder starten Sie Ihren dragonxd mit -salvagewallet neu, um den Speicherplatz zurückzugewinnen.",
"compact_started": "Wallet wird verdichtet – der Node startet neu und scannt die Chain erneut.",
"compact_switch_stop_failed": "Ihr externer Node konnte nicht beendet werden – er läuft noch, daher wurde nichts geändert.",
"compact_switching": "Ihr Node wird beendet und zum eingebauten gewechselt…",
"compact_wallet_confirm": "Verdichten",
"conf_count": "%d Best.",
"confirm_and_send": "Bestätigen & Senden",
"confirm_clear_ztx_title": "Z-Tx-Verlauf löschen bestätigen",
"confirm_clear_ztx_warning1": "Das Löschen des Z-Transaktionsverlaufs kann dazu führen, dass Ihr geschirmtes Guthaben als 0 angezeigt wird, bis ein Wallet-Rescan durchgeführt wird.",
"confirm_clear_ztx_warning2": "Wenn dies geschieht, müssen Sie Ihre Z-Adresse-Privatschlüssel mit aktiviertem Rescan neu importieren, um Ihr Guthaben wiederherzustellen.",
"confirm_compact_switch_msg": "Sie sind mit einem dragonxd verbunden, den Sie selbst gestartet haben. Zum Verdichten beendet die Wallet diesen Node, wechselt zu ihrem eigenen eingebauten Node und schreibt darauf die wallet.dat neu – dabei wird neu gestartet und die Blockchain erneut gescannt, was eine Weile dauern kann. Hinweis: Der eingebaute Node ist der dragonxd im eigenen Ordner der Wallet, daher ändert sich dadurch, welchen Node Sie verwenden.",
"confirm_compact_wallet_msg": "Das Konsolidieren hat Ihre Notes zusammengeführt, aber die Wallet-Datei kann nicht von selbst schrumpfen – der Node muss sie neu schreiben, um den freigewordenen Speicherplatz zurückzugewinnen. Dabei wird der Node neu gestartet und die Blockchain erneut gescannt, was bei einer großen Wallet eine Weile dauern kann.",
"confirm_compact_wallet_safe": "Ihre Mittel und Ihr Guthaben sind sicher – eine Sicherung der aktuellen Wallet mit Zeitstempel bleibt erhalten.",
"confirm_compact_wallet_title": "Wallet verdichten",
"confirm_delete_blockchain_msg": "Dies stoppt den Daemon, löscht alle Blockchain-Daten (blocks, chainstate, peers) und startet eine neue Synchronisierung. Dies kann mehrere Stunden dauern.",
"confirm_delete_blockchain_safe": "Ihre wallet.dat, Konfiguration und Transaktionshistorie sind sicher und werden nicht gelöscht.",
"confirm_delete_blockchain_title": "Blockchain-Daten löschen",
@@ -505,7 +564,7 @@
"console_welcome": "Willkommen bei ObsidianDragon Konsole",
"console_zoom_in": "Vergrößern",
"console_zoom_out": "Verkleinern",
"consolidate_desc": "Fassen Sie viele kleine Eingaben zu einer einzigen geschützten Note zusammen. Weniger Notes bedeuten eine kleinere Wallet-Datei und mehr Privatsphäre.",
"consolidate_desc": "Jede empfangene Zahlung wird als eigene „Note“ gespeichert – wie ein einzelner Geldschein in einer Geldbörse. Mit der Zeit häufen sie sich an, und jede Note fügt Witness-Daten hinzu, die die Synchronisierung der Wallet verlangsamen und sie immer weiter wachsen lassen. Das Konsolidieren fasst viele kleine Notes zu einer pro Adresse zusammen: Ihr Guthaben ändert sich nicht, aber die Wallet wird schneller durchsucht, wächst nicht weiter und verrät weniger über Ihre Mittel.",
"consolidate_funds_btn": "Konsolidieren",
"consolidate_title": "Guthaben konsolidieren",
"contact_avatar": "AVATAR",
@@ -639,6 +698,11 @@
"download": "Herunterladen",
"download_bootstrap": "Bootstrap herunterladen",
"dragonx_green": "DragonX (Grün)",
"earnings_format": "Anzeigeformat für Erträge",
"earnings_format_coin": "Nur Münze",
"earnings_format_coin_fiat": "Münze + Fiat",
"earnings_format_fiat": "Nur Fiat",
"earnings_label": "Erträge",
"edit": "Bearbeiten",
"empty_wallet_keys_suffix": "Schlüssel",
"empty_wallet_open_manager": "Wallet-Verwaltung öffnen",
@@ -863,6 +927,7 @@
"faq_w_set_4_q": "Wie zeige ich Fiat-Preise an?",
"faq_w_set_title": "Einstellungen & Erscheinungsbild",
"favorite_address": "Als Favorit markieren",
"favorite_opacity": "Deckkraft der Favoriten-Hervorhebung",
"fee": "Gebühr",
"fee_high": "Hoch",
"fee_label": "Gebühr:",
@@ -870,7 +935,7 @@
"fee_normal": "Normal",
"fetch_prices": "Preise abrufen",
"file": "Datei",
"file_save_location": "Datei wird gespeichert in: ~/.config/ObsidianDragon/",
"file_save_location": "Datei wird gespeichert in:",
"filter": "Filtern...",
"font_scale": "Schriftgröße",
"from": "Von",
@@ -941,6 +1006,7 @@
"hidden_tag": " (versteckt)",
"hide": "Ausblenden",
"hide_address": "Adresse ausblenden",
"hide_earnings": "Erträge ausblenden",
"hide_qr": "QR ausblenden",
"hide_zero_balances": "Nullsalden ausblenden",
"history": "Verlauf",
@@ -1307,6 +1373,8 @@
"mining_est_block": "Gesch. Block",
"mining_est_daily": "Gesch. täglich",
"mining_est_daily_pool_sub": "grobe Solo-Äquivalenz, vor Pool-Gebühr",
"mining_est_daily_sub": "Ø",
"mining_est_daily_sub_net": "Ø, netto",
"mining_filter_all": "Alle",
"mining_filter_tip_all": "Alle Einnahmen anzeigen",
"mining_filter_tip_pool": "Nur Pool-Einnahmen anzeigen",
@@ -1917,6 +1985,7 @@
"shielded_type": "Abgeschirmt",
"shielding_notice": "Hinweis: Dies wird Gelder von einer transparenten (T) Adresse auf eine private (Z) Adresse schirmen.",
"show": "Anzeigen",
"show_earnings": "Erträge anzeigen",
"show_hidden": "Ausgeblendete anzeigen (%d)",
"show_qr": "QR anzeigen",
"show_qr_code": "QR-Code anzeigen",
@@ -2038,6 +2107,7 @@
"tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer",
"tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten",
"tt_animate_avatars": "Animierte Kontakt-Avatare (GIF / WebP) abspielen; aus zeigt nur das erste Bild",
"tt_auto_consolidate": "Fasst kleine Notes im Hintergrund unauffällig in winzigen Durchgängen zusammen, solange die Wallet im Leerlauf ist. Langsamer als eine manuelle Konsolidierung, aber kaum bemerkbar – und die Mittel jeder Adresse bleiben getrennt. Gut geeignet für Mining-/Pool-Wallets, die Tausende von Notes ansammeln.",
"tt_auto_lock": "Wallet nach dieser Inaktivitätszeit sperren",
"tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben",
"tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen",
@@ -2065,10 +2135,16 @@
"tt_debug_expand": "Debug-Protokollierungsoptionen ausklappen",
"tt_delete_blockchain": "Alle Blockchain-Daten löschen und neu synchronisieren. wallet.dat und Konfiguration bleiben erhalten.",
"tt_download_bootstrap": "Blockchain-Bootstrap herunterladen, um die Synchronisierung zu beschleunigen\nVorhandene Blockdaten werden ersetzt",
"tt_earn_interval": "Ertragszeitraum: 1H = diese Stunde, 1D = heute, 1M = dieser Monat, 1Y = dieses Jahr",
"tt_earn_mode_inflows": "Ertragsmodus: erhaltene Beträge (einschließlich Mining)",
"tt_earn_mode_net": "Ertragsmodus: netto (erhalten minus gesendet)",
"tt_earn_show_all": "Ertragswert für jede Adresse anzeigen",
"tt_earnings_format": "Wie die Ertragswerte pro Adresse und insgesamt angezeigt werden",
"tt_encrypt": "wallet.dat mit einer Passphrase verschlüsseln",
"tt_export_all": "Alle privaten Schlüssel in eine Datei exportieren",
"tt_export_csv": "Transaktionsverlauf als CSV-Tabelle exportieren",
"tt_export_key": "Den privaten Schlüssel der ausgewählten Adresse exportieren",
"tt_favorite_opacity": "Deckkraft des Favoriten-Sterns und seiner goldenen Umrandung in der Adressliste (mindestens 15 %)",
"tt_fetch_prices": "DRGX-Marktpreise von der CoinGecko-API abrufen",
"tt_font_scale": "Alle Texte und UI skalieren (1.0x = Standard, bis 1.5x).",
"tt_idle_delay": "Wie lange vor dem Start des Minings gewartet werden soll",
@@ -2106,6 +2182,9 @@
"tt_low_spec": "Alle aufwendigen visuellen Effekte deaktivieren\\nHotkey: Ctrl+Shift+Down",
"tt_merge": "Mehrere UTXOs einer Adresse zusammenführen",
"tt_mine_idle": "Mining automatisch starten, wenn das\\nSystem inaktiv ist (keine Tastatur-/Mauseingabe)",
"tt_mining_est_daily": "Ein langfristiger statistischer Durchschnitt für Ihren aktuellen Hashrate-Anteil am Netzwerk. Echte Mining-Belohnungen kommen zufällig (Poisson) – oft lange nichts, dann ein Batzen – das ist also KEIN gleichmäßiges Tageseinkommen.",
"tt_mining_est_daily_fee_fmt": "Angezeigt nach der %.1f%% Pool-Gebühr.",
"tt_mining_est_daily_nofee": "Vor Abzug der Pool-Gebühr angezeigt (Gebühr für diesen Pool unbekannt).",
"tt_new_mining_address": "Erstellt eine transparente Adresse (R…) für Solo-Mining-Belohnungen. Sie kann keine Zahlungen empfangen – teilen Sie dafür eine abgeschirmte Adresse.",
"tt_noise": "Körnungstextur-Intensität (0% = aus, 100% = maximum)",
"tt_open_app_dir": "Den ObsidianDragon-Ordner (Einstellungen, Themes, Logs) im Dateimanager öffnen",
@@ -2154,7 +2233,9 @@
"tt_ui_opacity": "Karten- und Seitenleisten-Deckkraft (100% = vollständig undurchsichtig, niedriger = durchsichtiger)",
"tt_validate": "Prüfen, ob eine DragonX-Adresse gültig ist",
"tt_verbose": "Detaillierte Verbindungsdiagnosen,\\nDaemon-Status und Port-Besitzer-Info\\nin der Konsolen-Registerkarte protokollieren",
"tt_wallet_size_warn": "Geschützte Wallets wachsen mit den Witness-Daten jedes Notes — das Zusammenführen vieler Notes in eine Adresse verringert die Größe. Zuerst sichern.",
"tt_view_transactions": "Transaktionen mit dieser Adresse im Verlauf-Tab anzeigen",
"tt_wallet_size_compact": "Gibt den durch das Konsolidieren freigewordenen Speicherplatz frei. Berkeley DB kann die Datei nicht an Ort und Stelle verkleinern; daher schreibt dies die wallet.dat in eine kleinere Datei um und startet den Node mit einem vollständigen Neuscan neu (kann eine Weile dauern). Eine Sicherung der aktuellen Wallet bleibt erhalten.",
"tt_wallet_size_warn": "Jede Zahlung kommt als eigene Shielded-Note an, und jede Note trägt Witness-Daten, die Scans verlangsamen und die Wallet-Datei immer weiter wachsen lassen. Das Zusammenfassen vieler Notes zu einer pro Adresse hält die Wallet schnell und stoppt das Wachstum – Ihre Mittel und Ihr Guthaben bleiben unverändert. Die Datei gibt den freigewordenen Speicherplatz erst frei, wenn Sie sie verdichten (Berkeley DB kann nicht an Ort und Stelle schrumpfen). Sichern Sie zuerst Ihre Seed-Phrase. Sie können in den Einstellungen auch „Notes automatisch konsolidieren“ aktivieren.",
"tt_wallets_button": "Ihre Wallet-Dateien auflisten und zwischen ihnen wechseln",
"tt_website": "Die DragonX-Website öffnen",
"tt_window_opacity": "Hintergrund-Deckkraft (niedriger = Desktop durch Fenster sichtbar)",
@@ -2220,6 +2301,7 @@
"view_all": "Alle anzeigen",
"view_details": "Details anzeigen",
"view_on_explorer": "Im Explorer anzeigen",
"view_transactions": "Transaktionen anzeigen",
"waiting_for_daemon": "Warte auf Daemon-Verbindung...",
"wallet": "WALLET",
"wallet_degraded_notify": "Ihre Wallet wurde im eingeschränkten Modus geöffnet: Vorhandene Guthaben sind sicher und verfügbar, aber das Erstellen neuer Adressen und das Schützen von Guthaben sind deaktiviert. Sichern Sie Ihre Seed-Phrase und stellen Sie sie wieder her, um die Wallet vollständig zu reparieren.",
@@ -2282,8 +2364,10 @@
"wallet_restore_ok": "Ursprüngliche Wallet wiederhergestellt. Der Node lädt sie jetzt.",
"wallet_restore_started": "Ihre Originaldatei wird wiederhergestellt — bitte schließen Sie dieses Fenster nicht.",
"wallet_restore_stop_failed": "Der Node hat nicht rechtzeitig gestoppt, daher wurde nichts geändert. Versuchen Sie es erneut.",
"wallet_size_auto_info": "Ihre Wallet hat sich viele Notes angesammelt – einzelne Teile empfangener Mittel, die die Synchronisierung verlangsamen und die Datei immer weiter wachsen lassen. Die automatische Konsolidierung fasst sie unauffällig im Hintergrund zusammen, damit die Wallet schnell bleibt und nicht weiter wächst. Sie müssen nichts tun.",
"wallet_size_compact": "Wallet verdichten…",
"wallet_size_consolidate": "Notizen konsolidieren…",
"wallet_size_warn": "Diese Wallet-Datei ist groß. Das Konsolidieren Ihrer Notizen kann weiteres Wachstum eindämmen.",
"wallet_size_warn": "Ihre Wallet hat sich viele Notes angesammelt. Jede empfangene Zahlung wird als eigene „Note“ gespeichert; wenn sich viele davon anhäufen, verlangsamen sie die Synchronisierung der Wallet und lassen sie immer weiter wachsen. Das Konsolidieren fasst sie zu weniger Notes zusammen, damit sie schneller durchsucht wird und nicht weiter wächst – Ihr Guthaben bleibt dabei exakt gleich.",
"wallets_active": "Aktiv",
"wallets_add": "Ordner hinzufügen",
"wallets_add_folder": "Auch einen weiteren Ordner nach Wallet-Dateien durchsuchen:",
+88 -4
View File
@@ -141,6 +141,7 @@
"appx_wallet_op_running": "Aún se está ejecutando una importación de clave o un reescaneo. Vuelve a intentarlo cuando termine.",
"appx_wallet_op_shutting_down": "La cartera se está cerrando.",
"appx_wallet_open_failed_prefix": "Error al abrir la cartera: ",
"auto_consolidate": "Autoconsolidar notas",
"auto_shield": "Auto-proteger minería",
"autoshield_by_node": "Tu nodo blinda automáticamente el coinbase minado por ti.",
"autoshield_off_backup_seed": "El coinbase minado aún no se está blindando automáticamente. Respalda tu frase de recuperación (Ajustes → Copia de seguridad y datos) para que tu nodo pueda activarlo de forma segura.",
@@ -276,8 +277,12 @@
"chat_delete_body_prefix": "¿Eliminar tu copia local de la conversación con ",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "Eliminar",
"chat_delete_everywhere": "Eliminar en todas partes",
"chat_delete_everywhere_note": "También indica a tus otros dispositivos —y a una futura restauración desde tu frase de recuperación— que oculten esta conversación. Envía una pequeña señal en la cadena (cuesta la comisión habitual del chat). Puedes recuperarla más tarde desde Restaurar.",
"chat_delete_everywhere_toast": "Eliminando en todas partes: señal enviada",
"chat_delete_failed": "No se pudo eliminar la conversación — no se cambió nada.",
"chat_delete_local_note": "Esto solo afecta a este dispositivo — los mensajes permanecen en la cadena de bloques y la otra persona conserva su copia.",
"chat_delete_needs_sync": "Espera a que la cartera se sincronice por completo para eliminar en todas partes.",
"chat_delete_revive_note": "«Eliminar» borra el historial en este dispositivo. Si esa persona vuelve a escribirte, la conversación reaparece.",
"chat_delete_title": "¿Eliminar conversación?",
"chat_deleted_toast": "Conversación eliminada",
@@ -319,10 +324,24 @@
"chat_opt_global_clock": "Formato de reloj global",
"chat_opt_poll": "Frecuencia de sondeo",
"chat_opt_timestamp": "Marcas de tiempo",
"chat_parts_explain": "Este mensaje largo se envía como una sola transacción dividida en estas partes: llega tan rápido como un mensaje normal.",
"chat_parts_explain_multi": "Este mensaje es demasiado grande para una sola transacción, por lo que se divide en varias. El destinatario lo ve cuando han llegado todas las partes, así que tarda más en entregarse y cuesta una comisión de red por transacción.",
"chat_parts_label": "partes",
"chat_pick_contact": "Elegir de contactos…",
"chat_receiving_large": "Recibiendo un mensaje grande…",
"chat_rename": "Renombrar contacto",
"chat_rename_hint": "Nombre del contacto",
"chat_renamed": "Contacto renombrado",
"chat_restore_btn": "Restaurar",
"chat_restore_desc": "Las conversaciones que eliminaste en todas partes se pueden recuperar aquí, en este dispositivo o tras importar tu frase de recuperación. Restaurar reimporta los mensajes desde la cadena e indica a tus otros dispositivos que la vuelvan a mostrar.",
"chat_restore_manage": "Restaurar",
"chat_restore_none": "Aún no se han encontrado conversaciones eliminadas. Si eliminaste una en otro dispositivo, prueba con Escaneo rápido o pega su dirección de chat más abajo.",
"chat_restore_paste_hint": "Pega la dirección de chat (z) de la conversación",
"chat_restore_paste_label": "Restaurar por dirección de chat",
"chat_restore_scan": "Escaneo rápido",
"chat_restore_scanning": "Escaneando la cadena en busca de conversaciones eliminadas…",
"chat_restore_title": "Restaurar conversaciones",
"chat_restore_toast": "Restaurando conversación: reaparece tras el próximo escaneo",
"chat_retry": "Reintentar",
"chat_search": "Buscar conversaciones",
"chat_sec_appearance": "APARIENCIA",
@@ -331,6 +350,35 @@
"chat_send": "Enviar",
"chat_send_failed": "no enviado",
"chat_sending": "enviando…",
"chat_sensitive_btn_mark": "Marcar como confidencial",
"chat_sensitive_btn_setup": "Configurar y marcar como confidencial",
"chat_sensitive_btn_unlock": "Desbloquear",
"chat_sensitive_btn_unlock_mark": "Desbloquear y marcar como confidencial",
"chat_sensitive_btn_unlock_unmark": "Desbloquear y hacer normal",
"chat_sensitive_btn_unmark": "Hacer normal",
"chat_sensitive_err_create": "No se pudo crear la bóveda de chat confidencial.",
"chat_sensitive_err_pass": "Las contraseñas no coinciden o están vacías.",
"chat_sensitive_err_unlock": "Contraseña incorrecta.",
"chat_sensitive_lock_btn": "Bloquear confidenciales",
"chat_sensitive_mark_tip": "Marcar como confidencial",
"chat_sensitive_mark_title": "¿Marcar esta conversación como confidencial?",
"chat_sensitive_pass2_hint": "Confirmar contraseña",
"chat_sensitive_pass_hint": "Contraseña",
"chat_sensitive_setup_label": "Crea una contraseña para las conversaciones confidenciales",
"chat_sensitive_toast_marked": "La conversación ahora es confidencial",
"chat_sensitive_toast_unlocked": "Conversaciones confidenciales desbloqueadas",
"chat_sensitive_toast_unmarked": "La conversación ya no es confidencial",
"chat_sensitive_unlock_body": "Introduce tu contraseña de chat confidencial para volver a mostrar tus conversaciones confidenciales.",
"chat_sensitive_unlock_btn": "Desbloquear confidenciales",
"chat_sensitive_unlock_label": "Introduce tu contraseña de chat confidencial",
"chat_sensitive_unlock_title": "Desbloquear las conversaciones confidenciales",
"chat_sensitive_unmark_body": "Esta conversación dejará de estar cifrada con tu contraseña de chat confidencial. Los mensajes nuevos se guardarán como en cualquier otra conversación.",
"chat_sensitive_unmark_tip": "Confidencial: clic para cambiar",
"chat_sensitive_unmark_title": "¿Hacer normal esta conversación?",
"chat_sensitive_warn_1": "Los mensajes de esta conversación se cifrarán en este dispositivo con una contraseña aparte, no con tu frase de recuperación. Quien más adelante restaure tu cartera solo con tu frase de recuperación no verá esta conversación.",
"chat_sensitive_warn_dir": "Tus mensajes enviados aquí quedan totalmente protegidos. Los mensajes recibidos siguen en la cadena de bloques y, en teoría, alguien que tenga tu frase de recuperación y otro software podría recuperarlos; DragonX nunca los mostrará sin tu contraseña.",
"chat_sensitive_warn_hist": "Los mensajes que ya están en esta conversación siguen siendo recuperables desde la cadena; esto protege por completo los mensajes a partir de ahora.",
"chat_sensitive_warn_lose": "Si pierdes esta contraseña, estos mensajes se perderán para siempre: no se pueden recuperar con tu frase de recuperación.",
"chat_settings_done": "Listo",
"chat_settings_section": "CHAT Y CONTACTOS",
"chat_settings_tip": "Personalizar chat",
@@ -344,12 +392,14 @@
"chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.",
"chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).",
"chat_toast_request_queued": "Solicitud de contacto en cola.",
"chat_toast_too_long": "Mensaje demasiado largo: acórtalo y envíalo de nuevo.",
"chat_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.",
"chat_today": "Hoy",
"chat_ts_12h": "12 horas",
"chat_ts_24h": "24 horas",
"chat_ts_global": "Seguir global",
"chat_ts_global_short": "Global",
"chat_txns_label": "tx",
"chat_unblock": "Desbloquear",
"chat_unblocked_toast": "Conversación desbloqueada",
"chat_unhide": "Mostrar",
@@ -370,11 +420,20 @@
"click_to_copy": "Clic para copiar",
"clock_format": "Formato de hora",
"close": "Cerrar",
"compact_needs_embedded": "Compactar reescribe wallet.dat, y la cartera solo puede hacerlo con su nodo integrado. Estás conectado a un dragonxd que iniciaste tú mismo — activa \"Usar dragonxd integrado\" en Ajustes, o reinicia tu dragonxd con -salvagewallet para recuperar el espacio.",
"compact_started": "Compactando la cartera — el nodo se está reiniciando y volverá a escanear la cadena.",
"compact_switch_stop_failed": "No se pudo detener tu nodo externo — sigue en ejecución, así que no se cambió nada.",
"compact_switching": "Deteniendo tu nodo y cambiando al integrado…",
"compact_wallet_confirm": "Compactar",
"conf_count": "%d conf",
"confirm_and_send": "Confirmar y Enviar",
"confirm_clear_ztx_title": "Confirmar limpieza del historial Z-Tx",
"confirm_clear_ztx_warning1": "Limpiar el historial de z-transacciones puede hacer que su saldo blindado se muestre como 0 hasta que se realice un reescaneo de la billetera.",
"confirm_clear_ztx_warning2": "Si esto sucede, deberá reimportar las claves privadas de su dirección z con el reescaneo habilitado para recuperar su saldo.",
"confirm_compact_switch_msg": "Estás conectado a un dragonxd que iniciaste tú mismo. Para compactar, la cartera detendrá ese nodo, cambiará a su propio nodo integrado y reescribirá wallet.dat en él — reiniciando y volviendo a escanear la blockchain, lo que puede tardar un buen rato. Nota: el nodo integrado es el dragonxd de la carpeta de la propia cartera, así que esto cambia el nodo que ejecutas.",
"confirm_compact_wallet_msg": "Al consolidar se fusionaron tus notas, pero el archivo de la cartera no puede reducirse por sí solo — el nodo tiene que reescribirlo para recuperar el espacio liberado. Esto reinicia el nodo y vuelve a escanear la blockchain, lo que puede tardar un buen rato en una cartera grande.",
"confirm_compact_wallet_safe": "Tus fondos y tu saldo están a salvo — se conserva una copia de seguridad con marca de tiempo de la cartera actual.",
"confirm_compact_wallet_title": "Compactar cartera",
"confirm_delete_blockchain_msg": "Esto detendrá el daemon, eliminará todos los datos de la blockchain (blocks, chainstate, peers) y comenzará una nueva sincronización desde cero. Esto puede tardar varias horas.",
"confirm_delete_blockchain_safe": "Su wallet.dat, configuración e historial de transacciones están seguros y no se eliminarán.",
"confirm_delete_blockchain_title": "Eliminar Datos de Blockchain",
@@ -505,7 +564,7 @@
"console_welcome": "Bienvenido a la Consola de ObsidianDragon",
"console_zoom_in": "Acercar",
"console_zoom_out": "Alejar",
"consolidate_desc": "Combina muchas entradas pequeñas en una sola nota blindada. Menos notas significa un archivo de cartera más pequeño y mejor privacidad.",
"consolidate_desc": "Cada pago que recibes se almacena como una \"nota\" independiente — como un billete suelto en una cartera. Se acumulan con el tiempo, y cada nota añade datos de testigo que hacen que la cartera sea más lenta de sincronizar y que siga creciendo. Consolidar combina muchas notas pequeñas en una por dirección: tu saldo no cambia, pero la cartera escanea más rápido, deja de crecer y revela menos sobre tus fondos.",
"consolidate_funds_btn": "Consolidar",
"consolidate_title": "Consolidar fondos",
"contact_avatar": "AVATAR",
@@ -639,6 +698,11 @@
"download": "Descargar",
"download_bootstrap": "Descargar Bootstrap",
"dragonx_green": "DragonX (Verde)",
"earnings_format": "Formato de visualización de ganancias",
"earnings_format_coin": "Solo moneda",
"earnings_format_coin_fiat": "Moneda + fíat",
"earnings_format_fiat": "Solo fíat",
"earnings_label": "Ganancias",
"edit": "Editar",
"empty_wallet_keys_suffix": "claves",
"empty_wallet_open_manager": "Abrir administrador de carteras",
@@ -863,6 +927,7 @@
"faq_w_set_4_q": "¿Cómo muestro precios en moneda fiat?",
"faq_w_set_title": "Ajustes y apariencia",
"favorite_address": "Marcar como favorito",
"favorite_opacity": "Opacidad del resaltado de favoritos",
"fee": "Comisión",
"fee_high": "Alta",
"fee_label": "Comisión:",
@@ -870,7 +935,7 @@
"fee_normal": "Normal",
"fetch_prices": "Obtener precios",
"file": "Archivo",
"file_save_location": "El archivo se guardará en: ~/.config/ObsidianDragon/",
"file_save_location": "El archivo se guardará en:",
"filter": "Filtrar...",
"font_scale": "Escala de fuente",
"from": "Desde",
@@ -941,6 +1006,7 @@
"hidden_tag": " (oculto)",
"hide": "Ocultar",
"hide_address": "Ocultar dirección",
"hide_earnings": "Ocultar ganancias",
"hide_qr": "Ocultar QR",
"hide_zero_balances": "Ocultar saldos 0",
"history": "Historial",
@@ -1307,6 +1373,8 @@
"mining_est_block": "Bloque Est.",
"mining_est_daily": "Diario Est.",
"mining_est_daily_pool_sub": "equivalente solo aproximado, antes de la comisión del pool",
"mining_est_daily_sub": "prom.",
"mining_est_daily_sub_net": "prom., neto",
"mining_filter_all": "Todos",
"mining_filter_tip_all": "Mostrar todas las ganancias",
"mining_filter_tip_pool": "Mostrar solo ganancias del pool",
@@ -1917,6 +1985,7 @@
"shielded_type": "Protegido",
"shielding_notice": "Nota: Esto blindará fondos de una dirección transparente (T) a una dirección privada (Z).",
"show": "Mostrar",
"show_earnings": "Mostrar ganancias",
"show_hidden": "Mostrar ocultos (%d)",
"show_qr": "Mostrar QR",
"show_qr_code": "Mostrar Código QR",
@@ -2038,6 +2107,7 @@
"tt_addr_url": "URL base para ver direcciones en un explorador de bloques",
"tt_address_book": "Administrar direcciones guardadas para envío rápido",
"tt_animate_avatars": "Reproduce avatares de contacto animados (GIF / WebP); desactivado muestra solo el primer fotograma",
"tt_auto_consolidate": "Fusiona discretamente las notas pequeñas en segundo plano mientras la cartera está inactiva, en lotes diminutos. Es más lento que una consolidación manual, pero apenas se nota, y los fondos de cada dirección permanecen separados. Ideal para carteras de minería o de pool que acumulan miles de notas.",
"tt_auto_lock": "Bloquear billetera después de este tiempo de inactividad",
"tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad",
"tt_backup": "Crear una copia de seguridad de su wallet.dat",
@@ -2065,10 +2135,16 @@
"tt_debug_expand": "Expandir opciones de registro de depuración",
"tt_delete_blockchain": "Eliminar todos los datos de la blockchain e iniciar una nueva sincronización. Se preservan wallet.dat y la configuración.",
"tt_download_bootstrap": "Descargar bootstrap de blockchain para acelerar la sincronización\nLos datos de bloques existentes serán reemplazados",
"tt_earn_interval": "Periodo de ganancias: 1H = esta hora, 1D = hoy, 1M = este mes, 1Y = este año",
"tt_earn_mode_inflows": "Modo de ganancias: importes recibidos (incluida la minería)",
"tt_earn_mode_net": "Modo de ganancias: neto (recibido menos enviado)",
"tt_earn_show_all": "Mostrar la cifra de ganancias para cada dirección",
"tt_earnings_format": "Cómo se muestran las cifras de ganancias por dirección y totales",
"tt_encrypt": "Cifrar wallet.dat con una contraseña",
"tt_export_all": "Exportar todas las claves privadas a un archivo",
"tt_export_csv": "Exportar historial de transacciones como hoja de cálculo CSV",
"tt_export_key": "Exportar la clave privada de la dirección seleccionada",
"tt_favorite_opacity": "Opacidad del botón de estrella de favoritos y su contorno dorado en la lista de direcciones (mínimo 15 %)",
"tt_fetch_prices": "Obtener precios de mercado de DRGX desde la API de CoinGecko",
"tt_font_scale": "Escalar todo el texto y la interfaz (1.0x = predeterminado, hasta 1.5x).",
"tt_idle_delay": "Cuánto tiempo esperar antes de empezar a minar",
@@ -2106,6 +2182,9 @@
"tt_low_spec": "Desactivar todos los efectos visuales pesados\\nAtajo: Ctrl+Shift+Down",
"tt_merge": "Consolidar múltiples UTXOs en una dirección",
"tt_mine_idle": "Iniciar minería automáticamente cuando el\\nsistema esté inactivo (sin entrada de teclado/ratón)",
"tt_mining_est_daily": "Un promedio estadístico a largo plazo para tu proporción actual de hashrate en la red. Las recompensas reales de minería llegan al azar (Poisson): a menudo nada durante largos periodos y luego una suma; por eso NO es un ingreso diario constante.",
"tt_mining_est_daily_fee_fmt": "Se muestra tras la comisión del pool del %.1f%%.",
"tt_mining_est_daily_nofee": "Se muestra antes de la comisión del pool (comisión desconocida para este pool).",
"tt_new_mining_address": "Crea una dirección transparente (R…) para recompensas de minería en solitario. No puede recibir pagos; para eso comparte una dirección blindada.",
"tt_noise": "Intensidad de textura granulada (0% = apagado, 100% = máximo)",
"tt_open_app_dir": "Abrir la carpeta de ObsidianDragon (configuración, temas, registros) en el explorador de archivos",
@@ -2154,7 +2233,9 @@
"tt_ui_opacity": "Opacidad de tarjetas y barra lateral (100% = totalmente opaco, menor = más transparente)",
"tt_validate": "Comprobar si una dirección DragonX es válida",
"tt_verbose": "Registrar diagnósticos detallados de conexión,\\nestado del daemon e info de propietario de puerto\\nen la pestaña de Consola",
"tt_wallet_size_warn": "Las carteras blindadas crecen con los datos de testigo de cada nota — fusionar muchas notas en una sola dirección lo reduce. Haz una copia de seguridad primero.",
"tt_view_transactions": "Mostrar las transacciones relacionadas con esta dirección en la pestaña Historial",
"tt_wallet_size_compact": "Recupera el espacio en disco liberado al consolidar. Berkeley DB no puede reducir el archivo en el mismo sitio, así que esto reescribe wallet.dat en un archivo más pequeño y reinicia el nodo con un re-escaneo completo (puede tardar un buen rato). Se conserva una copia de seguridad de la cartera actual.",
"tt_wallet_size_warn": "Cada pago llega como una nota blindada independiente, y cada nota lleva datos de testigo que ralentizan los escaneos y hacen que el archivo de la cartera siga creciendo. Fusionar muchas notas en una por dirección la mantiene rápida y detiene el crecimiento — tus fondos y tu saldo no cambian. El archivo solo recupera el espacio liberado cuando lo Compactas (Berkeley DB no puede reducirse en el mismo sitio). Haz primero una copia de seguridad de tu semilla. También puedes activar Autoconsolidar notas en Ajustes.",
"tt_wallets_button": "Enumera tus archivos de cartera y cambia entre ellos",
"tt_website": "Abrir el sitio web de DragonX",
"tt_window_opacity": "Opacidad del fondo (menor = escritorio visible a través de la ventana)",
@@ -2220,6 +2301,7 @@
"view_all": "Ver todo",
"view_details": "Ver Detalles",
"view_on_explorer": "Ver en Explorador",
"view_transactions": "Ver transacciones",
"waiting_for_daemon": "Esperando conexión al daemon...",
"wallet": "CARTERA",
"wallet_degraded_notify": "Tu cartera se abrió en modo de funciones reducidas: los fondos existentes están seguros y disponibles, pero crear nuevas direcciones y blindar están desactivados. Respalda tu frase de recuperación y restáurala para reparar la cartera por completo.",
@@ -2282,8 +2364,10 @@
"wallet_restore_ok": "Cartera original restaurada. El nodo la está cargando ahora.",
"wallet_restore_started": "Restaurando tu archivo original — por favor no cierres esta ventana.",
"wallet_restore_stop_failed": "El nodo no se detuvo a tiempo, así que no se cambió nada. Inténtalo de nuevo.",
"wallet_size_auto_info": "Tu cartera ha acumulado muchas notas: piezas separadas de los fondos recibidos que ralentizan la sincronización y hacen que el archivo siga creciendo. La autoconsolidación las está fusionando discretamente en segundo plano para mantener la cartera rápida y evitar que crezca, así que no tienes que hacer nada.",
"wallet_size_compact": "Compactar cartera…",
"wallet_size_consolidate": "Consolidar notas…",
"wallet_size_warn": "Este archivo de cartera es grande. Consolidar tus notas puede frenar un mayor crecimiento.",
"wallet_size_warn": "Tu cartera ha acumulado muchas notas. Cada pago que recibes se almacena como una \"nota\" independiente; al acumularse, ralentizan la sincronización de la cartera y hacen que siga creciendo. Consolidar las fusiona en menos notas para que escanee más rápido y deje de crecer — tu saldo no cambia en absoluto.",
"wallets_active": "Activa",
"wallets_add": "Agregar carpeta",
"wallets_add_folder": "Escanear también otra carpeta en busca de archivos de cartera:",
+88 -4
View File
@@ -141,6 +141,7 @@
"appx_wallet_op_running": "Une importation de clé ou une réanalyse est encore en cours. Réessayez une fois qu'elle est finie.",
"appx_wallet_op_shutting_down": "Le portefeuille est en cours de fermeture.",
"appx_wallet_open_failed_prefix": "Échec de l'ouverture du portefeuille : ",
"auto_consolidate": "Consolidation auto des notes",
"auto_shield": "Auto-blindage du minage",
"autoshield_by_node": "Votre nœud blinde automatiquement les coinbase minées pour vous.",
"autoshield_off_backup_seed": "Les coinbase minées ne sont pas encore blindées automatiquement. Sauvegardez votre phrase de récupération (Paramètres → Sauvegarde et données) pour que votre nœud puisse l'activer en toute sécurité.",
@@ -276,8 +277,12 @@
"chat_delete_body_prefix": "Supprimer votre copie locale de la conversation avec ",
"chat_delete_body_suffix": " ?",
"chat_delete_confirm": "Supprimer",
"chat_delete_everywhere": "Supprimer partout",
"chat_delete_everywhere_note": "Indique aussi à vos autres appareils — et à une future restauration depuis votre phrase de récupération — de masquer cette conversation. Un petit signal est envoyé sur la chaîne (il coûte les frais de chat habituels). Vous pourrez la restaurer plus tard depuis Restaurer.",
"chat_delete_everywhere_toast": "Suppression partout — signal envoyé",
"chat_delete_failed": "Impossible de supprimer la conversation — rien n'a été modifié.",
"chat_delete_local_note": "Cela n'affecte que cet appareil — les messages restent sur la blockchain et l'autre personne conserve sa copie.",
"chat_delete_needs_sync": "Attendez que le portefeuille soit entièrement synchronisé pour supprimer partout.",
"chat_delete_revive_note": "« Supprimer » efface l'historique sur cet appareil. Si cette personne vous écrit à nouveau, la conversation revient.",
"chat_delete_title": "Supprimer la conversation ?",
"chat_deleted_toast": "Conversation supprimée",
@@ -319,10 +324,24 @@
"chat_opt_global_clock": "Format d'horloge global",
"chat_opt_poll": "Fréquence d'actualisation",
"chat_opt_timestamp": "Horodatage",
"chat_parts_explain": "Ce long message est envoyé en une seule transaction répartie entre ces parties — il arrive aussi vite qu'un message normal.",
"chat_parts_explain_multi": "Ce message est trop volumineux pour une seule transaction : il est donc réparti sur plusieurs. Le destinataire le voit une fois toutes les parties arrivées, sa livraison prend donc plus de temps et coûte des frais réseau par transaction.",
"chat_parts_label": "parties",
"chat_pick_contact": "Choisir dans les contacts…",
"chat_receiving_large": "Réception d'un message volumineux…",
"chat_rename": "Renommer le contact",
"chat_rename_hint": "Nom du contact",
"chat_renamed": "Contact renommé",
"chat_restore_btn": "Restaurer",
"chat_restore_desc": "Les conversations que vous avez supprimées partout peuvent être rétablies ici — sur cet appareil ou après avoir importé votre phrase de récupération. La restauration réimporte les messages depuis la chaîne et indique à vos autres appareils de l'afficher à nouveau.",
"chat_restore_manage": "Restaurer",
"chat_restore_none": "Aucune conversation supprimée trouvée pour l'instant. Si vous en avez supprimé une sur un autre appareil, essayez l'Analyse rapide ou collez son adresse de chat ci-dessous.",
"chat_restore_paste_hint": "Collez l'adresse de chat (z) de la conversation",
"chat_restore_paste_label": "Restaurer par adresse de chat",
"chat_restore_scan": "Analyse rapide",
"chat_restore_scanning": "Analyse de la chaîne à la recherche de conversations supprimées...",
"chat_restore_title": "Restaurer des conversations",
"chat_restore_toast": "Restauration de la conversation — elle réapparaît après la prochaine analyse",
"chat_retry": "Réessayer",
"chat_search": "Rechercher des conversations",
"chat_sec_appearance": "APPARENCE",
@@ -331,6 +350,35 @@
"chat_send": "Envoyer",
"chat_send_failed": "non envoyé",
"chat_sending": "envoi…",
"chat_sensitive_btn_mark": "Rendre confidentielle",
"chat_sensitive_btn_setup": "Configurer et rendre confidentielle",
"chat_sensitive_btn_unlock": "Déverrouiller",
"chat_sensitive_btn_unlock_mark": "Déverrouiller et rendre confidentielle",
"chat_sensitive_btn_unlock_unmark": "Déverrouiller et rendre normale",
"chat_sensitive_btn_unmark": "Rendre normale",
"chat_sensitive_err_create": "Impossible de créer le coffre des conversations confidentielles.",
"chat_sensitive_err_pass": "Les phrases secrètes ne correspondent pas, ou sont vides.",
"chat_sensitive_err_unlock": "Phrase secrète incorrecte.",
"chat_sensitive_lock_btn": "Verrouiller les confidentielles",
"chat_sensitive_mark_tip": "Rendre confidentielle",
"chat_sensitive_mark_title": "Rendre cette conversation confidentielle ?",
"chat_sensitive_pass2_hint": "Confirmez la phrase secrète",
"chat_sensitive_pass_hint": "Phrase secrète",
"chat_sensitive_setup_label": "Créez une phrase secrète pour les conversations confidentielles",
"chat_sensitive_toast_marked": "La conversation est désormais confidentielle",
"chat_sensitive_toast_unlocked": "Conversations confidentielles déverrouillées",
"chat_sensitive_toast_unmarked": "La conversation n'est plus confidentielle",
"chat_sensitive_unlock_body": "Saisissez votre phrase secrète des conversations confidentielles pour afficher à nouveau vos conversations confidentielles.",
"chat_sensitive_unlock_btn": "Déverrouiller les confidentielles",
"chat_sensitive_unlock_label": "Saisissez votre phrase secrète des conversations confidentielles",
"chat_sensitive_unlock_title": "Déverrouiller les conversations confidentielles",
"chat_sensitive_unmark_body": "Cette conversation ne sera plus chiffrée avec votre phrase secrète des conversations confidentielles. Les nouveaux messages seront stockés comme ceux de n'importe quelle autre conversation.",
"chat_sensitive_unmark_tip": "Confidentielle — cliquez pour modifier",
"chat_sensitive_unmark_title": "Rendre cette conversation normale ?",
"chat_sensitive_warn_1": "Ses messages seront chiffrés sur cet appareil avec une phrase secrète distincte — et non avec votre phrase de récupération. Quiconque restaurera plus tard votre portefeuille à partir de votre seule phrase de récupération ne verra pas cette conversation.",
"chat_sensitive_warn_dir": "Vos messages envoyés ici sont entièrement protégés. Les messages reçus restent sur la blockchain et pourraient, en théorie, être récupérés par une personne disposant de votre phrase de récupération et d'un autre logiciel — mais DragonX lui-même ne les affichera jamais sans votre phrase secrète.",
"chat_sensitive_warn_hist": "Les messages déjà présents dans cette conversation restent récupérables depuis la chaîne ; la protection s'applique pleinement aux messages à venir.",
"chat_sensitive_warn_lose": "Si vous perdez cette phrase secrète, ces messages sont perdus à jamais — ils ne pourront pas être récupérés à partir de votre phrase de récupération.",
"chat_settings_done": "Terminé",
"chat_settings_section": "CHAT ET CONTACTS",
"chat_settings_tip": "Personnaliser le chat",
@@ -344,12 +392,14 @@
"chat_toast_not_connected": "Non connecté — message non envoyé.",
"chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).",
"chat_toast_request_queued": "Demande de contact mise en file d'attente.",
"chat_toast_too_long": "Message trop long — raccourcissez-le et renvoyez-le.",
"chat_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.",
"chat_today": "Aujourd'hui",
"chat_ts_12h": "12 heures",
"chat_ts_24h": "24 heures",
"chat_ts_global": "Suivre global",
"chat_ts_global_short": "Global",
"chat_txns_label": "tx",
"chat_unblock": "Débloquer",
"chat_unblocked_toast": "Conversation débloquée",
"chat_unhide": "Afficher",
@@ -370,11 +420,20 @@
"click_to_copy": "Cliquez pour copier",
"clock_format": "Format d'horloge",
"close": "Fermer",
"compact_needs_embedded": "Le compactage réécrit wallet.dat, ce que le portefeuille ne peut faire qu'avec son nœud intégré. Vous êtes connecté à un dragonxd que vous avez lancé vous-même — activez « Utiliser le dragonxd intégré » dans Paramètres, ou redémarrez votre dragonxd avec -salvagewallet pour récupérer l'espace.",
"compact_started": "Compactage du portefeuille — le nœud redémarre et va re-scanner la chaîne.",
"compact_switch_stop_failed": "Impossible d'arrêter votre nœud externe — il est toujours en cours d'exécution, donc rien n'a été modifié.",
"compact_switching": "Arrêt de votre nœud et passage au nœud intégré…",
"compact_wallet_confirm": "Compacter",
"conf_count": "%d conf.",
"confirm_and_send": "Confirmer & Envoyer",
"confirm_clear_ztx_title": "Confirmer l'effacement de l'historique Z-Tx",
"confirm_clear_ztx_warning1": "L'effacement de l'historique des z-transactions peut faire apparaître votre solde blindé à 0 jusqu'à ce qu'un rescan du portefeuille soit effectué.",
"confirm_clear_ztx_warning2": "Si cela se produit, vous devrez réimporter les clés privées de votre adresse z avec le rescan activé pour récupérer votre solde.",
"confirm_compact_switch_msg": "Vous êtes connecté à un dragonxd que vous avez lancé vous-même. Pour compacter, le portefeuille va arrêter ce nœud, passer à son propre nœud intégré et y réécrire wallet.dat — ce qui redémarre le nœud et re-scanne la blockchain, et peut prendre du temps. Remarque : le nœud intégré est le dragonxd situé dans le dossier propre au portefeuille, donc cela change le nœud que vous utilisez.",
"confirm_compact_wallet_msg": "La consolidation a fusionné vos notes, mais le fichier du portefeuille ne peut pas rétrécir de lui-même — le nœud doit le réécrire pour récupérer l'espace libéré. Cela redémarre le nœud et re-scanne la blockchain, ce qui peut prendre du temps sur un gros portefeuille.",
"confirm_compact_wallet_safe": "Vos fonds et votre solde sont en sécurité — une sauvegarde horodatée du portefeuille actuel est conservée.",
"confirm_compact_wallet_title": "Compacter le portefeuille",
"confirm_delete_blockchain_msg": "Cela arrêtera le daemon, supprimera toutes les données de la blockchain (blocks, chainstate, peers) et démarrera une nouvelle synchronisation. Cela peut prendre plusieurs heures.",
"confirm_delete_blockchain_safe": "Votre wallet.dat, votre configuration et votre historique de transactions sont en sécurité et ne seront pas supprimés.",
"confirm_delete_blockchain_title": "Supprimer les données Blockchain",
@@ -505,7 +564,7 @@
"console_welcome": "Bienvenue dans la console ObsidianDragon",
"console_zoom_in": "Agrandir",
"console_zoom_out": "Réduire",
"consolidate_desc": "Combinez de nombreuses petites entrées en une seule note blindée. Moins de notes signifie un fichier de portefeuille plus petit et une meilleure confidentialité.",
"consolidate_desc": "Chaque paiement que vous recevez est stocké comme une « note » distincte — comme un billet individuel dans un portefeuille. Elles s'accumulent avec le temps, et chaque note ajoute des données de témoin qui ralentissent la synchronisation du portefeuille et le font grossir sans cesse. Consolider combine de nombreuses petites notes en une seule par adresse : votre solde ne change pas, mais le portefeuille s'analyse plus vite, cesse de grossir et révèle moins de choses sur vos fonds.",
"consolidate_funds_btn": "Consolider",
"consolidate_title": "Consolider les fonds",
"contact_avatar": "AVATAR",
@@ -639,6 +698,11 @@
"download": "Télécharger",
"download_bootstrap": "Télécharger Bootstrap",
"dragonx_green": "DragonX (Vert)",
"earnings_format": "Format d'affichage des gains",
"earnings_format_coin": "Pièce uniquement",
"earnings_format_coin_fiat": "Pièce + fiat",
"earnings_format_fiat": "Fiat uniquement",
"earnings_label": "Gains",
"edit": "Modifier",
"empty_wallet_keys_suffix": "clés",
"empty_wallet_open_manager": "Ouvrir le gestionnaire de portefeuilles",
@@ -863,6 +927,7 @@
"faq_w_set_4_q": "Comment afficher les prix en monnaie fiduciaire ?",
"faq_w_set_title": "Paramètres et apparence",
"favorite_address": "Ajouter aux favoris",
"favorite_opacity": "Opacité de la surbrillance des favoris",
"fee": "Frais",
"fee_high": "Élevés",
"fee_label": "Frais :",
@@ -870,7 +935,7 @@
"fee_normal": "Normal",
"fetch_prices": "Récupérer les prix",
"file": "Fichier",
"file_save_location": "Le fichier sera enregistré dans : ~/.config/ObsidianDragon/",
"file_save_location": "Le fichier sera enregistré dans :",
"filter": "Filtrer...",
"font_scale": "Taille de police",
"from": "De",
@@ -941,6 +1006,7 @@
"hidden_tag": " (masqué)",
"hide": "Masquer",
"hide_address": "Masquer l'adresse",
"hide_earnings": "Masquer les gains",
"hide_qr": "Masquer le QR",
"hide_zero_balances": "Masquer les soldes à 0",
"history": "Historique",
@@ -1307,6 +1373,8 @@
"mining_est_block": "Bloc est.",
"mining_est_daily": "Est. quotidien",
"mining_est_daily_pool_sub": "équivalent solo approximatif, avant les frais du pool",
"mining_est_daily_sub": "moy.",
"mining_est_daily_sub_net": "moy., net",
"mining_filter_all": "Tout",
"mining_filter_tip_all": "Afficher tous les gains",
"mining_filter_tip_pool": "Afficher uniquement les gains du pool",
@@ -1917,6 +1985,7 @@
"shielded_type": "Blindé",
"shielding_notice": "Note : Cela blindera des fonds d'une adresse transparente (T) vers une adresse privée (Z).",
"show": "Afficher",
"show_earnings": "Afficher les gains",
"show_hidden": "Afficher masqués (%d)",
"show_qr": "Afficher le QR",
"show_qr_code": "Afficher le code QR",
@@ -2038,6 +2107,7 @@
"tt_addr_url": "URL de base pour consulter les adresses dans un explorateur de blocs",
"tt_address_book": "Gérer les adresses enregistrées pour un envoi rapide",
"tt_animate_avatars": "Lit les avatars de contact animés (GIF / WebP) ; désactivé n'affiche que la première image",
"tt_auto_consolidate": "Fusionne discrètement les petites notes en arrière-plan lorsque le portefeuille est inactif, par minuscules lots. Plus lent qu'une consolidation manuelle, mais à peine perceptible, et les fonds de chaque adresse restent séparés. Idéal pour les portefeuilles de minage/pool qui accumulent des milliers de notes.",
"tt_auto_lock": "Verrouiller le portefeuille après cette durée d'inactivité",
"tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité",
"tt_backup": "Créer une sauvegarde de votre wallet.dat",
@@ -2065,10 +2135,16 @@
"tt_debug_expand": "Développer les options de journalisation de débogage",
"tt_delete_blockchain": "Supprimer toutes les données de la blockchain et démarrer une nouvelle synchronisation. wallet.dat et la configuration sont préservés.",
"tt_download_bootstrap": "Télécharger le bootstrap blockchain pour accélérer la synchronisation\nLes données de blocs existantes seront remplacées",
"tt_earn_interval": "Période de gains : 1H = cette heure, 1D = aujourd'hui, 1M = ce mois, 1Y = cette année",
"tt_earn_mode_inflows": "Mode de gains : montants reçus (minage inclus)",
"tt_earn_mode_net": "Mode de gains : net (reçu moins envoyé)",
"tt_earn_show_all": "Afficher le montant des gains pour chaque adresse",
"tt_earnings_format": "Comment les montants de gains par adresse et totaux sont affichés",
"tt_encrypt": "Chiffrer wallet.dat avec une phrase secrète",
"tt_export_all": "Exporter toutes les clés privées dans un fichier",
"tt_export_csv": "Exporter l'historique des transactions en feuille de calcul CSV",
"tt_export_key": "Exporter la clé privée de l'adresse sélectionnée",
"tt_favorite_opacity": "Opacité du bouton étoile des favoris et de son contour doré dans la liste d'adresses (minimum 15 %)",
"tt_fetch_prices": "Récupérer les prix du marché DRGX depuis l'API CoinGecko",
"tt_font_scale": "Mettre à l'échelle tout le texte et l'interface (1.0x = par défaut, jusqu'à 1.5x).",
"tt_idle_delay": "Combien de temps attendre avant de commencer le minage",
@@ -2106,6 +2182,9 @@
"tt_low_spec": "Désactiver tous les effets visuels lourds\\nRaccourci : Ctrl+Shift+Down",
"tt_merge": "Consolider plusieurs UTXOs vers une adresse",
"tt_mine_idle": "Démarrer le minage automatiquement quand le\\nsystème est inactif (aucune entrée clavier/souris)",
"tt_mining_est_daily": "Une moyenne statistique à long terme pour votre part actuelle de hashrate dans le réseau. Les récompenses réelles de minage arrivent au hasard (Poisson) — souvent rien pendant longtemps, puis une somme — ce n'est donc PAS un revenu quotidien régulier.",
"tt_mining_est_daily_fee_fmt": "Affiché après les %.1f%% de frais de pool.",
"tt_mining_est_daily_nofee": "Affiché avant les frais de pool (frais inconnus pour ce pool).",
"tt_new_mining_address": "Crée une adresse transparente (R…) pour les récompenses de minage en solo. Elle ne peut pas recevoir de paiements — partagez une adresse blindée pour cela.",
"tt_noise": "Intensité de texture grainée (0% = désactivé, 100% = maximum)",
"tt_open_app_dir": "Ouvrir le dossier ObsidianDragon (paramètres, thèmes, journaux) dans le gestionnaire de fichiers",
@@ -2154,7 +2233,9 @@
"tt_ui_opacity": "Opacité des cartes et de la barre latérale (100% = entièrement opaque, plus bas = plus transparent)",
"tt_validate": "Vérifier si une adresse DragonX est valide",
"tt_verbose": "Journaliser les diagnostics de connexion détaillés,\\nl'état du daemon et les informations de propriétaire de port\\ndans l'onglet Console",
"tt_wallet_size_warn": "Les portefeuilles blindés grandissent avec les données de témoin de chaque note — fusionner de nombreuses notes en une seule adresse réduit cette taille. Sauvegardez d'abord.",
"tt_view_transactions": "Afficher les transactions impliquant cette adresse dans l'onglet Historique",
"tt_wallet_size_compact": "Récupérez l'espace disque libéré par la consolidation. Berkeley DB ne peut pas réduire le fichier sur place : cette action réécrit wallet.dat dans un fichier plus petit et redémarre le nœud avec un re-scan complet (cela peut prendre du temps). Une sauvegarde du portefeuille actuel est conservée.",
"tt_wallet_size_warn": "Chaque paiement arrive sous forme de note blindée distincte, et chaque note porte des données de témoin qui ralentissent les analyses et font grossir le fichier du portefeuille. Fusionner de nombreuses notes en une seule par adresse le garde rapide et stoppe la croissance — vos fonds et votre solde restent inchangés. Le fichier ne récupère l'espace libéré que lorsque vous le compactez (Berkeley DB ne peut pas réduire un fichier sur place). Sauvegardez d'abord votre phrase de récupération. Vous pouvez aussi activer la consolidation auto des notes dans Paramètres.",
"tt_wallets_button": "Répertoriez vos fichiers de portefeuille et passez de l'un à l'autre",
"tt_website": "Ouvrir le site web DragonX",
"tt_window_opacity": "Opacité de l'arrière-plan (plus bas = bureau visible à travers la fenêtre)",
@@ -2220,6 +2301,7 @@
"view_all": "Tout afficher",
"view_details": "Voir les détails",
"view_on_explorer": "Voir dans l'explorateur",
"view_transactions": "Voir les transactions",
"waiting_for_daemon": "En attente de la connexion au daemon...",
"wallet": "PORTEFEUILLE",
"wallet_degraded_notify": "Votre portefeuille s'est ouvert en mode à fonctionnalités réduites : les fonds existants sont en sécurité et dépensables, mais la création de nouvelles adresses et le blindage sont désactivés. Sauvegardez votre phrase de récupération et restaurez-la pour réparer entièrement le portefeuille.",
@@ -2282,8 +2364,10 @@
"wallet_restore_ok": "Portefeuille original restauré. Le nœud le charge maintenant.",
"wallet_restore_started": "Restauration de votre fichier original — veuillez ne pas fermer cette fenêtre.",
"wallet_restore_stop_failed": "Le nœud ne s'est pas arrêté à temps, donc rien n'a été modifié. Réessayez.",
"wallet_size_auto_info": "Votre portefeuille a accumulé beaucoup de notes — des morceaux distincts de fonds reçus qui ralentissent la synchronisation et font grossir le fichier. La consolidation auto des notes les fusionne discrètement en arrière-plan pour garder le portefeuille rapide et l'empêcher de grossir, vous n'avez donc rien à faire.",
"wallet_size_compact": "Compacter le portefeuille…",
"wallet_size_consolidate": "Consolider les notes…",
"wallet_size_warn": "Ce fichier de portefeuille est volumineux. Consolider vos notes peut freiner sa croissance.",
"wallet_size_warn": "Votre portefeuille a accumulé beaucoup de notes. Chaque paiement que vous recevez est stocké comme une « note » distincte ; en s'accumulant, elles ralentissent la synchronisation du portefeuille et le font grossir sans cesse. Consolider les fusionne en moins de notes pour qu'il s'analyse plus vite et cesse de grossir — votre solde reste exactement le même.",
"wallets_active": "Actif",
"wallets_add": "Ajouter le dossier",
"wallets_add_folder": "Analyser aussi un autre dossier à la recherche de fichiers de portefeuille :",
+88 -4
View File
@@ -141,6 +141,7 @@
"appx_wallet_op_running": "鍵のインポートまたは再スキャンがまだ実行中です。完了してからもう一度お試しください。",
"appx_wallet_op_shutting_down": "ウォレットを終了しています。",
"appx_wallet_open_failed_prefix": "ウォレットを開けませんでした: ",
"auto_consolidate": "ノートを自動統合",
"auto_shield": "マイニング自動シールド",
"autoshield_by_node": "あなたのノードは、マイニングしたコインベースを自動的にシールドします。",
"autoshield_off_backup_seed": "マイニングしたコインベースはまだ自動シールドされていません。ノードが安全に有効化できるよう、シードフレーズをバックアップしてください(設定 → バックアップとデータ)。",
@@ -276,8 +277,12 @@
"chat_delete_body_prefix": "次の相手との会話のローカルコピーを削除しますか:",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "削除",
"chat_delete_everywhere": "すべての端末で削除",
"chat_delete_everywhere_note": "他の端末にも、そして将来シードフレーズから復元した際にも、この会話を非表示にするよう伝えます。チェーン上に小さな信号を送信します(通常のチャット手数料がかかります)。後で「復元」から元に戻せます。",
"chat_delete_everywhere_toast": "すべての端末で削除中 — 信号を送信しました",
"chat_delete_failed": "会話を削除できませんでした — 変更はありません。",
"chat_delete_local_note": "これはこの端末のみに影響します — メッセージはブロックチェーン上に残り、相手も自分のコピーを保持し続けます。",
"chat_delete_needs_sync": "すべての端末で削除するには、ウォレットの同期が完了するまでお待ちください。",
"chat_delete_revive_note": "「削除」はこの端末上の履歴を消去します。相手から再度メッセージが届くと、会話は復活します。",
"chat_delete_title": "会話を削除しますか?",
"chat_deleted_toast": "会話を削除しました",
@@ -319,10 +324,24 @@
"chat_opt_global_clock": "全体の時刻形式",
"chat_opt_poll": "取得間隔",
"chat_opt_timestamp": "タイムスタンプ",
"chat_parts_explain": "この長いメッセージは、これらのパートに分割された1つのトランザクションとして送信されます。通常のメッセージと同じ速さで届きます。",
"chat_parts_explain_multi": "このメッセージは1つのトランザクションに収まらないため、複数に分割して送信されます。受信者はすべてのパートが届いてから表示できるため、配信に時間がかかり、トランザクションごとにネットワーク手数料がかかります。",
"chat_parts_label": "パート",
"chat_pick_contact": "連絡先から選択…",
"chat_receiving_large": "大きなメッセージを受信中…",
"chat_rename": "連絡先の名前を変更",
"chat_rename_hint": "連絡先名",
"chat_renamed": "連絡先の名前を変更しました",
"chat_restore_btn": "復元",
"chat_restore_desc": "すべての端末で削除した会話は、ここから元に戻せます — この端末でも、シードフレーズをインポートした後でも可能です。復元すると、チェーンからメッセージが再インポートされ、他の端末にも再表示するよう伝えます。",
"chat_restore_manage": "復元",
"chat_restore_none": "削除された会話はまだ見つかりません。別の端末で削除した場合は、「クイックスキャン」を試すか、下にチャットアドレスを貼り付けてください。",
"chat_restore_paste_hint": "会話のチャット(z)アドレスを貼り付け",
"chat_restore_paste_label": "チャットアドレスで復元",
"chat_restore_scan": "クイックスキャン",
"chat_restore_scanning": "削除された会話をチェーンでスキャン中…",
"chat_restore_title": "会話を復元",
"chat_restore_toast": "会話を復元中 — 次回のスキャン後に再表示されます",
"chat_retry": "再送信",
"chat_search": "会話を検索",
"chat_sec_appearance": "外観",
@@ -331,6 +350,35 @@
"chat_send": "送信",
"chat_send_failed": "未送信",
"chat_sending": "送信中…",
"chat_sensitive_btn_mark": "機密にする",
"chat_sensitive_btn_setup": "設定して機密にする",
"chat_sensitive_btn_unlock": "ロック解除",
"chat_sensitive_btn_unlock_mark": "ロック解除して機密にする",
"chat_sensitive_btn_unlock_unmark": "ロック解除して通常に戻す",
"chat_sensitive_btn_unmark": "通常に戻す",
"chat_sensitive_err_create": "機密チャット用ボールトの作成に失敗しました。",
"chat_sensitive_err_pass": "パスフレーズが一致しないか、空です。",
"chat_sensitive_err_unlock": "パスフレーズが正しくありません。",
"chat_sensitive_lock_btn": "機密をロック",
"chat_sensitive_mark_tip": "機密にする",
"chat_sensitive_mark_title": "この会話を機密にしますか?",
"chat_sensitive_pass2_hint": "パスフレーズを再入力",
"chat_sensitive_pass_hint": "パスフレーズ",
"chat_sensitive_setup_label": "機密会話用のパスフレーズを作成",
"chat_sensitive_toast_marked": "会話を機密にしました",
"chat_sensitive_toast_unlocked": "機密会話のロックを解除しました",
"chat_sensitive_toast_unmarked": "会話を通常に戻しました",
"chat_sensitive_unlock_body": "機密会話を再び表示するには、機密チャット用パスフレーズを入力してください。",
"chat_sensitive_unlock_btn": "機密をロック解除",
"chat_sensitive_unlock_label": "機密チャット用パスフレーズを入力",
"chat_sensitive_unlock_title": "機密会話のロックを解除",
"chat_sensitive_unmark_body": "この会話は、機密チャット用パスフレーズで暗号化されなくなります。新しいメッセージは、他の会話と同じように保存されます。",
"chat_sensitive_unmark_tip": "機密 — タップして変更",
"chat_sensitive_unmark_title": "この会話を通常に戻しますか?",
"chat_sensitive_warn_1": "メッセージはこの端末上で、シードフレーズとは別の専用パスフレーズを使って暗号化されます。後でシードフレーズだけからウォレットを復元した人には、この会話は表示されません。",
"chat_sensitive_warn_dir": "ここで送信したメッセージは完全に保護されます。受信したメッセージはブロックチェーン上に残っているため、あなたのシードフレーズと別のソフトウェアを持つ人なら、理論上は復元できる可能性があります。ただしDragonX自体が、パスフレーズなしにそれらを表示することは決してありません。",
"chat_sensitive_warn_hist": "この会話に既にあるメッセージは、チェーンから復元可能なままです。この設定は、これ以降のメッセージを完全に保護します。",
"chat_sensitive_warn_lose": "このパスフレーズを失うと、これらのメッセージは永久に失われます。シードフレーズからは復元できません。",
"chat_settings_done": "完了",
"chat_settings_section": "チャットと連絡先",
"chat_settings_tip": "チャットのカスタマイズ",
@@ -344,12 +392,14 @@
"chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。",
"chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。",
"chat_toast_request_queued": "連絡リクエストを送信待ちに追加しました。",
"chat_toast_too_long": "メッセージが長すぎます。短くしてもう一度送信してください。",
"chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。",
"chat_today": "今日",
"chat_ts_12h": "12時間",
"chat_ts_24h": "24時間",
"chat_ts_global": "全体設定に従う",
"chat_ts_global_short": "全体",
"chat_txns_label": "件",
"chat_unblock": "ブロック解除",
"chat_unblocked_toast": "会話のブロックを解除しました",
"chat_unhide": "再表示",
@@ -370,11 +420,20 @@
"click_to_copy": "クリックしてコピー",
"clock_format": "時刻形式",
"close": "閉じる",
"compact_needs_embedded": "コンパクト化は wallet.dat を書き直す操作で、ウォレットは内蔵ノードでのみ実行できます。現在、ご自身で起動した dragonxd に接続しています — 設定で「内蔵dragonxdを使用」をオンにするか、dragonxd を -salvagewallet 付きで再起動して、領域を回収してください。",
"compact_started": "ウォレットをコンパクト化しています — ノードを再起動し、チェーンを再スキャンします。",
"compact_switch_stop_failed": "外部ノードを停止できませんでした — まだ実行中のため、何も変更されていません。",
"compact_switching": "ノードを停止し、内蔵ノードに切り替えています…",
"compact_wallet_confirm": "コンパクト化",
"conf_count": "%d 確認",
"confirm_and_send": "確認して送金",
"confirm_clear_ztx_title": "Z-Tx 履歴クリアの確認",
"confirm_clear_ztx_warning1": "z-トランザクション履歴をクリアすると、ウォレットの再スキャンが実行されるまでシールド残高が0と表示される場合があります。",
"confirm_clear_ztx_warning2": "これが発生した場合、残高を回復するにはz-アドレスの秘密鍵を再スキャンを有効にして再インポートする必要があります。",
"confirm_compact_switch_msg": "ご自身で起動した dragonxd に接続しています。コンパクト化するため、ウォレットはそのノードを停止し、独自の内蔵ノードに切り替えて、そのノード上で wallet.dat を書き直します — 再起動とブロックチェーンの再スキャンが行われるため、時間がかかる場合があります。注意: 内蔵ノードはウォレット自身のフォルダにある dragonxd のため、この操作で使用するノードが変わります。",
"confirm_compact_wallet_msg": "統合によってノートはまとまりましたが、ウォレットファイルは自動では縮小されません — 解放された領域を回収するには、ノードがファイルを書き直す必要があります。この操作ではノードが再起動され、ブロックチェーンが再スキャンされるため、大きなウォレットでは時間がかかる場合があります。",
"confirm_compact_wallet_safe": "資金と残高は安全です — 現在のウォレットのタイムスタンプ付きバックアップが保持されます。",
"confirm_compact_wallet_title": "ウォレットをコンパクト化",
"confirm_delete_blockchain_msg": "デーモンを停止し、すべてのブロックチェーンデータ(blocks、chainstate、peers)を削除して、最初から再同期を開始します。数時間かかる場合があります。",
"confirm_delete_blockchain_safe": "wallet.dat、設定、トランザクション履歴は安全で削除されません。",
"confirm_delete_blockchain_title": "ブロックチェーンデータを削除",
@@ -505,7 +564,7 @@
"console_welcome": "ObsidianDragonコンソールへようこそ",
"console_zoom_in": "拡大",
"console_zoom_out": "縮小",
"consolidate_desc": "多数の小さな入力を1つのシールドノートにまとめます。ノート数が減るとウォレットファイルが小さくなり、プライバシーも向上します。",
"consolidate_desc": "受け取った支払いはそれぞれ個別の「ノート」として保存されます — 財布に入っている1枚1枚の紙幣のようなものです。ノートは時間とともに溜まり、どのノートもウィットネスデータを追加するため、ウォレットの同期が遅くなり、ファイルも大きくなり続けます。統合すると、多数の小さなノートがアドレスごとに1つにまとまります。残高は変わらず、ウォレットのスキャンは速くなり、肥大化も止まり、資金に関して明らかになる情報も減ります。",
"consolidate_funds_btn": "統合",
"consolidate_title": "資金を統合",
"contact_avatar": "アバター",
@@ -639,6 +698,11 @@
"download": "ダウンロード",
"download_bootstrap": "ブートストラップをダウンロード",
"dragonx_green": "DragonX(グリーン)",
"earnings_format": "収益の表示形式",
"earnings_format_coin": "コインのみ",
"earnings_format_coin_fiat": "コイン+法定通貨",
"earnings_format_fiat": "法定通貨のみ",
"earnings_label": "収益",
"edit": "編集",
"empty_wallet_keys_suffix": "個の鍵",
"empty_wallet_open_manager": "ウォレットマネージャーを開く",
@@ -863,6 +927,7 @@
"faq_w_set_4_q": "法定通貨での価格を表示するには?",
"faq_w_set_title": "設定と外観",
"favorite_address": "お気に入りに追加",
"favorite_opacity": "お気に入りハイライトの不透明度",
"fee": "手数料",
"fee_high": "高い",
"fee_label": "手数料:",
@@ -870,7 +935,7 @@
"fee_normal": "通常",
"fetch_prices": "価格を取得",
"file": "ファイル",
"file_save_location": "ファイルの保存先:~/.config/ObsidianDragon/",
"file_save_location": "ファイルの保存先:",
"filter": "フィルター...",
"font_scale": "フォントサイズ",
"from": "送信元",
@@ -941,6 +1006,7 @@
"hidden_tag": " (非表示)",
"hide": "非表示",
"hide_address": "アドレスを非表示",
"hide_earnings": "収益を非表示",
"hide_qr": "QRを非表示",
"hide_zero_balances": "残高0を非表示",
"history": "履歴",
@@ -1307,6 +1373,8 @@
"mining_est_block": "予測ブロック",
"mining_est_daily": "予測日収",
"mining_est_daily_pool_sub": "おおよそのソロ換算(プール手数料前)",
"mining_est_daily_sub": "平均",
"mining_est_daily_sub_net": "平均・手取り",
"mining_filter_all": "すべて",
"mining_filter_tip_all": "すべての収益を表示",
"mining_filter_tip_pool": "プール収益のみ表示",
@@ -1917,6 +1985,7 @@
"shielded_type": "シールド",
"shielding_notice": "注意:トランスペアレント (T) アドレスからプライベート (Z) アドレスへ資金をシールドします。",
"show": "表示",
"show_earnings": "収益を表示",
"show_hidden": "非表示を表示 (%d)",
"show_qr": "QRを表示",
"show_qr_code": "QRコードを表示",
@@ -2038,6 +2107,7 @@
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
"tt_address_book": "クイック送信用の保存済みアドレスを管理",
"tt_animate_avatars": "アニメーション連絡先アバター(GIF / WebP)を再生。オフでは最初のフレームのみ表示",
"tt_auto_consolidate": "ウォレットがアイドル状態の間、バックグラウンドで小さなノートを少しずつ静かに統合します。手動で統合するより時間はかかりますが、ほとんど気になりません。各アドレスの資金は分かれたままです。数千件のノートが溜まるマイニング/プールのウォレットに最適です。",
"tt_auto_lock": "この無操作時間後にウォレットをロック",
"tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動",
"tt_backup": "wallet.dat のバックアップを作成",
@@ -2065,10 +2135,16 @@
"tt_debug_expand": "デバッグログオプションを展開",
"tt_delete_blockchain": "すべてのブロックチェーンデータを削除して新規同期を開始します。wallet.dat と設定は保持されます。",
"tt_download_bootstrap": "ブロックチェーンブートストラップをダウンロードして同期を高速化\n既存のブロックデータは置き換えられます",
"tt_earn_interval": "収益期間:1H=今の時間、1D=今日、1M=今月、1Y=今年",
"tt_earn_mode_inflows": "収益モード:受取額(マイニングを含む)",
"tt_earn_mode_net": "収益モード:純額(受取から送金を差し引いた額)",
"tt_earn_show_all": "すべてのアドレスの収益額を表示",
"tt_earnings_format": "アドレスごとおよび合計の収益額の表示方法",
"tt_encrypt": "パスフレーズで wallet.dat を暗号化",
"tt_export_all": "すべての秘密鍵をファイルにエクスポート",
"tt_export_csv": "トランザクション履歴を CSV スプレッドシートとしてエクスポート",
"tt_export_key": "選択したアドレスの秘密鍵をエクスポート",
"tt_favorite_opacity": "アドレス一覧のお気に入りスターボタンとその金色の枠線の不透明度(最小15%)",
"tt_fetch_prices": "CoinGecko API から DRGX の市場価格を取得",
"tt_font_scale": "すべてのテキストと UI をスケーリング(1.0x = デフォルト、最大 1.5x)。",
"tt_idle_delay": "マイニング開始前の待機時間",
@@ -2106,6 +2182,9 @@
"tt_low_spec": "すべての重い視覚効果を無効化\\nホットキー:Ctrl+Shift+Down",
"tt_merge": "複数の UTXO を一つのアドレスに統合",
"tt_mine_idle": "システムがアイドル状態(キーボード/マウス入力なし)\\nのとき自動的にマイニングを開始",
"tt_mining_est_daily": "ネットワーク全体に占める現在のハッシュレート比率に基づく長期的な統計平均です。実際のマイニング報酬はランダム(ポアソン分布)に発生し、長期間ゼロが続いた後にまとまって得られることが多いため、これは安定した日次収入ではありません。",
"tt_mining_est_daily_fee_fmt": "%.1f%% のプール手数料を差し引いた値です。",
"tt_mining_est_daily_nofee": "プール手数料を差し引く前の値です(このプールの手数料は不明)。",
"tt_new_mining_address": "ソロマイニング報酬用の透明(R…)アドレスを作成します。支払いは受け取れません。支払いにはシールドアドレスを共有してください。",
"tt_noise": "グレインテクスチャ強度(0% = オフ、100% = 最大)",
"tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
@@ -2154,7 +2233,9 @@
"tt_ui_opacity": "カードとサイドバーの不透明度(100% = 完全不透明、低い = より透過)",
"tt_validate": "DragonX アドレスが有効かどうかを確認",
"tt_verbose": "詳細な接続診断、デーモン状態、\\nポート所有者情報をコンソールタブに記録",
"tt_wallet_size_warn": "シールドウォレットはノートごとのウィットネスデータによって肥大化します — 多数のノートを1つのアドレスに統合するとサイズを抑えられます。事前にバックアップしてください。",
"tt_view_transactions": "このアドレスに関連する取引を履歴タブに表示",
"tt_wallet_size_compact": "統合によって解放されたディスク領域を回収します。Berkeley DB はファイルをその場で縮小できないため、wallet.dat をより小さいファイルに書き直し、ノードを再起動してフルの再スキャンを行います(時間がかかる場合があります)。現在のウォレットのバックアップは保持されます。",
"tt_wallet_size_warn": "支払いは1件ごとに個別のシールドノートとして届き、どのノートにもウィットネスデータが付随するため、スキャンが遅くなりウォレットファイルが大きくなり続けます。多数のノートをアドレスごとに1つへ統合すると、高速に保たれ肥大化も止まります — 資金と残高は変わりません。解放された領域がファイルに戻るのは、コンパクト化したときだけです(Berkeley DB はその場で縮小できません)。事前にシードをバックアップしてください。設定で「ノートを自動統合」をオンにすることもできます。",
"tt_wallets_button": "ウォレットファイルを一覧表示して切り替えます",
"tt_website": "DragonX ウェブサイトを開く",
"tt_window_opacity": "背景の不透明度(低い = デスクトップがウィンドウ越しに見える)",
@@ -2220,6 +2301,7 @@
"view_all": "すべて表示",
"view_details": "詳細を表示",
"view_on_explorer": "エクスプローラーで表示",
"view_transactions": "取引を表示",
"waiting_for_daemon": "デーモン接続を待機中...",
"wallet": "ウォレット",
"wallet_degraded_notify": "ウォレットは機能制限モードで開かれました。既存の資金は安全で使用可能ですが、新しいアドレスの作成とシールドは無効になっています。ウォレットを完全に修復するには、シードフレーズをバックアップしてそこから復元してください。",
@@ -2282,8 +2364,10 @@
"wallet_restore_ok": "オリジナルのウォレットを復元しました。ノードが読み込み中です。",
"wallet_restore_started": "オリジナルファイルを復元しています — このウィンドウを閉じないでください。",
"wallet_restore_stop_failed": "ノードが時間内に停止しなかったため、何も変更されていません。もう一度お試しください。",
"wallet_size_auto_info": "このウォレットには大量のノート(受け取った資金の個別の断片)が溜まっており、同期が遅くなってファイルも大きくなり続けています。ノートの自動統合がバックグラウンドで静かに統合を進め、ウォレットを高速に保ちつつ肥大化を止めているため、操作は必要ありません。",
"wallet_size_compact": "ウォレットをコンパクト化…",
"wallet_size_consolidate": "ノートを統合…",
"wallet_size_warn": "このウォレットファイルは大きくなっています。ノートを統合すると、さらなる増加を抑えられます。",
"wallet_size_warn": "このウォレットには大量のノートが溜まっています。受け取った支払いはそれぞれ個別の「ノート」として保存され、増えるほどウォレットの同期が遅くなり、ファイルも大きくなり続けます。統合すると、ノートの数が減ってスキャンが速くなり、肥大化も止まります — 残高はまったく変わりません。",
"wallets_active": "アクティブ",
"wallets_add": "フォルダを追加",
"wallets_add_folder": "ウォレットファイルを検索する別のフォルダを追加:",
+88 -4
View File
@@ -141,6 +141,7 @@
"appx_wallet_op_running": "키 가져오기 또는 재스캔이 아직 실행 중입니다. 끝난 뒤 다시 시도하세요.",
"appx_wallet_op_shutting_down": "지갑을 종료하는 중입니다.",
"appx_wallet_open_failed_prefix": "지갑 열기 실패: ",
"auto_consolidate": "노트 자동 통합",
"auto_shield": "채굴 자동 차폐",
"autoshield_by_node": "노드가 채굴한 코인베이스를 자동으로 보호 처리합니다.",
"autoshield_off_backup_seed": "채굴한 코인베이스가 아직 자동으로 보호 처리되지 않고 있습니다. 노드가 안전하게 이를 활성화할 수 있도록 시드 문구를 백업하세요 (설정 → 백업 & 데이터).",
@@ -276,8 +277,12 @@
"chat_delete_body_prefix": "다음 상대와의 대화 로컬 사본을 삭제할까요: ",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "삭제",
"chat_delete_everywhere": "모든 기기에서 삭제",
"chat_delete_everywhere_note": "다른 기기에도, 그리고 나중에 시드 문구로 복원할 때에도 이 대화를 숨기도록 알립니다. 아주 작은 온체인 신호를 전송하며(평소 채팅 수수료가 듭니다), 나중에 '복원'에서 다시 가져올 수 있습니다.",
"chat_delete_everywhere_toast": "모든 기기에서 삭제하는 중 — 신호를 보냈습니다",
"chat_delete_failed": "대화를 삭제하지 못했습니다 — 변경된 내용이 없습니다.",
"chat_delete_local_note": "이 기기에만 영향을 줍니다 — 메시지는 블록체인에 남아 있고 상대방도 자신의 사본을 계속 보관합니다.",
"chat_delete_needs_sync": "모든 기기에서 삭제하려면 지갑이 완전히 동기화될 때까지 기다리세요.",
"chat_delete_revive_note": "\"삭제\"는 이 기기의 기록만 지웁니다. 상대방이 다시 메시지를 보내면 대화가 되살아납니다.",
"chat_delete_title": "대화를 삭제할까요?",
"chat_deleted_toast": "대화를 삭제했습니다",
@@ -319,10 +324,24 @@
"chat_opt_global_clock": "전역 시간 형식",
"chat_opt_poll": "폴링 주기",
"chat_opt_timestamp": "타임스탬프",
"chat_parts_explain": "이 긴 메시지는 여러 조각으로 나뉜 하나의 거래로 전송됩니다. 일반 메시지만큼 빠르게 도착합니다.",
"chat_parts_explain_multi": "이 메시지는 하나의 거래에 담기에는 너무 커서 여러 거래로 나뉩니다. 받는 사람은 모든 조각이 도착한 후에야 볼 수 있으므로 전달에 시간이 더 걸리고 거래마다 네트워크 수수료가 듭니다.",
"chat_parts_label": "조각",
"chat_pick_contact": "연락처에서 선택…",
"chat_receiving_large": "큰 메시지를 받는 중…",
"chat_rename": "연락처 이름 변경",
"chat_rename_hint": "연락처 이름",
"chat_renamed": "연락처 이름이 변경되었습니다",
"chat_restore_btn": "복원",
"chat_restore_desc": "모든 기기에서 삭제한 대화를 여기서 다시 가져올 수 있습니다 — 이 기기에서든, 시드 문구를 가져온 뒤에든 가능합니다. 복원하면 체인에서 메시지를 다시 가져오고, 다른 기기에도 대화를 다시 표시하도록 알립니다.",
"chat_restore_manage": "복원",
"chat_restore_none": "삭제된 대화를 아직 찾지 못했습니다. 다른 기기에서 삭제한 대화가 있다면 '빠른 스캔'을 해보거나 아래에 채팅 주소를 붙여넣으세요.",
"chat_restore_paste_hint": "대화의 채팅 (z) 주소를 붙여넣으세요",
"chat_restore_paste_label": "채팅 주소로 복원",
"chat_restore_scan": "빠른 스캔",
"chat_restore_scanning": "체인에서 삭제된 대화를 스캔하는 중...",
"chat_restore_title": "대화 복원",
"chat_restore_toast": "대화를 복원하는 중 — 다음 스캔 후에 다시 표시됩니다",
"chat_retry": "다시 시도",
"chat_search": "대화 검색",
"chat_sec_appearance": "모양",
@@ -331,6 +350,35 @@
"chat_send": "전송",
"chat_send_failed": "전송 안 됨",
"chat_sending": "전송 중…",
"chat_sensitive_btn_mark": "민감한 대화로 전환",
"chat_sensitive_btn_setup": "암호 설정 후 민감한 대화로 전환",
"chat_sensitive_btn_unlock": "잠금 해제",
"chat_sensitive_btn_unlock_mark": "잠금 해제 후 민감한 대화로 전환",
"chat_sensitive_btn_unlock_unmark": "잠금 해제 후 일반 대화로 전환",
"chat_sensitive_btn_unmark": "일반 대화로 전환",
"chat_sensitive_err_create": "민감한 대화 보관소를 만들지 못했습니다.",
"chat_sensitive_err_pass": "암호가 일치하지 않거나 비어 있습니다.",
"chat_sensitive_err_unlock": "잘못된 암호입니다.",
"chat_sensitive_lock_btn": "민감한 대화 잠그기",
"chat_sensitive_mark_tip": "민감한 대화로 전환",
"chat_sensitive_mark_title": "이 대화를 민감한 대화로 전환할까요?",
"chat_sensitive_pass2_hint": "암호 확인",
"chat_sensitive_pass_hint": "암호",
"chat_sensitive_setup_label": "민감한 대화를 위한 암호를 만드세요",
"chat_sensitive_toast_marked": "이제 민감한 대화입니다",
"chat_sensitive_toast_unlocked": "민감한 대화 잠금이 해제되었습니다",
"chat_sensitive_toast_unmarked": "더 이상 민감한 대화가 아닙니다",
"chat_sensitive_unlock_body": "민감한 대화를 다시 표시하려면 민감한 대화 암호를 입력하세요.",
"chat_sensitive_unlock_btn": "민감한 대화 잠금 해제",
"chat_sensitive_unlock_label": "민감한 대화 암호를 입력하세요",
"chat_sensitive_unlock_title": "민감한 대화 잠금 해제",
"chat_sensitive_unmark_body": "이 대화는 더 이상 민감한 대화 암호로 암호화되지 않습니다. 새 메시지는 다른 대화와 똑같이 저장됩니다.",
"chat_sensitive_unmark_tip": "민감한 대화 — 눌러서 변경",
"chat_sensitive_unmark_title": "이 대화를 일반 대화로 전환할까요?",
"chat_sensitive_warn_1": "이 대화의 메시지는 시드 문구가 아닌 별도의 암호로 이 기기에서 암호화됩니다. 나중에 시드만으로 지갑을 복원하는 사람은 이 대화를 볼 수 없습니다.",
"chat_sensitive_warn_dir": "여기서 보낸 메시지는 완전히 보호됩니다. 받은 메시지는 여전히 블록체인에 남아 있어, 이론적으로는 시드와 다른 소프트웨어를 가진 사람이 복구할 수도 있습니다 — 다만 DragonX 자체는 암호 없이는 그 메시지를 절대 표시하지 않습니다.",
"chat_sensitive_warn_hist": "이 대화에 이미 있는 메시지는 체인에서 계속 복구될 수 있습니다. 이렇게 하면 지금부터의 메시지는 완전히 보호됩니다.",
"chat_sensitive_warn_lose": "이 암호를 잃어버리면 이 메시지는 영원히 사라집니다 — 시드로는 복구할 수 없습니다.",
"chat_settings_done": "완료",
"chat_settings_section": "채팅 및 연락처",
"chat_settings_tip": "채팅 사용자 지정",
@@ -344,12 +392,14 @@
"chat_toast_not_connected": "연결되지 않음 — 채팅 메시지가 전송되지 않았습니다.",
"chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).",
"chat_toast_request_queued": "연락 요청이 대기열에 추가되었습니다.",
"chat_toast_too_long": "메시지가 너무 깁니다. 줄여서 다시 보내세요.",
"chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.",
"chat_today": "오늘",
"chat_ts_12h": "12시간",
"chat_ts_24h": "24시간",
"chat_ts_global": "전역 설정 따르기",
"chat_ts_global_short": "전역",
"chat_txns_label": "건",
"chat_unblock": "차단 해제",
"chat_unblocked_toast": "대화 차단을 해제했습니다",
"chat_unhide": "다시 표시",
@@ -370,11 +420,20 @@
"click_to_copy": "복사하려면 클릭",
"clock_format": "시간 형식",
"close": "닫기",
"compact_needs_embedded": "압축은 wallet.dat을 다시 쓰는 작업이며, 지갑은 내장 노드에서만 이 작업을 수행할 수 있습니다. 현재 직접 실행한 dragonxd에 연결되어 있습니다. 설정에서 \"내장 dragonxd 사용\"을 켜거나, dragonxd를 -salvagewallet 옵션으로 다시 시작하여 공간을 회수하세요.",
"compact_started": "지갑을 압축하는 중입니다. 노드가 재시작되며 체인을 다시 스캔합니다.",
"compact_switch_stop_failed": "외부 노드를 중지하지 못했습니다. 노드가 여전히 실행 중이므로 변경된 것은 없습니다.",
"compact_switching": "노드를 중지하고 내장 노드로 전환하는 중…",
"compact_wallet_confirm": "압축",
"conf_count": "%d 확인",
"confirm_and_send": "확인 후 전송",
"confirm_clear_ztx_title": "Z-Tx 기록 삭제 확인",
"confirm_clear_ztx_warning1": "z-트랜잭션 기록을 삭제하면 지갑 재스캔이 수행될 때까지 차폐 잔액이 0으로 표시될 수 있습니다.",
"confirm_clear_ztx_warning2": "이런 경우, 잔액을 복구하려면 재스캔을 활성화하여 z-주소 개인키를 다시 가져와야 합니다.",
"confirm_compact_switch_msg": "직접 실행한 dragonxd에 연결되어 있습니다. 압축하려면 지갑이 해당 노드를 중지하고 자체 내장 노드로 전환한 뒤, 그 노드에서 wallet.dat을 다시 씁니다. 이 과정에서 노드가 재시작되고 블록체인을 다시 스캔하며, 시간이 오래 걸릴 수 있습니다. 참고: 내장 노드는 지갑 폴더에 있는 dragonxd이므로, 실행하는 노드가 바뀌게 됩니다.",
"confirm_compact_wallet_msg": "통합으로 노트가 병합되었지만 지갑 파일은 저절로 줄어들지 않습니다. 비워진 공간을 회수하려면 노드가 파일을 다시 써야 합니다. 이 작업은 노드를 재시작하고 블록체인을 다시 스캔하며, 큰 지갑에서는 시간이 오래 걸릴 수 있습니다.",
"confirm_compact_wallet_safe": "자금과 잔액은 안전합니다. 현재 지갑의 타임스탬프 백업이 보관됩니다.",
"confirm_compact_wallet_title": "지갑 압축",
"confirm_delete_blockchain_msg": "데몬을 중지하고 모든 블록체인 데이터(blocks, chainstate, peers)를 삭제한 후 처음부터 다시 동기화합니다. 몇 시간이 걸릴 수 있습니다.",
"confirm_delete_blockchain_safe": "wallet.dat, 설정 및 거래 내역은 안전하며 삭제되지 않습니다.",
"confirm_delete_blockchain_title": "블록체인 데이터 삭제",
@@ -505,7 +564,7 @@
"console_welcome": "ObsidianDragon 콘솔에 오신 것을 환영합니다",
"console_zoom_in": "확대",
"console_zoom_out": "축소",
"consolidate_desc": "여러 개의 작은 입력을 하나의 보호된 노트로 합칩니다. 노트 수가 줄면 지갑 파일이 작아지고 프라이버시도 향상됩니다.",
"consolidate_desc": "받은 결제는 각각 별도의 \"노트\"로 저장됩니다. 지갑 속의 개별 지폐와 비슷합니다. 노트는 시간이 지나면서 쌓이고, 노트마다 위트니스 데이터가 추가되어 지갑 동기화는 느려지고 지갑 파일은 계속 커집니다. 통합하면 여러 개의 작은 노트가 주소당 하나로 합쳐집니다. 잔액은 변하지 않지만 지갑 스캔은 빨라지고, 더 이상 커지지 않으며, 자금에 대해 드러나는 정보도 줄어듭니다.",
"consolidate_funds_btn": "통합",
"consolidate_title": "자금 통합",
"contact_avatar": "아바타",
@@ -639,6 +698,11 @@
"download": "다운로드",
"download_bootstrap": "부트스트랩 다운로드",
"dragonx_green": "DragonX(그린)",
"earnings_format": "수익 표시 형식",
"earnings_format_coin": "코인만",
"earnings_format_coin_fiat": "코인 + 법정화폐",
"earnings_format_fiat": "법정화폐만",
"earnings_label": "수익",
"edit": "편집",
"empty_wallet_keys_suffix": "개 키",
"empty_wallet_open_manager": "지갑 관리자 열기",
@@ -863,6 +927,7 @@
"faq_w_set_4_q": "법정화폐 가격을 어떻게 표시하나요?",
"faq_w_set_title": "설정 및 화면",
"favorite_address": "즐겨찾기에 추가",
"favorite_opacity": "즐겨찾기 강조 불투명도",
"fee": "수수료",
"fee_high": "높음",
"fee_label": "수수료:",
@@ -870,7 +935,7 @@
"fee_normal": "보통",
"fetch_prices": "가격 조회",
"file": "파일",
"file_save_location": "파일 저장 위치: ~/.config/ObsidianDragon/",
"file_save_location": "파일 저장 위치:",
"filter": "필터...",
"font_scale": "글꼴 크기",
"from": "보낸 곳",
@@ -941,6 +1006,7 @@
"hidden_tag": " (숨김)",
"hide": "숨기기",
"hide_address": "주소 숨기기",
"hide_earnings": "수익 숨기기",
"hide_qr": "QR 숨기기",
"hide_zero_balances": "잔액 0 숨기기",
"history": "내역",
@@ -1307,6 +1373,8 @@
"mining_est_block": "예상 블록",
"mining_est_daily": "예상 일일 수익",
"mining_est_daily_pool_sub": "대략적인 솔로 환산, 풀 수수료 전",
"mining_est_daily_sub": "평균",
"mining_est_daily_sub_net": "평균, 순",
"mining_filter_all": "전체",
"mining_filter_tip_all": "모든 수익 표시",
"mining_filter_tip_pool": "풀 수익만 표시",
@@ -1917,6 +1985,7 @@
"shielded_type": "차폐",
"shielding_notice": "참고: 투명 (T) 주소에서 프라이빗 (Z) 주소로 자금을 실딩합니다.",
"show": "표시",
"show_earnings": "수익 표시",
"show_hidden": "숨겨진 항목 표시 (%d)",
"show_qr": "QR 표시",
"show_qr_code": "QR 코드 표시",
@@ -2038,6 +2107,7 @@
"tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL",
"tt_address_book": "빠른 전송을 위해 저장된 주소 관리",
"tt_animate_avatars": "애니메이션 연락처 아바타(GIF / WebP) 재생, 끄면 첫 프레임만 표시",
"tt_auto_consolidate": "지갑이 유휴 상태일 때 백그라운드에서 작은 노트를 아주 작은 배치로 조용히 병합합니다. 수동 통합보다 느리지만 거의 눈에 띄지 않으며, 각 주소의 자금은 그대로 분리되어 유지됩니다. 수천 개의 노트가 쌓이는 채굴/풀 지갑에 적합합니다.",
"tt_auto_lock": "이 비활성 시간 후 지갑 잠금",
"tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동",
"tt_backup": "wallet.dat 백업 만들기",
@@ -2065,10 +2135,16 @@
"tt_debug_expand": "디버그 로깅 옵션 펼치기",
"tt_delete_blockchain": "모든 블록체인 데이터를 삭제하고 새로 동기화합니다. wallet.dat 및 설정은 보존됩니다.",
"tt_download_bootstrap": "블록체인 부트스트랩을 다운로드하여 동기화 가속\n기존 블록 데이터가 교체됩니다",
"tt_earn_interval": "수익 기간: 1H = 현재 시간, 1D = 오늘, 1M = 이번 달, 1Y = 올해",
"tt_earn_mode_inflows": "수익 모드: 받은 금액(채굴 포함)",
"tt_earn_mode_net": "수익 모드: 순액(받은 금액에서 보낸 금액을 뺀 값)",
"tt_earn_show_all": "모든 주소의 수익 금액 표시",
"tt_earnings_format": "주소별 및 합계 수익 금액의 표시 방식",
"tt_encrypt": "비밀번호로 wallet.dat 암호화",
"tt_export_all": "모든 개인키를 파일로 내보내기",
"tt_export_csv": "거래 내역을 CSV 스프레드시트로 내보내기",
"tt_export_key": "선택한 주소의 개인키 내보내기",
"tt_favorite_opacity": "주소 목록에서 즐겨찾기 별 버튼과 금색 테두리의 불투명도(최소 15%)",
"tt_fetch_prices": "CoinGecko API에서 DRGX 시장 가격 가져오기",
"tt_font_scale": "모든 텍스트 및 UI 크기 조정 (1.0x = 기본, 최대 1.5x).",
"tt_idle_delay": "채굴 시작 전 대기 시간",
@@ -2106,6 +2182,9 @@
"tt_low_spec": "모든 고부하 시각 효과 비활성화\\n단축키: Ctrl+Shift+Down",
"tt_merge": "여러 UTXO를 하나의 주소로 통합",
"tt_mine_idle": "시스템이 유휴 상태(키보드/마우스 입력 없음)일 때\\n자동으로 채굴 시작",
"tt_mining_est_daily": "네트워크에서 현재 해시레이트 비중에 대한 장기 통계 평균입니다. 실제 채굴 보상은 무작위(푸아송)로 도착하여 오랫동안 아무것도 없다가 한꺼번에 들어오는 경우가 많으므로, 이는 꾸준한 일일 수입이 아닙니다.",
"tt_mining_est_daily_fee_fmt": "%.1f%% 풀 수수료를 제외하고 표시됩니다.",
"tt_mining_est_daily_nofee": "풀 수수료를 제외하기 전 값입니다(이 풀의 수수료는 알 수 없음).",
"tt_new_mining_address": "솔로 채굴 보상용 투명(R…) 주소를 만듭니다. 결제는 받을 수 없으니, 결제용으로는 차폐 주소를 공유하세요.",
"tt_noise": "그레인 텍스처 강도 (0% = 끔, 100% = 최대)",
"tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다",
@@ -2154,7 +2233,9 @@
"tt_ui_opacity": "카드 및 사이드바 불투명도 (100% = 완전 불투명, 낮을수록 더 투명)",
"tt_validate": "DragonX 주소가 유효한지 확인",
"tt_verbose": "콘솔 탭에 상세 연결 진단,\\n데몬 상태 및 포트 소유자 정보 기록",
"tt_wallet_size_warn": "보호 지갑은 각 노트의 위트니스 데이터로 인해 커집니다 — 여러 노트를 하나의 주소로 병합하면 크기를 줄일 수 있습니다. 먼저 백업하세요.",
"tt_view_transactions": "이 주소와 관련된 거래를 기록 탭에 표시",
"tt_wallet_size_compact": "통합으로 비워진 디스크 공간을 회수합니다. Berkeley DB는 파일을 제자리에서 줄일 수 없으므로, wallet.dat을 더 작은 파일로 다시 쓰고 전체 재스캔과 함께 노드를 재시작합니다(시간이 걸릴 수 있습니다). 현재 지갑의 백업은 보관됩니다.",
"tt_wallet_size_warn": "받은 결제는 각각 별도의 차폐 노트로 저장되며, 노트마다 위트니스 데이터가 있어 스캔이 느려지고 지갑 파일이 계속 커집니다. 여러 노트를 주소당 하나로 병합하면 지갑이 빠르게 유지되고 더 이상 커지지 않으며, 자금과 잔액은 그대로입니다. 비워진 공간은 지갑을 압축할 때에만 파일에서 회수됩니다(Berkeley DB는 파일을 제자리에서 줄일 수 없습니다). 먼저 시드를 백업하세요. 설정에서 노트 자동 통합을 켤 수도 있습니다.",
"tt_wallets_button": "지갑 파일 목록을 보고 전환합니다",
"tt_website": "DragonX 웹사이트 열기",
"tt_window_opacity": "배경 불투명도 (낮을수록 = 창을 통해 바탕 화면이 보임)",
@@ -2220,6 +2301,7 @@
"view_all": "모두 보기",
"view_details": "상세 보기",
"view_on_explorer": "탐색기에서 보기",
"view_transactions": "거래 보기",
"waiting_for_daemon": "데몬 연결 대기 중...",
"wallet": "지갑",
"wallet_degraded_notify": "지갑이 기능 제한 모드로 열렸습니다. 기존 자금은 안전하며 사용할 수 있지만, 새 주소 생성과 보호 처리는 비활성화되어 있습니다. 시드 문구를 백업하고 이를 통해 복원하면 지갑을 완전히 복구할 수 있습니다.",
@@ -2282,8 +2364,10 @@
"wallet_restore_ok": "원본 지갑이 복원되었습니다. 노드가 지금 불러오고 있습니다.",
"wallet_restore_started": "원본 파일을 복원하는 중입니다 — 이 창을 닫지 마세요.",
"wallet_restore_stop_failed": "노드가 제시간에 멈추지 않아 아무것도 변경되지 않았습니다. 다시 시도하세요.",
"wallet_size_auto_info": "지갑에 노트가 많이 쌓였습니다. 노트는 받은 자금의 개별 조각으로, 동기화를 느리게 만들고 파일을 계속 키웁니다. 노트 자동 통합이 백그라운드에서 조용히 노트를 병합하여 지갑을 빠르게 유지하고 더 이상 커지지 않게 하고 있으니, 따로 하실 일은 없습니다.",
"wallet_size_compact": "지갑 압축…",
"wallet_size_consolidate": "노트 통합…",
"wallet_size_warn": "이 지갑 파일이 큽니다. 노트를 통합하면 추가 증가를 억제할 수 있습니다.",
"wallet_size_warn": "지갑에 노트가 많이 쌓였습니다. 받은 결제는 각각 별도의 \"노트\"로 저장되며, 노트가 쌓일수록 지갑 동기화가 느려지고 지갑 파일은 계속 커집니다. 통합하면 노트를 더 적은 수로 병합하여 스캔이 빨라지고 더 이상 커지지 않습니다. 잔액은 정확히 그대로 유지됩니다.",
"wallets_active": "활성",
"wallets_add": "폴더 추가",
"wallets_add_folder": "지갑 파일을 검색할 폴더를 추가로 지정:",
+88 -4
View File
@@ -141,6 +141,7 @@
"appx_wallet_op_running": "Ainda há uma importação de chave ou um reescaneamento em andamento. Tente novamente quando terminar.",
"appx_wallet_op_shutting_down": "A carteira está sendo encerrada.",
"appx_wallet_open_failed_prefix": "Falha ao abrir a carteira: ",
"auto_consolidate": "Consolidar notas automaticamente",
"auto_shield": "Auto-blindar mineração",
"autoshield_by_node": "Seu node blinda automaticamente o coinbase minerado para você.",
"autoshield_off_backup_seed": "O coinbase minerado ainda não está sendo blindado automaticamente. Faça backup da sua frase de recuperação (Configurações → Backup e Dados) para que seu node possa ativar isso com segurança.",
@@ -276,8 +277,12 @@
"chat_delete_body_prefix": "Excluir sua cópia local da conversa com ",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "Excluir",
"chat_delete_everywhere": "Excluir em todo lugar",
"chat_delete_everywhere_note": "Também avisa seus outros dispositivos — e uma futura restauração a partir da sua frase de recuperação — para ocultar esta conversa. Isso envia um pequeno sinal on-chain (custa a taxa de chat habitual). Você pode trazê-la de volta depois em Restaurar.",
"chat_delete_everywhere_toast": "Excluindo em todo lugar — sinal enviado",
"chat_delete_failed": "Não foi possível excluir a conversa — nada foi alterado.",
"chat_delete_local_note": "Isso afeta apenas este dispositivo — as mensagens permanecem na blockchain e a outra pessoa mantém sua cópia.",
"chat_delete_needs_sync": "Aguarde a carteira sincronizar totalmente para excluir em todo lugar.",
"chat_delete_revive_note": "\"Excluir\" limpa o histórico neste dispositivo. Se essa pessoa enviar uma mensagem novamente, a conversa volta.",
"chat_delete_title": "Excluir conversa?",
"chat_deleted_toast": "Conversa excluída",
@@ -319,10 +324,24 @@
"chat_opt_global_clock": "Formato de relógio global",
"chat_opt_poll": "Taxa de atualização",
"chat_opt_timestamp": "Carimbos de data/hora",
"chat_parts_explain": "Esta mensagem longa é enviada como uma única transação dividida nestas partes — ela chega tão rápido quanto uma mensagem normal.",
"chat_parts_explain_multi": "Esta mensagem é grande demais para uma única transação, por isso é dividida em várias. O destinatário a vê somente depois que todas as partes chegam, então leva mais tempo para ser entregue e custa uma taxa de rede por transação.",
"chat_parts_label": "partes",
"chat_pick_contact": "Escolher dos contatos…",
"chat_receiving_large": "Recebendo uma mensagem grande…",
"chat_rename": "Renomear contato",
"chat_rename_hint": "Nome do contato",
"chat_renamed": "Contato renomeado",
"chat_restore_btn": "Restaurar",
"chat_restore_desc": "Conversas que você excluiu em todo lugar podem ser trazidas de volta aqui — neste dispositivo ou após importar sua frase de recuperação. Restaurar reimporta as mensagens a partir da chain e avisa seus outros dispositivos para exibi-la novamente.",
"chat_restore_manage": "Restaurar",
"chat_restore_none": "Nenhuma conversa excluída encontrada ainda. Se você excluiu uma em outro dispositivo, tente o Escaneamento rápido ou cole o endereço de chat dela abaixo.",
"chat_restore_paste_hint": "Cole o endereço de chat (z) da conversa",
"chat_restore_paste_label": "Restaurar pelo endereço de chat",
"chat_restore_scan": "Escaneamento rápido",
"chat_restore_scanning": "Escaneando a chain em busca de conversas excluídas...",
"chat_restore_title": "Restaurar conversas",
"chat_restore_toast": "Restaurando a conversa — ela reaparece após o próximo escaneamento",
"chat_retry": "Tentar novamente",
"chat_search": "Pesquisar conversas",
"chat_sec_appearance": "APARÊNCIA",
@@ -331,6 +350,35 @@
"chat_send": "Enviar",
"chat_send_failed": "não enviada",
"chat_sending": "enviando…",
"chat_sensitive_btn_mark": "Tornar confidencial",
"chat_sensitive_btn_setup": "Configurar e tornar confidencial",
"chat_sensitive_btn_unlock": "Desbloquear",
"chat_sensitive_btn_unlock_mark": "Desbloquear e tornar confidencial",
"chat_sensitive_btn_unlock_unmark": "Desbloquear e tornar normal",
"chat_sensitive_btn_unmark": "Tornar normal",
"chat_sensitive_err_create": "Não foi possível criar o cofre das conversas confidenciais.",
"chat_sensitive_err_pass": "As senhas não coincidem ou estão vazias.",
"chat_sensitive_err_unlock": "Senha incorreta.",
"chat_sensitive_lock_btn": "Bloquear confidenciais",
"chat_sensitive_mark_tip": "Tornar confidencial",
"chat_sensitive_mark_title": "Tornar esta conversa confidencial?",
"chat_sensitive_pass2_hint": "Confirmar senha",
"chat_sensitive_pass_hint": "Senha",
"chat_sensitive_setup_label": "Crie uma senha para as conversas confidenciais",
"chat_sensitive_toast_marked": "A conversa agora é confidencial",
"chat_sensitive_toast_unlocked": "Conversas confidenciais desbloqueadas",
"chat_sensitive_toast_unmarked": "A conversa não é mais confidencial",
"chat_sensitive_unlock_body": "Digite a sua senha de conversas confidenciais para exibir novamente as suas conversas confidenciais.",
"chat_sensitive_unlock_btn": "Desbloquear confidenciais",
"chat_sensitive_unlock_label": "Digite a sua senha de conversas confidenciais",
"chat_sensitive_unlock_title": "Desbloquear conversas confidenciais",
"chat_sensitive_unmark_body": "Esta conversa deixará de ser criptografada com a sua senha de conversas confidenciais. As novas mensagens serão armazenadas como em qualquer outra conversa.",
"chat_sensitive_unmark_tip": "Confidencial — toque para alterar",
"chat_sensitive_unmark_title": "Tornar esta conversa normal?",
"chat_sensitive_warn_1": "As mensagens dela serão criptografadas neste dispositivo com uma senha separada — não a sua frase de recuperação. Quem mais tarde restaurar sua carteira apenas a partir da sua semente não verá esta conversa.",
"chat_sensitive_warn_dir": "As mensagens que você envia aqui ficam totalmente protegidas. As mensagens recebidas continuam na blockchain e poderiam, em teoria, ser recuperadas por alguém que tenha a sua semente e outros softwares — o próprio DragonX nunca as exibirá sem a sua senha.",
"chat_sensitive_warn_hist": "As mensagens que já estão nesta conversa continuam recuperáveis a partir da chain; isso protege totalmente as mensagens daqui em diante.",
"chat_sensitive_warn_lose": "Se você perder esta senha, estas mensagens desaparecem para sempre — elas não podem ser recuperadas a partir da sua semente.",
"chat_settings_done": "Concluído",
"chat_settings_section": "CHAT E CONTATOS",
"chat_settings_tip": "Personalizar chat",
@@ -344,12 +392,14 @@
"chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.",
"chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).",
"chat_toast_request_queued": "Solicitação de contato na fila.",
"chat_toast_too_long": "Mensagem muito longa — encurte-a e envie novamente.",
"chat_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.",
"chat_today": "Hoje",
"chat_ts_12h": "12 horas",
"chat_ts_24h": "24 horas",
"chat_ts_global": "Seguir global",
"chat_ts_global_short": "Global",
"chat_txns_label": "tx",
"chat_unblock": "Desbloquear",
"chat_unblocked_toast": "Conversa desbloqueada",
"chat_unhide": "Mostrar",
@@ -370,11 +420,20 @@
"click_to_copy": "Clique para copiar",
"clock_format": "Formato de hora",
"close": "Fechar",
"compact_needs_embedded": "Compactar reescreve o wallet.dat, o que a carteira só consegue fazer com o seu nó integrado. Você está conectado a um dragonxd que iniciou por conta própria — ative \"Usar dragonxd integrado\" em Ajustes, ou reinicie o seu dragonxd com -salvagewallet para recuperar o espaço.",
"compact_started": "Compactando a carteira — o nó está reiniciando e vai escanear a cadeia novamente.",
"compact_switch_stop_failed": "Não foi possível parar o seu nó externo — ele ainda está em execução, então nada foi alterado.",
"compact_switching": "Parando o seu nó e mudando para o integrado…",
"compact_wallet_confirm": "Compactar",
"conf_count": "%d conf.",
"confirm_and_send": "Confirmar & Enviar",
"confirm_clear_ztx_title": "Confirmar limpeza do histórico Z-Tx",
"confirm_clear_ztx_warning1": "Limpar o histórico de z-transações pode fazer com que seu saldo blindado apareça como 0 até que um reescaneamento da carteira seja realizado.",
"confirm_clear_ztx_warning2": "Se isso acontecer, você precisará reimportar as chaves privadas do seu endereço z com reescaneamento habilitado para recuperar seu saldo.",
"confirm_compact_switch_msg": "Você está conectado a um dragonxd que iniciou por conta própria. Para compactar, a carteira vai parar esse nó, mudar para o seu próprio nó integrado e reescrever o wallet.dat nele — reiniciando e escaneando o blockchain novamente, o que pode demorar. Observação: o nó integrado é o dragonxd da pasta da própria carteira, então isso muda qual nó você usa.",
"confirm_compact_wallet_msg": "A consolidação mesclou suas notas, mas o arquivo da carteira não encolhe sozinho — o nó precisa reescrevê-lo para recuperar o espaço liberado. Isso reinicia o nó e escaneia o blockchain novamente, o que pode demorar em uma carteira grande.",
"confirm_compact_wallet_safe": "Seus fundos e saldo estão seguros — um backup com data e hora da carteira atual é mantido.",
"confirm_compact_wallet_title": "Compactar carteira",
"confirm_delete_blockchain_msg": "Isso irá parar o daemon, excluir todos os dados da blockchain (blocks, chainstate, peers) e iniciar uma nova sincronização do zero. Isso pode levar várias horas.",
"confirm_delete_blockchain_safe": "Seu wallet.dat, configuração e histórico de transações estão seguros e não serão excluídos.",
"confirm_delete_blockchain_title": "Excluir Dados da Blockchain",
@@ -505,7 +564,7 @@
"console_welcome": "Bem-vindo ao Console ObsidianDragon",
"console_zoom_in": "Aumentar zoom",
"console_zoom_out": "Diminuir zoom",
"consolidate_desc": "Combine muitas entradas pequenas em uma única nota blindada. Menos notas significa um arquivo de carteira menor e mais privacidade.",
"consolidate_desc": "Cada pagamento que você recebe é armazenado como uma \"nota\" separada — como uma cédula individual em uma carteira. Elas se acumulam com o tempo, e cada nota adiciona dados de testemunha que deixam a carteira mais lenta para sincronizar e fazem com que continue crescendo. Consolidar combina muitas notas pequenas em uma por endereço: seu saldo não muda, mas a carteira escaneia mais rápido, para de crescer e revela menos sobre os seus fundos.",
"consolidate_funds_btn": "Consolidar",
"consolidate_title": "Consolidar fundos",
"contact_avatar": "AVATAR",
@@ -639,6 +698,11 @@
"download": "Baixar",
"download_bootstrap": "Baixar Bootstrap",
"dragonx_green": "DragonX (Verde)",
"earnings_format": "Formato de exibição de ganhos",
"earnings_format_coin": "Apenas moeda",
"earnings_format_coin_fiat": "Moeda + fiduciária",
"earnings_format_fiat": "Apenas fiduciária",
"earnings_label": "Ganhos",
"edit": "Editar",
"empty_wallet_keys_suffix": "chaves",
"empty_wallet_open_manager": "Abrir gerenciador de carteiras",
@@ -863,6 +927,7 @@
"faq_w_set_4_q": "Como exibo preços em moeda fiduciária?",
"faq_w_set_title": "Configurações e aparência",
"favorite_address": "Adicionar aos favoritos",
"favorite_opacity": "Opacidade do destaque de favoritos",
"fee": "Taxa",
"fee_high": "Alta",
"fee_label": "Taxa:",
@@ -870,7 +935,7 @@
"fee_normal": "Normal",
"fetch_prices": "Buscar preços",
"file": "Arquivo",
"file_save_location": "O arquivo será salvo em: ~/.config/ObsidianDragon/",
"file_save_location": "O arquivo será salvo em:",
"filter": "Filtrar...",
"font_scale": "Escala da Fonte",
"from": "De",
@@ -941,6 +1006,7 @@
"hidden_tag": " (oculto)",
"hide": "Ocultar",
"hide_address": "Ocultar endereço",
"hide_earnings": "Ocultar ganhos",
"hide_qr": "Ocultar QR",
"hide_zero_balances": "Ocultar saldos zero",
"history": "Histórico",
@@ -1307,6 +1373,8 @@
"mining_est_block": "Bloco Est.",
"mining_est_daily": "Est. Diário",
"mining_est_daily_pool_sub": "equivalente solo aproximado, antes da taxa do pool",
"mining_est_daily_sub": "méd.",
"mining_est_daily_sub_net": "méd., líq.",
"mining_filter_all": "Todos",
"mining_filter_tip_all": "Mostrar todos os ganhos",
"mining_filter_tip_pool": "Mostrar apenas ganhos do pool",
@@ -1917,6 +1985,7 @@
"shielded_type": "Blindado",
"shielding_notice": "Nota: Isso blindará fundos de um endereço transparente (T) para um endereço privado (Z).",
"show": "Mostrar",
"show_earnings": "Mostrar ganhos",
"show_hidden": "Mostrar ocultos (%d)",
"show_qr": "Mostrar QR",
"show_qr_code": "Mostrar Código QR",
@@ -2038,6 +2107,7 @@
"tt_addr_url": "URL base para visualizar endereços em um explorador de blocos",
"tt_address_book": "Gerenciar endereços salvos para envio rápido",
"tt_animate_avatars": "Reproduz avatares de contato animados (GIF / WebP); desativado mostra só o primeiro quadro",
"tt_auto_consolidate": "Mescla discretamente as notas pequenas em segundo plano enquanto a carteira está ociosa, em lotes minúsculos. Mais lento que uma consolidação manual, mas quase imperceptível, e os fundos de cada endereço continuam separados. Ideal para carteiras de mineração/pool que acumulam milhares de notas.",
"tt_auto_lock": "Bloquear carteira após este tempo de inatividade",
"tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade",
"tt_backup": "Criar um backup do seu wallet.dat",
@@ -2065,10 +2135,16 @@
"tt_debug_expand": "Expandir opções de registro de depuração",
"tt_delete_blockchain": "Excluir todos os dados da blockchain e iniciar uma nova sincronização. wallet.dat e configuração são preservados.",
"tt_download_bootstrap": "Baixar bootstrap da blockchain para acelerar a sincronização\nDados de blocos existentes serão substituídos",
"tt_earn_interval": "Período de ganhos: 1H = esta hora, 1D = hoje, 1M = este mês, 1Y = este ano",
"tt_earn_mode_inflows": "Modo de ganhos: valores recebidos (incluindo mineração)",
"tt_earn_mode_net": "Modo de ganhos: líquido (recebido menos enviado)",
"tt_earn_show_all": "Mostrar o valor de ganhos para cada endereço",
"tt_earnings_format": "Como os valores de ganhos por endereço e totais são exibidos",
"tt_encrypt": "Encriptar wallet.dat com uma frase secreta",
"tt_export_all": "Exportar todas as chaves privadas para um arquivo",
"tt_export_csv": "Exportar histórico de transações como planilha CSV",
"tt_export_key": "Exportar a chave privada do endereço selecionado",
"tt_favorite_opacity": "Opacidade do botão de estrela de favoritos e do seu contorno dourado na lista de endereços (mínimo 15%)",
"tt_fetch_prices": "Obter preços de mercado DRGX da API CoinGecko",
"tt_font_scale": "Escalar todo o texto e interface (1.0x = padrão, até 1.5x).",
"tt_idle_delay": "Quanto tempo esperar antes de iniciar a mineração",
@@ -2106,6 +2182,9 @@
"tt_low_spec": "Desativar todos os efeitos visuais pesados\\nAtalho: Ctrl+Shift+Down",
"tt_merge": "Consolidar múltiplos UTXOs em um endereço",
"tt_mine_idle": "Iniciar mineração automaticamente quando o\\nsistema estiver ocioso (sem entrada de teclado/mouse)",
"tt_mining_est_daily": "Uma média estatística de longo prazo para a sua fração atual de hashrate na rede. As recompensas reais de mineração chegam de forma aleatória (Poisson) — muitas vezes nada por longos períodos e depois um valor — por isso NÃO é uma renda diária constante.",
"tt_mining_est_daily_fee_fmt": "Mostrado após a taxa de %.1f%% do pool.",
"tt_mining_est_daily_nofee": "Mostrado antes da taxa do pool (taxa desconhecida para este pool).",
"tt_new_mining_address": "Cria um endereço transparente (R…) para recompensas de mineração solo. Ele não pode receber pagamentos — compartilhe um endereço blindado para isso.",
"tt_noise": "Intensidade de textura granulada (0% = desligado, 100% = máximo)",
"tt_open_app_dir": "Abrir a pasta ObsidianDragon (configurações, temas, logs) no gerenciador de arquivos",
@@ -2154,7 +2233,9 @@
"tt_ui_opacity": "Opacidade de cartões e barra lateral (100% = totalmente opaco, menor = mais transparente)",
"tt_validate": "Verificar se um endereço DragonX é válido",
"tt_verbose": "Registrar diagnósticos detalhados de conexão,\\nestado do daemon e info de proprietário de porta\\nna aba Console",
"tt_wallet_size_warn": "Carteiras blindadas crescem com os dados de testemunha de cada nota — mesclar várias notas em um único endereço reduz esse tamanho. Faça backup antes.",
"tt_view_transactions": "Mostrar as transações que envolvem este endereço na aba Histórico",
"tt_wallet_size_compact": "Recupere o espaço em disco liberado pela consolidação. O Berkeley DB não consegue encolher o arquivo no próprio local, então isto reescreve o wallet.dat em um arquivo menor e reinicia o nó com um reescaneamento completo (pode demorar). Um backup da carteira atual é mantido.",
"tt_wallet_size_warn": "Cada pagamento chega como uma nota blindada separada, e cada nota carrega dados de testemunha que deixam as varreduras lentas e fazem o arquivo da carteira continuar crescendo. Mesclar muitas notas em uma por endereço mantém a carteira rápida e interrompe o crescimento — seus fundos e saldo não mudam. O arquivo só recupera o espaço liberado quando você o Compacta (o Berkeley DB não consegue encolher no próprio arquivo). Faça backup da sua seed primeiro. Você também pode ativar a opção Consolidar notas automaticamente em Ajustes.",
"tt_wallets_button": "Liste os arquivos de carteira e alterne entre eles",
"tt_website": "Abrir o site do DragonX",
"tt_window_opacity": "Opacidade do fundo (menor = área de trabalho visível através da janela)",
@@ -2220,6 +2301,7 @@
"view_all": "Ver Tudo",
"view_details": "Ver Detalhes",
"view_on_explorer": "Ver no Explorador",
"view_transactions": "Ver transações",
"waiting_for_daemon": "Aguardando conexão com o daemon...",
"wallet": "CARTEIRA",
"wallet_degraded_notify": "Sua carteira foi aberta em modo de função reduzida: os fundos existentes estão seguros e disponíveis para gasto, mas a criação de novos endereços e o blindamento estão desativados. Faça backup da sua frase de recuperação e restaure-a para reparar totalmente a carteira.",
@@ -2282,8 +2364,10 @@
"wallet_restore_ok": "Carteira original restaurada. O node está carregando-a agora.",
"wallet_restore_started": "Restaurando seu arquivo original — por favor não feche esta janela.",
"wallet_restore_stop_failed": "O node não parou a tempo, então nada foi alterado. Tente novamente.",
"wallet_size_auto_info": "Sua carteira acumulou muitas notas — partes separadas dos fundos recebidos que deixam a sincronização lenta e fazem o arquivo continuar crescendo. A consolidação automática as está mesclando discretamente em segundo plano para manter a carteira rápida e impedir que ela cresça, então não há nada que você precise fazer.",
"wallet_size_compact": "Compactar carteira…",
"wallet_size_consolidate": "Consolidar notas…",
"wallet_size_warn": "Este arquivo de carteira está grande. Consolidar suas notas pode conter o crescimento.",
"wallet_size_warn": "Sua carteira acumulou muitas notas. Cada pagamento que você recebe é armazenado como uma \"nota\" separada; conforme se acumulam, elas deixam a carteira mais lenta para sincronizar e fazem com que continue crescendo. Consolidar as mescla em menos notas para que ela escaneie mais rápido e pare de crescer — seu saldo permanece exatamente o mesmo.",
"wallets_active": "Ativa",
"wallets_add": "Adicionar pasta",
"wallets_add_folder": "Verificar também outra pasta em busca de arquivos de carteira:",
+88 -4
View File
@@ -141,6 +141,7 @@
"appx_wallet_op_running": "Импорт ключа или повторное сканирование ещё выполняется. Повторите попытку, когда оно завершится.",
"appx_wallet_op_shutting_down": "Кошелёк завершает работу.",
"appx_wallet_open_failed_prefix": "Не удалось открыть кошелёк: ",
"auto_consolidate": "Автообъединение заметок",
"auto_shield": "Авто-экранирование майнинга",
"autoshield_by_node": "Ваш узел автоматически защищает добытый coinbase за вас.",
"autoshield_off_backup_seed": "Добытый coinbase пока не защищается автоматически. Сделайте резервную копию сид-фразы (Настройки → Резервное копирование и данные), чтобы узел мог безопасно включить эту функцию.",
@@ -276,8 +277,12 @@
"chat_delete_body_prefix": "Удалить локальную копию разговора с ",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "Удалить",
"chat_delete_everywhere": "Удалить везде",
"chat_delete_everywhere_note": "Также сообщает другим вашим устройствам — и кошельку, который вы позже восстановите из сид-фразы, — что этот разговор нужно скрыть. Отправляется крошечный сигнал в блокчейне (взимается обычная комиссия чата). Позже вы сможете вернуть его в разделе «Восстановить».",
"chat_delete_everywhere_toast": "Удаление везде — сигнал отправлен",
"chat_delete_failed": "Не удалось удалить разговор — ничего не изменено.",
"chat_delete_local_note": "Это влияет только на это устройство — сообщения остаются в блокчейне, и у собеседника сохранится своя копия.",
"chat_delete_needs_sync": "Дождитесь полной синхронизации кошелька, чтобы удалить везде.",
"chat_delete_revive_note": "«Удалить» очищает историю на этом устройстве. Если вам снова напишут, разговор появится снова.",
"chat_delete_title": "Удалить разговор?",
"chat_deleted_toast": "Разговор удалён",
@@ -319,10 +324,24 @@
"chat_opt_global_clock": "Глобальный формат времени",
"chat_opt_poll": "Частота опроса",
"chat_opt_timestamp": "Метки времени",
"chat_parts_explain": "Это длинное сообщение отправляется как одна транзакция, разделённая на эти части, — оно доходит так же быстро, как обычное сообщение.",
"chat_parts_explain_multi": "Это сообщение слишком велико для одной транзакции, поэтому оно разбивается на несколько. Получатель увидит его только после того, как придут все части, поэтому доставка занимает больше времени и за каждую транзакцию взимается сетевая комиссия.",
"chat_parts_label": "частей",
"chat_pick_contact": "Выбрать из контактов…",
"chat_receiving_large": "Получение большого сообщения…",
"chat_rename": "Переименовать контакт",
"chat_rename_hint": "Имя контакта",
"chat_renamed": "Контакт переименован",
"chat_restore_btn": "Восстановить",
"chat_restore_desc": "Разговоры, которые вы удалили везде, можно вернуть здесь — на этом устройстве или после импорта сид-фразы. Восстановление заново импортирует сообщения из блокчейна и сообщает другим вашим устройствам, что разговор снова нужно показывать.",
"chat_restore_manage": "Восстановить",
"chat_restore_none": "Удалённых разговоров пока не найдено. Если вы удалили разговор на другом устройстве, попробуйте «Быстрое сканирование» или вставьте его адрес чата ниже.",
"chat_restore_paste_hint": "Вставьте защищённый (z) адрес этого разговора",
"chat_restore_paste_label": "Восстановить по адресу чата",
"chat_restore_scan": "Быстрое сканирование",
"chat_restore_scanning": "Сканирование блокчейна в поиске удалённых разговоров…",
"chat_restore_title": "Восстановление разговоров",
"chat_restore_toast": "Восстановление разговора — он появится снова после следующего сканирования",
"chat_retry": "Повторить",
"chat_search": "Поиск разговоров",
"chat_sec_appearance": "ВИД",
@@ -331,6 +350,35 @@
"chat_send": "Отправить",
"chat_send_failed": "не отправлено",
"chat_sending": "отправка…",
"chat_sensitive_btn_mark": "Сделать конфиденциальным",
"chat_sensitive_btn_setup": "Настроить и сделать конфиденциальным",
"chat_sensitive_btn_unlock": "Разблокировать",
"chat_sensitive_btn_unlock_mark": "Разблокировать и сделать конфиденциальным",
"chat_sensitive_btn_unlock_unmark": "Разблокировать и сделать обычным",
"chat_sensitive_btn_unmark": "Сделать обычным",
"chat_sensitive_err_create": "Не удалось создать хранилище конфиденциальных разговоров.",
"chat_sensitive_err_pass": "Пароли не совпадают или не заполнены.",
"chat_sensitive_err_unlock": "Неверный пароль.",
"chat_sensitive_lock_btn": "Заблокировать конфиденциальные",
"chat_sensitive_mark_tip": "Сделать конфиденциальным",
"chat_sensitive_mark_title": "Сделать этот разговор конфиденциальным?",
"chat_sensitive_pass2_hint": "Подтвердите пароль",
"chat_sensitive_pass_hint": "Пароль",
"chat_sensitive_setup_label": "Создайте пароль для конфиденциальных разговоров",
"chat_sensitive_toast_marked": "Разговор теперь конфиденциальный",
"chat_sensitive_toast_unlocked": "Конфиденциальные разговоры разблокированы",
"chat_sensitive_toast_unmarked": "Разговор больше не конфиденциальный",
"chat_sensitive_unlock_body": "Введите пароль для конфиденциальных разговоров, чтобы снова показать их.",
"chat_sensitive_unlock_btn": "Разблокировать конфиденциальные",
"chat_sensitive_unlock_label": "Введите пароль для конфиденциальных разговоров",
"chat_sensitive_unlock_title": "Разблокировать конфиденциальные разговоры",
"chat_sensitive_unmark_body": "Этот разговор больше не будет зашифрован вашим паролем для конфиденциальных разговоров. Новые сообщения будут храниться как в любом другом разговоре.",
"chat_sensitive_unmark_tip": "Конфиденциальный — нажмите, чтобы изменить",
"chat_sensitive_unmark_title": "Сделать этот разговор обычным?",
"chat_sensitive_warn_1": "Сообщения в нём будут зашифрованы на этом устройстве отдельным паролем, а не вашей сид-фразой. Любой, кто позже восстановит ваш кошелёк только по сид-фразе, не увидит этот разговор.",
"chat_sensitive_warn_dir": "Отправленные вами здесь сообщения защищены полностью. Полученные сообщения по-прежнему находятся в блокчейне, и теоретически их может восстановить тот, у кого есть ваша сид-фраза и другое ПО, — но сам DragonX никогда не покажет их без вашего пароля.",
"chat_sensitive_warn_hist": "Сообщения, которые уже есть в этом разговоре, по-прежнему можно восстановить из блокчейна; полная защита действует для сообщений, начиная с этого момента.",
"chat_sensitive_warn_lose": "Если вы потеряете этот пароль, эти сообщения пропадут навсегда — их нельзя восстановить по сид-фразе.",
"chat_settings_done": "Готово",
"chat_settings_section": "ЧАТ И КОНТАКТЫ",
"chat_settings_tip": "Настройка чата",
@@ -344,12 +392,14 @@
"chat_toast_not_connected": "Нет подключения — сообщение не отправлено.",
"chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).",
"chat_toast_request_queued": "Запрос контакта поставлен в очередь.",
"chat_toast_too_long": "Сообщение слишком длинное — сократите его и отправьте снова.",
"chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.",
"chat_today": "Сегодня",
"chat_ts_12h": "12 часов",
"chat_ts_24h": "24 часа",
"chat_ts_global": "Как глобально",
"chat_ts_global_short": "Общий",
"chat_txns_label": "транз.",
"chat_unblock": "Разблокировать",
"chat_unblocked_toast": "Разговор разблокирован",
"chat_unhide": "Показать",
@@ -370,11 +420,20 @@
"click_to_copy": "Нажмите для копирования",
"clock_format": "Формат времени",
"close": "Закрыть",
"compact_needs_embedded": "Сжатие перезаписывает wallet.dat, а кошелёк может сделать это только с помощью встроенного узла. Вы подключены к dragonxd, который запустили сами — включите «Использовать встроенный dragonxd» в разделе «Настройки» или перезапустите свой dragonxd с параметром -salvagewallet, чтобы вернуть место.",
"compact_started": "Сжатие кошелька — узел перезапускается и заново просканирует блокчейн.",
"compact_switch_stop_failed": "Не удалось остановить ваш внешний узел — он всё ещё работает, поэтому ничего не изменено.",
"compact_switching": "Остановка вашего узла и переключение на встроенный…",
"compact_wallet_confirm": "Сжать",
"conf_count": "%d подтв.",
"confirm_and_send": "Подтвердить и отправить",
"confirm_clear_ztx_title": "Подтвердить очистку истории Z-Tx",
"confirm_clear_ztx_warning1": "Очистка истории z-транзакций может привести к отображению защищённого баланса как 0, пока не будет выполнено пересканирование кошелька.",
"confirm_clear_ztx_warning2": "Если это произойдёт, вам потребуется повторно импортировать приватные ключи вашего z-адреса с включённым пересканированием для восстановления баланса.",
"confirm_compact_switch_msg": "Вы подключены к dragonxd, который запустили сами. Чтобы выполнить сжатие, кошелёк остановит этот узел, переключится на собственный встроенный узел и перезапишет на нём wallet.dat — с перезапуском и повторным сканированием блокчейна, что может занять много времени. Обратите внимание: встроенный узел — это dragonxd из собственной папки кошелька, поэтому это меняет то, какой узел вы используете.",
"confirm_compact_wallet_msg": "Объединение слило ваши заметки, но файл кошелька сам по себе не уменьшается — чтобы вернуть освободившееся место, узел должен перезаписать его. Это перезапускает узел и заново сканирует блокчейн, что может занять много времени для большого кошелька.",
"confirm_compact_wallet_safe": "Ваши средства и баланс в безопасности — сохраняется резервная копия текущего кошелька с отметкой времени.",
"confirm_compact_wallet_title": "Сжать кошелёк",
"confirm_delete_blockchain_msg": "Это остановит демон, удалит все данные блокчейна (blocks, chainstate, peers) и начнёт синхронизацию с нуля. Это может занять несколько часов.",
"confirm_delete_blockchain_safe": "Ваш wallet.dat, конфигурация и история транзакций в безопасности и не будут удалены.",
"confirm_delete_blockchain_title": "Удалить данные блокчейна",
@@ -505,7 +564,7 @@
"console_welcome": "Добро пожаловать в консоль ObsidianDragon",
"console_zoom_in": "Увеличить",
"console_zoom_out": "Уменьшить",
"consolidate_desc": "Объедините множество мелких входов в одну защищённую заметку. Меньше заметок означает файл кошелька меньшего размера и лучшую приватность.",
"consolidate_desc": "Каждый полученный платёж хранится как отдельная «заметка» — словно отдельная купюра в кошельке. Со временем они накапливаются, и каждая заметка добавляет данные свидетеля, из-за которых кошелёк медленнее синхронизируется и продолжает расти. Объединение сливает множество мелких заметок в одну на каждый адрес: баланс не меняется, но кошелёк сканируется быстрее, перестаёт расти и раскрывает меньше информации о ваших средствах.",
"consolidate_funds_btn": "Объединить",
"consolidate_title": "Объединение средств",
"contact_avatar": "АВАТАР",
@@ -639,6 +698,11 @@
"download": "Скачать",
"download_bootstrap": "Скачать бутстрап",
"dragonx_green": "DragonX (Зелёная)",
"earnings_format": "Формат отображения дохода",
"earnings_format_coin": "Только монета",
"earnings_format_coin_fiat": "Монета + фиат",
"earnings_format_fiat": "Только фиат",
"earnings_label": "Доход",
"edit": "Редактировать",
"empty_wallet_keys_suffix": "ключей",
"empty_wallet_open_manager": "Открыть менеджер кошельков",
@@ -863,6 +927,7 @@
"faq_w_set_4_q": "Как показать цены в фиатной валюте?",
"faq_w_set_title": "Настройки и внешний вид",
"favorite_address": "Добавить в избранное",
"favorite_opacity": "Непрозрачность выделения избранного",
"fee": "Комиссия",
"fee_high": "Высокая",
"fee_label": "Комиссия:",
@@ -870,7 +935,7 @@
"fee_normal": "Обычная",
"fetch_prices": "Получить цены",
"file": "Файл",
"file_save_location": "Файл будет сохранён в: ~/.config/ObsidianDragon/",
"file_save_location": "Файл будет сохранён в:",
"filter": "Фильтр...",
"font_scale": "Масштаб шрифта",
"from": "От",
@@ -941,6 +1006,7 @@
"hidden_tag": " (скрыт)",
"hide": "Скрыть",
"hide_address": "Скрыть адрес",
"hide_earnings": "Скрыть доход",
"hide_qr": "Скрыть QR",
"hide_zero_balances": "Скрыть нулевые балансы",
"history": "История",
@@ -1307,6 +1373,8 @@
"mining_est_block": "Расч. блок",
"mining_est_daily": "Расч. за день",
"mining_est_daily_pool_sub": "примерный соло-эквивалент, до комиссии пула",
"mining_est_daily_sub": "сред.",
"mining_est_daily_sub_net": "сред., нетто",
"mining_filter_all": "Все",
"mining_filter_tip_all": "Показать все доходы",
"mining_filter_tip_pool": "Показать только доходы пула",
@@ -1917,6 +1985,7 @@
"shielded_type": "Экранированный",
"shielding_notice": "Примечание: это переведёт средства с прозрачного (T) адреса на приватный (Z) адрес.",
"show": "Показать",
"show_earnings": "Показать доход",
"show_hidden": "Показать скрытые (%d)",
"show_qr": "Показать QR",
"show_qr_code": "Показать QR-код",
@@ -2038,6 +2107,7 @@
"tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков",
"tt_address_book": "Управление сохранёнными адресами для быстрой отправки",
"tt_animate_avatars": "Воспроизводить анимированные аватары контактов (GIF / WebP); при отключении показывается только первый кадр",
"tt_auto_consolidate": "Незаметно объединять мелкие заметки в фоновом режиме, пока кошелёк простаивает, небольшими партиями. Медленнее ручного объединения, но почти не ощущается, а средства каждого адреса остаются раздельными. Подходит для кошельков майнинга/пула, где накапливаются тысячи заметок.",
"tt_auto_lock": "Заблокировать кошелёк после этого времени бездействия",
"tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности",
"tt_backup": "Создать резервную копию вашего wallet.dat",
@@ -2065,10 +2135,16 @@
"tt_debug_expand": "Развернуть параметры журнала отладки",
"tt_delete_blockchain": "Удалить все данные блокчейна и начать синхронизацию заново. wallet.dat и конфигурация сохраняются.",
"tt_download_bootstrap": "Скачать бутстрап блокчейна для ускорения синхронизации\nСуществующие данные блоков будут заменены",
"tt_earn_interval": "Период дохода: 1H — текущий час, 1D — сегодня, 1M — текущий месяц, 1Y — текущий год",
"tt_earn_mode_inflows": "Режим дохода: полученные суммы (включая майнинг)",
"tt_earn_mode_net": "Режим дохода: нетто (получено минус отправлено)",
"tt_earn_show_all": "Показывать сумму дохода для каждого адреса",
"tt_earnings_format": "Как отображаются суммы дохода по адресам и итогам",
"tt_encrypt": "Зашифровать wallet.dat паролем",
"tt_export_all": "Экспортировать все приватные ключи в файл",
"tt_export_csv": "Экспортировать историю транзакций в виде таблицы CSV",
"tt_export_key": "Экспортировать приватный ключ выбранного адреса",
"tt_favorite_opacity": "Непрозрачность кнопки-звёздочки избранного и её золотой обводки в списке адресов (минимум 15 %)",
"tt_fetch_prices": "Получить рыночные цены DRGX из API CoinGecko",
"tt_font_scale": "Масштабировать весь текст и интерфейс (1.0x = по умолчанию, до 1.5x).",
"tt_idle_delay": "Сколько ждать перед началом майнинга",
@@ -2106,6 +2182,9 @@
"tt_low_spec": "Отключить все тяжёлые визуальные эффекты\\nГорячая клавиша: Ctrl+Shift+Down",
"tt_merge": "Объединить несколько UTXO в один адрес",
"tt_mine_idle": "Автоматически начать майнинг при\\nпростое системы (нет ввода с клавиатуры/мыши)",
"tt_mining_est_daily": "Долгосрочное статистическое среднее для вашей текущей доли хешрейта в сети. Реальные награды за майнинг приходят случайно (по Пуассону) — часто подолгу ничего, а затем сразу много, — поэтому это НЕ стабильный ежедневный доход.",
"tt_mining_est_daily_fee_fmt": "Показано за вычетом комиссии пула %.1f%%.",
"tt_mining_est_daily_nofee": "Показано до вычета комиссии пула (комиссия этого пула неизвестна).",
"tt_new_mining_address": "Создаёт прозрачный адрес (R…) для наград за соло-майнинг. Он не принимает платежи — для них сообщайте экранированный адрес.",
"tt_noise": "Интенсивность зернистой текстуры (0% = выкл., 100% = максимум)",
"tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере",
@@ -2154,7 +2233,9 @@
"tt_ui_opacity": "Непрозрачность карточек и боковой панели (100% = полностью непрозрачно, ниже = прозрачнее)",
"tt_validate": "Проверить, действителен ли адрес DragonX",
"tt_verbose": "Записывать подробную диагностику подключений,\\nсостояние демона и информацию о владельце порта\\nна вкладке Консоль",
"tt_wallet_size_warn": "Защищённые кошельки растут за счёт данных witness каждой записи — объединение множества записей в один адрес уменьшает его размер. Сначала сделайте резервную копию.",
"tt_view_transactions": "Показать транзакции с этим адресом на вкладке «История»",
"tt_wallet_size_compact": "Вернуть место на диске, освобождённое после объединения. Berkeley DB не умеет уменьшать файл на месте, поэтому wallet.dat перезаписывается в файл меньшего размера, а узел перезапускается с полным пересканированием (это может занять много времени). Резервная копия текущего кошелька сохраняется.",
"tt_wallet_size_warn": "Каждый платёж поступает как отдельная экранированная заметка, и каждая заметка несёт данные свидетеля, которые замедляют сканирование и заставляют файл кошелька расти. Объединение множества заметок в одну на каждый адрес сохраняет быстродействие и останавливает рост — ваши средства и баланс не меняются. Освободившееся место файл возвращает только после сжатия (Berkeley DB не умеет уменьшать файл на месте). Сначала сделайте резервную копию seed-фразы. Также можно включить автообъединение заметок в разделе «Настройки».",
"tt_wallets_button": "Показать файлы кошельков и переключаться между ними",
"tt_website": "Открыть сайт DragonX",
"tt_window_opacity": "Непрозрачность фона (ниже = рабочий стол виден сквозь окно)",
@@ -2220,6 +2301,7 @@
"view_all": "Показать всё",
"view_details": "Подробнее",
"view_on_explorer": "Посмотреть в обозревателе",
"view_transactions": "Показать транзакции",
"waiting_for_daemon": "Ожидание подключения к daemon...",
"wallet": "КОШЕЛЁК",
"wallet_degraded_notify": "Ваш кошелёк открыт в режиме с ограниченной функциональностью: существующие средства в безопасности и доступны для расходования, но создание новых адресов и защита средств отключены. Сделайте резервную копию сид-фразы и восстановите из неё кошелёк, чтобы полностью его исправить.",
@@ -2282,8 +2364,10 @@
"wallet_restore_ok": "Исходный кошелёк восстановлен. Узел сейчас его загружает.",
"wallet_restore_started": "Восстановление вашего исходного файла — пожалуйста, не закрывайте это окно.",
"wallet_restore_stop_failed": "Узел не остановился вовремя, поэтому ничего не изменено. Попробуйте снова.",
"wallet_size_auto_info": "В вашем кошельке накопилось много заметок — отдельных частей полученных средств, которые замедляют синхронизацию и заставляют файл расти. Автообъединение незаметно объединяет их в фоновом режиме, чтобы кошелёк оставался быстрым и перестал расти, так что вам ничего делать не нужно.",
"wallet_size_compact": "Сжать кошелёк…",
"wallet_size_consolidate": "Объединить заметки…",
"wallet_size_warn": "Файл этого кошелька велик. Объединение ваших заметок может сдержать дальнейший рост.",
"wallet_size_warn": "В вашем кошельке накопилось много заметок. Каждый полученный платёж хранится как отдельная «заметка»; по мере накопления они замедляют синхронизацию кошелька и заставляют его расти. Объединение сливает их в меньшее число заметок, чтобы кошелёк сканировался быстрее и перестал расти — ваш баланс при этом остаётся точно таким же.",
"wallets_active": "Активен",
"wallets_add": "Добавить папку",
"wallets_add_folder": "Также сканировать другую папку на наличие файлов кошельков:",
+88 -4
View File
@@ -141,6 +141,7 @@
"appx_wallet_op_running": "密钥导入或重新扫描仍在进行中。请在完成后重试。",
"appx_wallet_op_shutting_down": "钱包正在关闭。",
"appx_wallet_open_failed_prefix": "钱包打开失败:",
"auto_consolidate": "自动合并票据",
"auto_shield": "自动屏蔽挖矿",
"autoshield_by_node": "您的节点会自动为您屏蔽挖出的 coinbase。",
"autoshield_off_backup_seed": "挖出的 coinbase 尚未自动屏蔽。请备份您的助记词(设置 → 备份与数据),以便节点可以安全启用此功能。",
@@ -276,8 +277,12 @@
"chat_delete_body_prefix": "删除与以下用户会话的本地副本:",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "删除",
"chat_delete_everywhere": "在所有设备删除",
"chat_delete_everywhere_note": "这还会告知您的其他设备——以及日后通过助记词恢复的钱包——隐藏此会话。此操作会发送一个微小的链上信号(需支付常规聊天手续费)。之后您可以在“恢复”中将其重新找回。",
"chat_delete_everywhere_toast": "正在所有设备上删除——信号已发送",
"chat_delete_failed": "无法删除会话——未做任何更改。",
"chat_delete_local_note": "此操作仅影响此设备——消息仍保留在区块链上,对方也仍保有自己的副本。",
"chat_delete_needs_sync": "请等待钱包完全同步后,才能在所有设备删除。",
"chat_delete_revive_note": "“删除”会清除此设备上的记录。如果对方再次给您发消息,会话会重新出现。",
"chat_delete_title": "删除会话?",
"chat_deleted_toast": "会话已删除",
@@ -319,10 +324,24 @@
"chat_opt_global_clock": "全局时间格式",
"chat_opt_poll": "轮询频率",
"chat_opt_timestamp": "时间戳",
"chat_parts_explain": "这条长消息会作为一笔交易发送,拆分成这些部分——它和普通消息一样快送达。",
"chat_parts_explain_multi": "这条消息太大,无法放入一笔交易,因此会拆分到多笔交易中发送。接收方需等所有部分都到达后才能看到,因此送达较慢,且每笔交易都会产生网络手续费。",
"chat_parts_label": "部分",
"chat_pick_contact": "从联系人中选择…",
"chat_receiving_large": "正在接收大消息…",
"chat_rename": "重命名联系人",
"chat_rename_hint": "联系人名称",
"chat_renamed": "联系人已重命名",
"chat_restore_btn": "恢复",
"chat_restore_desc": "在所有设备删除的会话可以在此处找回——无论是在本设备上,还是在导入助记词之后。恢复会从链上重新导入消息,并通知您的其他设备再次显示该会话。",
"chat_restore_manage": "恢复",
"chat_restore_none": "尚未找到已删除的会话。如果您曾在其他设备上删除过某个会话,请尝试“快速扫描”,或在下方粘贴其聊天地址。",
"chat_restore_paste_hint": "粘贴该会话的聊天(z)地址",
"chat_restore_paste_label": "通过聊天地址恢复",
"chat_restore_scan": "快速扫描",
"chat_restore_scanning": "正在扫描区块链以查找已删除的会话…",
"chat_restore_title": "恢复会话",
"chat_restore_toast": "正在恢复会话——将在下次扫描后重新显示",
"chat_retry": "重试",
"chat_search": "搜索会话",
"chat_sec_appearance": "外观",
@@ -331,6 +350,35 @@
"chat_send": "发送",
"chat_send_failed": "未发送",
"chat_sending": "发送中…",
"chat_sensitive_btn_mark": "设为私密",
"chat_sensitive_btn_setup": "设置并设为私密",
"chat_sensitive_btn_unlock": "解锁",
"chat_sensitive_btn_unlock_mark": "解锁并设为私密",
"chat_sensitive_btn_unlock_unmark": "解锁并设为普通",
"chat_sensitive_btn_unmark": "设为普通",
"chat_sensitive_err_create": "无法设置私密聊天保险库。",
"chat_sensitive_err_pass": "两次输入的密码短语不一致,或为空。",
"chat_sensitive_err_unlock": "密码短语错误。",
"chat_sensitive_lock_btn": "锁定私密会话",
"chat_sensitive_mark_tip": "设为私密",
"chat_sensitive_mark_title": "将此会话设为私密?",
"chat_sensitive_pass2_hint": "确认密码短语",
"chat_sensitive_pass_hint": "密码短语",
"chat_sensitive_setup_label": "为私密会话创建一个密码短语",
"chat_sensitive_toast_marked": "会话现已设为私密",
"chat_sensitive_toast_unlocked": "私密会话已解锁",
"chat_sensitive_toast_unmarked": "会话已设为普通",
"chat_sensitive_unlock_body": "输入您的私密聊天密码短语,以重新显示您的私密会话。",
"chat_sensitive_unlock_btn": "解锁私密会话",
"chat_sensitive_unlock_label": "输入您的私密聊天密码短语",
"chat_sensitive_unlock_title": "解锁私密会话",
"chat_sensitive_unmark_body": "此会话将不再使用您的私密聊天密码短语加密。新消息将像其他任何会话一样存储。",
"chat_sensitive_unmark_tip": "私密——点击可更改",
"chat_sensitive_unmark_title": "将此会话设为普通?",
"chat_sensitive_warn_1": "其消息将在此设备上以一个单独的密码短语加密——而非您的助记词。日后任何仅凭您的助记词恢复钱包的人,都将看不到此会话。",
"chat_sensitive_warn_dir": "您在此发送的消息受到完全保护。收到的消息仍保留在区块链上,理论上,拥有您助记词及其他软件的人有可能将其恢复——但没有您的密码短语,DragonX 本身绝不会显示它们。",
"chat_sensitive_warn_hist": "此会话中已有的消息仍可从链上恢复;此功能将完全保护此后的新消息。",
"chat_sensitive_warn_lose": "如果您丢失此密码短语,这些消息将永远消失——它们无法通过您的助记词恢复。",
"chat_settings_done": "完成",
"chat_settings_section": "聊天与联系人",
"chat_settings_tip": "聊天自定义",
@@ -344,12 +392,14 @@
"chat_toast_not_connected": "未连接——聊天消息未发送。",
"chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。",
"chat_toast_request_queued": "联系人请求已排队。",
"chat_toast_too_long": "消息过长——请缩短后重新发送。",
"chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
"chat_today": "今天",
"chat_ts_12h": "12小时",
"chat_ts_24h": "24小时",
"chat_ts_global": "跟随全局",
"chat_ts_global_short": "全局",
"chat_txns_label": "笔",
"chat_unblock": "取消屏蔽",
"chat_unblocked_toast": "会话已取消屏蔽",
"chat_unhide": "取消隐藏",
@@ -370,11 +420,20 @@
"click_to_copy": "点击复制",
"clock_format": "时间格式",
"close": "关闭",
"compact_needs_embedded": "压缩会重写 wallet.dat,而钱包只有使用其内置节点才能做到这一点。您当前连接的是自己启动的 dragonxd——请在“设置”中开启\"使用内置 dragonxd\",或使用 -salvagewallet 重启您的 dragonxd 以回收空间。",
"compact_started": "正在压缩钱包——节点正在重启,并将重新扫描区块链。",
"compact_switch_stop_failed": "无法停止您的外部节点——它仍在运行,因此未做任何更改。",
"compact_switching": "正在停止您的节点并切换到内置节点…",
"compact_wallet_confirm": "压缩",
"conf_count": "%d 确认",
"confirm_and_send": "确认并发送",
"confirm_clear_ztx_title": "确认清除 Z-Tx 历史",
"confirm_clear_ztx_warning1": "清除 z-交易历史可能导致您的屏蔽余额显示为 0,直到执行钱包重新扫描。",
"confirm_clear_ztx_warning2": "如果发生这种情况,您需要在启用重新扫描的情况下重新导入 z-地址私钥以恢复余额。",
"confirm_compact_switch_msg": "您当前连接的是自己启动的 dragonxd。为了进行压缩,钱包将停止该节点,切换到自带的内置节点,并在其上重写 wallet.dat——这会重启节点并重新扫描区块链,可能需要一段时间。注意:内置节点是钱包自身文件夹中的 dragonxd,因此这会改变您所运行的节点。",
"confirm_compact_wallet_msg": "合并已将您的票据合成较少的票据,但钱包文件无法自行缩小——节点必须重写文件才能回收释放出的空间。这会重启节点并重新扫描区块链,钱包较大时可能需要一段时间。",
"confirm_compact_wallet_safe": "您的资金和余额是安全的——当前钱包的带时间戳备份会被保留。",
"confirm_compact_wallet_title": "压缩钱包",
"confirm_delete_blockchain_msg": "这将停止守护进程,删除所有区块链数据(blocks、chainstate、peers),并从头开始重新同步。这可能需要几个小时。",
"confirm_delete_blockchain_safe": "您的 wallet.dat、配置和交易历史是安全的,不会被删除。",
"confirm_delete_blockchain_title": "删除区块链数据",
@@ -505,7 +564,7 @@
"console_welcome": "欢迎使用 ObsidianDragon 控制台",
"console_zoom_in": "放大",
"console_zoom_out": "缩小",
"consolidate_desc": "将多个小额输入合并为单个隐私票据。票据越少,钱包文件越小,隐私性也越好。",
"consolidate_desc": "您收到的每笔付款都会存为一张单独的“票据”——就像钱包里的一张张零钱。它们会随时间不断堆积,而且每张票据都会增加见证数据,使钱包同步变慢并不断变大。合并会将许多小额票据合成每个地址一张:您的余额不会改变,但钱包扫描更快、不再继续变大,并且泄露的资金信息更少。",
"consolidate_funds_btn": "合并",
"consolidate_title": "合并资金",
"contact_avatar": "头像",
@@ -639,6 +698,11 @@
"download": "下载",
"download_bootstrap": "下载引导程序",
"dragonx_green": "DragonX(绿色)",
"earnings_format": "收益显示格式",
"earnings_format_coin": "仅代币",
"earnings_format_coin_fiat": "代币+法币",
"earnings_format_fiat": "仅法币",
"earnings_label": "收益",
"edit": "编辑",
"empty_wallet_keys_suffix": "个密钥",
"empty_wallet_open_manager": "打开钱包管理器",
@@ -863,6 +927,7 @@
"faq_w_set_4_q": "如何显示法币价格?",
"faq_w_set_title": "设置与外观",
"favorite_address": "收藏地址",
"favorite_opacity": "收藏高亮不透明度",
"fee": "手续费",
"fee_high": "高",
"fee_label": "手续费:",
@@ -870,7 +935,7 @@
"fee_normal": "普通",
"fetch_prices": "获取价格",
"file": "文件",
"file_save_location": "文件将保存至:~/.config/ObsidianDragon/",
"file_save_location": "文件将保存至:",
"filter": "筛选...",
"font_scale": "字体大小",
"from": "从",
@@ -941,6 +1006,7 @@
"hidden_tag": " (已隐藏)",
"hide": "隐藏",
"hide_address": "隐藏地址",
"hide_earnings": "隐藏收益",
"hide_qr": "隐藏二维码",
"hide_zero_balances": "隐藏零余额",
"history": "历史",
@@ -1307,6 +1373,8 @@
"mining_est_block": "预计区块",
"mining_est_daily": "预计日收益",
"mining_est_daily_pool_sub": "粗略的单人挖矿等值,扣除矿池费用前",
"mining_est_daily_sub": "平均",
"mining_est_daily_sub_net": "平均·净",
"mining_filter_all": "全部",
"mining_filter_tip_all": "显示所有收益",
"mining_filter_tip_pool": "仅显示矿池收益",
@@ -1917,6 +1985,7 @@
"shielded_type": "屏蔽",
"shielding_notice": "注意:这将把资金从透明 (T) 地址转移到隐私 (Z) 地址。",
"show": "显示",
"show_earnings": "显示收益",
"show_hidden": "显示已隐藏 (%d)",
"show_qr": "显示二维码",
"show_qr_code": "显示二维码",
@@ -2038,6 +2107,7 @@
"tt_addr_url": "在区块浏览器中查看地址的基础 URL",
"tt_address_book": "管理已保存的地址以快速发送",
"tt_animate_avatars": "播放动画联系人头像(GIF / WebP);关闭时仅显示第一帧",
"tt_auto_consolidate": "在钱包空闲时于后台悄悄地分小批合并零散的小额票据。比手动合并慢,但几乎无感,而且各地址的资金仍保持独立。适合会累积成千上万张票据的挖矿/矿池钱包。",
"tt_auto_lock": "在此不活动时间后锁定钱包",
"tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私",
"tt_backup": "创建 wallet.dat 的备份",
@@ -2065,10 +2135,16 @@
"tt_debug_expand": "展开调试日志选项",
"tt_delete_blockchain": "删除所有区块链数据并重新同步。wallet.dat 和配置将被保留。",
"tt_download_bootstrap": "下载区块链引导程序以加速同步\n现有区块数据将被替换",
"tt_earn_interval": "收益周期:1H=本小时,1D=今天,1M=本月,1Y=今年",
"tt_earn_mode_inflows": "收益模式:收到的金额(含挖矿)",
"tt_earn_mode_net": "收益模式:净额(收到减去发送)",
"tt_earn_show_all": "为每个地址显示收益数值",
"tt_earnings_format": "按地址和总计的收益数值的显示方式",
"tt_encrypt": "使用密码加密 wallet.dat",
"tt_export_all": "将所有私钥导出到文件",
"tt_export_csv": "将交易历史导出为 CSV 电子表格",
"tt_export_key": "导出所选地址的私钥",
"tt_favorite_opacity": "地址列表中收藏星形按钮及其金色轮廓的不透明度(最低 15%)",
"tt_fetch_prices": "从 CoinGecko API 获取 DRGX 市场价格",
"tt_font_scale": "缩放所有文本和界面(1.0x = 默认,最大 1.5x)。",
"tt_idle_delay": "开始挖矿前等待多长时间",
@@ -2106,6 +2182,9 @@
"tt_low_spec": "禁用所有重度视觉效果\\n快捷键:Ctrl+Shift+Down",
"tt_merge": "将多个 UTXO 合并到一个地址",
"tt_mine_idle": "系统空闲时自动开始挖矿\\n(无键盘/鼠标输入)",
"tt_mining_est_daily": "基于您当前在全网算力占比的长期统计平均值。实际挖矿奖励是随机到达的(泊松分布)——常常很长时间没有,然后一次性获得一笔,因此这并不是稳定的每日收入。",
"tt_mining_est_daily_fee_fmt": "已扣除 %.1f%% 矿池手续费。",
"tt_mining_est_daily_nofee": "未扣除矿池手续费(此矿池手续费未知)。",
"tt_new_mining_address": "创建一个用于单人挖矿奖励的透明(R…)地址。它无法接收付款——收款请提供屏蔽地址。",
"tt_noise": "颗粒纹理强度(0% = 关闭,100% = 最大)",
"tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)",
@@ -2154,7 +2233,9 @@
"tt_ui_opacity": "卡片和侧边栏不透明度(100% = 完全不透明,越低越透明)",
"tt_validate": "检查 DragonX 地址是否有效",
"tt_verbose": "将详细连接诊断、守护进程状态\\n和端口所有者信息记录到控制台选项卡",
"tt_wallet_size_warn": "屏蔽钱包会随每个票据的见证数据增长——将多个票据合并到一个地址可以缩小体积。请先备份。",
"tt_view_transactions": "在历史记录标签页中显示涉及此地址的交易",
"tt_wallet_size_compact": "回收合并后释放出的磁盘空间。Berkeley DB 无法原地缩小文件,因此此操作会将 wallet.dat 重写为更小的文件,并重启节点进行完整重新扫描(可能需要一段时间)。当前钱包的备份会被保留。",
"tt_wallet_size_warn": "每笔付款都会作为一张单独的屏蔽票据到账,而每张票据都带有见证数据,会拖慢扫描并使钱包文件不断变大。将多张票据合并为每个地址一张,即可保持快速并停止变大——您的资金和余额不会改变。只有在您执行“压缩”后,文件才会回收释放出的空间(Berkeley DB 无法原地缩小)。请先备份您的助记词。您也可以在设置中开启“自动合并票据”。",
"tt_wallets_button": "列出您的钱包文件并在它们之间切换",
"tt_website": "打开 DragonX 网站",
"tt_window_opacity": "背景不透明度(越低 = 桌面透过窗口可见)",
@@ -2220,6 +2301,7 @@
"view_all": "查看全部",
"view_details": "查看详情",
"view_on_explorer": "在浏览器中查看",
"view_transactions": "查看交易",
"waiting_for_daemon": "等待守护进程连接...",
"wallet": "钱包",
"wallet_degraded_notify": "您的钱包以受限功能模式打开:现有资金安全且可用,但无法创建新地址或进行屏蔽操作。请备份助记词并用它恢复钱包以完全修复。",
@@ -2282,8 +2364,10 @@
"wallet_restore_ok": "原始钱包已还原。节点正在加载中。",
"wallet_restore_started": "正在还原您的原始文件——请勿关闭此窗口。",
"wallet_restore_stop_failed": "节点未能及时停止,因此未做任何更改。请重试。",
"wallet_size_auto_info": "您的钱包已累积了大量票据——这些是分散的已收资金,会拖慢同步并使文件不断变大。自动合并票据正在后台悄悄地合并它们,让钱包保持快速并停止变大,您无需任何操作。",
"wallet_size_compact": "压缩钱包…",
"wallet_size_consolidate": "合并笔记…",
"wallet_size_warn": "此钱包文件较大。合并您的笔记可以抑制其进一步增长。",
"wallet_size_warn": "您的钱包已累积了大量票据。您收到的每笔付款都会存为一张单独的“票据”;票据越积越多,钱包同步就会变慢,文件也会不断变大。合并会将它们合成较少的票据,让钱包扫描更快、不再继续变大——您的余额完全不变。",
"wallets_active": "活跃",
"wallets_add": "添加文件夹",
"wallets_add_folder": "同时扫描另一个文件夹中的钱包文件:",
+4 -2
View File
@@ -1225,12 +1225,14 @@ pill-margin-y = { size = 3.0 }
pill-rounding = { size = 12.0 }
padding-x = { size = 12.0 }
glass-fill-alpha = { size = 18.0 }
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 }
progress-bar-height = { size = 2.0 }
duration-info = { size = 1.0 }
duration-success = { size = 2.5 }
duration-warning = { size = 3.5 }
duration-error = { size = 4.0 }
duration-warning = { size = 7.0 }
duration-error = { size = 9.0 }
window = { width = 350, height = 60, border-radius = 8, border-width = 1 }
viewport-padding = { size = 20.0 }
stack-gap = { size = 10.0 }
+30
View File
@@ -48,6 +48,36 @@ for f in "${files[@]}"; do
fi
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
echo "" >&2
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
+367 -106
View File
@@ -36,11 +36,11 @@
#include "ui/windows/explorer_tab.h"
#include "ui/windows/seed_display.h"
#include "ui/windows/market_tab.h"
#include "ui/windows/settings_window.h"
#include "ui/windows/about_dialog.h"
#include "ui/windows/faq_dialog.h"
#include "embedded/IconsMaterialDesign.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/qr_popup_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(import_key_input_, sizeof(import_key_input_)); // pasted private key (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
@@ -943,6 +965,9 @@ void App::update()
lite_wallet_->pumpAsyncOpen();
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
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.
lite_wallet_->pumpWalletOps();
// 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:
default:
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;
}
break;
@@ -1233,14 +1262,18 @@ void App::update()
if (xmrig_manager_ && state_.pool_mining.xmrig_running
&& xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
state_.pool_mining.xmrig_running = false;
state_.pool_mining.running_since = 0;
state_.pool_mining.last_error.clear();
state_.pool_mining.hashrate_10s = 0.0;
state_.pool_mining.hashrate_60s = 0.0;
state_.pool_mining.hashrate_15m = 0.0;
pool_starting_.store(false, std::memory_order_relaxed);
pool_threads_target_.store(0, std::memory_order_relaxed); // that miner is gone
const std::string err = xmrig_manager_->getLastError();
ui::Notifications::instance().error(err.empty() ? TR("appx_miner_stopped_unexpectedly")
: (std::string(TR("appx_miner_stopped_prefix")) + err));
const std::string mmsg = err.empty() ? std::string(TR("appx_miner_stopped_unexpectedly"))
: (std::string(TR("appx_miner_stopped_prefix")) + err);
setMiningError(mmsg); // persist: a miner that crashed/was killed otherwise just "did nothing"
ui::Notifications::instance().error(mmsg);
}
// Poll xmrig stats every ~2 seconds (use a simple toggle)
@@ -1273,6 +1306,19 @@ void App::update()
ps.threads_active = xs.threads_active;
ps.pool_hashrate = xs.pool_hashrate;
ps.log_lines = xmrig_manager_->getRecentLines(30);
if (ps.running_since == 0) ps.running_since = std::time(nullptr);
// While not yet connected, surface the newest error-looking log line so "Connecting" can flip
// to a real "can't reach pool" after a timeout instead of sitting on red forever.
ps.last_error.clear();
if (!ps.connected) {
for (auto it = ps.log_lines.rbegin(); it != ps.log_lines.rend(); ++it) {
std::string l = *it;
std::transform(l.begin(), l.end(), l.begin(), [](unsigned char c){ return (char)std::tolower(c); });
if (l.find("error") != std::string::npos || l.find("refused") != std::string::npos ||
l.find("timed out") != std::string::npos || l.find("unreachable") != std::string::npos ||
l.find("dns") != std::string::npos) { ps.last_error = *it; break; }
}
}
// Record hashrate sample for the chart
ps.hashrate_history.push_back(ps.hashrate_10s);
@@ -1283,6 +1329,8 @@ void App::update()
}
} else if (xmrig_manager_ && !xmrig_manager_->isRunning()) {
state_.pool_mining.xmrig_running = false;
state_.pool_mining.running_since = 0;
state_.pool_mining.last_error.clear();
pool_starting_.store(false, std::memory_order_relaxed); // stopped / never came up
}
@@ -1544,6 +1592,13 @@ void App::update()
if (send_progress_active_ && !addressScanCanRunNow()) maybeFinishTransactionSendProgress();
// C210: the "Keep in each address" consolidation run, if one is going (paced; one RPC at a time).
pumpConsolidation();
// Background auto-consolidation (settings toggle): gently trickle-merge notes when the wallet is idle.
pumpAutoConsolidation();
// While a USER-started consolidation is running, pause the heavy routine wallet scans (balance /
// addresses / history / recent-tx) so its merges get more of the node's cs_main budget and finish
// sooner — the end-of-run refresh repaints. An auto (background) run stays gentle and is left alone, and
// explicit needs (the user's own send, a never-loaded list) still go through. Idempotent per frame.
scan_governor_.suppressRoutineWalletScans(consolidation_.active() && !auto_consolidate_run_);
// A Settings bootstrap that finished while its dialog wasn't drawn (clicked away, minimized): process it
// here so the connect and reconcile gates release and the post-bootstrap rescan is armed.
@@ -1944,6 +1999,12 @@ void App::render()
}
settings_->setWizardCompleted(true);
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)
@@ -2368,6 +2429,8 @@ void App::render()
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
else
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_);
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
@@ -2509,9 +2572,6 @@ void App::render()
// content area, status bar, and every other widget in this window.
// ====================================================================
PERF_BEGIN(_perfDialogs);
if (show_settings_) {
ui::RenderSettingsWindow(this, &show_settings_);
}
if (show_about_) {
ui::RenderAboutDialog(this, &show_about_);
@@ -2529,7 +2589,11 @@ void App::render()
if (show_import_key_) {
renderImportKeyDialog();
}
if (show_import_wallet_file_) {
renderImportWalletFileDialog();
}
if (show_export_key_) {
renderExportKeyDialog();
}
@@ -2637,7 +2701,9 @@ void App::renderNodeStatusBanner()
// wallet switch, an in-flight daemon restart, the screenshot sweep (forces demo state), and
// shutdown. tryConnect() sets connection_in_progress_ before the first render on normal
// startup, so the ordinary boot path is covered by the evaluator's own in-progress guard.
if (capture_mode_ || isShuttingDown()) return;
// Suppressed during a UI sweep (its forced demo state would otherwise paint a banner over every
// tab) — except on the one sweep target that exists to capture the banner itself.
if ((capture_mode_ && !sweep_show_node_banner_) || isShuttingDown()) return;
if (getWizardPhase() != WizardPhase::None) return;
if (wallet_switch_phase_.load() != 0) return;
if (daemon_restarting_.load()) return;
@@ -2660,6 +2726,11 @@ void App::renderNodeStatusBanner()
const NodeBusyText busy = nodeBusyText();
in.node_busy = busy.busy;
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
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);
if (!banner.show) return;
@@ -2696,6 +2767,10 @@ void App::renderNodeStatusBanner()
title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break;
case ui::NodeBannerReason::NodeBusy:
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
case ui::NodeBannerReason::AuthFailed:
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:
default:
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
@@ -2704,6 +2779,17 @@ void App::renderNodeStatusBanner()
const char* actionLabel = nullptr;
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
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
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
// translated, actionable hint instead; every other reason keeps its live status/error text.
std::string detailText;
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::LegacyFundsStuck) detailText = TR("node_banner_legacy_detail");
else detailText = banner.detail;
const float padX = ui::Layout::spacingLg();
@@ -2741,7 +2827,7 @@ void App::renderNodeStatusBanner()
ImGui::PopStyleColor();
// Detail (dim) on the same row, clipped with an ellipsis so it can't push the button off-screen.
if (!banner.detail.empty()) {
if (!detailText.empty()) {
ImGui::SameLine(0.0f, ui::Layout::spacingSm());
ImGui::SetCursorPosY(txtCy);
const float budget = winW - ImGui::GetCursorPosX() - actionReserve;
@@ -2752,7 +2838,7 @@ void App::renderNodeStatusBanner()
static util::textfit::EllipsisFitCache s_detailFit; // UI thread only (render path)
ImFont* detailFont = ImGui::GetFont();
const std::string& shown = s_detailFit.get(
banner.detail, budget, detailFont->FontId, ImGui::GetFontSize(),
detailText, budget, detailFont->FontId, ImGui::GetFontSize(),
detailFont->CurrentRasterizerDensity,
[&](const std::string& detail) {
const std::string prefix = "\xC2\xB7 "; // "· "
@@ -2792,6 +2878,12 @@ void App::renderNodeStatusBanner()
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) showSeedMigrationDialog();
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) {
const std::string conf = rpc::Connection::getDefaultConfPath();
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
/*createIfMissing=*/false);
}
}
}
@@ -3041,50 +3133,28 @@ void App::renderStatusBar()
} else if (rpc_busy_gate_->busy()) {
// 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"));
} 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) {
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
int blocksLeft = chainTip - state_.sync.blocks;
if (blocksLeft < 0) blocksLeft = 0;
// Calculate sync speed (blocks/sec) using a smoothed rolling average
static int s_prev_blocks = 0;
static double s_prev_time = 0.0;
static double s_blocks_per_sec = 0.0;
double now = ImGui::GetTime();
if (s_prev_time == 0.0) {
// First sample — seed baseline, don't compute rate yet
s_prev_blocks = state_.sync.blocks;
s_prev_time = now;
} else if (state_.sync.blocks > s_prev_blocks) {
double dt = now - s_prev_time;
if (dt > 0.5) {
double raw = (state_.sync.blocks - s_prev_blocks) / dt;
// Exponential smoothing (alpha ~0.3 per update)
s_blocks_per_sec = s_blocks_per_sec > 0.0
? s_blocks_per_sec * 0.7 + raw * 0.3
: raw;
s_prev_blocks = state_.sync.blocks;
s_prev_time = now;
}
} else if (now - s_prev_time > 10.0) {
// No new blocks for 10s — decay the displayed rate
s_blocks_per_sec *= 0.5;
s_prev_time = now;
}
if (s_blocks_per_sec > 0.1) {
int eta_sec = (int)(blocksLeft / s_blocks_per_sec);
// App-owned smoothed sync-rate estimator: self-resets after a rescan/restart height drop (and on
// connect/disconnect via the pending flag), so the ETA never goes stale/absurd ("412h"); days-aware.
if (sync_eta_reset_pending_.exchange(false)) sync_eta_.reset();
sync_eta_.update(state_.sync.blocks, ImGui::GetTime());
const ImVec4 syncCol = ImGui::ColorConvertU32ToFloat4(ui::material::Warning());
const long long etaSec = sync_eta_.etaSeconds(blocksLeft);
if (etaSec >= 0) {
char eta[32];
if (eta_sec >= 3600)
snprintf(eta, sizeof(eta), "%dh %dm", eta_sec / 3600, (eta_sec % 3600) / 60);
else if (eta_sec >= 60)
snprintf(eta, sizeof(eta), "%dm %ds", eta_sec / 60, eta_sec % 60);
else
snprintf(eta, sizeof(eta), "%ds", eta_sec);
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_eta"),
state_.sync.verification_progress * 100.0, blocksLeft, s_blocks_per_sec, eta);
util::formatEta(etaSec, eta, sizeof(eta));
ImGui::TextColored(syncCol, TR("sb_syncing_eta"),
state_.sync.verification_progress * 100.0, blocksLeft, sync_eta_.rate(), eta);
} else {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_basic"),
ImGui::TextColored(syncCol, TR("sb_syncing_basic"),
state_.sync.verification_progress * 100.0, blocksLeft);
}
} else if (state_.connected && !isLiteBuild()) {
@@ -3303,7 +3373,7 @@ void App::renderStatusBar()
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
ImGui::OpenPopup("##AlertHistoryPopup");
}
@@ -3450,14 +3520,20 @@ void App::renderLiteFirstRunPrompt()
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));
// 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 = [&]() {
wallet::secureWipeLiteSecret(seed);
sodium_memzero(restoreSeed, sizeof(restoreSeed));
restoreBirthday = 0;
restoring = false;
restoreErr.clear();
words.clear();
chips.clear();
wipeWords(words);
wipeChips(chips);
progress = 0;
step = 0;
skipConfirm = false;
@@ -3565,7 +3641,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::Spacing(); ImGui::Spacing();
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);
std::mt19937 rng{std::random_device{}()};
std::shuffle(chips.begin(), chips.end(), rng);
@@ -3576,7 +3652,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::SameLine();
if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
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) {
skipConfirm = true; // require a second, deliberate click
} else {
@@ -3639,9 +3715,9 @@ void App::renderLiteFirstRunPrompt()
}
if (!verified) ImGui::EndDisabled();
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();
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) {
skipConfirm = true; // require a second, deliberate click
} else {
@@ -3738,7 +3814,7 @@ void App::renderLiteFirstRunPrompt()
}
ImGui::EndDisabled();
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));
restoreErr.clear();
step = 0;
@@ -3776,12 +3852,13 @@ void App::renderLiteUnlockPrompt()
const float btnW = 130.0f * dp;
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
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));
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
else {
wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed"));
ui::Notifications::instance().error(TR("lite_unlock_failed"));
wallet::liteLog(std::string("Unlock failed: ") + unlockErr);
ui::Notifications::instance().error(unlockErr);
}
lite_unlock_prompt_ = false;
ImGui::CloseCurrentPopup();
@@ -3883,6 +3960,85 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
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()
{
namespace m = ui::material;
@@ -4310,8 +4466,11 @@ void App::renderExportKeyDialog()
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
if (m::TactileButton(TR("copy")))
ImGui::SetClipboardText(export_result_);
if (m::TactileButton(TR("copy"))) {
// 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_) {
ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f);
@@ -4626,6 +4785,7 @@ void App::renderSeedBackupDialog()
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
seed_backup_phrase_.clear();
seed_backup_status_.clear();
seed_backup_status_ok_ = true;
seed_backup_fetch_started_ = false;
seed_backup_loading_ = false;
seed_backup_no_mnemonic_ = false;
@@ -4647,6 +4807,12 @@ void App::renderSeedBackupDialog()
seed_backup_loading_ = false;
if (ok) {
seed_backup_phrase_ = phrase; // working copy — wiped on close
// The user is now looking at their seed via the dedicated backup flow — treat that as
// backed up, so the periodic nudge (maybeRemindSeedBackup) stops for good.
if (settings_ && !settings_->getSeedBackupReminded()) {
settings_->setSeedBackupReminded(true);
settings_->save();
}
} else if (noMnemonic) {
seed_backup_no_mnemonic_ = true;
} else {
@@ -4688,11 +4854,13 @@ void App::renderSeedBackupDialog()
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
ImGui::Spacing();
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
{ auto words = ui::SplitSeedWords(seed_backup_phrase_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
ImGui::Spacing();
if (!seed_backup_status_.empty()) {
ImGui::PushTextWrapPos(0.0f);
ImGui::TextColored(ui::material::SuccessVec4(), "%s", seed_backup_status_.c_str());
ImGui::TextColored(seed_backup_status_ok_ ? ui::material::SuccessVec4()
: ImVec4(0.85f, 0.33f, 0.33f, 1.0f),
"%s", seed_backup_status_.c_str());
ImGui::PopTextWrapPos();
ImGui::Spacing();
}
@@ -4709,6 +4877,7 @@ void App::renderSeedBackupDialog()
const bool ok = util::Platform::writeFileAtomically(path, content,
/*restrictPermissions=*/true);
sodium_memzero(&content[0], content.size());
seed_backup_status_ok_ = ok; // red when the write failed (was always green before)
seed_backup_status_ = (ok ? std::string(TR("seed_backup_saved"))
: std::string(TR("seed_backup_save_failed"))) + path;
}
@@ -4740,6 +4909,9 @@ void App::renderSeedMigrationDialog()
if (!seed_migration_seed_.empty())
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
seed_migration_seed_.clear();
// Disarm the inline confirms so an armed state can never survive a close/reopen.
seed_migration_confirm_discard_ = false;
seed_migration_confirm_sweep_ = false;
};
auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };
@@ -4852,7 +5024,7 @@ void App::renderSeedMigrationDialog()
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
{
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::TextColored(kMedium, "%s", TR("mig_receive_addr"));
@@ -4873,11 +5045,15 @@ void App::renderSeedMigrationDialog()
}
ImGui::Spacing();
ImGui::Checkbox(TR("mig_backed_up"), &seed_migration_backed_up_);
ImGui::Checkbox(TR("mig_understood"), &seed_migration_understood_);
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::TextWrapped("%s", TR("mig_step1_done"));
ImGui::PopStyleColor();
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
// Continue requires BOTH acknowledgements, and is suppressed while the Discard confirm is armed.
const bool ackOk = seed_migration_backed_up_ && seed_migration_understood_;
const bool continueDisabled = !ackOk || seed_migration_confirm_discard_;
if (continueDisabled) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_continue_sweep"), ImVec2(170 * dp, 0))) {
wipeSeed(); // the sweep/adopt steps only use the address, never the seed itself
seed_migration_step_ = SeedMigrationStep::Sweep;
@@ -4885,23 +5061,40 @@ void App::renderSeedMigrationDialog()
seed_migration_nofunds_confirmed_ = false;
refreshSeedMigrationBalance();
}
if (!seed_migration_backed_up_) ImGui::EndDisabled();
if (continueDisabled) ImGui::EndDisabled();
if (!ackOk && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ui::material::Tooltip("%s", TR("mig_ack_required"));
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0))) {
// Throw away the isolated new wallet + clear the pending state.
if (!seed_migration_temp_dir_.empty()) {
std::error_code ec;
std::filesystem::remove_all(seed_migration_temp_dir_, ec);
// Discard now takes a second click: first click arms a confirm, since it deletes the new wallet.
if (!seed_migration_confirm_discard_) {
if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0)))
seed_migration_confirm_discard_ = true;
}
if (seed_migration_confirm_discard_) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
ImGui::TextWrapped("%s", TR("mig_discard_confirm"));
ImGui::PopStyleColor();
if (ui::material::TactileButton(TR("mig_discard_yes"), ImVec2(160 * dp, 0))) {
// Throw away the isolated new wallet + clear the pending state. (body unchanged)
if (!seed_migration_temp_dir_.empty()) {
std::error_code ec;
std::filesystem::remove_all(seed_migration_temp_dir_, ec);
}
if (settings_) {
settings_->setSeedMigrationPending(false);
settings_->setSeedMigrationDest("");
settings_->setSeedMigrationTempDir("");
settings_->save();
}
seed_migration_temp_dir_.clear();
seed_migration_dest_.clear();
seed_migration_confirm_discard_ = false;
close();
}
if (settings_) {
settings_->setSeedMigrationPending(false);
settings_->setSeedMigrationDest("");
settings_->setSeedMigrationTempDir("");
settings_->save();
}
seed_migration_temp_dir_.clear();
seed_migration_dest_.clear();
close();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
seed_migration_confirm_discard_ = false;
}
break;
}
@@ -4953,7 +5146,7 @@ void App::renderSeedMigrationDialog()
ImGui::Spacing();
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
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();
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
@@ -4977,17 +5170,51 @@ void App::renderSeedMigrationDialog()
ImGui::Spacing();
{
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
if (!canSweep) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
beginSweepToSeedWallet();
if (!canSweep) ImGui::EndDisabled();
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 (!canSweep) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
seed_migration_confirm_sweep_ = true; // first click arms the confirm
if (!canSweep) ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
refreshSeedMigrationBalance();
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close(); // pending state persists; resumes here next time
} else {
// Armed: state the amount being moved before the single sweep transaction fires.
char cbuf[160]; snprintf(cbuf, sizeof(cbuf), TR("mig_sweep_confirm"), seed_migration_balance_);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
ImGui::TextWrapped("%s", cbuf);
ImGui::PopStyleColor();
if (!canSweep) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(170 * dp, 0))) {
seed_migration_confirm_sweep_ = false;
beginSweepToSeedWallet(); // unchanged
}
if (!canSweep) ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
seed_migration_confirm_sweep_ = false;
}
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
refreshSeedMigrationBalance();
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close(); // pending state persists; resumes here next time
break;
}
@@ -4999,7 +5226,8 @@ void App::renderSeedMigrationDialog()
case SeedMigrationStep::Confirming: {
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 &&
seed_migration_legacy_remaining_ <= dust;
ImGui::TextWrapped("%s", TR("mig_confirming"));
@@ -5019,25 +5247,47 @@ void App::renderSeedMigrationDialog()
ImGui::Spacing();
if (confirmed && legacyEmpty) {
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
beginAdoptSeedWallet();
} else if (confirmed && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
verifyThenAdoptSeedWallet(); // re-reads confs + balance right before the daemon stop
} 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
// 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.
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
ImGui::TextWrapped("%s", TR("mig_remainder"));
ImGui::PopStyleColor();
if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
beginSweepToSeedWallet();
if (!seed_migration_confirm_sweep_) {
if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
seed_migration_confirm_sweep_ = true; // first click arms
} else {
char rcbuf[160];
snprintf(rcbuf, sizeof(rcbuf), TR("mig_sweep_confirm"), seed_migration_legacy_remaining_);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
ImGui::TextWrapped("%s", rcbuf);
ImGui::PopStyleColor();
if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(180 * dp, 0))) {
seed_migration_confirm_sweep_ = false;
beginSweepToSeedWallet(); // unchanged
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
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 {
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
pollSweepStatus(/*forceBalance=*/true);
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close(); // pending state + txid persist; resumes here next time
// Refresh / Later are hidden while the sweep confirm is armed, to keep that row uncluttered.
if (!seed_migration_confirm_sweep_) {
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
pollSweepStatus(/*forceBalance=*/true);
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close(); // pending state + txid persist; resumes here next time
}
break;
}
@@ -5954,16 +6204,16 @@ bool App::startEmbeddedDaemon()
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
switch (state) {
case daemon::EmbeddedDaemon::State::Starting:
setDaemonStatus(TR("sb_starting_daemon"));
setDaemonStatus(util::trCopy("sb_starting_daemon"));
break;
case daemon::EmbeddedDaemon::State::Running:
setDaemonStatus(TR("sb_dragonxd_running"));
setDaemonStatus(util::trCopy("sb_dragonxd_running"));
break;
case daemon::EmbeddedDaemon::State::Stopping:
setDaemonStatus(TR("sb_dragonxd_stopping"));
setDaemonStatus(util::trCopy("sb_dragonxd_stopping"));
break;
case daemon::EmbeddedDaemon::State::Stopped:
setDaemonStatus(TR("sb_dragonxd_stopped"));
setDaemonStatus(util::trCopy("sb_dragonxd_stopped"));
break;
case daemon::EmbeddedDaemon::State::Error:
setDaemonStatus("Error: " + msg);
@@ -5972,6 +6222,14 @@ bool App::startEmbeddedDaemon()
});
}
// Compact-via-switch: the user chose to compact after switching from an external node to this built-in
// one. Arm -salvagewallet on this start so the fresh node rewrites wallet.dat into a compact file
// (-salvagewallet implies a rescan). One-shot: it fires only for the start that follows the switch.
if (compact_on_embedded_start_.exchange(false)) {
daemon_controller_->setSalvageOnNextStart(true);
user_initiated_rescan_ = true; // the implied rescan is wallet-triggered → its completion should toast
}
// Only a process we actually launched starts a new story: a start that finds a node already running (and
// launches nothing) must not wipe what the tracker knows about that node from its debug.log.
const bool launched = daemon_controller_->start(
@@ -6289,6 +6547,7 @@ void App::rescanBlockchain()
// actually observe the restarted daemon rescanning — so the first poll (which may still reach the
// pre-restart daemon and see rescanning=false) can't be misread as instant completion.
state_.sync.rescanning = true;
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // rescan drops height → reset ETA
rescan_confirmed_active_ = false;
user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
state_.sync.rescan_progress = 0.0f;
@@ -8088,6 +8347,7 @@ std::string App::buildDiagnosticsReport()
void App::restartDaemon()
{
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
return;
@@ -8098,6 +8358,7 @@ void App::restartDaemon()
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
if (!decision.allowed) return;
if (refuseIfNodeBusy()) return;
daemon_restart_pending_.store(false); // a restart is actually happening — clear the reminder
daemon_restarting_ = true;
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
+134 -6
View File
@@ -42,12 +42,14 @@
#include "resources/provision_state.h"
#include "util/background_sampler.h"
#include "util/coalescing_writer.h"
#include "util/sync_eta.h"
#include "util/fetch_backoff.h"
#include "util/idle_tracker.h"
#include "util/pool_stats_service.h"
#include "wallet/wallet_capabilities.h"
#include "chat/chat_service.h"
#include "chat/chat_database.h"
#include "chat/chat_sensitive_vault.h"
#include "ui/sidebar.h"
#include "ui/windows/console_tab.h"
#include "imgui.h"
@@ -113,6 +115,7 @@ class App {
public:
App();
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();
// Non-copyable
@@ -302,6 +305,16 @@ public:
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
chat::ChatService& chatService() { return chat_service_; }
// Public forwarder for the UI: the wallet z-address others use to reach this user in chat. Call on
// click (not per-frame) — the underlying chatReplyZaddr() may persist a freshly chosen address.
std::string chatMyAddress() { return chatReplyZaddr(); }
// Why the Chat tab has no identity yet, so it can show the right prompt instead of always
// "unlock your wallet". Locked = encrypted & locked (seed unreadable); Preparing = unlocked and
// deriving the identity (fetch in flight, or waiting on the node / address list); Unavailable =
// no usable secret for this wallet (view-only, or a legacy wallet on a daemon without the needed
// export RPC) — correctly latched, so the prompt points at Back up / Migrate instead.
enum class ChatIdentityStatus { Ready, Locked, Preparing, Unavailable };
ChatIdentityStatus chatIdentityStatus() const;
// HushChat composing: construct, broadcast (broadcastChatMemos), and locally echo an outgoing
// message (to a conversation whose peer key we know) / a new-conversation contact request.
void sendChatMessage(const std::string& conversationId, const std::string& text);
@@ -309,6 +322,25 @@ public:
// Send a contact request into an existing conversation (used to retry a failed request in place).
void sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
const std::string& text);
// Broadcast a self-addressed Control memo (op "del"/"res") so every device and a fresh seed import
// hides/restores this conversation. `label` is a plaintext hint (peer address/name) carried on-chain.
// Reuses the chat send coordinator + the 0-value kChatMinFeeDrgx z_sendmany path; no UI echo bubble.
void sendChatControl(const std::string& conversationId, const std::string& op, const std::string& label);
// --- Sensitive conversations (per-conversation local-key encryption) ---
enum class ChatVaultState { NotConfigured, Locked, Unlocked };
ChatVaultState chatSensitiveVaultState() const;
bool chatSensitiveVaultConfigured() const; // a vault file exists (regardless of lock state)
// Create the vault (first-time setup) and unlock it. Returns false if one already exists or on error.
bool createChatSensitiveVault(const std::string& passphrase);
// Provide the vault key to the chat DB so sensitive rows decrypt; also pulls them into the store.
bool unlockChatSensitiveVault(const std::string& passphrase);
void lockChatSensitiveVault(); // drop the key; sensitive threads vanish from the view
// Mark/unmark a conversation sensitive. Requires the vault UNLOCKED (migrates its rows to/from the
// vault key) and broadcasts the on-chain "sensitive"/"unsensitive" signal. `peerAddr` labels it.
bool markChatConversationSensitive(const std::string& cid, const std::string& peerAddr);
void unmarkChatConversationSensitive(const std::string& cid);
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
// the chat feature is off. Returns true when it had to fabricate the (random) demo identity
@@ -321,6 +353,10 @@ public:
// 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).
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
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
void rebuildLiteWallet(bool force = false);
@@ -379,6 +415,11 @@ public:
void startMining(int threads);
void stopMining();
bool isMiningToggleInProgress() const { return mining_toggle_in_progress_.load(std::memory_order_relaxed); }
// Persistent mining error (last start/stop/pool failure) so a failed miner isn't just a fading toast.
const std::string& miningLastError() const { return mining_last_error_; }
bool miningErrorIsAntivirus() const { return mining_error_av_; }
void clearMiningError() { mining_last_error_.clear(); mining_error_av_ = false; }
void showAntivirusHelp() { pending_antivirus_dialog_ = true; } // Windows-only dialog; inert elsewhere
// Pool mining (xmrig)
void startPoolMining(int threads);
@@ -432,6 +473,9 @@ public:
// Address operations
void createNewZAddress(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)
void hideAddress(const std::string& addr);
@@ -541,10 +585,11 @@ public:
private:
App& app_;
};
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most
// 30 s, as before), so a user's own send is never cut off mid-call.
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call (any call, not just a
// maintenance one: disconnect() needs the curl lock the call holds, which would freeze the UI), and
// refuse maintenance calls that start before the next connect (onConnected() lifts that). The abort
// 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();
// 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.
@@ -625,6 +670,9 @@ public:
// Dialog triggers (used by settings page to open modal dialogs)
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
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 showBackupDialog() { show_backup_ = true; }
void showSeedBackupDialog() { show_seed_backup_ = true; }
@@ -753,6 +801,23 @@ public:
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
*/
void restartDaemon();
// Compact wallet.dat to reclaim the space freed by consolidating notes: Berkeley DB never shrinks a
// file in place, so a restart with -salvagewallet rewrites it into a fresh, compact file (keeping a
// backup). -salvagewallet implies a full rescan, so this restarts the node and re-scans the chain.
void compactWallet();
// True when Compact can actually run: a full-node build whose EMBEDDED node the wallet manages (it
// restarts it with -salvagewallet). False for lite or when connected to an external/standalone dragonxd.
bool canCompactWallet() const {
return supportsFullNodeLifecycleActions() && isUsingEmbeddedDaemon() && daemon_controller_ != nullptr;
}
// Compact when connected to an EXTERNAL node: stop it (graceful RPC "stop"), switch the wallet to its
// built-in node, and start that with -salvagewallet so it compacts. Needs a full-node build with
// embedded-daemon support. A one-shot flag arms the salvage on the resulting embedded start.
void compactViaEmbeddedSwitch();
std::atomic<bool> compact_on_embedded_start_{false};
// 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
// (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.
@@ -911,6 +976,8 @@ private:
// curve on every failed path (a PIN RPC failure used to skip it).
void applyUnlockSuccess(const std::string& passphrase, int timeout);
void applyUnlockFailure(const std::string& errorMessage);
// Restore the persisted unlock-failure count + lockout (once per lock-screen session, per wallet).
void restoreLockoutState();
// Non-credential failure (busy node, unknown error): message only, no attempt counted.
void applyUnlockNotCounted(const std::string& errorMessage);
// Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four
@@ -987,6 +1054,8 @@ private:
// z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants.
chat::ChatService chat_service_;
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
chat::ChatSensitiveVault chat_sensitive_vault_; // local key vault for "sensitive" conversations
std::string chatSensitiveVaultPath() const; // <configDir>/chat_sensitive.vault
bool chat_identity_provisioned_ = false; // identity set on the service this session
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure
@@ -1040,10 +1109,20 @@ private:
};
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
// Larger-messages tier 3: a message split across several transactions shares one echo. This counts the
// not-yet-broadcast parts per echo id; the echo flips to Sent only when the last part lands, and to
// Failed (with the remaining parts purged) if any part fails. Absent/one ⇒ an ordinary single-tx send.
std::unordered_map<std::string, int> chat_send_parts_remaining_;
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
int chat_verified_note_budget_ = 0;
// Stuck-send notice: when a chat message sits queued ("Sending") because the wallet is offline or
// has no shielded funds for the fee, tell the user WHY once (throttled) — the send stays queued and
// goes out when the condition clears. cause: 0=none, 1=offline, 2=unfunded. See maybeNoticeStuckChatSend.
int chat_stuck_cause_ = 0;
double chat_stuck_since_ = 0.0; // ImGui::GetTime() when this cause began (dwell gate)
double chat_stuck_notice_at_ = 0.0; // next time a repeat notice is allowed (throttle)
int chat_pipeline_note_count_ = 0; // verified + maturing self-notes (drives shouldSplit)
std::uint64_t chat_verified_shielded_zat_ = 0; // verified shielded balance (split affordability)
bool chat_note_model_seen_ = false; // saw a refresh/scan carrying per-note visibility
@@ -1132,6 +1211,8 @@ private:
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
// feature is off, already provisioned, in flight, or unavailable.
void maybeProvisionChatIdentity();
// Surface (throttled) why an outgoing chat message is stuck "Sending" — offline or unfunded.
void maybeNoticeStuckChatSend();
// Drop the in-memory chat identity + decrypted message store, lock the chat DB, and re-arm
// provisioning. MUST be called whenever the loaded wallet changes (switch / seed-migration adopt)
// so wallet A's private chat can't surface — or be signed with A's keys — under wallet B.
@@ -1158,6 +1239,7 @@ private:
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 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);
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
@@ -1285,6 +1367,7 @@ private:
// Daemon restart (e.g. after changing debug log categories)
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's daemon won't start/stay up; the main loop then reverts active_wallet_file to
@@ -1313,6 +1396,11 @@ private:
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
bool wallet_auto_recovered_ = false; // a salvage happened this session
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
// We armed -salvagewallet ourselves to COMPACT a healthy wallet (not to recover a corrupt one), so the
// resulting salvage must NOT raise the "needs a quick repair" dialog — detectWalletAutoRecovery consumes
// this and shows a calm "compacted" toast instead. Atomic: compaction can be armed off the UI thread.
std::atomic<bool> compaction_salvage_expected_{false};
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_warned_ = false; // guard: surface the degraded-mode notice once per session
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
@@ -1375,10 +1463,15 @@ private:
// UI State
bool quit_requested_ = false;
bool show_demo_window_ = false;
bool show_settings_ = false;
bool show_about_ = false;
bool show_faq_ = 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_backup_ = false;
bool show_seed_backup_ = false;
@@ -1387,10 +1480,13 @@ private:
// mnemonic) and is wiped with sodium_memzero when the dialog closes.
std::string seed_backup_phrase_;
std::string seed_backup_status_;
bool seed_backup_status_ok_ = true; // colour the status line by outcome (green save vs red fail)
bool seed_backup_fetch_started_ = false;
bool seed_backup_loading_ = false;
bool seed_backup_no_mnemonic_ = false;
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
bool seed_backup_nudged_this_session_ = false; // nudged (or definitively classified) once this session;
// unlike the persisted "reminded" flag it re-nudges next run
std::chrono::steady_clock::time_point seed_backup_reminder_retry_at_{}; // after an inconclusive probe
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
@@ -1426,6 +1522,9 @@ private:
std::string seed_migration_dest_; // new shielded z-address (Phase 2 sweep target)
std::string seed_migration_temp_dir_; // temp datadir root holding the new wallet (kept)
bool seed_migration_backed_up_ = false; // "I've written it down" confirmation
bool seed_migration_understood_ = false; // 2nd ack: funds recoverable only via the phrase
bool seed_migration_confirm_discard_ = false; // inline "are you sure" gate for Discard (never persisted)
bool seed_migration_confirm_sweep_ = false; // inline "are you sure" gate for Sweep (never persisted)
std::string seed_migration_status_; // main-thread display (progress / error text)
double seed_migration_balance_ = 0.0; // legacy total to sweep (shown on the Sweep step)
std::string seed_migration_sweep_txid_; // the z_mergetoaddress sweep transaction id
@@ -1538,6 +1637,7 @@ private:
// Connection
std::string connection_status_ = "Disconnected";
bool rpc_auth_failed_ = false; // last connect hit an auth failure (bad rpcuser/rpcpassword); drives the banner
bool connection_in_progress_ = false;
bool remote_rpc_plaintext_warning_shown_ = false;
// Startup daemon-launch diagnostics: bound the "RPC port busy, no config" wait before warning,
@@ -1659,7 +1759,9 @@ private:
// The full sweep's private temp dir (demo wallet files and folders); "" when none.
std::string sweep_sandbox_;
bool sweep_chat_identity_fabricated_ = false; // seedChatDemoData() made the demo chat identity
bool sweep_show_node_banner_ = false; // let the node-status banner render on its sweep target
void applyHealthyDemoState(); // reset the connection/encryption flags to the healthy demo values
void sweepEnsureDemoChatIdentity(); // re-fabricate a demo chat identity if none (sweep teardown)
void writeSweepManifest() const;
// Snapshot of the state_ fields the demo installer mutates, restored at sweep end. Kept as a
// plain struct because WalletState has reference-alias members and isn't copy-assignable.
@@ -1675,6 +1777,7 @@ private:
double market_price_usd=0;
double market_change_24h=0;
WizardPhase wizard_phase = WizardPhase::None; // the demo state dismisses the wizard
bool lite_firstrun_dismissed = false; // the demo dismisses the lite welcome prompt
} sweep_state_snapshot_;
bool sidebar_user_toggled_ = false; // user manually toggled — suppress auto-collapse
float sidebar_width_anim_ = 0.0f; // animated width (0 = uninitialized)
@@ -1720,6 +1823,14 @@ private:
// Mining toggle guard (prevents concurrent setgenerate calls)
std::atomic<bool> mining_toggle_in_progress_{false};
// Mining error state (main-thread only — every writer is in a MainCb or render).
std::string mining_last_error_;
// Sync-ETA estimator (UI thread only). onConnected/onDisconnected may run off-thread, so they set
// the atomic flag and the status bar consumes it before update().
util::SyncRateEstimator sync_eta_;
std::atomic<bool> sync_eta_reset_pending_{false};
bool mining_error_av_ = false;
void setMiningError(std::string msg, bool av = false) { mining_last_error_ = std::move(msg); mining_error_av_ = av; }
// True from a successful startPoolMining() until the miner is confirmed connected/hashing in the
// poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay).
@@ -1761,9 +1872,14 @@ private:
services::ShieldedHistoryPass history_pass_;
std::uint64_t history_scan_epoch_ = 0; // bumped when the scan heights are invalidated
std::uint64_t history_sent_epoch_ = 0; // bumped when the sent sweep's watermark is reset
int history_sent_scan_height_ = -1; // tip of the last successful z_listsentbyaddress; -1 = none
int history_sent_scan_height_ = -1; // W: tip of the last successful z_listsentbyaddress; -1 = none
int history_sent_anchor_height_ = -1; // A: bottom edge the last sweep's window covered (W - margin)
std::string history_sent_anchor_hash_; // H_A: getblockhash(A) at that sweep; re-verified after a load
bool history_sent_verify_pending_ = false; // a loaded watermark owes an anchor verify before its next sweep
int history_full_sweep_failures_ = 0; // whole-wallet sweeps failed in a row (backoff)
std::time_t history_full_sweep_retry_at_ = 0;
std::time_t history_sent_last_full_time_ = 0; // last successful FULL sweep: drives the weekly safety-net
int history_sent_last_full_height_ = -1; // re-sweep (sentSweepSafetyNetDue)
std::unordered_set<std::string> history_enrich_backlog_; // txids found, not enriched yet
double history_addr_scan_ms_ = 0.0; // per-address z_listreceivedbyaddress cost (estimate)
std::size_t history_transparent_rows_ = 0; // rows the last listtransactions chain returned
@@ -1844,6 +1960,15 @@ private:
void postConsolidationCall(int addrIdx);
void postConsolidationPoll();
void finishConsolidation();
// Automatic background consolidation (settings-gated): drives the SAME paced ConsolidationRun in tiny,
// gentle cycles when the wallet is idle, so a mining/pool wallet sheds notes without the user ever
// clicking Consolidate. pumpAutoConsolidation() is pumped from update() right after pumpConsolidation().
void pumpAutoConsolidation();
void postAutoConsolidationScan(); // a quiet z_listunspent scan → startConsolidationQuiet on candidates
bool startConsolidationQuiet(const std::vector<services::NoteGroup>& groups, services::RunConfig cfg);
bool auto_consolidate_run_ = false; // the active run is ours (silent; gentle config)
bool auto_consolidate_scan_in_flight_ = false; // an auto note scan is posted
double auto_consolidate_next_at_ = 0.0; // steadySeconds() before which no new cycle/scan starts
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
int consecutive_core_failures_ = 0;
@@ -1883,6 +2008,7 @@ private:
// First-run wizard state
WizardPhase wizard_phase_ = WizardPhase::None;
std::unique_ptr<util::Bootstrap> bootstrap_;
std::string wizard_bootstrap_url_; // source the wizard last started ("" = default); Retry keeps it
bool bootstrap_downloading_ = false; // true while settings bootstrap dialog is active
std::string wizard_pending_passphrase_; // held until daemon connects
std::string wizard_saved_passphrase_; // held until PinSetup completes/skipped
@@ -1928,6 +2054,7 @@ private:
int lock_attempts_ = 0;
float lock_lockout_timer_ = 0.0f;
std::chrono::steady_clock::time_point lock_timer_armed_at_{}; // when lock_error/lockout timers were last armed
bool lock_state_restored_ = false; // one-shot: persisted lockout restored for this lock-screen session
bool lock_unlock_in_progress_ = false;
// Encrypt wallet dialog state
@@ -1995,6 +2122,7 @@ private:
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
void renderImportKeyDialog();
void renderImportWalletFileDialog(); // typed-path import of a legacy key dump (z_importwallet)
void renderExportKeyDialog();
void renderBackupDialog();
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
+883 -111
View File
File diff suppressed because it is too large Load Diff
+98 -39
View File
@@ -33,6 +33,7 @@
#include <algorithm>
#include <cstring>
#include <ctime>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
@@ -476,6 +477,12 @@ void App::applyUnlockSuccess(const std::string& passphrase, int timeout) {
lock_unlock_in_progress_ = false;
lock_error_msg_.clear();
lock_attempts_ = 0;
// Clear the persisted lockout on a successful unlock (guarded so a normal unlock isn't a disk write).
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;
@@ -499,9 +506,37 @@ void App::applyUnlockFailure(const std::string& errorMessage) {
if (lock_attempts_ >= maxAttempts) {
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
}
// Persist the attempt count + lockout expiry (keyed to this wallet) so killing and relaunching the
// app can't reset the escalating lockout. The PIN/passphrase itself is never stored.
if (settings_) {
settings_->setLockStateWallet(settings_->getActiveWalletFile());
settings_->setLockFailedAttempts(lock_attempts_);
settings_->setLockoutUntilEpoch(lock_lockout_timer_ > 0.0f
? (long long)std::time(nullptr) + (long long)std::ceil(lock_lockout_timer_) : 0);
settings_->save();
}
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_);
}
// Restore the persisted lockout at the start of a lock-screen session so a kill-and-relaunch can't reset
// the escalating failure curve. Only applies when the persisted state is for the active wallet.
void App::restoreLockoutState() {
if (!settings_) return;
if (settings_->getLockStateWallet() != settings_->getActiveWalletFile()) return;
lock_attempts_ = std::max(0, std::min(settings_->getLockFailedAttempts(), 64));
long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
// Cap the restored countdown so a forward clock change can't create an absurd wait. The attempt count
// still persists, so rolling the clock back only buys a short window before the next failure re-arms
// the full curve.
const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
const long long maxLock = (long long)(baseDelay * (float)(1 << 8));
if (remaining > maxLock) remaining = maxLock;
if (remaining > 0) {
lock_lockout_timer_ = (float)remaining;
lock_timer_armed_at_ = std::chrono::steady_clock::now();
}
}
// Failure that is NOT a wrong credential (busy node, not-encrypted, unknown error): surface the
// message and clear the buffer, but do not bump lock_attempts_ or arm the lockout.
void App::applyUnlockNotCounted(const std::string& msg) {
@@ -513,6 +548,15 @@ void App::applyUnlockNotCounted(const std::string& msg) {
}
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;
lock_unlock_in_progress_ = true;
@@ -563,6 +607,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
void App::lockWallet() {
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
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;
// Use fast-lane worker to avoid blocking behind refreshData.
@@ -755,12 +800,11 @@ void App::checkAutoLock() {
if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
if (!state_.isEncrypted() || state_.isLocked()) return;
// Don't auto-lock while mining — mining is a long-running intentional
// operation and locking the wallet on the daemon side stops solo mining.
bool miningActive = state_.mining.generate
|| (xmrig_manager_ && xmrig_manager_->isRunning());
if (miningActive) {
// Keep resetting the idle timer so we don't lock the instant mining stops
// Don't auto-lock while SOLO mining — it runs in the daemon and locking the wallet there would stop
// it (and it shields matured rewards, which needs the wallet). Pool mining pays an external address
// and needs no unlocked wallet, so it must NOT hold the wallet open indefinitely.
if (state_.mining.generate) {
// Keep resetting the idle timer so we don't lock the instant solo mining stops.
idle_.noteActivity();
return;
}
@@ -1040,9 +1084,15 @@ void App::tickMiningThreadCommit()
void App::renderLockScreen() {
using namespace ui::material;
// Restore any persisted lockout once per lock-screen session (re-armed on wallet switch).
if (!lock_state_restored_) {
lock_state_restored_ = true;
restoreLockoutState();
}
// Keep frames coming while something on the lock screen is moving without input: the unlock in
// flight (its dots), the lockout countdown, an error message about to clear.
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f || lock_error_timer_ > 0.0f) {
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f) {
RequestProgressFrame();
}
@@ -1264,9 +1314,9 @@ void App::renderLockScreen() {
lock_screen_was_visible_ = true;
}
// Error message
if (!lock_error_msg_.empty() && lock_error_timer_ > 0) {
lock_error_timer_ -= lockDt;
// Error message — stays visible until the next unlock attempt / mode switch / success, instead of
// fading after a few seconds (a failed-unlock reason was often gone before it could be read).
if (!lock_error_msg_.empty()) {
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str());
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(),
@@ -1312,6 +1362,7 @@ void App::renderLockScreen() {
// Handle submit (Enter key or button click)
if ((submitted || btnClicked) && canSubmit) {
lock_error_msg_.clear(); // the prior error clears only when a new attempt actually starts
// Derive daemon unlock timeout from auto-lock setting:
// 2x the idle timeout so the daemon stays unlocked longer than
// the GUI auto-lock, acting as a safety net if the GUI crashes.
@@ -1452,15 +1503,16 @@ void App::renderEncryptWalletDialog() {
ImGui::TextUnformatted(TR("wiz_passphrase"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##enc_pass", encrypt_pass_buf_, sizeof(encrypt_pass_buf_),
ImGuiInputTextFlags_Password);
static bool s_showEncPass = false;
const ImGuiInputTextFlags encPwFlags = s_showEncPass ? 0 : ImGuiInputTextFlags_Password;
ImGui::InputText("##enc_pass", encrypt_pass_buf_, sizeof(encrypt_pass_buf_), encPwFlags);
ImGui::PopItemWidth();
ImGui::TextUnformatted(TR("enc_confirm"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##enc_confirm", encrypt_confirm_buf_, sizeof(encrypt_confirm_buf_),
ImGuiInputTextFlags_Password);
ImGui::InputText("##enc_confirm", encrypt_confirm_buf_, sizeof(encrypt_confirm_buf_), encPwFlags);
ImGui::PopItemWidth();
ImGui::Checkbox(TR("enc_show_pass"), &s_showEncPass);
// Strength meter bar
{
@@ -1504,16 +1556,23 @@ void App::renderEncryptWalletDialog() {
}
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();
bool valid = strlen(encrypt_pass_buf_) >= 8 &&
strcmp(encrypt_pass_buf_, encrypt_confirm_buf_) == 0;
const size_t encPassLen = strlen(encrypt_pass_buf_);
const bool encMatch = strcmp(encrypt_pass_buf_, encrypt_confirm_buf_) == 0;
bool valid = encPassLen >= 8 && encMatch;
// Say WHY the button is disabled, once the user has started typing (was silent before).
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
const char* why = (encPassLen < 8) ? TR("enc_min_len")
: (!encMatch ? TR("enc_mismatch") : "");
if (*why) ImGui::TextColored(ui::material::WarningVec4(), "%s", why);
}
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
std::string pass(encrypt_pass_buf_);
enc_dlg_saved_passphrase_ = pass;
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
@@ -1525,7 +1584,7 @@ void App::renderEncryptWalletDialog() {
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
show_encrypt_dialog_ = false;
@@ -1592,7 +1651,7 @@ void App::renderEncryptWalletDialog() {
ImGui::PopItemWidth();
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();
@@ -1604,7 +1663,7 @@ void App::renderEncryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!pinValid || !hasPassphrase || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
enc_dlg_pin_status_.clear();
std::string savedPass = enc_dlg_saved_passphrase_;
@@ -1647,7 +1706,7 @@ void App::renderEncryptWalletDialog() {
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
if (!enc_dlg_saved_passphrase_.empty()) {
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
enc_dlg_saved_passphrase_.size());
@@ -1710,7 +1769,7 @@ void App::renderEncryptWalletDialog() {
ImGui::PopItemWidth();
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();
@@ -1722,7 +1781,7 @@ void App::renderEncryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
// Not while a PIN operation runs: PIN setup could verify and seal the OLD passphrase.
ImGui::BeginDisabled(!valid || encrypt_in_progress_ || pin_in_progress_);
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
changePassphrase(std::string(change_old_pass_buf_),
std::string(change_new_pass_buf_));
}
@@ -1731,7 +1790,7 @@ void App::renderEncryptWalletDialog() {
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_change_passphrase_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
show_change_passphrase_ = false;
}
EndOverlayDialog();
@@ -1791,7 +1850,7 @@ void App::renderDecryptWalletDialog() {
ImGui::PopItemWidth();
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();
@@ -1800,7 +1859,7 @@ void App::renderDecryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || decryptState.inProgress);
// Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click).
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) &&
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale())) || (enterPressed && valid)) &&
!refuseIfNodeBusy()) {
std::string passphrase(decrypt_pass_buf_);
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
@@ -2009,7 +2068,7 @@ void App::renderDecryptWalletDialog() {
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
show_decrypt_dialog_ = false;
}
@@ -2039,7 +2098,7 @@ void App::renderDecryptWalletDialog() {
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
services::WalletSecurityWorkflow::stepFromIndex(i))) {
// 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)) {
// In progress - animate
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
@@ -2124,7 +2183,7 @@ void App::renderDecryptWalletDialog() {
ImGui::TextWrapped("%s", TR("decrypt_success_desc"));
ImGui::Spacing();
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
show_decrypt_dialog_ = false;
}
@@ -2141,11 +2200,11 @@ void App::renderDecryptWalletDialog() {
ImGui::Spacing();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
wallet_security_workflow_.reset();
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
show_decrypt_dialog_ = false;
}
}
@@ -2198,7 +2257,7 @@ void App::renderPinDialogs() {
ImGui::PopItemWidth();
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();
@@ -2211,7 +2270,7 @@ void App::renderPinDialogs() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
// Not while a passphrase change runs: it could land after the old passphrase is verified.
ImGui::BeginDisabled(!valid || pin_in_progress_ || encrypt_in_progress_);
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
pin_status_ = TR("sec_verifying_passphrase");
@@ -2284,7 +2343,7 @@ void App::renderPinDialogs() {
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_pin_setup_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
show_pin_setup_ = false;
}
EndOverlayDialog();
@@ -2328,7 +2387,7 @@ void App::renderPinDialogs() {
ImGui::PopItemWidth();
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();
@@ -2338,7 +2397,7 @@ void App::renderPinDialogs() {
strcmp(pin_buf_, pin_confirm_buf_) == 0;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
pin_status_ = TR("sec_changing_pin");
std::string oldPin(pin_old_buf_);
@@ -2397,13 +2456,13 @@ void App::renderPinDialogs() {
ImGui::PopItemWidth();
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();
bool valid = strlen(pin_old_buf_) >= 4;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
pin_status_ = TR("sec_verifying_pin");
std::string oldPin(pin_old_buf_);
+78 -3
View File
@@ -38,6 +38,8 @@
#include "ui/windows/folder_picker.h"
#include "ui/pages/settings_page.h"
#include "chat/chat_protocol.h"
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret / ChatIdentityStatus (sweep identity)
#include "chat/chat_crypto.h" // ChatKeyPair / wipeChatKeyPair
#include "util/i18n.h"
#include "util/platform.h"
#include "util/private_temp_dir.h"
@@ -159,11 +161,15 @@ void App::installDemoWalletData()
s.market_price_usd = state_.market.price_usd;
s.market_change_24h = state_.market.change_24h;
s.wizard_phase = wizard_phase_;
s.lite_firstrun_dismissed = lite_firstrun_dismissed_;
s.valid = true;
applyHealthyDemoState();
state_.sync.blocks = state_.sync.headers = 3124322;
state_.sync.verification_progress = 1.0; state_.sync.syncing = false;
state_.sync.tip_known = true; // a healthy synced node knows the peer-derived tip (so the no-peers
state_.sync.building_witnesses = false; state_.sync.rescanning = false; // banner only shows on its target
lite_firstrun_dismissed_ = true; // the demo "has" a wallet — don't overlay the lite welcome prompt
// Legacy (pre-seed) wallet so the Settings "Migrate to seed" button glows in the sweep.
wallet_seed_status_ = WalletSeedStatus::NoMnemonic;
@@ -260,6 +266,20 @@ void App::applyHealthyDemoState()
wizard_phase_ = WizardPhase::None;
}
// Re-fabricate an identity-only demo chat identity (no messages — the demo store is untouched by
// clearIdentity) so a chat-identity sweep target's teardown restores the populated chat UI. Mirrors the
// identity half of seedChatDemoData(); never clobbers a real identity (only runs when none is held).
void App::sweepEnsureDemoChatIdentity()
{
if (!chat::hushChatFeatureEnabledAtBuild()) return;
if (chat_service_.hasIdentity()) return;
chat::ChatKeyPair keys;
const std::string demoSecret = generateChatLocalId("demo-chat:", 24); // random, per-run
if (chat::deriveChatIdentityFromSecret(demoSecret, keys).status == chat::ChatIdentityStatus::Ready)
chat_service_.setIdentity(keys);
chat::wipeChatKeyPair(keys);
}
// Refuse a full sweep while any real workflow it would fake is in flight: its surfaces overwrite the
// migration, wallet-switch, security-workflow and seed-backup state in memory, and its teardown resets it.
const char* App::fullSweepBlockedReason() const
@@ -307,6 +327,7 @@ void App::clearDemoWalletData()
state_.market.price_history = g_marketHistorySnapshot;
g_marketHistorySnapshot.clear();
wizard_phase_ = s.wizard_phase; // applyHealthyDemoState() dismissed it
lite_firstrun_dismissed_ = s.lite_firstrun_dismissed;
s = SweepStateSnapshot{}; // invalidate
// Sandbox: close what shows it, then delete it (error_code overloads only; at worst temp debris,
@@ -333,6 +354,9 @@ void App::clearDemoWalletData()
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
seed_migration_precheck_started_ = false;
seed_migration_backed_up_ = false;
seed_migration_understood_ = false;
seed_migration_confirm_discard_ = false;
seed_migration_confirm_sweep_ = false;
seed_migration_balance_ = 0.0;
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
@@ -546,8 +570,6 @@ void App::buildSweepCatalog()
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
add("modal-about", ui::NavPage::Overview,
[](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.
add("modal-seed-backup", ui::NavPage::Overview,
@@ -602,7 +624,7 @@ void App::buildSweepCatalog()
a.seed_migration_precheck_started_ = true;
});
mig("modal-migrate-showseed", SeedMigrationStep::ShowSeed,
[](App& a) { a.seed_migration_seed_ = kDemoMnemonic; a.seed_migration_backed_up_ = false; });
[](App& a) { a.seed_migration_seed_ = kDemoMnemonic; a.seed_migration_backed_up_ = false; a.seed_migration_understood_ = false; });
mig("modal-migrate-sweep", SeedMigrationStep::Sweep,
[](App& a) { a.seed_migration_balance_ = 15.75000000; a.seed_migration_balance_loaded_ = true; });
mig("modal-migrate-nofunds", SeedMigrationStep::Sweep,
@@ -703,6 +725,59 @@ void App::buildSweepCatalog()
[](App& a) { a.state_.connected = false; a.state_.encryption_state_known = false; },
[](App& a) { a.applyHealthyDemoState(); });
// Restore the healthy synced demo sync-state (applyHealthyDemoState leaves state_.sync alone).
auto resetDemoSync = [](App& a) {
a.applyHealthyDemoState();
a.state_.sync.syncing = false; a.state_.sync.building_witnesses = false;
a.state_.sync.rescanning = false; a.state_.sync.verification_progress = 1.0;
a.state_.sync.tip_known = true;
a.state_.sync.blocks = a.state_.sync.headers = 3124322;
};
// Balance tab while the node is still catching up (connected, tip known, verifying) → the shared
// "catching up — your funds are safe" strip above whichever layout renders (P4).
add("overview-syncing", ui::NavPage::Overview,
[](App& a) {
a.applyHealthyDemoState();
a.state_.sync.tip_known = true;
a.state_.sync.syncing = true;
a.state_.sync.building_witnesses = false;
a.state_.sync.rescanning = false;
a.state_.sync.verification_progress = 0.62;
a.state_.sync.blocks = 1934000; a.state_.sync.headers = 3124322;
},
resetDemoSync);
// Full node connected to the daemon but with zero network peers → the node-status banner's honest
// detail (P2). Full-node only: the Lite build has no node, so there this renders as a plain Overview.
add("overview-no-peers", ui::NavPage::Overview,
[](App& a) {
a.applyHealthyDemoState();
a.state_.sync.tip_known = false; // no peer-derived tip → NoPeers banner
a.state_.sync.syncing = false;
a.state_.sync.blocks = 3124322; a.state_.sync.headers = 3124322;
a.connection_status_ = "Connected";
a.connection_in_progress_ = false; // else the evaluator treats it as "connecting", not no-peers
a.sweep_show_node_banner_ = true; // allow the banner (suppressed on every other target)
},
[resetDemoSync](App& a) { a.sweep_show_node_banner_ = false; resetDemoSync(a); });
// Chat with no derived identity yet — the states that previously ALL said "unlock your wallet" (P3).
// Setup clears the fabricated demo identity (clearIdentity wipes only the key; the demo messages stay
// in the store); teardown re-fabricates an identity so the populated chat UI returns for later targets.
add("chat-identity-preparing", ui::NavPage::Chat,
[](App& a) {
a.chat_service_.clearIdentity();
a.chat_identity_unavailable_ = false;
a.chat_identity_fetch_in_flight_ = false;
},
[](App& a) { a.chat_identity_unavailable_ = false; a.sweepEnsureDemoChatIdentity(); });
add("chat-identity-unavailable", ui::NavPage::Chat,
[](App& a) {
a.chat_service_.clearIdentity();
a.chat_identity_unavailable_ = true; // view-only / legacy wallet with no exportable secret
a.chat_identity_fetch_in_flight_ = false;
},
[](App& a) { a.chat_identity_unavailable_ = false; a.sweepEnsureDemoChatIdentity(); });
// A mining (transparent) address as the recipient: shown as unpayable, Send disabled.
add("send-mining-recipient", ui::NavPage::Send,
[](App& a) { ui::SweepSetSendRecipient(&a, "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"); },
+66 -30
View File
@@ -73,7 +73,7 @@ WizardUiState s_wizardUi;
void App::restartWizard()
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Lite wallet lifecycle requests are available from Settings as dry-run readiness checks");
ui::Notifications::instance().warning(TR("toast_wizard_lite_unavailable"));
return;
}
@@ -800,21 +800,22 @@ void App::renderFirstRunWizard() {
// Daemon status indicator
{
bool daemonUp = isEmbeddedDaemonRunning();
const std::string& dStatus = getDaemonStatus();
ImU32 dotCol = daemonUp ? IM_COL32(76, 175, 80, 200) // green
: IM_COL32(120, 120, 120, 160); // gray
if (dStatus.find("Stopping") != std::string::npos)
dotCol = IM_COL32(255, 167, 38, 200); // orange
// Key the dot + label off the daemon state ENUM, not an English substring of the status
// text — the substring match silently failed under every non-English language.
const bool daemonStopping = daemon_controller_ &&
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Stopping;
ImU32 dotCol = daemonStopping ? IM_COL32(255, 167, 38, 230) // orange
: daemonUp ? IM_COL32(76, 175, 80, 230) // green
: IM_COL32(120, 120, 120, 180); // gray
float dotR = 3.5f * dp;
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
dotR, dotCol);
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
? TR("bootstrap_daemon_stopping")
: TR("bootstrap_daemon_running"))
: TR("bootstrap_daemon_stopped");
const char* label = daemonStopping ? TR("bootstrap_daemon_stopping")
: daemonUp ? TR("bootstrap_daemon_running")
: TR("bootstrap_daemon_stopped");
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,140), label);
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,220), label);
cy += captionFont->LegacySize + 6.0f * dp;
}
@@ -862,14 +863,19 @@ void App::renderFirstRunWizard() {
ui::material::Error(), TR("wiz_download_failed"));
cy += bodyFont->LegacySize + 8.0f * dp;
// Wrap the (often long) error within the card instead of letting it clip off the edge.
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), textCol,
errMsg.c_str());
cy += captionFont->LegacySize + 16.0f * dp;
errMsg.c_str(), nullptr, contentW);
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, contentW, errMsg.c_str());
cy += es.y + 16.0f * dp;
// Retry / Skip
// Retry / (Try mirror) / Skip. Offer the mirror unless the last attempt already used it.
const std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
const bool showMirror = (wizard_bootstrap_url_ != mirrorUrl);
float btnW2 = 120.0f * dp;
float btnH2 = 40.0f * dp;
float totalBW = btnW2 * 2 + 12.0f * dp;
const int nBtn = showMirror ? 3 : 2;
float totalBW = btnW2 * nBtn + 12.0f * dp * (nBtn - 1);
float bx = rightX + (colW - totalBW) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(bx, cy));
@@ -883,14 +889,33 @@ void App::renderFirstRunWizard() {
stopDaemonForBootstrap();
bootstrap_ = std::make_unique<util::Bootstrap>();
std::string dataDir = util::Platform::getDragonXDataDir();
bootstrap_->start(dataDir);
if (wizard_bootstrap_url_.empty()) bootstrap_->start(dataDir);
else bootstrap_->start(dataDir, wizard_bootstrap_url_); // keep the user's chosen source
wizard_phase_ = WizardPhase::BootstrapInProgress;
}
ImGui::EndDisabled();
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
ImGui::SetCursorScreenPos(ImVec2(bx + btnW2 + 12.0f * dp, cy));
float nextBx = bx + btnW2 + 12.0f * dp;
if (showMirror) {
ImGui::SetCursorScreenPos(ImVec2(nextBx, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
if (ui::material::TactileButton(TR("bootstrap_try_mirror"), ImVec2(btnW2, btnH2)) && !refuseIfNodeBusy()) {
stopDaemonForBootstrap();
bootstrap_ = std::make_unique<util::Bootstrap>();
bootstrap_->start(util::Platform::getDragonXDataDir(), mirrorUrl);
wizard_bootstrap_url_ = mirrorUrl;
wizard_phase_ = WizardPhase::BootstrapInProgress;
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered()) ui::material::Tooltip(TR("bootstrap_use_mirror_tooltip"));
ImGui::PopStyleVar();
nextBx += btnW2 + 12.0f * dp;
}
ImGui::SetCursorScreenPos(ImVec2(nextBx, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW2, btnH2))) {
wizard_phase_ = WizardPhase::EncryptOffer;
@@ -1014,6 +1039,15 @@ void App::renderFirstRunWizard() {
cy += bsSize.y + 8.0f * dp;
}
// Honest size/time hint (no number is known until the download HEADs the remote file).
{
const char* hintText = TR("wiz_bootstrap_size_hint");
ImVec2 hs = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, contentW, hintText);
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
(dimCol & 0x00FFFFFF) | IM_COL32(0, 0, 0, 200), hintText, nullptr, contentW);
cy += hs.y + 8.0f * dp;
}
// Trust warning
{
float warnOpacity = S.drawElement("screens.first-run", "trust-warning").opacity;
@@ -1031,21 +1065,20 @@ void App::renderFirstRunWizard() {
// Daemon status indicator (subtle, before buttons)
{
bool daemonUp = isEmbeddedDaemonRunning();
const std::string& dStatus = getDaemonStatus();
ImU32 dotCol = daemonUp ? IM_COL32(76, 175, 80, 200)
: IM_COL32(120, 120, 120, 160);
if (dStatus.find("Stopping") != std::string::npos)
dotCol = IM_COL32(255, 167, 38, 200);
const bool daemonStopping = daemon_controller_ &&
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Stopping;
ImU32 dotCol = daemonStopping ? IM_COL32(255, 167, 38, 230)
: daemonUp ? IM_COL32(76, 175, 80, 230)
: IM_COL32(120, 120, 120, 180);
float dotR = 3.5f * dp;
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
dotR, dotCol);
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
? TR("bootstrap_daemon_stopping")
: TR("bootstrap_daemon_running"))
: TR("bootstrap_daemon_stopped");
const char* label = daemonStopping ? TR("bootstrap_daemon_stopping")
: daemonUp ? TR("bootstrap_daemon_running")
: TR("bootstrap_daemon_stopped");
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,140), label);
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,220), label);
cy += captionFont->LegacySize + 10.0f * dp;
}
@@ -1071,6 +1104,7 @@ void App::renderFirstRunWizard() {
bootstrap_ = std::make_unique<util::Bootstrap>();
std::string dataDir = util::Platform::getDragonXDataDir();
bootstrap_->start(dataDir);
wizard_bootstrap_url_.clear(); // default source; Retry won't force the mirror
wizard_phase_ = WizardPhase::BootstrapInProgress;
}
ImGui::EndDisabled();
@@ -1084,17 +1118,18 @@ void App::renderFirstRunWizard() {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) {
if (ui::material::TactileButton(TR("bootstrap_use_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) {
stopDaemonForBootstrap();
bootstrap_ = std::make_unique<util::Bootstrap>();
std::string dataDir = util::Platform::getDragonXDataDir();
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
bootstrap_->start(dataDir, mirrorUrl);
wizard_bootstrap_url_ = mirrorUrl; // Retry on the failed card keeps this source
wizard_phase_ = WizardPhase::BootstrapInProgress;
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered()) {
ui::material::Tooltip(TR("bootstrap_mirror_tooltip"));
ui::material::Tooltip(TR("bootstrap_use_mirror_tooltip"));
}
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
@@ -1421,7 +1456,8 @@ void App::renderFirstRunWizard() {
wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true);
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::PopStyleVar();
+73 -13
View File
@@ -97,6 +97,7 @@ void ChatDatabase::lock()
{
sodium_memzero(key_.data(), key_.size());
key_ready_ = false;
lockSensitive(); // also drop the sensitive-conversation vault key
wallet_tag_.clear();
tombstones_.clear();
}
@@ -106,10 +107,24 @@ bool ChatDatabase::append(const ChatMessage& message)
if (!key_ready_ || !ensureOpen()) return false;
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
bool skip = false;
const unsigned char* wkey = writeKeyFor(message.conversation_id, skip);
if (skip) return false; // sensitive conversation, vault locked — don't persist (re-imports when unlocked)
std::vector<unsigned char> nonce;
std::vector<unsigned char> cipher;
std::string plain = serialize(message); // full plaintext (decrypted body + metadata)
const bool encrypted = encrypt(plain, nonce, cipher);
std::string plain;
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(wkey, 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 (!encrypted) return false;
@@ -142,10 +157,13 @@ int ChatDatabase::appendBatch(const std::vector<ChatMessage>& messages)
rows.reserve(messages.size());
for (const auto& m : messages) {
if (isTombstoned(m)) continue; // locally deleted — don't re-persist
bool skip = false;
const unsigned char* wkey = writeKeyFor(m.conversation_id, skip);
if (skip) continue; // sensitive + vault locked — don't persist
try {
std::string plain = serialize(m); // strict json.dump() may throw on non-UTF-8 body
std::vector<unsigned char> nonce, cipher;
const bool ok = encrypt(plain, nonce, cipher);
const bool ok = encrypt(wkey, plain, nonce, cipher);
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
if (!ok) continue;
rows.push_back({ dedupHash(m.txid, m.payload_position), std::move(nonce), std::move(cipher) });
@@ -185,10 +203,22 @@ bool ChatDatabase::upsert(const ChatMessage& message)
{
if (!key_ready_ || !ensureOpen()) return false;
bool skip = false;
const unsigned char* wkey = writeKeyFor(message.conversation_id, skip);
if (skip) return false; // sensitive conversation, vault locked — don't persist the echo under the seed key
std::vector<unsigned char> nonce;
std::vector<unsigned char> cipher;
std::string plain = serialize(message);
const bool encrypted = encrypt(plain, nonce, cipher);
std::string plain;
bool encrypted = false;
try {
plain = serialize(message); // strict dump → a malformed body throws; skip rather than crash
encrypted = encrypt(wkey, 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 (!encrypted) return false;
@@ -232,7 +262,13 @@ std::vector<ChatMessage> ChatDatabase::load()
std::vector<unsigned char> nonce(noncePtr, noncePtr + nonceLen);
std::vector<unsigned char> cipher(cipherPtr, cipherPtr + cipherLen);
std::string plain;
if (!decrypt(nonce, cipher, plain)) continue; // wrong wallet / tampered — skip
// Try the seed key first; a sensitive row only decrypts under the vault key (and is silently
// withheld when the vault is locked / on a fresh seed import that has no vault key). A wrong
// wallet / tampered row fails both and is skipped.
if (!decrypt(key_.data(), nonce, cipher, plain) &&
!(sensitive_ready_ && decrypt(sensitive_key_.data(), nonce, cipher, plain))) {
continue;
}
ChatMessage message;
if (deserialize(plain, message)) out.push_back(std::move(message));
@@ -416,7 +452,7 @@ std::string ChatDatabase::serialize(const ChatMessage& message) const
json["ts"] = message.timestamp;
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
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
@@ -442,11 +478,34 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
}
}
bool ChatDatabase::encrypt(const std::string& plain,
void ChatDatabase::setSensitiveKey(const std::array<unsigned char, 32>& key)
{
sensitive_key_ = key; // copy; the caller still owns & wipes its own copy
sensitive_ready_ = true;
}
void ChatDatabase::lockSensitive()
{
sodium_memzero(sensitive_key_.data(), sensitive_key_.size());
sensitive_ready_ = false;
}
const unsigned char* ChatDatabase::writeKeyFor(const std::string& cid, bool& skip) const
{
skip = false;
if (sensitive_pred_ && sensitive_pred_(cid)) {
if (!sensitive_ready_) { skip = true; return nullptr; } // don't persist under the seed key
return sensitive_key_.data();
}
return key_.data();
}
bool ChatDatabase::encrypt(const unsigned char* key,
const std::string& plain,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipher) const
{
if (!key_ready_) return false;
if (!key) return false;
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
randombytes_buf(nonce.data(), nonce.size());
@@ -457,18 +516,19 @@ bool ChatDatabase::encrypt(const std::string& plain,
cipher.data(), &cipherLen,
reinterpret_cast<const unsigned char*>(plain.data()), plain.size(),
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
nullptr, nonce.data(), key_.data()) != 0) {
nullptr, nonce.data(), key) != 0) {
return false;
}
cipher.resize(static_cast<std::size_t>(cipherLen));
return true;
}
bool ChatDatabase::decrypt(const std::vector<unsigned char>& nonce,
bool ChatDatabase::decrypt(const unsigned char* key,
const std::vector<unsigned char>& nonce,
const std::vector<unsigned char>& cipher,
std::string& plain) const
{
if (!key_ready_) return false;
if (!key) return false;
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) return false;
if (cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) return false;
@@ -479,7 +539,7 @@ bool ChatDatabase::decrypt(const std::vector<unsigned char>& nonce,
out.data(), &outLen, nullptr,
cipher.data(), cipher.size(),
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
nonce.data(), key_.data()) != 0) {
nonce.data(), key) != 0) {
return false;
}
plain.assign(reinterpret_cast<const char*>(out.data()), static_cast<std::size_t>(outLen));
+23 -2
View File
@@ -15,6 +15,7 @@
#include <array>
#include <cstddef>
#include <functional>
#include <string>
#include <unordered_set>
#include <vector>
@@ -40,6 +41,17 @@ public:
void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op
bool hasKey() const { return key_ready_; }
// --- Sensitive conversations (per-conversation local-key encryption) ---
// Provide the local vault key (from ChatSensitiveVault). Rows of conversations the sensitive
// predicate marks sensitive are AEAD-encrypted under THIS key instead of the seed-derived key, so
// they can't be read without it (even by the seed). The caller still owns and should wipe `key`.
void setSensitiveKey(const std::array<unsigned char, 32>& key);
void lockSensitive(); // wipe the sensitive key; sensitive rows then withheld
bool hasSensitiveKey() const { return sensitive_ready_; }
// Predicate: true for a conversation id that should be stored under the sensitive key. Consulted on
// WRITE to pick the key; on READ both keys are tried, so a predicate isn't needed to decrypt.
void setSensitivePredicate(std::function<bool(const std::string&)> pred) { sensitive_pred_ = std::move(pred); }
// Persist one message (INSERT OR IGNORE, deduped by a keyed hash of txid+payload_position).
// Returns true if newly inserted; false on duplicate or while locked.
bool append(const ChatMessage& message);
@@ -80,12 +92,18 @@ private:
std::string dedupHash(const std::string& txid, std::size_t position) const;
std::string serialize(const ChatMessage& message) const;
bool deserialize(const std::string& json, ChatMessage& out) const;
bool encrypt(const std::string& plain,
bool encrypt(const unsigned char* key,
const std::string& plain,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipher) const;
bool decrypt(const std::vector<unsigned char>& nonce,
bool decrypt(const unsigned char* key,
const std::vector<unsigned char>& nonce,
const std::vector<unsigned char>& cipher,
std::string& plain) const;
// The key a row for `cid` is written under. Returns nullptr and sets skip=true when the conversation
// is sensitive but the vault is locked — the row must NOT be persisted (under the seed key it would be
// readable without the vault, defeating the point); it re-imports from chain once the vault is unlocked.
const unsigned char* writeKeyFor(const std::string& cid, bool& skip) const;
void close();
sqlite3* db_ = nullptr;
@@ -93,6 +111,9 @@ private:
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
bool key_ready_ = false;
std::array<unsigned char, 32> sensitive_key_{}; // local vault key for sensitive conversations' rows
bool sensitive_ready_ = false;
std::function<bool(const std::string&)> sensitive_pred_; // true => store this cid under the sensitive key
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
};
+3 -1
View File
@@ -26,7 +26,9 @@ struct ChatMessage {
std::string body; // decrypted plaintext (Message) or request text (ContactRequest)
std::int64_t timestamp = 0; // tx time in seconds; set by the ingesting caller
std::size_t payload_position = 0; // together with txid, the dedup key
ChatDelivery delivery = ChatDelivery::Sent; // outgoing only
ChatDelivery delivery = ChatDelivery::Sent; // outgoing only. Sent = broadcast to the network (not
// necessarily confirmed/delivered yet).
std::string fail_reason; // outgoing Failed only; in-memory, NOT persisted
};
} // namespace dragonx::chat
+171 -16
View File
@@ -5,6 +5,10 @@
#include "chat_protocol.h" // kHushChat* constants
#include <nlohmann/json.hpp>
#include <sodium.h>
#include <algorithm>
#include <ctime>
namespace dragonx::chat {
@@ -17,31 +21,61 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
const char* type,
const std::string& streamHeaderHex,
const std::string& publicKeyHex,
std::int64_t sentAt)
std::int64_t sentAt,
const std::string& op = "",
int parts = 1,
const std::string& messageId = "")
{
nlohmann::json header;
header["h"] = 1; // header number (>= 1)
header["v"] = kHushChatSupportedVersion; // 0
header["z"] = replyZaddr; // where the peer should reply (my address)
header["cid"] = conversationId;
header["t"] = type; // "Memo" or "Cont"
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
header["t"] = type; // "Memo" / "Cont" / "Ctrl"
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont, Ctrl)
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
if (!op.empty()) header["op"] = op; // Control only; alphabetically sorts to cid,e,h,op,p,t,v,z
if (parts > 1) header["n"] = parts; // multi-part Message only; omitted (==1) keeps 1-part byte-identical
if (!messageId.empty()) header["mid"] = messageId; // tier 3 only: ties this tx's slice to its siblings
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
return header.dump();
}
// The N 2-hex index prefix for a multi-part ciphertext chunk (index 0..kHushChatMaxMessageParts-1).
std::string partIndexPrefix(std::size_t i)
{
static const char* kHex = "0123456789abcdef";
std::string p;
p += kHex[(i >> 4) & 0xF];
p += kHex[i & 0xF];
return p;
}
bool present(const std::string& value) { return !value.empty(); }
// A random 8-byte message id as 16 hex chars — ties a tier-3 message's sibling transactions together.
std::string newMessageId()
{
unsigned char buf[8];
randombytes_buf(buf, sizeof buf);
static const char* kHex = "0123456789abcdef";
std::string s;
s.reserve(sizeof(buf) * 2);
for (unsigned char b : buf) { s += kHex[b >> 4]; s += kHex[b & 0xF]; }
return s;
}
} // namespace
std::array<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
std::vector<ChatSendOutput> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
{
const std::string prefix = utf8Prefix ? "utf8:" : "";
return {{
{ memos.recipientZaddr, prefix + memos.headerMemo }, // header — the lower memo position
{ memos.recipientZaddr, prefix + memos.payloadMemo },
}};
std::vector<ChatSendOutput> outs;
outs.reserve(1 + memos.payloadMemos.size());
outs.push_back({ memos.recipientZaddr, prefix + memos.headerMemo }); // header (position doesn't matter;
for (const auto& p : memos.payloadMemos) // the receiver reassembles by index)
outs.push_back({ memos.recipientZaddr, prefix + p });
return outs;
}
ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
@@ -67,19 +101,111 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
return ChatComposeStatus::EncryptFailed;
}
// Chunk the ciphertext hex across payload memos. A single memo → the classic 1-part message, byte-
// identical on the wire (no "n", no index prefix). Longer → N index-prefixed chunks (tier 2), capped
// at kHushChatMaxMessageParts; beyond that it is refused as TooLong (tier 3 would lift the ceiling).
std::vector<std::string> payloads;
int parts = 1;
if (ciphertextHex.size() <= kHushChatMemoByteLimit) {
payloads.push_back(ciphertextHex);
} else {
const std::size_t chunkHex = (kHushChatMemoByteLimit - kHushChatPartIndexHexLen) & ~std::size_t(1);
const std::size_t n = (ciphertextHex.size() + chunkHex - 1) / chunkHex;
if (static_cast<int>(n) > kHushChatMaxMessageParts) return ChatComposeStatus::TooLong;
parts = static_cast<int>(n);
payloads.reserve(n);
for (std::size_t i = 0; i < n; ++i) {
payloads.push_back(partIndexPrefix(i) + ciphertextHex.substr(i * chunkHex, chunkHex));
}
}
OutgoingChatMemos memos;
memos.recipientZaddr = peerZaddr;
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
static_cast<std::int64_t>(std::time(nullptr)));
memos.payloadMemo = ciphertextHex;
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
return ChatComposeStatus::TooLong;
}
static_cast<std::int64_t>(std::time(nullptr)), "", parts);
memos.payloadMemos = std::move(payloads);
if (memos.headerMemo.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
for (const auto& p : memos.payloadMemos)
if (p.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
out = std::move(memos);
return ChatComposeStatus::Ok;
}
ChatComposeStatus splitOutgoingMessage(const ChatKeyPair& mine,
const std::string& myPublicKeyHex,
const std::string& myReplyZaddr,
const std::string& peerPublicKeyHex,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& plaintext,
std::vector<OutgoingChatMemos>& out)
{
if (plaintext.empty()) return ChatComposeStatus::EmptyBody;
if (!present(myPublicKeyHex) || !present(myReplyZaddr) || !present(peerZaddr) ||
!present(conversationId)) {
return ChatComposeStatus::MissingField;
}
if (peerPublicKeyHex.size() != kHushChatPublicKeyHexLength) return ChatComposeStatus::BadPeerKey;
// Encrypt the whole message ONCE (one secretstream session). Chunking is transport-only, so every
// transaction of a tier-3 message carries the same secretstream header "e" and its slice of the one
// ciphertext — the crypto is byte-identical to the single-tx path.
std::string streamHeaderHex;
std::string ciphertextHex;
if (encryptOutgoing(mine, peerPublicKeyHex, plaintext, streamHeaderHex, ciphertextHex)
!= ChatCryptoStatus::Ok) {
return ChatComposeStatus::EncryptFailed;
}
const std::int64_t now = static_cast<std::int64_t>(std::time(nullptr));
// 1 chunk if the ciphertext fits one memo, else ceil(hex / chunkHex) chunks (chunkHex = the 512-byte
// memo limit minus the 2-hex global index prefix, rounded to an even hex length).
const std::size_t chunkHex = (kHushChatMemoByteLimit - kHushChatPartIndexHexLen) & ~std::size_t(1);
const std::size_t nChunks = (ciphertextHex.size() <= kHushChatMemoByteLimit)
? 1
: (ciphertextHex.size() + chunkHex - 1) / chunkHex;
if (static_cast<int>(nChunks) > kHushChatMaxMessageChunks) return ChatComposeStatus::TooLong;
std::vector<OutgoingChatMemos> txs;
if (nChunks <= 1) {
// Tier 1: one classic payload — no index prefix, no "n", no "mid" (byte-identical on the wire).
OutgoingChatMemos m;
m.recipientZaddr = peerZaddr;
m.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex,
myPublicKeyHex, now, "", 1, "");
m.payloadMemos = { ciphertextHex };
txs.push_back(std::move(m));
} else {
// Tier 2/3: index each chunk with its GLOBAL index and pack up to kHushChatMaxMessageParts chunks
// per transaction. One tx (≤ the per-tx cap) is tier 2 (no "mid"); several is tier 3 (shared "mid",
// same "n" = total chunk count in every sibling header).
const bool multiTx = static_cast<int>(nChunks) > kHushChatMaxMessageParts;
const std::string mid = multiTx ? newMessageId() : std::string();
const std::size_t perTx = static_cast<std::size_t>(kHushChatMaxMessageParts);
for (std::size_t base = 0; base < nChunks; base += perTx) {
OutgoingChatMemos m;
m.recipientZaddr = peerZaddr;
m.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex,
myPublicKeyHex, now, "", static_cast<int>(nChunks), mid);
const std::size_t end = std::min(nChunks, base + perTx);
m.payloadMemos.reserve(end - base);
for (std::size_t i = base; i < end; ++i)
m.payloadMemos.push_back(partIndexPrefix(i) + ciphertextHex.substr(i * chunkHex, chunkHex));
txs.push_back(std::move(m));
}
}
for (const auto& m : txs) {
if (m.headerMemo.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
for (const auto& p : m.payloadMemos)
if (p.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
}
out = std::move(txs);
return ChatComposeStatus::Ok;
}
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
const std::string& myReplyZaddr,
const std::string& peerZaddr,
@@ -100,9 +226,38 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
memos.recipientZaddr = peerZaddr;
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
static_cast<std::int64_t>(std::time(nullptr)));
memos.payloadMemo = requestText;
memos.payloadMemos = { requestText };
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
return ChatComposeStatus::TooLong;
}
out = std::move(memos);
return ChatComposeStatus::Ok;
}
ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
const std::string& selfZaddr,
const std::string& conversationId,
const std::string& op,
const std::string& label,
OutgoingChatMemos& out)
{
// A control signal is addressed to SELF: only this wallet (any device holding the seed) harvests
// it, and it never reaches the peer. op carries the action; the label is a plaintext hint (peer
// address / display name) so a fresh import can show the conversation it refers to.
if (label.empty()) return ChatComposeStatus::EmptyBody;
if (label.front() == '{') return ChatComposeStatus::BadRequestText; // would parse as a header
if (!present(op) || !present(myPublicKeyHex) || !present(selfZaddr) || !present(conversationId)) {
return ChatComposeStatus::MissingField;
}
OutgoingChatMemos memos;
memos.recipientZaddr = selfZaddr;
memos.headerMemo = buildHeaderMemo(selfZaddr, conversationId, "Ctrl", "", myPublicKeyHex,
static_cast<std::int64_t>(std::time(nullptr)), op);
memos.payloadMemos = { label };
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
return ChatComposeStatus::TooLong;
}
out = std::move(memos);
+34 -4
View File
@@ -12,13 +12,17 @@
#include <array>
#include <string>
#include <vector>
namespace dragonx::chat {
struct OutgoingChatMemos {
std::string recipientZaddr; // the peer's z-address (recipient of both memo outputs)
std::string headerMemo; // JSON header — MUST occupy the lower memo position on the wire
std::string payloadMemo; // ciphertext hex (Message) or plaintext (ContactRequest)
std::string recipientZaddr; // the peer's z-address (recipient of every memo output)
std::string headerMemo; // JSON header
// One payload (ciphertext hex for a Message, plaintext for a ContactRequest/Control), OR — for a
// multi-part Message (header "n" = N > 1) — N index-prefixed ciphertext chunks (a 2-hex part index
// then that chunk's ciphertext hex), reassembled by the receiver regardless of on-chain order.
std::vector<std::string> payloadMemos;
};
// One of the two 0-value memo outputs a HushChat send produces (amount is always 0).
@@ -31,7 +35,7 @@ struct ChatSendOutput {
// `utf8Prefix` prepends the daemon's "utf8:" marker required by full-node z_sendmany (which then
// UTF-8-encodes the bytes on-chain, byte-identical to SDXLite's Memo::from_str); lite backends take
// raw UTF-8, so pass false there.
std::array<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix);
std::vector<ChatSendOutput> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix);
enum class ChatComposeStatus {
Ok,
@@ -54,6 +58,21 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
const std::string& plaintext,
OutgoingChatMemos& out);
// Build an ENCRYPTED message, splitting it across as many transactions as its length needs (larger-
// messages tier 3). Returns one OutgoingChatMemos per transaction to broadcast (in order): a short
// message yields a single element identical to buildOutgoingMessage (tier 1/2); a long one yields
// several, each a self-contained chat send carrying a slice of the one ciphertext under a shared
// message id. The receiver reassembles across the siblings. Refused as TooLong beyond
// kHushChatMaxMessageChunks total chunks. The caller broadcasts each element as its own chat tx.
ChatComposeStatus splitOutgoingMessage(const ChatKeyPair& mine,
const std::string& myPublicKeyHex,
const std::string& myReplyZaddr,
const std::string& peerPublicKeyHex,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& plaintext,
std::vector<OutgoingChatMemos>& out);
// Build a plaintext contact request — no peer public key needed yet; this is how the peer first
// learns your public key + reply address. The payload is the (plaintext) request text.
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
@@ -63,4 +82,15 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
const std::string& requestText,
OutgoingChatMemos& out);
// Build a self-addressed control signal (delete / restore / mark-sensitive). It is sent to your OWN
// z-address, so only this wallet's seed can harvest it and it never reaches the peer. `op` is the
// action ("del"/"res"/...); `label` is a plaintext hint (peer address or name) carried as the payload
// so a fresh seed import can name the conversation the signal refers to.
ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
const std::string& selfZaddr,
const std::string& conversationId,
const std::string& op,
const std::string& label,
OutgoingChatMemos& out);
} // namespace dragonx::chat
+130
View File
@@ -81,6 +81,8 @@ const char* hushChatHeaderTypeName(HushChatHeaderType type)
return "Memo";
case HushChatHeaderType::ContactRequest:
return "Cont";
case HushChatHeaderType::Control:
return "Ctrl";
}
return "Unknown";
}
@@ -138,7 +140,15 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
header.type = HushChatHeaderType::Message;
} else if (type == "Cont") {
header.type = HushChatHeaderType::ContactRequest;
} else if (type == "Ctrl") {
header.type = HushChatHeaderType::Control;
// A control signal carries a non-empty op ("del"/"res"/...); the op VALUE isn't restricted here so
// a future op reaches newer clients and is ignored by the reducer on older ones (forward-compat).
if (!readRequiredString(object, "op", header.op, error)) return fail(error);
if (header.op.empty()) return fail("control op is empty");
} else {
// Unknown type (older client seeing a newer memo type): fail here so it is grouped as an invalid
// header and ignored, never crashing. Keep this the catch-all.
return fail("unknown HushChat header type");
}
@@ -151,6 +161,22 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
!isHexString(header.secretstream_header_hex)) {
return fail("message header must include a 24 byte secretstream header encoded as hex");
}
// Optional part count ("n"); absent/1 = classic single-payload message. Bound it so a garbage or
// future-oversized header can't make the grouper try to collect an unbounded number of payloads.
// A single-tx (tier 2) message chunks at most kHushChatMaxMessageParts; a multi-tx (tier 3)
// message carries the larger TOTAL chunk count here alongside a "mid", capped at the chunk ceiling.
if (auto it = object.find("n"); it != object.end() && it->is_number_integer()) {
const int n = it->get<int>();
if (n < 1 || n > kHushChatMaxMessageChunks) return fail("message part count out of range");
header.parts = n;
}
// Optional tier-3 message id ("mid"): present only when the message is split across several
// transactions, tying this tx's chunks to its siblings. Short hex (the sender stamps 8 bytes).
if (auto it = object.find("mid"); it != object.end() && it->is_string()) {
header.message_id = it->get<std::string>();
if (header.message_id.empty() || header.message_id.size() > 64 || !isHexString(header.message_id))
return fail("message id must be short hex");
}
} else if (!header.secretstream_header_hex.empty()) {
return fail("contact request header must not include a secretstream header");
}
@@ -179,6 +205,106 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMe
});
HushChatMemoGroupingResult result;
// --- Larger-messages tier 2: one message split across N payload outputs in a SINGLE tx. ---
// A lone Message header with part count ("n") > 1 means its ciphertext was chunked across N payload
// outputs, each prefixed with a 2-hex part index so order survives the daemon's output shuffle.
// Reassemble here and emit one pair. Only a clean one-header + exactly-N-chunks tx takes this path;
// anything else falls through to the classic single-pair logic. An older client lacks this branch,
// mis-pairs the first chunk, fails to decrypt, and drops it — graceful degradation.
{
std::optional<HushChatHeader> multiHeader;
std::size_t multiHeaderPos = 0;
int validHeaders = 0;
bool anyOversize = false;
std::vector<const HushChatMemoOutput*> payloads;
for (const auto& entry : ordered) {
const auto& o = entry.output;
if (o.memo.size() > kHushChatMemoByteLimit) { anyOversize = true; break; }
if (!o.memo.empty() && o.memo.front() == '{') {
auto parsed = parseHushChatHeaderMemo(o.memo);
if (parsed.ok) {
++validHeaders;
if (parsed.header.type == HushChatHeaderType::Message && parsed.header.parts > 1) {
multiHeader = std::move(parsed.header);
multiHeaderPos = o.position;
}
}
} else if (!o.memo.empty()) {
payloads.push_back(&o);
}
}
if (!anyOversize && validHeaders == 1 && multiHeader) {
const int want = multiHeader->parts;
auto hexVal = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
// --- Tier 3: this transaction carries only a SLICE of a larger message (message_id set) ---
// Surface its chunks with their GLOBAL indices as a partial; the chat service accumulates
// slices across the sibling txs that share this message_id and reassembles once all `parts`
// chunks are present. One tx holds at most kHushChatMaxMessageParts of them.
if (!multiHeader->message_id.empty()) {
HushChatMemoPair pair;
pair.header_position = multiHeaderPos;
pair.message_id = multiHeader->message_id;
pair.total_parts = want;
bool okp = true;
std::size_t lowPos = 0; bool haveLow = false;
for (const auto* p : payloads) {
const std::string& m = p->memo;
if (m.size() < kHushChatPartIndexHexLen || (m.size() % 2) != 0 || !isHexString(m)) { okp = false; break; }
const int idx = hexVal(m[0]) * 16 + hexVal(m[1]);
if (idx < 0 || idx >= want) { okp = false; break; }
pair.partial_chunks.emplace_back(idx, m.substr(kHushChatPartIndexHexLen));
if (!haveLow || p->position < lowPos) { lowPos = p->position; haveLow = true; }
}
if (okp && !pair.partial_chunks.empty()) {
pair.header = std::move(*multiHeader);
pair.payload_position = haveLow ? lowPos : 0;
result.pairs.push_back(std::move(pair));
return result;
}
result.issues.push_back(HushChatMemoGroupingIssueInfo{
HushChatMemoGroupingIssue::MissingPayload, multiHeaderPos,
"tier-3 message slice did not parse"});
return result;
}
std::vector<std::string> chunks(static_cast<std::size_t>(want));
std::vector<bool> seen(static_cast<std::size_t>(want), false);
bool ok = (static_cast<int>(payloads.size()) == want);
std::size_t lowPos = 0; bool haveLow = false;
for (const auto* p : payloads) {
if (!ok) break;
const std::string& m = p->memo;
if (m.size() < kHushChatPartIndexHexLen || (m.size() % 2) != 0 || !isHexString(m)) { ok = false; break; }
const int idx = hexVal(m[0]) * 16 + hexVal(m[1]);
if (idx < 0 || idx >= want || seen[static_cast<std::size_t>(idx)]) { ok = false; break; }
seen[static_cast<std::size_t>(idx)] = true;
chunks[static_cast<std::size_t>(idx)] = m.substr(kHushChatPartIndexHexLen);
if (!haveLow || p->position < lowPos) { lowPos = p->position; haveLow = true; }
}
if (ok) {
std::string full;
for (const auto& c : chunks) full += c;
if (!full.empty() && (full.size() % 2) == 0) {
HushChatMemoPair pair;
pair.header = std::move(*multiHeader);
pair.header_position = multiHeaderPos;
pair.payload_position = haveLow ? lowPos : 0;
pair.payload_memo = std::move(full); // the reassembled full ciphertext hex
result.pairs.push_back(std::move(pair));
return result;
}
}
result.issues.push_back(HushChatMemoGroupingIssueInfo{
HushChatMemoGroupingIssue::MissingPayload, multiHeaderPos,
"multi-part message did not reassemble"});
return result;
}
}
std::optional<HushChatMemoOutput> pending_header_output;
std::optional<HushChatHeader> pending_header;
// A payload memo seen BEFORE its header. The full-node daemon shuffles the two 0-value memo
@@ -299,6 +425,7 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
HushChatTransactionMetadata metadata;
metadata.txid = transaction.txid;
metadata.type = pair.header.type;
metadata.op = pair.header.op; // Control only; empty for Message/ContactRequest
metadata.conversation_id = pair.header.conversation_id;
metadata.reply_zaddr = pair.header.reply_zaddr;
metadata.header_position = pair.header_position;
@@ -308,6 +435,9 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
metadata.payload_memo = pair.payload_memo;
metadata.sent_at = pair.header.sent_at; // carry the sender's compose time (0 if absent)
metadata.message_id = pair.message_id; // tier 3: set when this tx is a slice of a bigger message
metadata.total_parts = pair.total_parts; // (payload_memo empty; chunks below)
metadata.partial_chunks = pair.partial_chunks;
result.metadata.push_back(std::move(metadata));
}
+40 -1
View File
@@ -1,7 +1,9 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#ifndef DRAGONX_ENABLE_CHAT
@@ -12,7 +14,8 @@ namespace dragonx::chat {
enum class HushChatHeaderType {
Message,
ContactRequest
ContactRequest,
Control // a self-addressed control signal (delete / restore / mark sensitive); carries an `op`
};
struct HushChatHeader {
@@ -21,8 +24,18 @@ struct HushChatHeader {
std::string reply_zaddr;
std::string conversation_id;
HushChatHeaderType type = HushChatHeaderType::Message;
std::string op; // Control only ("del" / "res" / "sensitive" / "unsensitive" / future ops); empty otherwise
std::string secretstream_header_hex;
std::string public_key_hex;
// Message only: how many payload outputs this message's ciphertext is split across (header "n").
// 1 = the classic single-payload message (field omitted on the wire). >1 means the payloads are
// index-prefixed ciphertext chunks reassembled in order. For tier 2 all N chunks ride ONE tx; for
// tier 3 (message_id set) "n" is the TOTAL chunk count spread across several sibling transactions.
int parts = 1;
// Message only, larger-messages tier 3: when a message needs more chunks than one tx can carry, it
// is split across several transactions that all share this random id (header "mid"); each tx's
// payloads carry their GLOBAL chunk index (0..parts-1). Empty = a single-tx message (tier 1/2).
std::string message_id;
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
std::int64_t sent_at = 0;
@@ -44,6 +57,12 @@ struct HushChatMemoPair {
std::size_t header_position = 0;
std::size_t payload_position = 0;
std::string payload_memo;
// Larger-messages tier 3: a single transaction that carries only PART of a bigger message (the rest
// ride sibling txs sharing message_id). payload_memo is then empty; partial_chunks holds this tx's
// (global index, ciphertext-hex chunk) pairs and total_parts is the whole message's chunk count.
std::string message_id;
int total_parts = 0;
std::vector<std::pair<int, std::string>> partial_chunks;
};
enum class HushChatMemoGroupingIssue {
@@ -73,6 +92,7 @@ struct HushChatTransactionInput {
struct HushChatTransactionMetadata {
std::string txid;
HushChatHeaderType type = HushChatHeaderType::Message;
std::string op; // Control only: the control op ("del"/"res"/...); empty otherwise
std::string conversation_id;
std::string reply_zaddr;
std::size_t header_position = 0;
@@ -84,6 +104,13 @@ struct HushChatTransactionMetadata {
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
std::int64_t sent_at = 0; // header "ts": sender compose time (Unix s); 0 = absent → use tx time
// Larger-messages tier 3 (a multi-transaction Message): message_id ties this tx's slice to its
// siblings, total_parts is the whole message's chunk count, and partial_chunks are this tx's
// (global index, ciphertext-hex) chunks. Empty message_id = an ordinary single-tx message (payload
// in payload_memo). The chat service accumulates partials by message_id and reassembles when whole.
std::string message_id;
int total_parts = 0;
std::vector<std::pair<int, std::string>> partial_chunks;
};
struct HushChatTransactionExtractionResult {
@@ -95,6 +122,18 @@ struct HushChatTransactionExtractionResult {
constexpr int kHushChatSupportedVersion = 0;
constexpr std::size_t kHushChatMemoByteLimit = 512;
// Larger-messages tier 2: the most payload outputs a single transaction may carry (one tx). Keeps the
// chat tx well under the shielded max_tx_size after Sietch decoy padding + proving cost. A message
// needing more chunks than this spills into sibling transactions (tier 3), up to kHushChatMaxMessageChunks.
constexpr int kHushChatMaxMessageParts = 16;
// Larger-messages tier 3: the most chunks a single message may span IN TOTAL, across all its sibling
// transactions (ceil(chunks / kHushChatMaxMessageParts) txs, each a separate 0-value chat send + fee).
// Bounds the on-chain cost / broadcast time of one message; beyond this compose returns TooLong. The
// 2-hex global part index (below) tops out at 256, so this must stay ≤ 256.
constexpr int kHushChatMaxMessageChunks = 64;
// Each multi-part payload chunk is prefixed with a 2-hex-char (1-byte) part index so the receiver can
// reorder chunks after the daemon shuffles a tx's outputs. 2 hex covers indices 0..255 (>> the cap).
constexpr std::size_t kHushChatPartIndexHexLen = 2;
constexpr std::size_t kHushChatPublicKeyHexLength = 64;
constexpr std::size_t kHushChatSecretstreamHeaderHexLength = 48;
+147
View File
@@ -0,0 +1,147 @@
// DragonX Wallet - HushChat sensitive-conversation local key vault (implementation).
#include "chat_sensitive_vault.h"
#include <sodium.h>
#include <cstdio>
#include <fstream>
#include <iterator>
#include <vector>
#if !defined(_WIN32)
#include <sys/stat.h>
#endif
namespace dragonx::chat {
namespace {
constexpr unsigned char kVersion = 0x01;
constexpr std::size_t kSaltLen = 16; // crypto_pwhash_SALTBYTES
constexpr std::size_t kNonceLen = 24; // crypto_secretbox_NONCEBYTES
constexpr std::size_t kKeyLen = 32; // the vault key / crypto_secretbox_KEYBYTES
constexpr std::size_t kMacLen = 16; // crypto_secretbox_MACBYTES
constexpr std::size_t kCipherLen = kKeyLen + kMacLen; // 48
constexpr std::size_t kFileLen = 1 + kSaltLen + kNonceLen + kCipherLen; // 89
} // namespace
ChatSensitiveVault::ChatSensitiveVault(std::string vaultPath) : path_(std::move(vaultPath)) {}
void ChatSensitiveVault::setPath(std::string vaultPath) { path_ = std::move(vaultPath); }
bool ChatSensitiveVault::deriveKek(const std::string& passphrase, const unsigned char* salt,
unsigned char* kek) const {
// Fixed MODERATE limits (≈0.7s / 256 MiB) resist brute-forcing a weak passphrase; because they are
// fixed, the file carries no KDF parameters and unlock derives with exactly the same cost.
return crypto_pwhash(kek, kKeyLen, passphrase.c_str(), passphrase.size(), salt,
crypto_pwhash_OPSLIMIT_MODERATE, crypto_pwhash_MEMLIMIT_MODERATE,
crypto_pwhash_ALG_ARGON2ID13) == 0;
}
bool ChatSensitiveVault::writeVault(const unsigned char* salt, const unsigned char* nonce,
const unsigned char* cipher, std::size_t cipherLen) const {
if (path_.empty()) return false;
const std::string tmp = path_ + ".tmp";
{
std::ofstream f(tmp, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.put(static_cast<char>(kVersion));
f.write(reinterpret_cast<const char*>(salt), kSaltLen);
f.write(reinterpret_cast<const char*>(nonce), kNonceLen);
f.write(reinterpret_cast<const char*>(cipher), static_cast<std::streamsize>(cipherLen));
if (!f) { f.close(); std::remove(tmp.c_str()); return false; }
}
#if !defined(_WIN32)
::chmod(tmp.c_str(), 0600); // best-effort: the key is passphrase-wrapped, so this is defense-in-depth
#endif
if (std::rename(tmp.c_str(), path_.c_str()) != 0) { std::remove(tmp.c_str()); return false; }
return true;
}
bool ChatSensitiveVault::exists() const {
if (path_.empty()) return false;
std::ifstream f(path_, std::ios::binary);
if (!f) return false;
char ver = 0;
if (!f.get(ver) || static_cast<unsigned char>(ver) != kVersion) return false;
f.seekg(0, std::ios::end);
return f.good() && static_cast<std::size_t>(f.tellg()) == kFileLen;
}
bool ChatSensitiveVault::create(const std::string& passphrase, std::array<unsigned char, 32>& keyOut) {
keyOut.fill(0);
if (path_.empty() || passphrase.empty()) return false;
if (exists()) return false; // never clobber an existing vault
if (sodium_init() < 0) return false;
unsigned char salt[kSaltLen], nonce[kNonceLen], kek[kKeyLen];
unsigned char cipher[kCipherLen];
randombytes_buf(salt, kSaltLen);
randombytes_buf(nonce, kNonceLen);
randombytes_buf(keyOut.data(), kKeyLen); // the random vault key
bool ok = deriveKek(passphrase, salt, kek);
if (ok) {
crypto_secretbox_easy(cipher, keyOut.data(), kKeyLen, nonce, kek);
ok = writeVault(salt, nonce, cipher, kCipherLen);
}
sodium_memzero(kek, sizeof kek);
sodium_memzero(cipher, sizeof cipher);
if (!ok) keyOut.fill(0);
return ok;
}
bool ChatSensitiveVault::unlock(const std::string& passphrase, std::array<unsigned char, 32>& keyOut) const {
keyOut.fill(0);
if (path_.empty() || passphrase.empty()) return false;
if (sodium_init() < 0) return false;
std::ifstream f(path_, std::ios::binary);
if (!f) return false;
std::vector<unsigned char> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (buf.size() != kFileLen || buf[0] != kVersion) return false;
const unsigned char* salt = buf.data() + 1;
const unsigned char* nonce = salt + kSaltLen;
const unsigned char* cipher = nonce + kNonceLen;
unsigned char kek[kKeyLen];
if (!deriveKek(passphrase, salt, kek)) { sodium_memzero(kek, sizeof kek); return false; }
int rc = crypto_secretbox_open_easy(keyOut.data(), cipher, kCipherLen, nonce, kek);
sodium_memzero(kek, sizeof kek);
if (rc != 0) { keyOut.fill(0); return false; } // wrong passphrase or corrupt file
return true;
}
bool ChatSensitiveVault::changePassphrase(const std::string& oldPassphrase, const std::string& newPassphrase) {
if (newPassphrase.empty()) return false;
std::array<unsigned char, 32> key{};
if (!unlock(oldPassphrase, key)) return false; // wrong old passphrase
unsigned char salt[kSaltLen], nonce[kNonceLen], kek[kKeyLen], cipher[kCipherLen];
randombytes_buf(salt, kSaltLen);
randombytes_buf(nonce, kNonceLen);
bool ok = deriveKek(newPassphrase, salt, kek);
if (ok) {
crypto_secretbox_easy(cipher, key.data(), kKeyLen, nonce, kek);
ok = writeVault(salt, nonce, cipher, kCipherLen);
}
sodium_memzero(kek, sizeof kek);
sodium_memzero(cipher, sizeof cipher);
sodium_memzero(key.data(), key.size());
return ok;
}
bool ChatSensitiveVault::remove() {
if (path_.empty()) return true;
std::error_code ec;
(void)ec;
if (std::remove(path_.c_str()) != 0) {
// Treat "already absent" as success; anything else is a real failure only if the file is still there.
std::ifstream f(path_, std::ios::binary);
return !f.good();
}
return true;
}
} // namespace dragonx::chat
+59
View File
@@ -0,0 +1,59 @@
#pragma once
// DragonX Wallet - HushChat "sensitive conversation" local key vault.
//
// A per-wallet random 32-byte key, wrapped under a USER PASSPHRASE (Argon2id-derived KEK +
// XSalsa20-Poly1305), stored in a small local file. It encrypts the at-rest rows of conversations the
// user marks "sensitive" in ChatDatabase, INDEPENDENTLY of the seed-derived chat key — so those rows
// cannot be read by someone who has the wallet seed but not this passphrase, and a wallet freshly
// restored from the seed alone cannot read them at all. Losing the passphrase (or the file) makes the
// sensitive messages unrecoverable BY DESIGN. Not thread-safe; drive from the main thread.
//
// File format (version 1), 89 bytes:
// [1] version (0x01)
// [16] Argon2id salt
// [24] XSalsa20-Poly1305 nonce
// [48] ciphertext = 32-byte vault key + 16-byte Poly1305 tag
// Argon2id uses MODERATE ops/mem limits (fixed), so the format needs to carry no KDF parameters.
#include <array>
#include <cstddef>
#include <string>
namespace dragonx::chat {
class ChatSensitiveVault {
public:
ChatSensitiveVault() = default;
explicit ChatSensitiveVault(std::string vaultPath);
void setPath(std::string vaultPath);
const std::string& path() const { return path_; }
// True if a well-formed vault file exists (version byte + exact size). No passphrase / Argon2.
bool exists() const;
// Create a NEW vault: generate a random 32-byte key, wrap it under `passphrase`, write the file, and
// return the key in keyOut. Refuses (returns false) if a vault already exists, the passphrase is
// empty, or on IO/crypto error. keyOut is zeroed on any failure.
bool create(const std::string& passphrase, std::array<unsigned char, 32>& keyOut);
// Unlock: derive the KEK from `passphrase`, decrypt the stored key into keyOut. Returns false on a
// wrong passphrase, a missing/corrupt file, or crypto failure; keyOut is zeroed unless it succeeds.
bool unlock(const std::string& passphrase, std::array<unsigned char, 32>& keyOut) const;
// Re-wrap the existing key under a new passphrase (the key is unchanged). False if old is wrong.
bool changePassphrase(const std::string& oldPassphrase, const std::string& newPassphrase);
// Delete the vault file. Returns true if removed or already absent.
bool remove();
private:
bool deriveKek(const std::string& passphrase, const unsigned char* salt, unsigned char* kek) const;
bool writeVault(const unsigned char* salt, const unsigned char* nonce,
const unsigned char* cipher, std::size_t cipherLen) const;
std::string path_;
};
} // namespace dragonx::chat
+179 -14
View File
@@ -39,15 +39,14 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
int added = 0;
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
for (const auto& meta : metadata) {
// Build, decrypt (Message) and store one piece of message metadata — a single-transaction message,
// or a tier-3 message reassembled from its slices below. Shared so both apply the same guards exactly.
auto processMessage = [&](const HushChatTransactionMetadata& meta) {
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
// decrypt for all but the new messages.
if (store_.contains(meta.txid, meta.payload_position)) continue;
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
// "from peer" message. (This also collapses same-seed self-chat, where the "peer" wallet shares
// our identity, so every message would otherwise loop back.)
if (!myPubKey.empty() && meta.sender_public_key_hex == myPubKey) continue;
// decrypt for all but the new messages. For a reassembled tier-3 message this keys off its stable
// canonical txid, deduping it across scans.
if (store_.contains(meta.txid, meta.payload_position)) return;
ChatMessage message;
message.direction = ChatDirection::Incoming;
@@ -76,8 +75,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
// is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
// deleted with "revive on new message", so only that exact message is skipped — a new message
// in the same conversation has a different txid and flows through normally.
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
if (db_ && db_->isTombstoned(message)) continue;
if (blocked_pred_ && blocked_pred_(message.conversation_id)) return;
if (conversationHiddenOnChain(message.conversation_id)) return; // hidden by an on-chain delete
// Withhold a sensitive conversation's messages while its local vault key is absent (locked vault or
// a fresh seed import). Fail-closed: if we can't store it under the vault key we don't surface it.
// They re-import and persist under the vault key once it is unlocked and a scan runs.
if (isConversationSensitive(message.conversation_id) && !(db_ && db_->hasSensitiveKey())) return;
if (db_ && db_->isTombstoned(message)) return;
if (meta.type == HushChatHeaderType::ContactRequest) {
message.kind = ChatMessageKind::ContactRequest;
@@ -88,7 +92,7 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
// different header with this payload gets its own try.
const std::string failKey = ChatStore::messageKey(meta.txid, meta.payload_position) + "|" +
meta.sender_public_key_hex + "|" + meta.secretstream_header_hex;
if (undecryptable_.count(failKey)) continue;
if (undecryptable_.count(failKey)) return;
++decrypt_attempts_;
std::string plaintext;
const ChatCryptoStatus status = decryptIncoming(
@@ -99,7 +103,7 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
if (undecryptable_.size() >= 4096) undecryptable_.clear();
undecryptable_.insert(failKey);
}
continue;
return;
}
message.body = std::move(plaintext);
}
@@ -114,8 +118,92 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
}
};
for (const auto& meta : metadata) {
// A self-addressed control signal (delete / restore / ...). Reduce it and move on: it is never a
// message, and it must be processed even though WE are its sender — unlike ordinary self-sent memos,
// which the guard below skips. The label (peer hint) rides the payload.
if (meta.type == HushChatHeaderType::Control) {
const auto ctlTimeIt = txTimestamps.find(meta.txid);
const std::int64_t ctlRef = ctlTimeIt != txTimestamps.end() ? ctlTimeIt->second : fallbackTimestamp;
applyControl(meta.conversation_id, meta.op, meta.payload_memo,
meta.sent_at > 0 ? meta.sent_at : ctlRef, meta.txid);
continue;
}
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
// "from peer" message (this also collapses same-seed self-chat). Skips our own tier-3 slices too.
if (!myPubKey.empty() && meta.sender_public_key_hex == myPubKey) continue;
// Larger-messages tier 3: a slice of a multi-transaction message. Accumulate it by message id;
// the whole message is reassembled below once every chunk has arrived (across txs and scans).
if (!meta.message_id.empty()) {
if (meta.total_parts <= 0 || meta.total_parts > kHushChatMaxMessageChunks) continue; // sanity
// Bound the buffer against a flood of never-completing partials (re-fed from chain, so a clear
// only delays, never loses, a genuine message).
if (tier3_pending_.size() > 256 && !tier3_pending_.count(meta.message_id)) tier3_pending_.clear();
Tier3Pending& g = tier3_pending_[meta.message_id];
if (g.chunks.empty()) { // first slice seen: capture the shared header fields
g.total_parts = meta.total_parts;
g.secretstream_header_hex = meta.secretstream_header_hex;
g.sender_public_key_hex = meta.sender_public_key_hex;
g.conversation_id = meta.conversation_id;
g.reply_zaddr = meta.reply_zaddr;
g.sent_at = meta.sent_at;
g.canonical_txid = meta.txid;
}
if (meta.txid < g.canonical_txid) g.canonical_txid = meta.txid; // deterministic stable key
for (const auto& c : meta.partial_chunks)
if (c.first >= 0 && c.first < g.total_parts) g.chunks[c.first] = c.second;
continue;
}
processMessage(meta);
}
// Finalize every tier-3 message whose chunks have all arrived: reassemble the one ciphertext and run
// it through the shared message path (decrypt + the block/hide/sensitive guards). Retire the group
// afterwards unless it was withheld only because the sensitive vault is still locked.
{
std::vector<std::string> retire;
for (auto& entry : tier3_pending_) {
Tier3Pending& g = entry.second;
if (g.total_parts <= 0 || static_cast<int>(g.chunks.size()) != g.total_parts) continue;
bool complete = true;
for (int i = 0; i < g.total_parts; ++i) if (!g.chunks.count(i)) { complete = false; break; }
if (!complete) continue;
if (store_.contains(g.canonical_txid, 0)) { retire.push_back(entry.first); continue; } // already have it
HushChatTransactionMetadata synth;
synth.type = HushChatHeaderType::Message;
synth.txid = g.canonical_txid;
synth.payload_position = 0;
synth.conversation_id = g.conversation_id;
synth.reply_zaddr = g.reply_zaddr;
synth.sender_public_key_hex = g.sender_public_key_hex;
synth.secretstream_header_hex = g.secretstream_header_hex;
synth.sent_at = g.sent_at;
std::string full;
for (int i = 0; i < g.total_parts; ++i) full += g.chunks[i];
synth.payload_memo = std::move(full);
processMessage(synth);
const bool withheldSensitive =
isConversationSensitive(g.conversation_id) && !(db_ && db_->hasSensitiveKey());
if (!withheldSensitive) retire.push_back(entry.first);
}
for (const auto& mid : retire) tier3_pending_.erase(mid);
}
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
// Enforce on-chain deletions against the store view: erase any conversation whose latest control op is
// "del". This catches rows appended earlier in this very batch (before their delete signal was seen in
// the metadata order) and rows rehydrated from the DB at startup before any control was reduced. The DB
// rows are left intact so a later "res" — or a rescan — can restore the thread.
for (const auto& entry : control_) {
if (entry.second.op == "del") store_.eraseConversation(entry.first);
}
return added;
}
@@ -146,6 +234,72 @@ bool ChatService::deleteConversation(const std::string& conversationId, bool blo
return true;
}
void ChatService::applyControl(const std::string& cid, const std::string& op, const std::string& label,
std::int64_t timestamp, const std::string& txid) {
// Route the op to its axis; ignore unknown future ops so they never pollute either reduction.
std::unordered_map<std::string, ControlState>* target = nullptr;
if (op == "del" || op == "res") target = &control_;
else if (op == "sensitive" || op == "unsensitive") target = &sensitive_control_;
else return;
auto it = target->find(cid);
if (it != target->end()) {
const ControlState& cur = it->second;
// A later signal wins; equal timestamps break by txid so the reduction is deterministic and a
// replay of an older op can never flip the resolved state.
if (timestamp < cur.timestamp || (timestamp == cur.timestamp && txid <= cur.txid)) return;
it->second = ControlState{op, label, timestamp, txid};
} else {
target->emplace(cid, ControlState{op, label, timestamp, txid});
}
}
bool ChatService::conversationHiddenOnChain(const std::string& conversationId) const {
auto it = control_.find(conversationId);
return it != control_.end() && it->second.op == "del";
}
std::vector<std::pair<std::string, std::string>> ChatService::onChainDeletedConversations() const {
std::vector<std::pair<std::string, std::string>> out;
for (const auto& entry : control_) {
if (entry.second.op == "del") out.emplace_back(entry.first, entry.second.label);
}
return out;
}
bool ChatService::conversationSensitiveOnChain(const std::string& conversationId) const {
auto it = sensitive_control_.find(conversationId);
return it != sensitive_control_.end() && it->second.op == "sensitive";
}
bool ChatService::isConversationSensitive(const std::string& conversationId) const {
if (conversationSensitiveOnChain(conversationId)) return true;
return local_sensitive_pred_ && local_sensitive_pred_(conversationId);
}
std::vector<ChatService::Tier3Progress> ChatService::pendingLargeMessages() const {
std::vector<Tier3Progress> out;
for (const auto& entry : tier3_pending_) {
const Tier3Pending& g = entry.second;
const int received = static_cast<int>(g.chunks.size());
// Report only the genuinely in-progress ones. A complete group waiting on the sensitive vault key
// (received == total but withheld) is done receiving, so it isn't shown as "incoming".
if (g.total_parts > 0 && received < g.total_parts)
out.push_back(Tier3Progress{ g.conversation_id, received, g.total_parts });
}
return out;
}
void ChatService::setPersistence(ChatDatabase* db) {
db_ = db;
// The DB picks the per-row key by this predicate: a sensitive conversation's rows are stored under the
// local vault key. Combines the on-chain reduction with the local (instant) mark.
if (db_) db_->setSensitivePredicate([this](const std::string& cid) { return isConversationSensitive(cid); });
}
std::vector<std::pair<std::string, std::string>> ChatService::onChainSensitiveConversations() const {
std::vector<std::pair<std::string, std::string>> out;
for (const auto& entry : sensitive_control_) {
if (entry.second.op == "sensitive") out.emplace_back(entry.first, entry.second.label);
}
return out;
}
std::string ChatService::identityPublicKeyHex() const {
if (!has_identity_) return {};
return chatIdentityPublicKeyHex(identity_);
@@ -162,6 +316,17 @@ ChatComposeStatus ChatService::composeMessage(const std::string& myReplyZaddr,
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
}
ChatComposeStatus ChatService::composeMessageParts(const std::string& myReplyZaddr,
const std::string& peerPublicKeyHex,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& plaintext,
std::vector<OutgoingChatMemos>& out) const {
if (!has_identity_) return ChatComposeStatus::MissingField;
return splitOutgoingMessage(identity_, chatIdentityPublicKeyHex(identity_), myReplyZaddr,
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
}
ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr,
const std::string& peerZaddr,
const std::string& conversationId,
@@ -189,8 +354,8 @@ bool ChatService::recordOutgoingPending(const ChatMessage& message) {
return appended;
}
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) {
const ChatMessage* updated = store_.updateDelivery(txid, delivery);
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery, const std::string& reason) {
const ChatMessage* updated = store_.updateDelivery(txid, delivery, reason);
if (updated && db_) db_->upsert(*updated); // overwrite the Sending row with the final status
}
+82 -2
View File
@@ -15,6 +15,7 @@
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
namespace dragonx::chat {
@@ -52,7 +53,8 @@ public:
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
// from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it.
void setPersistence(ChatDatabase* db) { db_ = db; }
// Also wires the DB's per-conversation sensitivity predicate to isConversationSensitive().
void setPersistence(ChatDatabase* db);
// Load previously-persisted messages (already decrypted at ingest, re-encrypted at rest under
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
void loadFromDatabase();
@@ -70,6 +72,44 @@ public:
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
bool deleteConversation(const std::string& conversationId, bool block);
// On-chain control signals (self-addressed Control memos) reduced per conversation: the latest op,
// by sender compose time with a txid tiebreak, wins. A conversation whose latest op is "del" is hidden
// on-chain — ingest() drops its messages and erases it from the store view, on every device and on a
// fresh seed import — until a later "res" restores it. The state is re-derived from chain each session
// (the control memos re-harvest on every scan), so it needs no local persistence.
bool conversationHiddenOnChain(const std::string& conversationId) const;
// Conversations whose latest on-chain control op is "del" (for the restore picker), each paired with
// the label carried in its control memo (the peer address / display name at delete time).
std::vector<std::pair<std::string, std::string>> onChainDeletedConversations() const;
// Sensitive axis (independent of hide/restore): a conversation whose latest on-chain control op is
// "sensitive" is marked sensitive — its local rows are stored under the local vault key and withheld
// on a device without that key (including a fresh seed import), until "unsensitive". Re-derived from
// chain each session, like the hide axis.
bool conversationSensitiveOnChain(const std::string& conversationId) const;
// Conversations whose latest on-chain control op is "sensitive", each paired with its control label.
std::vector<std::pair<std::string, std::string>> onChainSensitiveConversations() const;
// A local "sensitive" predicate (typically Settings::isChatSensitive) giving INSTANT effect the moment
// the user marks a conversation, before the on-chain signal is harvested back. OR-combined with the
// on-chain reduction by isConversationSensitive().
void setLocalSensitivePredicate(std::function<bool(const std::string&)> pred) {
local_sensitive_pred_ = std::move(pred);
}
// True if this conversation is sensitive (locally marked OR on-chain). Its rows are stored under the
// local vault key; while that key is absent its messages are withheld from the store on ingest/load.
bool isConversationSensitive(const std::string& conversationId) const;
// Larger-messages tier 3: a message still being received across transactions (some chunks arrived,
// not yet all). Lets the UI show an "incoming large message — N of M parts" hint until it completes
// and surfaces as an ordinary message. One entry per in-progress message; empty when none.
struct Tier3Progress {
std::string conversation_id;
int received = 0; // chunks gathered so far
int total = 0; // chunks the whole message needs
};
std::vector<Tier3Progress> pendingLargeMessages() const;
// --- Outgoing (compose) ---
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
std::string identityPublicKeyHex() const;
@@ -80,6 +120,15 @@ public:
const std::string& conversationId,
const std::string& plaintext,
OutgoingChatMemos& out) const;
// Construct the outgoing memos for an ENCRYPTED message, splitting it across as many transactions as
// its length needs (larger-messages tier 3). Returns one OutgoingChatMemos per transaction to send in
// order; a short message yields a single element (identical to composeMessage).
ChatComposeStatus composeMessageParts(const std::string& myReplyZaddr,
const std::string& peerPublicKeyHex,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& plaintext,
std::vector<OutgoingChatMemos>& out) const;
// Construct the outgoing memos for a plaintext contact request (no peer key needed yet).
ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr,
const std::string& peerZaddr,
@@ -95,7 +144,7 @@ public:
// quit), then resolveOutgoing() upserts the final status once the broadcast completes. A stray
// persisted Sending (crash mid-broadcast) loads back as Sent.
bool recordOutgoingPending(const ChatMessage& message);
void resolveOutgoing(const std::string& txid, ChatDelivery delivery);
void resolveOutgoing(const std::string& txid, ChatDelivery delivery, const std::string& reason = {});
const ChatStore& store() const { return store_; }
ChatStore& store() { return store_; }
@@ -112,6 +161,37 @@ private:
ChatStore store_;
ChatDatabase* db_ = nullptr; // optional; not owned
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
std::function<bool(const std::string&)> local_sensitive_pred_; // true => cid locally marked sensitive
// Reduced on-chain control state, cid -> latest signal. Not persisted (re-derived from chain).
struct ControlState {
std::string op; // "del" / "res" / future ops
std::string label; // the control memo's payload hint (peer address / display name)
std::int64_t timestamp = 0;
std::string txid;
};
std::unordered_map<std::string, ControlState> control_; // hide axis: del / res
std::unordered_map<std::string, ControlState> sensitive_control_; // sensitive axis: sensitive / unsensitive
// Larger-messages tier 3: a big message split across several transactions arrives one slice at a
// time (siblings share a message id). We accumulate the slices here until every chunk is present,
// then reassemble + decrypt + emit the whole message. Not persisted — a rescan re-sends every slice
// so the buffer re-derives from chain (like the control reducer); a completed group is retired.
struct Tier3Pending {
int total_parts = 0;
std::string secretstream_header_hex; // "e", shared by every slice
std::string sender_public_key_hex; // "p", shared
std::string conversation_id;
std::string reply_zaddr;
std::int64_t sent_at = 0;
std::string canonical_txid; // smallest sibling txid — the reassembled message's stable key
std::unordered_map<int, std::string> chunks; // global index -> ciphertext-hex chunk
};
std::unordered_map<std::string, Tier3Pending> tier3_pending_; // message id -> accumulating slices
// Monotonic reduce: route the op to its axis (hide vs sensitive), ignore unknown ops (forward-compat),
// and within an axis a later (timestamp, txid) wins so a replay of an older op never flips the state.
void applyControl(const std::string& cid, const std::string& op, const std::string& label,
std::int64_t timestamp, const std::string& txid);
};
} // namespace dragonx::chat
+8 -1
View File
@@ -3,6 +3,7 @@
#include "chat_store.h"
#include <algorithm>
#include <sodium.h>
namespace dragonx::chat {
@@ -61,10 +62,12 @@ std::vector<ChatMessage> ChatStore::conversation(const std::string& conversation
return out;
}
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) {
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery,
const std::string& reason) {
for (auto& message : messages_) {
if (message.txid == txid) {
message.delivery = delivery;
message.fail_reason = (delivery == ChatDelivery::Failed) ? reason : std::string();
++revision_;
return &message;
}
@@ -110,6 +113,10 @@ std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& convers
}
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();
seen_.clear();
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
+2 -1
View File
@@ -40,7 +40,8 @@ public:
// Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated
// message (for the caller to persist), or nullptr if no message has that txid.
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery);
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery,
const std::string& reason = {});
// Distinct conversation ids, in first-seen order.
std::vector<std::string> conversationIds() const;
+47
View File
@@ -153,6 +153,7 @@ bool Settings::load(const std::string& path)
loadScalar(j, "theme", theme_);
loadScalar(j, "save_ztxs", save_ztxs_);
loadScalar(j, "auto_shield", auto_shield_);
loadScalar(j, "auto_consolidate", auto_consolidate_);
loadScalar(j, "use_tor", use_tor_);
loadScalar(j, "allow_custom_fees", allow_custom_fees_);
loadScalar(j, "default_fee", default_fee_);
@@ -180,6 +181,14 @@ bool Settings::load(const std::string& path)
(c.contains("name") && c["name"].is_string())
? c["name"].get<std::string>() : std::string()});
}
if (j.contains("sensitive_chat_convs") && j["sensitive_chat_convs"].is_array()) {
sensitive_chat_convs_.clear();
for (const auto& c : j["sensitive_chat_convs"])
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
sensitive_chat_convs_.push_back({c["cid"].get<std::string>(),
(c.contains("name") && c["name"].is_string())
? c["name"].get<std::string>() : std::string()});
}
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
@@ -204,6 +213,8 @@ bool Settings::load(const std::string& path)
ui_opacity_ = 1.0f; // legacy: reduced = fully opaque
}
loadScalar(j, "window_opacity", window_opacity_);
loadScalar(j, "favorite_opacity", favorite_opacity_);
favorite_opacity_ = std::clamp(favorite_opacity_, 0.15f, 1.0f); // matches setFavoriteOpacity()
if (j.contains("balance_layout")) {
if (j["balance_layout"].is_string())
balance_layout_ = j["balance_layout"].get<std::string>();
@@ -240,6 +251,18 @@ bool Settings::load(const std::string& path)
for (const auto& a : j["favorite_addresses"])
if (a.is_string()) favorite_addresses_.insert(a.get<std::string>());
}
if (j.contains("earnings_addresses") && j["earnings_addresses"].is_array()) {
earnings_addresses_.clear();
for (const auto& a : j["earnings_addresses"])
if (a.is_string()) earnings_addresses_.insert(a.get<std::string>());
}
loadScalar(j, "earnings_interval", earnings_interval_);
earnings_interval_ = std::clamp(earnings_interval_, 0, 3);
loadScalar(j, "earnings_mode", earnings_mode_);
earnings_mode_ = std::clamp(earnings_mode_, 0, 1);
loadScalar(j, "earnings_format", earnings_format_);
earnings_format_ = std::clamp(earnings_format_, 0, 2);
loadScalar(j, "earnings_show_all", earnings_show_all_);
if (j.contains("address_meta") && j["address_meta"].is_object()) {
address_meta_.clear();
for (auto& [addr, meta] : j["address_meta"].items()) {
@@ -257,6 +280,10 @@ bool Settings::load(const std::string& path)
}
loadScalar(j, "wizard_completed", wizard_completed_);
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, "lockout_until_epoch", lockout_until_epoch_);
loadScalar(j, "lock_state_wallet", lock_state_wallet_);
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
empty_wallet_warning_acked_.clear();
@@ -504,6 +531,7 @@ bool Settings::save(const std::string& path)
j["theme"] = theme_;
j["save_ztxs"] = save_ztxs_;
j["auto_shield"] = auto_shield_;
j["auto_consolidate"] = auto_consolidate_;
j["use_tor"] = use_tor_;
j["allow_custom_fees"] = allow_custom_fees_;
j["default_fee"] = default_fee_;
@@ -526,6 +554,13 @@ bool Settings::save(const std::string& path)
o["name"] = b.name;
j["blocked_chat_convs"].push_back(o);
}
j["sensitive_chat_convs"] = json::array();
for (const auto& s : sensitive_chat_convs_) {
json o;
o["cid"] = s.cid;
o["name"] = s.name;
j["sensitive_chat_convs"].push_back(o);
}
j["chat_emoji_color"] = chat_emoji_color_;
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
j["chat_bubble_style"] = chat_bubble_style_;
@@ -542,6 +577,7 @@ bool Settings::save(const std::string& path)
j["gradient_background"] = gradient_background_;
j["ui_opacity"] = ui_opacity_;
j["window_opacity"] = window_opacity_;
j["favorite_opacity"] = favorite_opacity_;
j["balance_layout"] = balance_layout_; // saved as string ID
j["portfolio_style"] = portfolio_style_;
j["contacts_view_mode"] = contacts_view_mode_;
@@ -559,6 +595,13 @@ bool Settings::save(const std::string& path)
j["favorite_addresses"] = json::array();
for (const auto& addr : favorite_addresses_)
j["favorite_addresses"].push_back(addr);
j["earnings_addresses"] = json::array();
for (const auto& addr : earnings_addresses_)
j["earnings_addresses"].push_back(addr);
j["earnings_interval"] = earnings_interval_;
j["earnings_mode"] = earnings_mode_;
j["earnings_format"] = earnings_format_;
j["earnings_show_all"] = earnings_show_all_;
{
json meta_obj = json::object();
for (const auto& [addr, m] : address_meta_) {
@@ -574,6 +617,10 @@ bool Settings::save(const std::string& path)
}
j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_;
j["chat_intro_dismissed"] = chat_intro_dismissed_;
j["lock_failed_attempts"] = lock_failed_attempts_;
j["lockout_until_epoch"] = lockout_until_epoch_;
j["lock_state_wallet"] = lock_state_wallet_;
j["large_wallet_warned"] = large_wallet_warned_;
j["empty_wallet_warning_acked"] = json::array();
for (const auto& w : empty_wallet_warning_acked_)
+70
View File
@@ -182,6 +182,26 @@ public:
}
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
// Sensitive chat conversations (by cid): their local rows are stored under the local chat vault key
// (not the seed key) and are withheld without it. Kept locally for INSTANT effect the moment the user
// marks one; the mark is also signaled on-chain so other devices / a fresh seed import gate it too.
// The last-known peer name labels the sensitive-conversations list.
struct SensitiveChatConv { std::string cid; std::string name; };
bool isChatSensitive(const std::string& cid) const {
for (const auto& s : sensitive_chat_convs_) if (s.cid == cid) return true;
return false;
}
void setChatSensitive(const std::string& cid, const std::string& name, bool sensitive) {
const bool already = isChatSensitive(cid);
if (sensitive && !already) sensitive_chat_convs_.push_back({cid, name});
else if (!sensitive && already)
sensitive_chat_convs_.erase(
std::remove_if(sensitive_chat_convs_.begin(), sensitive_chat_convs_.end(),
[&](const SensitiveChatConv& s) { return s.cid == cid; }),
sensitive_chat_convs_.end());
}
const std::vector<SensitiveChatConv>& sensitiveChatConversations() const { return sensitive_chat_convs_; }
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
bool getChatEmojiColor() const { return chat_emoji_color_; }
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
@@ -209,6 +229,12 @@ public:
bool getAutoShield() const { return auto_shield_; }
void setAutoShield(bool shield) { auto_shield_ = shield; }
// Automatic background note consolidation (full-node): when on, the wallet quietly merges small notes
// in tiny batches while idle (see App::pumpAutoConsolidation). Each address's notes are merged back into
// THAT SAME address (z_mergetoaddress [addr] -> addr), so funds never move between addresses. On by
// default — a missing key in an older config therefore enables it on upgrade, which is intended.
bool getAutoConsolidate() const { return auto_consolidate_; }
void setAutoConsolidate(bool v) { auto_consolidate_ = v; }
bool getUseTor() const { return use_tor_; }
void setUseTor(bool tor) { use_tor_ = tor; }
@@ -261,6 +287,11 @@ public:
float getNoiseOpacity() const { return noise_opacity_; }
void setNoiseOpacity(float v) { noise_opacity_ = v; }
// Opacity multiplier for the favorite gold visuals (row outline + star button) in the
// Balance address list. 1.0 = theme default (unchanged); floor 0.15 so it can't vanish.
float getFavoriteOpacity() const { return favorite_opacity_; }
void setFavoriteOpacity(float v) { favorite_opacity_ = std::max(0.15f, std::min(1.0f, v)); }
// Gradient background mode (use gradient variant of theme background)
bool getGradientBackground() const { return gradient_background_; }
void setGradientBackground(bool v) { gradient_background_ = v; }
@@ -316,6 +347,22 @@ public:
void unfavoriteAddress(const std::string& addr) { favorite_addresses_.erase(addr); }
int getFavoriteAddressCount() const { return (int)favorite_addresses_.size(); }
// Inline period-earnings (Balance tab). interval: 0=Hour 1=Day 2=Month 3=Year; mode: 0=Inflows
// 1=Net; format: 0=DRGX 1=DRGX+fiat 2=Fiat. show-all toggles the per-address earnings column for
// every address at once; earnings_addresses_ is the opt-in set shown when show-all is off.
int getEarningsInterval() const { return earnings_interval_; }
void setEarningsInterval(int v) { earnings_interval_ = (v < 0 ? 0 : (v > 3 ? 3 : v)); }
int getEarningsMode() const { return earnings_mode_; }
void setEarningsMode(int v) { earnings_mode_ = (v < 0 ? 0 : (v > 1 ? 1 : v)); }
int getEarningsFormat() const { return earnings_format_; }
void setEarningsFormat(int v) { earnings_format_ = (v < 0 ? 0 : (v > 2 ? 2 : v)); }
bool getEarningsShowAll() const { return earnings_show_all_; }
void setEarningsShowAll(bool v) { earnings_show_all_ = v; }
const std::set<std::string>& getEarningsAddresses() const { return earnings_addresses_; }
bool isAddressEarningsShown(const std::string& addr) const { return earnings_addresses_.count(addr) > 0; }
void showAddressEarnings(const std::string& addr) { earnings_addresses_.insert(addr); }
void hideAddressEarnings(const std::string& addr) { earnings_addresses_.erase(addr); }
// Address metadata (labels, icons, custom ordering)
struct AddressMeta {
std::string label;
@@ -371,6 +418,17 @@ public:
// Whether the one-time "back up your seed phrase" reminder has already been shown.
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
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
// killing the app. Keyed by the wallet file it applies to; the PIN/passphrase is never stored.
int getLockFailedAttempts() const { return lock_failed_attempts_; }
void setLockFailedAttempts(int v) { lock_failed_attempts_ = v; durable_next_save_ = true; }
long long getLockoutUntilEpoch() const { return lockout_until_epoch_; }
void setLockoutUntilEpoch(long long v) { lockout_until_epoch_ = v; durable_next_save_ = true; }
std::string getLockStateWallet() const { return lock_state_wallet_; }
void setLockStateWallet(const std::string& v) { lock_state_wallet_ = v; durable_next_save_ = true; }
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
bool getLargeWalletWarned() const { return large_wallet_warned_; }
@@ -662,6 +720,7 @@ private:
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
std::vector<SensitiveChatConv> sensitive_chat_convs_; // sensitive chat conversations (cid + last-known name)
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
@@ -674,6 +733,7 @@ private:
int time_format_ = 0; // global clock: 0 = 24h, 1 = 12h
bool save_ztxs_ = true;
bool auto_shield_ = true;
bool auto_consolidate_ = true;
bool use_tor_ = false;
bool allow_custom_fees_ = false;
double default_fee_ = 0.0001;
@@ -685,6 +745,7 @@ private:
int acrylic_quality_ = 2;
float blur_multiplier_ = 0.10f;
float noise_opacity_ = 0.5f;
float favorite_opacity_ = 1.0f; // Favorite gold outline/star opacity multiplier (0.15–1.0, 1.0 = theme default)
bool gradient_background_ = false;
#ifdef _WIN32
float ui_opacity_ = 0.50f; // Card/sidebar opacity (0.3–1.0, 1.0 = opaque)
@@ -706,9 +767,18 @@ private:
bool console_auto_focus_ = false; // focus the command input when the Console tab is opened (opt-in)
std::set<std::string> hidden_addresses_;
std::set<std::string> favorite_addresses_;
std::set<std::string> earnings_addresses_; // per-address earnings column opt-in (when show-all off)
int earnings_interval_ = 1; // 0=Hour 1=Day 2=Month 3=Year
int earnings_mode_ = 0; // 0=Inflows 1=Net
int earnings_format_ = 1; // 0=DRGX 1=DRGX+fiat 2=Fiat
bool earnings_show_all_ = false;
std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = 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)
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
bool large_wallet_warned_ = false;
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
bool encryption_pending_ = false;
+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
// stores the Error message into last_error_ — instead of clobbering it with a generic
// string that would then be all getLastError()/the UI ever sees.
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
: last_error_;
std::string detail = getLastError();
if (detail.empty()) detail = "Failed to start dragonxd process";
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
setState(State::Error, detail);
return false;
@@ -822,7 +822,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
char errBuf[256] = {0};
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
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());
return false;
}
@@ -1232,7 +1232,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
// Create pipe for stdout/stderr
int pipefd[2];
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;
}
@@ -1245,7 +1245,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
// macOS, which has no pipe2().
int execpipe[2];
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[1]);
return false;
@@ -1255,7 +1255,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
pid_t pid = fork();
if (pid == -1) {
last_error_ = "Fork failed: " + std::string(strerror(errno));
setLastError("Fork failed: " + std::string(strerror(errno)));
close(pipefd[0]);
close(pipefd[1]);
close(execpipe[0]);
@@ -1370,8 +1370,8 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
close(pipefd[0]);
int status;
waitpid(pid, &status, 0);
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
" — not executable or wrong architecture";
setLastError("dragonxd could not be executed: " + std::string(strerror(child_errno)) +
" — not executable or wrong architecture");
return false;
}
+5 -3
View File
@@ -504,6 +504,8 @@ private:
std::atomic<bool> external_daemon_detected_{false};
std::string last_error_;
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_
std::string process_output_;
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
@@ -511,14 +513,14 @@ private:
// Process handle
#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)
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_workdir_; // working directory used (for diagnostics)
#else
pid_t process_pid_ = 0;
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
std::atomic<pid_t> process_pid_{0}; // written by start/stop paths and the monitor (reap), read by accessors
std::atomic<int> stdout_fd_{-1}; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
void tailDebugLog();
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
+126
View File
@@ -0,0 +1,126 @@
#pragma once
// Pure period-earnings aggregation for the Balance tab's inline earnings figures. Header-only and
// dependency-free (beyond TransactionInfo + the std lib) so it can be unit-tested without the GUI.
//
// It sums wallet INFLOWS (received + mined) over the CURRENT calendar period (hour / day / month /
// year), optionally NET of sends, both per receiving address and wallet-wide. Values are "as seen"
// (no confirmation/maturity gate), matching the balances the wallet already shows.
//
// Attribution is best-effort: inflows attribute to TransactionInfo::address (our receiving address);
// in Net mode a send debits TransactionInfo::from_address when present (our source address). A send
// with no from_address still counts in the wallet TOTAL but not in any per-address figure, so the
// per-address numbers can be less complete than the total in Net mode.
#include <cstddef>
#include <cstdint>
#include <ctime>
#include <cmath>
#include <map>
#include <string>
#include <vector>
#include "data/wallet_state.h" // TransactionInfo
namespace dragonx {
namespace data {
enum class EarningsInterval { Hour = 0, Day = 1, Month = 2, Year = 3 };
enum class EarningsMode { Inflows = 0, Net = 1 };
// Start (Unix seconds, local calendar) of the current period containing `nowSec` for `interval`:
// Hour = top of the current clock hour; Day = local midnight; Month/Year = the 1st of the local
// calendar month / Jan 1 of the local year. DST quirks follow std::mktime (as LocalDayStartMemo does).
inline std::int64_t earningsPeriodStart(std::int64_t nowSec, EarningsInterval interval) {
std::time_t t = (std::time_t)nowSec;
std::tm lt{};
#if defined(_WIN32)
localtime_s(&lt, &t);
#else
localtime_r(&t, &lt);
#endif
lt.tm_sec = 0;
lt.tm_min = 0; // all intervals zero out seconds + minutes
if (interval != EarningsInterval::Hour) lt.tm_hour = 0; // Day/Month/Year
if (interval == EarningsInterval::Month || interval == EarningsInterval::Year) lt.tm_mday = 1;
if (interval == EarningsInterval::Year) lt.tm_mon = 0; // January
lt.tm_isdst = -1; // let mktime resolve DST for the normalized time
return (std::int64_t)std::mktime(&lt);
}
struct EarningsResult {
double total = 0.0; // wallet-wide earnings for the period (mode applied)
std::map<std::string, double> perAddress; // per receiving (Inflows) / source-debited (Net) address
int inflowCount = 0; // inflow txs counted in the period
};
// Sum earnings over the current `interval` period. Inflows = received + mined (>= 0). Net also
// subtracts sends (so the total can be negative). `nowSec` is passed in for testability.
inline EarningsResult computeEarnings(const std::vector<TransactionInfo>& txs, std::int64_t nowSec,
EarningsInterval interval, EarningsMode mode) {
EarningsResult r;
const std::int64_t start = earningsPeriodStart(nowSec, interval);
const bool net = (mode == EarningsMode::Net);
double totalIn = 0.0, totalOut = 0.0;
for (const auto& tx : txs) {
if (tx.timestamp < start) continue;
if (tx.type == "receive" || tx.type == "mined") {
const double a = std::fabs(tx.amount);
totalIn += a;
r.inflowCount++;
if (!tx.address.empty()) r.perAddress[tx.address] += a;
} else if (net && tx.type == "send") {
const double a = std::fabs(tx.amount);
totalOut += a;
if (!tx.from_address.empty()) r.perAddress[tx.from_address] -= a;
}
}
r.total = net ? (totalIn - totalOut) : totalIn;
return r;
}
// Convenience: the earnings for a single address in a result (0 if the address earned nothing).
inline double earningsForAddress(const EarningsResult& r, const std::string& address) {
auto it = r.perAddress.find(address);
return (it != r.perAddress.end()) ? it->second : 0.0;
}
// Every input the result depends on: equal keys => the cached result is still valid. `periodStart` is
// computed by the caller (earningsPeriodStart) so a period roll-over invalidates the cache.
struct EarningsKey {
std::uint64_t txRevision = 0; // WalletState::tx_revision (bumped by every write to transactions)
std::size_t txCount = 0; // belt: a writer that skips the bump but changes the row count
EarningsInterval interval = EarningsInterval::Day;
EarningsMode mode = EarningsMode::Inflows;
std::int64_t periodStart = 0;
bool operator==(const EarningsKey& o) const {
return txRevision == o.txRevision && txCount == o.txCount && interval == o.interval &&
mode == o.mode && periodStart == o.periodStart;
}
};
// Revision-keyed cache (mirrors ui::MiningEarningsCache): the O(transactions) pass reruns only when an
// input changes, plus a staleness safety net for a future writer that skips the revision bump.
class EarningsCache {
public:
static constexpr std::int64_t kMaxAgeMs = 2000;
const EarningsResult& get(const EarningsKey& key, const std::vector<TransactionInfo>& txs,
std::int64_t nowSec, std::int64_t nowMs) {
if (!valid_ || !(key_ == key) || (nowMs - computedAtMs_) > kMaxAgeMs) {
result_ = computeEarnings(txs, nowSec, key.interval, key.mode);
key_ = key;
valid_ = true;
computedAtMs_ = nowMs;
computes_++;
}
return result_;
}
int computeCount() const { return computes_; } // tests
private:
bool valid_ = false;
EarningsKey key_;
std::int64_t computedAtMs_ = 0;
EarningsResult result_;
int computes_ = 0;
};
} // namespace data
} // namespace dragonx
+20
View File
@@ -42,4 +42,24 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
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
+45 -1
View File
@@ -366,6 +366,29 @@ TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
}
}
}
// Sent-sweep watermark (optional). Drop it unless W/A are self-consistent and W is not above the
// stored tip (a replay after an unclean stop would otherwise use a stale watermark); the anchor
// hash is re-verified against the live chain on the next sweep regardless.
if (payload.contains("sent_sweep") && payload["sent_sweep"].is_object()) {
const auto& s = payload["sent_sweep"];
SentSweepState sw;
sw.height = s.value("height", -1);
sw.anchorHeight = s.value("anchor_height", -1);
sw.anchorHash = s.value("anchor_hash", std::string());
sw.fullFailures = s.value("full_failures", 0);
sw.fullRetryDelaySec = static_cast<long>(s.value("full_retry_delay_s", std::int64_t{0}));
sw.daemonId = s.value("daemon_id", std::string());
sw.lastFullTime = s.value("last_full_time", std::int64_t{0});
sw.lastFullHeight = s.value("last_full_height", -1);
if (s.contains("backlog") && s["backlog"].is_array()) {
for (const auto& txid : s["backlog"])
if (txid.is_string() && !txid.get<std::string>().empty())
sw.backlog.push_back(txid.get<std::string>());
}
const bool consistent = sw.height >= 0 && sw.anchorHeight > 0 &&
sw.anchorHeight <= sw.height && sw.height <= tipHeight;
if (consistent) result.sentSweep = std::move(sw);
}
result.tipHeight = tipHeight;
result.tipHash = tipHash;
result.updatedAt = updatedAt;
@@ -383,7 +406,8 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
const std::string& tipHash,
const std::vector<TransactionInfo>& transactions,
std::time_t updatedAt,
const std::unordered_map<std::string, int>& shieldedScanHeights)
const std::unordered_map<std::string, int>& shieldedScanHeights,
const SentSweepState& sentSweep)
{
// The synchronous form (tests, tools): the same write the background writer does.
SnapshotJob job;
@@ -394,6 +418,7 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
job.transactions = transactions;
job.updatedAt = updatedAt;
job.shieldedScanHeights = shieldedScanHeights;
job.sentSweep = sentSweep;
return writeSnapshot(std::move(job));
}
@@ -426,6 +451,25 @@ bool TransactionHistoryCache::writeSnapshot(SnapshotJob&& job)
}
}
// Sent-sweep watermark, only when it is self-consistent (0 < A <= W <= tip). An unknown key means
// W = -1 on load, which is the pre-watermark behaviour, so older builds read a new payload safely.
const SentSweepState& sw = job.sentSweep;
if (sw.height >= 0 && sw.anchorHeight > 0 && sw.anchorHeight <= sw.height && sw.height <= job.tipHeight) {
json s;
s["height"] = sw.height;
s["anchor_height"] = sw.anchorHeight;
s["anchor_hash"] = sw.anchorHash;
s["backlog"] = json::array();
for (const auto& txid : sw.backlog)
if (!txid.empty()) s["backlog"].push_back(txid);
s["full_failures"] = sw.fullFailures;
s["full_retry_delay_s"] = static_cast<std::int64_t>(sw.fullRetryDelaySec);
s["daemon_id"] = sw.daemonId;
s["last_full_time"] = sw.lastFullTime;
s["last_full_height"] = sw.lastFullHeight;
payload["sent_sweep"] = std::move(s);
}
std::string plainText = payload.dump();
std::vector<unsigned char> nonce;
std::vector<unsigned char> cipherText;
+24 -1
View File
@@ -15,6 +15,26 @@ struct sqlite3;
namespace dragonx {
namespace data {
// Persisted z_listsentbyaddress watermark, so a relaunch runs a windowed sweep instead of a full one.
// height (W) = tip of the last successful sweep; anchorHeight (A) = W - margin, the bottom edge the window
// covered; anchorHash (H_A) = getblockhash(A) at that time, verified on the next load. backlog = txids found
// but not yet enriched (must persist WITH W, or a pure z->z send whose txid lives only in a sweep result is
// lost once the window moves past it). The backoff fields keep a relaunch from immediately re-running a full
// sweep that keeps failing; daemonId resets the backoff on a version change. (Namespace-scope, not nested, so
// it is a complete type for the default argument of replace().)
struct SentSweepState {
int height = -1;
int anchorHeight = -1;
std::string anchorHash;
std::vector<std::string> backlog;
int fullFailures = 0;
long fullRetryDelaySec = 0;
std::string daemonId;
std::int64_t lastFullTime = 0; // when the last successful FULL sweep finished: drives the weekly
int lastFullHeight = -1; // safety-net re-sweep that catches changes the per-event guards miss
bool valid() const { return height >= 0; }
};
class TransactionHistoryCache {
public:
struct LoadResult {
@@ -25,6 +45,7 @@ public:
std::time_t updatedAt = 0;
std::vector<TransactionInfo> transactions;
std::unordered_map<std::string, int> shieldedScanHeights;
SentSweepState sentSweep;
};
// Everything one snapshot write needs, BY VALUE, so it can run on the history writer thread after the UI
@@ -38,6 +59,7 @@ public:
std::vector<TransactionInfo> transactions;
std::time_t updatedAt = 0;
std::unordered_map<std::string, int> shieldedScanHeights;
SentSweepState sentSweep;
SnapshotJob() = default;
SnapshotJob(const SnapshotJob&) = delete;
@@ -81,7 +103,8 @@ public:
const std::string& tipHash,
const std::vector<TransactionInfo>& transactions,
std::time_t updatedAt,
const std::unordered_map<std::string, int>& shieldedScanHeights = {});
const std::unordered_map<std::string, int>& shieldedScanHeights = {},
const SentSweepState& sentSweep = SentSweepState{});
// Thread-safe snapshot write (the history writer thread; replace() wraps it): JSON + AEAD with no lock
// held, then INSERT OR REPLACE + prune under the DB lock. Always wipes job.key. False for a keyless job.
bool writeSnapshot(SnapshotJob&& job);
+16 -1
View File
@@ -65,7 +65,11 @@ HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>&
std::uint64_t keyId,
const std::unordered_map<std::string, int>& shieldedScanHeights,
int tipHeight,
const std::string& tipHash)
const std::string& tipHash,
int sentHeight,
int sentAnchorHeight,
const std::string& sentAnchorHash,
const std::unordered_set<std::string>& sentBacklog)
{
HistorySnapshotSig sig;
std::uint64_t rowSum = 0; // sums: commutative, so row order does not matter; duplicates still count
@@ -89,11 +93,19 @@ HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>&
exactSum += finalize(e);
++sig.rows;
}
std::uint64_t backlogSum = 0; // order-independent: a set, so sum is commutative
for (const auto& txid : sentBacklog) {
if (txid.empty()) continue;
std::uint64_t h = kOffset;
mixStr(h, txid);
backlogSum += finalize(h);
}
std::uint64_t c = kOffset;
mixU64(c, rowSum);
mixU64(c, sig.rows);
mixStr(c, walletHash);
mixU64(c, keyId);
mixU64(c, backlogSum);
sig.content = finalize(c);
std::uint64_t heightSum = 0;
@@ -109,6 +121,9 @@ HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>&
mixU64(s, exactSum);
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(tipHeight)));
mixStr(s, tipHash);
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(sentHeight)));
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(sentAnchorHeight)));
mixStr(s, sentAnchorHash);
sig.slow = finalize(s);
return sig;
}
+9 -1
View File
@@ -38,7 +38,15 @@ HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>&
std::uint64_t keyId,
const std::unordered_map<std::string, int>& shieldedScanHeights,
int tipHeight,
const std::string& tipHash);
const std::string& tipHash,
// Sent-sweep watermark: W/A/H_A move per sweep (folded into `slow`,
// written on the slow cadence and always on disconnect/lock/exit),
// the enrichment backlog into `content` (written within ~20 s), so an
// invalidation that only changes the watermark still reaches disk.
int sentHeight = -1,
int sentAnchorHeight = -1,
const std::string& sentAnchorHash = std::string(),
const std::unordered_set<std::string>& sentBacklog = {});
enum class HistoryStoreMode {
Coalesce, // routine refresh: content changes at most every kHistoryMinStoreIntervalSec, slow drift every
+34 -2
View File
@@ -75,6 +75,13 @@ struct TransactionInfo {
std::string getTypeDisplay() const;
bool isConfirmed() const { return confirmations >= 1; }
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
@@ -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.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) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i)
@@ -189,6 +205,11 @@ struct SyncInfo {
bool tip_known = false;
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); }
};
/**
@@ -254,6 +275,8 @@ struct PoolMiningState {
int64_t uptime_sec = 0;
double pool_diff = 0;
bool connected = false;
std::time_t running_since = 0; // wall-clock the miner was first seen running this session (0 = not running)
std::string last_error; // newest xmrig log line that looks like a connection error ("" = none)
// Memory/thread usage (bytes for memory)
int64_t memory_used = 0;
@@ -368,12 +391,21 @@ struct WalletState {
// 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).
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(); }
// 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) {
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;
}
int64_t last_peer_update = 0;
+9 -18
View File
@@ -460,15 +460,14 @@ bool Connection::createDefaultConfig(const std::string& path)
return false;
}
std::ofstream file(path);
if (!file.is_open()) {
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
return false;
}
// Build the content in memory, then write it 0600 from the start (writeFileAtomically with
// restrictPermissions) — the old ofstream+chmod left a brief world-readable window during which
// another local user could read the freshly-generated rpcuser/rpcpassword.
std::ostringstream file;
// Get the data directory for exportdir
std::string dataDir = getDefaultDataDir();
file << "# DragonX configuration file\n";
file << "# Auto-generated by DragonX Wallet\n";
file << "\n";
@@ -485,18 +484,10 @@ bool Connection::createDefaultConfig(const std::string& path)
file << "addnode=seed.dragonx.is\n";
file << "addnode=node1.dragonx.is\n";
file << "addnode=node5.dragonx.is\n";
file.close();
// The file holds the freshly-generated rpcuser/rpcpassword in plaintext. ofstream creates it
// with the process umask (typically world-readable 0644), so restrict it to owner read/write
// before another local user can read the credentials.
{
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());
if (!util::Platform::writeFileAtomically(path, file.str(), /*restrictPermissions=*/true)) {
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
return false;
}
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_WRITEFUNCTION, WriteCallback);
// 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_XFERINFOFUNCTION, xferInfoCallback);
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);
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;
// Set POST data
+49
View File
@@ -10,6 +10,7 @@
#include <curl/curl.h>
#include <cctype>
#include <stdexcept>
#include <string>
@@ -152,6 +153,54 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
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;
// 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 };
+32 -2
View File
@@ -37,18 +37,48 @@ bool sentSweepIsFull(int tip, int fromHeight)
return tip <= 0 || fromHeight < 0 || fromHeight > tip;
}
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight)
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight, int margin)
{
nlohmann::json params = nlohmann::json::array();
params.push_back(anyZAddress);
params.push_back(0);
if (sentSweepIsFull(tip, fromHeight)) return params;
const int maxDepth = (tip - fromHeight) + kSentSweepReorgMarginBlocks;
const int maxDepth = (tip - fromHeight) + std::max(0, margin);
params.push_back(2); // filter type 2: depth
params.push_back(maxDepth); // at most this deep (mempool txs, depth 0, always pass)
return params;
}
int sentSweepMargin(bool firstAfterLoad)
{
return firstAfterLoad ? (kSentSweepReorgMarginBlocks + kSentSweepFirstLoadExtraBlocks)
: kSentSweepReorgMarginBlocks;
}
int sentSweepAnchor(int fromHeight, bool firstAfterLoad)
{
return std::max(1, fromHeight - sentSweepMargin(firstAfterLoad));
}
bool sentSweepNeedsLongCall(int tip, int fromHeight)
{
return tip > 0 && fromHeight >= 0 && (tip - fromHeight) > kSentSweepLongCallBlocks;
}
bool sentSweepSafetyNetDue(std::int64_t nowSec, std::int64_t lastFullSec)
{
return lastFullSec > 0 && (nowSec - lastFullSec) > kSentSweepSafetyNetSec;
}
int sentSweepNextBandSize(int prevSize, double prevMs)
{
if (!(prevMs > 0.0)) return std::clamp(prevSize, kSentSweepBandMinBlocks, kSentSweepBandMaxBlocks);
double scale = static_cast<double>(kSentSweepBandTargetMs) / prevMs;
scale = std::clamp(scale, 0.5, 4.0); // don't swing more than 2x smaller / 4x larger per step
int next = static_cast<int>(std::lround(prevSize * scale));
return std::clamp(next, kSentSweepBandMinBlocks, kSentSweepBandMaxBlocks);
}
long fullSentSweepRetryDelaySec(int consecutiveFailures)
{
if (consecutiveFailures <= 0) return 0;
+32 -2
View File
@@ -15,6 +15,7 @@
#include <nlohmann/json.hpp>
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <unordered_set>
@@ -28,7 +29,20 @@ constexpr double kHistoryScanDuty = 0.10; // the same 10% as the othe
constexpr int kHistoryMinStaleBlocks = 8; // re-scan an address at most every 8 blocks...
constexpr int kHistoryMaxStaleBlocks = 500; // ...and at least every 500 (a safety net)
constexpr int kSentSweepReorgMarginBlocks = 100; // > MAX_REORG_LENGTH (99), plus tip drift
constexpr int kSentSweepFirstLoadExtraBlocks = 1000; // E: extra margin on the first sweep after a load,
// to absorb wallet-notifier lag at the last save
constexpr int kSentSweepLongCallBlocks = 2400; // tip - W beyond this: use callLong + count failures
constexpr long kSentSweepSafetyNetSec = 7 * 24 * 3600; // ~weekly full re-sweep: catches external wallet
// changes the per-event guards can't see
constexpr long kFullSentSweepTimeoutSec = 120; // a whole-wallet sweep must be able to finish
// Banded full sweep: cover [0, tip] in depth bands (minconf=a + filter-2 maxDepth=b) so each cs_main hold is
// short (~kSentSweepBandTargetMs), instead of one call that holds it for the whole sweep. The band grows in
// sparse depth (no sends) and shrinks in dense depth, so it stays near the target duration either way.
constexpr long kSentSweepBandTargetMs = 5000;
constexpr int kSentSweepBandInitialBlocks = 2000;
constexpr int kSentSweepBandMinBlocks = 500;
constexpr int kSentSweepBandMaxBlocks = 100000;
constexpr int kSentSweepBandOverlap = 20; // overlap adjacent bands; dedupe by txid
constexpr int kTransparentPageMin = 1000;
constexpr int kTransparentPageMax = 5000;
constexpr int kStallCyclesBeforeGating = 1;
@@ -50,8 +64,24 @@ int transparentPageSize(std::size_t previousRowCount);
// tip after a reorg), else only txs at most (tip - fromHeight) + kSentSweepReorgMarginBlocks deep.
bool sentSweepIsFull(int tip, int fromHeight);
// [z, 0] for the full sweep, [z, 0, 2, maxDepth] for the window: JSON integers, and never 3 params (the
// daemon rejects that count).
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight);
// daemon rejects that count). `margin` widens the window (the first sweep after a load uses a bigger one).
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight,
int margin = kSentSweepReorgMarginBlocks);
// Window margin: steady-state kSentSweepReorgMarginBlocks, bigger on the first sweep after a load.
int sentSweepMargin(bool firstAfterLoad);
// A: the bottom edge the window covers (W - margin), clamped to >= 1. The anchor whose block hash is stored
// (H_A) and re-verified on the next load.
int sentSweepAnchor(int fromHeight, bool firstAfterLoad);
// A watermark saved long ago makes a huge "windowed" call: past this gap, use callLong and count failures
// toward the backoff, so it doesn't time out on every block while the daemon keeps working.
bool sentSweepNeedsLongCall(int tip, int fromHeight);
// A watermarked wallet is due a periodic full re-sweep to catch changes made outside the GUI (a Console
// import, an external same-seed restore) that the per-event guards miss. lastFullSec == 0 (no full sweep
// recorded yet) never triggers one: the seeding full sweep records it.
bool sentSweepSafetyNetDue(std::int64_t nowSec, std::int64_t lastFullSec);
// Next banded-sweep depth size, adapting the previous band's block count toward kSentSweepBandTargetMs (at
// most 2x down / 4x up per step), clamped to [min, max]. prevMs <= 0 (unmeasured) keeps the size.
int sentSweepNextBandSize(int prevSize, double prevMs);
// Backoff after full sweeps that failed in a way that cost the daemon the work: 0, 10 min, 1 h, then 6 h.
long fullSentSweepRetryDelaySec(int consecutiveFailures);
// Whether a failed full sweep counts toward that backoff: only when the daemon did the work (reply timeout,
+121 -33
View File
@@ -2,6 +2,8 @@
#include "history_scan_policy.h"
#include "rpc/rpc_busy_gate.h"
#include "../util/logger.h"
#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 <cctype>
@@ -33,11 +35,30 @@ std::optional<T> readOptional(const json& source, const char* key)
std::optional<double> readBalanceString(const json& source, const char* key)
{
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 (...) {}
return std::nullopt;
}
// Whole-wallet balance sweeps (z_gettotalbalance / z_listunspent) hold the daemon's cs_main and take tens
// of seconds on a very large note set. Give them a long reply timeout (like the sent-history sweep) so a
// slow pass completes and populates per-address balances instead of aborting at the default 30 s and
// leaving them at 0. The governor's cost-proportional floor keeps these from being issued too often.
constexpr long kBalanceSweepTimeoutSec = 120;
void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& unspent)
{
if (!unspent.is_array()) return;
@@ -45,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
// 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.
std::map<std::string, double> total;
std::map<std::string, double> spendable;
// Accumulated in integer satoshis (not doubles) so many notes can't drift by an ulp per addition.
std::map<std::string, std::int64_t> total;
std::map<std::string, std::int64_t> spendable;
for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount");
if (!address || !amount) continue;
total[*address] += *amount;
if (!address || !amount || !std::isfinite(*amount)) continue;
const std::int64_t sats = util::toSats(*amount);
total[*address] += sats;
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
@@ -61,8 +84,8 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
for (auto& info : addresses) {
auto t = total.find(info.address);
auto s = spendable.find(info.address);
info.balance = (t != total.end()) ? t->second : 0.0;
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
info.balance = (t != total.end()) ? static_cast<double>(t->second) / 1e8 : 0.0;
info.spendableBalance = (s != spendable.end()) ? static_cast<double>(s->second) / 1e8 : 0.0;
}
}
@@ -397,7 +420,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
// (balanceRefreshDue()), keeping balance scans from starving block connection.
const auto balanceStart = std::chrono::steady_clock::now();
try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
totalBalance = rpc.callLong("z_gettotalbalance", json::array({0}), kBalanceSweepTimeoutSec);
balanceOk = true;
} catch (const std::exception& e) {
balanceErrKind = rpc::rpcFailureKindOf(e);
@@ -420,7 +443,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
if ((plan.needSpendable || totalMoved) && !(behind && plan.evenIfBehind)) {
spendableQueried = true;
try { // If it fails, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
spendableBalance = rpc.callLong("z_gettotalbalance", json::array({1}), kBalanceSweepTimeoutSec);
} catch (...) {}
}
}
@@ -832,7 +855,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
// failed (the caller discards the whole pass and keeps the previous lists).
const bool scanBalances = !listOnly && result.addressListOk;
if (scanBalances) try {
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
json unspent = rpc.callLong("z_listunspent", json::array({0, 9999999, false}), kBalanceSweepTimeoutSec); // minconf=0 → include 0-conf change
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
// Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead
// of issuing its own z_listunspent. rawconfirmations is the TRUE depth; `confirmations` is
@@ -1033,8 +1056,13 @@ NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseVie
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
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) {
bool alreadyTracked = false;
for (const auto& existing : transactions) {
@@ -1066,7 +1094,7 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
}
}
if (!foundExistingMetadata) {
info.confirmations = entry.confirmations;
info.confirmations = entryConfirmations;
info.timestamp = entry.timestamp;
}
transactions.push_back(std::move(info));
@@ -1314,32 +1342,91 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
const bool sweepIsFull = sentSweepIsFull(currentBlockHeight, snapshot.sentScanFromHeight);
if (!snapshot.shieldedAddresses.empty() && !snapshot.skipSentSweep &&
(result.shieldedScanComplete || (snapshot.allowMidPassSentSweep && !sweepIsFull))) {
result.sentSweepRan = true;
result.sentSweepFull = sweepIsFull;
const json params = sentSweepParams(snapshot.shieldedAddresses.front(), currentBlockHeight,
snapshot.sentScanFromHeight);
const auto sweepStart = std::chrono::steady_clock::now();
try {
json sent = result.sentSweepFull
? rpc.callLong("z_listsentbyaddress", params, kFullSentSweepTimeoutSec)
: rpc.call("z_listsentbyaddress", params);
if (sent.is_array()) {
int effectiveFromHeight = snapshot.sentScanFromHeight;
bool effectiveFull = sweepIsFull;
// Anchor verify: a watermark restored from disk is trusted only if the block at A still matches the
// stored H_A (no reorg deeper than the margin, same chain/datadir). A mismatch, or an inability to
// check, drops the watermark and forces a full sweep. Checked only for a windowed sweep.
if (snapshot.sentVerifyPending && !sweepIsFull && snapshot.sentVerifyAnchorHeight > 0) {
bool anchorOk = false;
try {
json h = rpc.call("getblockhash", json::array({snapshot.sentVerifyAnchorHeight}));
if (h.is_string()) anchorOk = (h.get<std::string>() == snapshot.sentVerifyAnchorHash);
} catch (...) { anchorOk = false; }
if (!anchorOk) { effectiveFromHeight = -1; effectiveFull = true; }
}
// A verify that turned a windowed mid-pass sweep into a full one must wait for a completed pass,
// rather than run a whole-wallet sweep in the middle of one.
if (!(effectiveFull && !result.shieldedScanComplete)) {
result.sentSweepRan = true;
result.sentSweepFull = effectiveFull;
const std::string& z = snapshot.shieldedAddresses.front();
const auto sweepStart = std::chrono::steady_clock::now();
std::unordered_set<std::string> sweepSeen; // dedupe across overlapping bands
auto takeRows = [&](const json& sent) {
if (!sent.is_array()) return;
for (const auto& sentTx : sent) {
auto txid = readOptional<std::string>(sentTx, "txid");
if (!txid || txid->empty()) continue;
if (!sweepSeen.insert(*txid).second) continue; // from the previous band's overlap
appendSentTransparentOutputs(result.transactions, *txid, sentTx);
knownTxids.insert(*txid); // shielded (z->z) legs enriched via z_viewtransaction
}
result.sentSweepOk = true;
result.sentSweepHeight = currentBlockHeight;
};
try {
if (effectiveFull) {
// Banded full sweep: cover [0, tip] in adaptive depth bands (minconf=a + filter-2
// maxDepth=b => depth [a, b]) so no single call holds the daemon's cs_main for the whole
// sweep. Newest band first (its minconf=0 includes the mempool); bands grow in sparse
// depth and shrink in dense depth to stay near the target duration. W is set only after
// every band succeeds, so a mid-sweep failure re-sweeps instead of advancing the mark.
int bandLow = 0;
int bandSize = kSentSweepBandInitialBlocks;
for (;;) {
// maxDepth = currentBlockHeight covers down to genesis (a genesis tx has depth = tip).
const int bandHigh = std::min(bandLow + bandSize, currentBlockHeight);
const json bandParams = json::array({z, bandLow, 2, bandHigh});
const auto bandStart = std::chrono::steady_clock::now();
takeRows(rpc.callLong("z_listsentbyaddress", bandParams, kFullSentSweepTimeoutSec));
if (bandHigh >= currentBlockHeight) break; // reached the deepest possible depth
bandLow = bandHigh - kSentSweepBandOverlap;
bandSize = sentSweepNextBandSize(bandSize, msSince(bandStart));
}
result.sentSweepOk = true;
result.sentSweepHeight = currentBlockHeight;
} else {
// Windowed sweep: a single call. Bigger margin on the first one after a load (absorbs
// notifier lag); the long timeout only for a watermark saved far back.
const int margin = sentSweepMargin(snapshot.sentVerifyPending);
const json params = sentSweepParams(z, currentBlockHeight, effectiveFromHeight, margin);
const bool longCall = sentSweepNeedsLongCall(currentBlockHeight, effectiveFromHeight);
json sent = longCall
? rpc.callLong("z_listsentbyaddress", params, kFullSentSweepTimeoutSec)
: rpc.call("z_listsentbyaddress", params);
if (sent.is_array()) {
takeRows(sent);
result.sentSweepOk = true;
result.sentSweepHeight = currentBlockHeight;
}
}
if (result.sentSweepOk) {
// Anchor for the NEXT load's verify: A = W - steady margin, H_A = getblockhash(A). An
// empty H_A (fetch failed) just means the next load can't verify and does a full sweep.
const int newAnchor = sentSweepAnchor(currentBlockHeight, false);
result.sentSweepAnchorHeight = newAnchor;
try {
json ha = rpc.call("getblockhash", json::array({newAnchor}));
if (ha.is_string()) result.sentSweepAnchorHash = ha.get<std::string>();
} catch (...) {}
}
} catch (const std::exception& e) {
transactionRpcError = true;
result.sentSweepErrKind = rpc::rpcFailureKindOf(e);
result.replyTimedOut |= result.sentSweepErrKind == rpc::RpcFailureKind::ReplyTimeout;
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
}
} catch (const std::exception& e) {
transactionRpcError = true;
result.sentSweepErrKind = rpc::rpcFailureKindOf(e);
result.replyTimedOut |= result.sentSweepErrKind == rpc::RpcFailureKind::ReplyTimeout;
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
result.sentSweepMs = msSince(sweepStart);
}
result.sentSweepMs = msSince(sweepStart);
}
for (const auto& txid : snapshot.sendTxids) {
@@ -1394,7 +1481,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
auto cached = snapshot.viewTxCache.find(txid);
if (cached != snapshot.viewTxCache.end()) {
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
// 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
@@ -1427,6 +1514,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
}
auto entry = parseViewTransactionCacheEntry(viewTransaction);
entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip
const bool storedRow = rowBacklog.count(txid) > 0;
if (storedRow && entry.timestamp == 0) {
const auto settled = settledStoredMeta.find(txid);
@@ -1930,7 +2018,7 @@ void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
{
state.market.price_loading = false;
state.market.price_error = errorMessage.empty()
? std::string("Price fetch failed")
? std::string(util::tr("market_price_fetch_failed"))
: errorMessage;
}
+15 -3
View File
@@ -224,6 +224,7 @@ public:
std::string from_address;
std::int64_t timestamp = 0;
int confirmations = 0;
int observed_height = -1; // chain tip when `confirmations` was read (-1 unknown): lets a cached entry advance
struct Output {
std::string address;
double value = 0.0;
@@ -254,7 +255,13 @@ public:
std::unordered_map<std::string, int> priorityScanFloors;
bool skipShieldedScans = false; // a Light cycle (or a probe-less recent poll): no receive scans
bool skipSentSweep = false;
int sentScanFromHeight = -1; // the sweep's watermark; -1 = sweep the whole wallet
int sentScanFromHeight = -1; // the sweep's watermark (W); -1 = sweep the whole wallet
// A loaded watermark owes an anchor verify before its first windowed sweep: getblockhash(A) must
// still equal H_A (no reorg deeper than the margin). A mismatch forces a full sweep. While pending,
// the window also uses the bigger first-after-load margin.
bool sentVerifyPending = false;
int sentVerifyAnchorHeight = -1; // A
std::string sentVerifyAnchorHash; // H_A
std::size_t transparentRowHint = 0; // listtransactions rows last time: sizes the first page
// Same tip and nothing changed since the rows on screen were fetched: reuse their transparent rows
// instead of paging listtransactions again.
@@ -291,7 +298,9 @@ public:
bool sentSweepRan = false;
bool sentSweepOk = false;
bool sentSweepFull = false;
int sentSweepHeight = -1; // the tip a successful sweep covered
int sentSweepHeight = -1; // W: the tip a successful sweep covered
int sentSweepAnchorHeight = -1; // A = W - margin, the bottom edge the window covered
std::string sentSweepAnchorHash; // H_A = getblockhash(A), fetched after a successful sweep
double sentSweepMs = 0.0; // time in the sweep (part of scanMs)
rpc::RpcFailureKind sentSweepErrKind = rpc::RpcFailureKind::None;
// Txids found but not enriched (over the per-cycle cap, or the view failed transiently): the caller
@@ -419,9 +428,12 @@ public:
const std::set<std::string>& miningAddresses = {},
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
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,
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
// (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).
+37 -2
View File
@@ -53,6 +53,14 @@ bool worthConsolidating(const NoteGroup& group, double fee)
return group.notes >= 2 && group.amount > 2.0 * fee;
}
std::vector<NoteGroup> autoConsolidateCandidates(const std::vector<NoteGroup>& groups, int minNotes, double fee)
{
std::vector<NoteGroup> out;
for (const auto& g : groups)
if (g.notes > minNotes && worthConsolidating(g, fee)) out.push_back(g);
return out;
}
int estimateBatches(int notes, int limit)
{
if (notes < 2) return 0;
@@ -184,6 +192,22 @@ MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& m
return MergeFailure::Fatal;
}
bool isDeferrableMergeOpError(const std::string& message)
{
// A batch rejected from the mempool because it clashed with an earlier, still-unconfirmed batch (its
// Sapling nullifier/anchor, or an input, already in flight). Re-trying after those confirm succeeds.
// Substring match — the daemon prefixes a reject code (e.g. "18: bad-txns-shielded-requirements-not-met").
static const char* const kNeedles[] = {
"shielded-requirements-not-met", // Sapling nullifier/anchor clash (REJECT_DUPLICATE)
"joinsplit-requirements-not-met", // the Sprout analogue
"txn-mempool-conflict", // spends an output another mempool tx already spends
"bad-txns-inputs-spent", // an input already spent by an unconfirmed sibling
};
for (const char* n : kNeedles)
if (message.find(n) != std::string::npos) return true;
return false;
}
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e)
{
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
@@ -290,7 +314,7 @@ void ConsolidationRun::endAddress(RunAddress& a, AddrOutcome o, const std::strin
if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg);
}
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep)
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep, double now)
{
b.state = st;
if (b.addrIdx < 0 || b.addrIdx >= static_cast<int>(addrs_.size())) return;
@@ -299,10 +323,20 @@ void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep
case OpState::Success:
if (rep) b.txid = rep->txid;
++a.opsDone;
a.defers = 0; // progress made: this address's consecutive-defer budget resets
opFailStreak_ = 0;
break;
case OpState::Failed:
if (rep) b.error = rep->error;
// A transient mempool conflict (clashed with an earlier, still-unconfirmed batch): don't fail the
// address — hold it and retry once the earlier batches confirm. Bounded, and it doesn't feed the
// halt breaker. The batch itself stays terminal (Failed) so it isn't re-polled; the address stays
// not-done and gets a fresh batch after the hold (see next()).
if (isDeferrableMergeOpError(b.error) && a.defers < kRunMaxDeferRetries) {
++a.defers;
a.deferUntil = now + kRunMempoolClearSec;
break;
}
if (a.failure.empty()) a.failure = b.error;
a.done = true;
worsen(a, AddrOutcome::Failed);
@@ -367,6 +401,7 @@ RunStep ConsolidationRun::next(const RunEnv& env, double now)
if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked);
if (env.userSendBusy) return wait(RunWait::UserSend);
if (now < nextCallAt_) return wait(RunWait::Batch);
if (now < addrs_[cur_].deferUntil) return wait(RunWait::Batch); // held after a mempool conflict; retry once it clears
if (!batches_.empty() && (batches_.back().state == OpState::Unknown || batches_.back().state == OpState::Queued))
return wait(RunWait::Batch); // at most 2 unfinished ops, at most 2 x limit notes locked
if (env.scanBusy) return wait(RunWait::Scan); // another GUI wallet scan is in the daemon
@@ -476,7 +511,7 @@ void ConsolidationRun::onPollResult(std::uint64_t gen, bool ok, const std::vecto
}
b.misses = 0;
const OpState st = opStateFromStatus(it->second->status);
if (st != OpState::Unknown) applyOpState(b, st, it->second);
if (st != OpState::Unknown) applyOpState(b, st, it->second, now);
}
}
+27 -1
View File
@@ -46,6 +46,22 @@ bool worthConsolidating(const NoteGroup& group, double fee);
// note isn't a call (the daemon refuses to merge one note into its own address).
int estimateBatches(int notes, int limit);
// ── Automatic (background) consolidation ──
// Gentle defaults for App::pumpAutoConsolidation: a small batch and only a couple of batches per address
// per cycle, spaced well apart, so each cycle is a few small merges that barely touch the node. The manual
// dialog's 50-input / 20-batch run stays for when the user wants speed.
inline constexpr int kAutoConsolidateLimit = 15; // inputs per merge (vs 50 in a manual run)
inline constexpr int kAutoConsolidateMaxBatches = 3; // batches per address per cycle
inline constexpr int kAutoConsolidateMinNotes = 25; // only act on addresses above this; dormant below
inline constexpr double kAutoConsolidateCooldownSec = 600; // between cycles that did work (~10 min)
inline constexpr double kAutoConsolidateIdleRescanSec = 3600; // between scans while nothing needs merging (~1 h)
inline constexpr double kAutoConsolidateRetrySec = 1800; // back off longer after a failed/halted cycle
// Addresses worth consolidating automatically: worthConsolidating(group, fee) AND more than `minNotes`
// notes, so a near-tidy address is left alone (and the driver can go dormant). Pure; preserves input order
// (groupSpendableNotes already returns most-notes-first).
std::vector<NoteGroup> autoConsolidateCandidates(const std::vector<NoteGroup>& groups, int minNotes, double fee);
// What one z_mergetoaddress call reported.
struct MergeCallResult {
std::string opid;
@@ -97,6 +113,10 @@ enum class MergeFailure : std::uint8_t {
Fatal, // anything else: this address is done
};
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message);
// True when an async op FAILURE message (not an RPC-call failure) is a transient mempool conflict — a batch
// that clashed with an earlier, still-unconfirmed batch (bad-txns-shielded-requirements-not-met and kin).
// The run holds the address and retries once the mempool clears instead of failing it. Pure; substring match.
bool isDeferrableMergeOpError(const std::string& message);
// The failure kind of a z_mergetoaddress call that threw. Sent, then cut off before any HTTP status
// (connection reset, an empty or partial reply): the daemon may have queued the merge, exactly as after a
// reply timeout, so it reads as one. Any other failure keeps its kind.
@@ -122,6 +142,10 @@ inline constexpr int kRunMaxTransientRetries = 5;
inline constexpr int kRunMaxUnlockRefusals = 2;
inline constexpr int kRunMaxFailStreak = 3;
inline constexpr int kRunMaxPollMisses = 2;
// An op that failed with a transient mempool conflict (it clashed with an earlier, still-unconfirmed batch)
// holds its address for this long so the earlier batches can confirm, then retries — up to this many times.
inline constexpr double kRunMempoolClearSec = 120.0;
inline constexpr int kRunMaxDeferRetries = 5;
struct RunConfig {
double fee = 0.0001;
@@ -153,6 +177,8 @@ struct RunAddress {
int opsDone = 0;
int transient = 0;
int unlockRefusals = 0;
double deferUntil = 0.0; // held until this time after a mempool-conflict op failure, then retried
int defers = 0; // consecutive mempool-conflict defers (reset on a success; capped at kRunMaxDeferRetries)
std::string failure;
};
struct RunBatch {
@@ -210,7 +236,7 @@ private:
enum class Phase : std::uint8_t { Idle, Running, Finished };
static void worsen(RunAddress& a, AddrOutcome o) { if (o > a.outcome) a.outcome = o; }
void endAddress(RunAddress& a, AddrOutcome o, const std::string& msg);
void applyOpState(RunBatch& b, OpState st, const OpReport* rep);
void applyOpState(RunBatch& b, OpState st, const OpReport* rep, double now = 0.0);
void halt(const std::string& msg) { halted_ = true; if (haltError_.empty()) haltError_ = msg; }
Phase phase_ = Phase::Idle;
+15
View File
@@ -44,6 +44,13 @@ double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed)
return std::max(floorSec, 50.0 * std::max(0.0, emaCostSec));
}
// Minimum gap between VISIBLE balance scans, as a multiple of the measured scan cost. A whole-wallet
// z_gettotalbalance / z_listunspent holds the daemon's cs_main for its whole run (tens of seconds on a
// very large note set); re-polling before it recovers starves block connection and times the next scan
// out, which leaves per-address balances at 0. 3x the cost gives the node room to breathe while staying
// responsive — on a normal wallet the cost is milliseconds, so this floor is effectively zero.
constexpr double kVisibleBalanceCostMultiple = 3.0;
BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
{
if (!in.allowBalance || !in.gateOpen) return BalancePollDecision::Skip;
@@ -57,6 +64,11 @@ BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
if (in.forced && !in.background) return BalancePollDecision::Explicit;
if (!in.changed) return BalancePollDecision::Skip;
if (in.visible) {
// Cost-proportional floor: don't re-scan before ~3x the measured scan cost has elapsed, so a
// wallet whose whole-wallet sweep costs many seconds can't saturate the node by re-polling before
// it recovers. Negligible for small wallets. The forced / first-read paths above bypass this.
if (in.sinceLastScanSec < kVisibleBalanceCostMultiple * std::max(0.0, in.emaCostSec))
return BalancePollDecision::Skip;
// Just came into view with an old value: fetch it now rather than queue behind the other streams.
if (!in.waiting && in.shownAgeSec >= in.visibleFloorSec && in.sinceLastScanSec >= in.visibleFloorSec)
return BalancePollDecision::Explicit;
@@ -159,6 +171,9 @@ ScanTicket WalletScanGovernor::tryAdmit(ScanStream stream, bool visible, const S
if (st.waitingSince < 0.0 || now - st.lastAskAt > cfg_.waitLapseSec) st.waitingSince = now; // a new wait
st.lastAskAt = now;
st.askVisible = visible;
// Paused (e.g. a user-started consolidation owns the cs_main budget): keep the stream waiting, admit
// nothing. It resumes immediately once unpaused; explicit needs still go through beginExplicit.
if (suppressed_[static_cast<std::size_t>(stream)]) return {};
pruneLeases(now);
if (!live_.empty()) return {}; // one wallet scan in the daemon at a time
if (bestWaiter(now) != stream) return {};
+16
View File
@@ -159,6 +159,21 @@ public:
void invalidate(ScanStream stream); // forget its basis: the next routine scan is needed
void reset(); // a new connection: forget everything; old tickets complete as no-ops
// Pause/resume a stream's ROUTINE scans (tryAdmit returns empty while paused; the stream keeps waiting
// and resumes the moment it is unpaused). Explicit needs (beginExplicit) are NEVER paused.
void suppressStream(ScanStream stream, bool paused) { suppressed_[static_cast<std::size_t>(stream)] = paused; }
bool streamSuppressed(ScanStream stream) const { return suppressed_[static_cast<std::size_t>(stream)]; }
// Convenience: pause/resume the heavy wallet-display scans (balance, addresses, history, recent-tx) as a
// group — used to hand a user-started consolidation more of the cs_main budget. Chat scans and Adhoc
// (the consolidation's own merge calls) are left running.
void suppressRoutineWalletScans(bool paused)
{
suppressStream(ScanStream::Balance, paused);
suppressStream(ScanStream::Addresses, paused);
suppressStream(ScanStream::History, paused);
suppressStream(ScanStream::RecentTx, paused);
}
bool inFlight(ScanStream stream) const;
bool anyInFlight() const { return !live_.empty(); }
double quietUntil() const { return quietUntil_; }
@@ -208,6 +223,7 @@ private:
Config cfg_;
std::array<Stream, static_cast<std::size_t>(ScanStream::Count)> streams_{};
std::array<bool, static_cast<std::size_t>(ScanStream::Count)> suppressed_{}; // routine scans paused per stream
std::vector<Live> live_;
double quietUntil_ = 0.0;
int tipLag_ = 0;
+22 -1
View File
@@ -123,6 +123,7 @@ bool ExplorerBlockCache::ensureOpen()
}
db_ = openedDb;
++revision_;
// 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.
sqlite3_busy_timeout(db_, 250);
@@ -176,6 +177,20 @@ std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight,
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> results;
@@ -256,6 +271,7 @@ bool ExplorerBlockCache::storeBlockRow(const ExplorerBlockSummary& block)
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
{
if (block.height < 1 || block.hash.empty()) return false;
++revision_;
const bool stored = storeBlockRow(block);
if (stored) {
spill_.erase(block.height);
@@ -297,6 +313,7 @@ int ExplorerBlockCache::cachedBlockCount()
void ExplorerBlockCache::clearBlocks()
{
++revision_;
spill_.clear();
if (!ensureOpen()) return;
exec("DELETE FROM explorer_blocks");
@@ -305,6 +322,7 @@ void ExplorerBlockCache::clearBlocks()
void ExplorerBlockCache::pruneTo(int maxRows)
{
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
// 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).
@@ -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).
if (!spill_.empty() && !currentBestHash.empty() &&
(currentHeight < spill_tip_height_ ||
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_)))
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_))) {
spill_.clear();
++revision_;
}
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
return validation;
}
@@ -526,6 +546,7 @@ void ExplorerBlockCache::close()
{
tip_known_ = false;
if (!db_) return;
++revision_;
sqlite3_close(db_);
db_ = nullptr;
}
+11
View File
@@ -5,6 +5,7 @@
#include <cstdint>
#include <map>
#include <string>
#include <utility>
#include <vector>
struct sqlite3;
@@ -44,6 +45,11 @@ public:
const std::string& databasePath() const { return database_path_; }
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)
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
@@ -102,6 +108,11 @@ private:
int open_attempts_ = 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
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
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
+33 -8
View File
@@ -36,27 +36,52 @@ inline void PopTooltipStyle()
ImGui::PopStyleVar();
}
// Drop-in replacement for ImGui::SetTooltip (same printf-style signature). (No IM_FMTARGS: GCC
// rejects the format attribute on an inline definition; call sites already passed SetTooltip's check.)
// Comfortable maximum tooltip text width (logical px, scaled by DPI). Long tooltip text wraps at this
// instead of stretching across the whole screen; short text is unaffected (it's narrower than the cap).
inline constexpr float kTooltipWrapWidth = 420.0f;
// The wrap position (window-local x) to clamp a just-begun tooltip's text to. Called right after
// ImGui::BeginTooltip, when the cursor sits at the tooltip's content origin.
inline float tooltipWrapPosX()
{
return ImGui::GetCursorPosX() + kTooltipWrapWidth * Layout::dpiScale();
}
// Drop-in replacement for ImGui::SetTooltip (same printf-style signature), with the text clamped to a
// comfortable width. (No IM_FMTARGS: GCC rejects the format attribute on an inline definition; call sites
// already passed SetTooltip's check.)
inline void Tooltip(const char* fmt, ...)
{
PushTooltipStyle();
va_list args;
va_start(args, fmt);
ImGui::SetTooltipV(fmt, args);
va_end(args);
if (ImGui::BeginTooltip()) {
ImGui::PushTextWrapPos(tooltipWrapPosX());
va_list args;
va_start(args, fmt);
ImGui::TextV(fmt, args);
va_end(args);
ImGui::PopTextWrapPos();
ImGui::EndTooltip();
}
PopTooltipStyle();
}
// Drop-in replacements for ImGui::BeginTooltip / ImGui::EndTooltip (must be paired, as before).
// Drop-in replacements for ImGui::BeginTooltip / ImGui::EndTooltip (must be paired, as before). The begin
// clamps text to a comfortable width by default; a caller that pushes its own wrap pos nests cleanly (it
// pops back to this one). Text-only callers need do nothing; non-text content ignores the wrap pos.
inline bool BeginTooltip()
{
PushTooltipStyle();
return ImGui::BeginTooltip();
if (ImGui::BeginTooltip()) {
ImGui::PushTextWrapPos(tooltipWrapPosX());
return true;
}
PopTooltipStyle(); // not shown: EndTooltip won't run, so undo the style push here
return false;
}
inline void EndTooltip()
{
ImGui::PopTextWrapPos(); // pair the wrap pos pushed in BeginTooltip
ImGui::EndTooltip();
PopTooltipStyle();
}
+49 -6
View File
@@ -19,9 +19,11 @@ enum class NodeBannerSeverity {
// What the banner's action button does. App maps this to the concrete call.
enum class NodeBannerAction {
None, // no button — nothing the user can usefully do from here
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
None, // no button — nothing the user can usefully do from here
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
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,
@@ -33,6 +35,9 @@ enum class NodeBannerReason {
LiteOpenFailed, // lite build: the wallet failed to open
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)
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 {
@@ -71,6 +76,13 @@ struct NodeBannerInputs {
bool node_busy = false;
std::string node_busy_detail;
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 daemon_last_error; // DaemonController::lastError() (may be empty)
std::string lite_open_error; // lite: last wallet-open failure reason
@@ -116,14 +128,30 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
// Connected to the daemon (RPC works) but with no peer-derived tip → the node has no peers and
// cannot learn about new blocks; it silently stalls at a stale height while everything looks
// synced. Surface it as a recoverable warning (reconnect won't help, so no action button).
// synced. Surface it as a recoverable warning. A plain reconnect won't help, but restarting the
// embedded daemon re-triggers peer discovery (it re-reads the -addnode seeds), so offer that when
// we own the node; an external node is the user's own to restart, so no button there. The detail
// is left empty: here connection_status is just "Connected", which would contradict the
// "no peers" headline — App renders a translated, actionable hint for this reason instead.
if (in.connected) {
if (!in.tip_known && in.blocks > 0) {
s.show = true;
s.severity = NodeBannerSeverity::Warning;
s.reason = NodeBannerReason::NoPeers;
s.action = NodeBannerAction::None;
s.detail = in.connection_status;
s.action = (in.using_embedded_daemon && in.has_daemon_controller)
? NodeBannerAction::RestartNode
: NodeBannerAction::None;
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;
}
@@ -140,6 +168,21 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
return s;
}
// The node rejected the wallet's RPC login — retrying won't help (the creds are wrong). Restarting
// the embedded node regenerates its cookie/creds; for an external node the user fixes DRAGONX.conf,
// so offer to open its folder. App renders a translated, actionable detail (connection_status here
// is the jargon "Auth failed…" line).
if (in.auth_failed) {
s.show = true;
s.severity = NodeBannerSeverity::Error;
s.reason = NodeBannerReason::AuthFailed;
s.action = (in.using_embedded_daemon && in.has_daemon_controller)
? NodeBannerAction::RestartNode
: NodeBannerAction::OpenConfigFolder;
s.detail.clear();
return s;
}
s.show = true;
s.severity = NodeBannerSeverity::Warning;
s.reason = NodeBannerReason::FullNodeOffline;
+62 -3
View File
@@ -6,6 +6,7 @@
#include "schema/ui_schema.h"
#include "material/type.h"
#include "material/draw_helpers.h"
#include "material/tooltip_style.h" // material::Tooltip (width-clamped)
#include "../embedded/IconsMaterialDesign.h"
#include "imgui.h"
@@ -14,6 +15,11 @@ namespace ui {
void Notifications::render()
{
// Pause-on-hover: while the pointer rests on the toast, keep it alive so a long error message can
// actually be read (errors/warnings already last longer, but hovering holds any of them open).
if (toast_hovered_ && !notifications_.empty())
notifications_.back().created_at = std::chrono::steady_clock::now();
// Remove every expired notification; the newest remaining one is then both the last and a live one.
pruneExpired(notifications_, std::chrono::steady_clock::now());
@@ -95,6 +101,15 @@ void Notifications::render()
float iconGap = 4.0f * dp;
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;
// "+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
float maxPillW = viewport->WorkSize.x * 0.5f;
if (pillWidth > maxPillW) pillWidth = maxPillW;
@@ -118,9 +133,16 @@ void Notifications::render()
glassSpec.borderWidth = 1.0f;
material::DrawGlassPanel(dl, pMin, pMax, glassSpec);
// Severity-tinted fill so the toast differs by more than hue (helps when color alone is hard to read).
{
ImVec4 tint = accent_color; // already premultiplied by fade `alpha`
tint.w *= nde("accent-fill-alpha", 0.22f);
dl->AddRectFilled(pMin, pMax, ImGui::ColorConvertFloat4ToU32(tint), pillRounding);
}
// Colored accent border on top of the glass border for type indication
ImU32 accentCol = ImGui::ColorConvertFloat4ToU32(accent_color);
dl->AddRect(pMin, pMax, accentCol, pillRounding, 0, 1.0f);
dl->AddRect(pMin, pMax, accentCol, pillRounding, 0, 1.5f * dp);
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
// corners. Draw a full-pill-size rounded rect and clip it to just the
@@ -146,13 +168,50 @@ void Notifications::render()
cursorX += iconW + iconGap;
}
// Message text (clipped to pill bounds)
// Message text — ellipsized to fit the (viewport-clamped) pill rather than hard-clipped mid-word,
// with the full text shown in a tooltip on hover.
bool clipped = false;
if (textFont) {
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color);
const float availTextW = (pMax.x - pillPadX) - cursorX - (badge.empty() ? 0.0f : (badgeW + badgeGap));
std::string shown = notif.message;
if (availTextW > 0.0f &&
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
clipped = true;
const std::string ell = "\xE2\x80\xA6"; // "…"
while (!shown.empty() &&
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f,
(shown + ell).c_str()).x > availTextW) {
shown.pop_back();
while (!shown.empty() && (static_cast<unsigned char>(shown.back()) & 0xC0) == 0x80)
shown.pop_back(); // drop any trailing UTF-8 continuation bytes
}
shown += ell;
}
dl->PushClipRect(ImVec2(cursorX, pillY), ImVec2(pMax.x - pillPadX, pMax.y), true);
dl->AddText(textFont, textFont->LegacySize, ImVec2(cursorX, contentY), textCol, notif.message.c_str());
dl->AddText(textFont, textFont->LegacySize, ImVec2(cursorX, contentY), textCol, shown.c_str());
dl->PopClipRect();
}
// "+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);
if (toast_hovered_) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory();
}
if (toast_hovered_ && clipped)
material::Tooltip("%s", notif.message.c_str()); // wrapped: a long alert message mustn't span the screen
}
} // namespace ui
+14
View File
@@ -169,8 +169,20 @@ public:
void 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) {
max_notifications_ = max;
}
@@ -191,7 +203,9 @@ private:
Notifications& operator=(const Notifications&) = delete;
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;
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
std::chrono::steady_clock::time_point last_drawn_{};
std::function<void(const std::string&, bool)> console_callback_;
+226 -60
View File
@@ -92,6 +92,7 @@ struct SettingsPageState {
bool save_ztxs = true;
bool allow_custom_fees = false;
bool auto_shield = false;
bool auto_consolidate = true; // on by default (loaded from settings on open; see getAutoConsolidate)
bool fetch_prices = true;
bool use_tor = false;
char rpc_host[128] = DRAGONX_DEFAULT_RPC_HOST;
@@ -106,6 +107,8 @@ struct SettingsPageState {
float noise_opacity = 0.5f;
float ui_opacity = 1.0f;
float window_opacity = 1.0f;
float favorite_opacity = 1.0f;
int earnings_format = 1;
std::string balance_layout = "classic";
bool scanline_enabled = true;
bool theme_effects_enabled = true;
@@ -183,6 +186,7 @@ struct SettingsPageState {
bool rescan_probe_failed = false; // the probe could not tell (node busy): warn before the -rescan restart
int rescan_probe_seq = 0; // the probe is async: a late result for an earlier opening is dropped
bool confirm_repair_wallet = false;
bool confirm_compact_wallet = false;
bool confirm_reinstall_daemon = false;
// Cached daemon-binary status for the "daemon binary" panel (loaded once / on Refresh,
// since reading the installed binary to scan its version is a one-off disk read).
@@ -421,6 +425,7 @@ static void loadSettingsPageState(App* app) {
s_settingsState.save_ztxs = settings->getSaveZtxs();
s_settingsState.allow_custom_fees = settings->getAllowCustomFees();
s_settingsState.auto_shield = settings->getAutoShield();
s_settingsState.auto_consolidate = settings->getAutoConsolidate();
s_settingsState.fetch_prices = settings->getFetchPrices();
s_settingsState.use_tor = settings->getUseTor();
@@ -448,6 +453,8 @@ static void loadSettingsPageState(App* app) {
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
s_settingsState.ui_opacity = settings->getUIOpacity();
s_settingsState.window_opacity = settings->getWindowOpacity();
s_settingsState.favorite_opacity = settings->getFavoriteOpacity();
s_settingsState.earnings_format = settings->getEarningsFormat();
s_settingsState.noise_opacity = settings->getNoiseOpacity();
s_settingsState.gradient_background = settings->getGradientBackground();
@@ -508,6 +515,7 @@ static void saveSettingsPageState(config::Settings* settings) {
settings->setSaveZtxs(s_settingsState.save_ztxs);
settings->setAllowCustomFees(s_settingsState.allow_custom_fees);
settings->setAutoShield(s_settingsState.auto_shield);
settings->setAutoConsolidate(s_settingsState.auto_consolidate);
settings->setFetchPrices(s_settingsState.fetch_prices);
settings->setUseTor(s_settingsState.use_tor);
settings->setTxExplorerUrl(s_settingsState.tx_explorer);
@@ -532,6 +540,8 @@ static void saveSettingsPageState(config::Settings* settings) {
settings->setBlurMultiplier(blur);
settings->setUIOpacity(lowSpecOverrides ? realPrefs.ui_opacity : s_settingsState.ui_opacity);
settings->setWindowOpacity(s_settingsState.window_opacity); // 1.0 under low-spec, as the hotkey saves it
settings->setFavoriteOpacity(s_settingsState.favorite_opacity); // not low-spec gated — cosmetic
settings->setEarningsFormat(s_settingsState.earnings_format);
settings->setNoiseOpacity(s_settingsState.noise_opacity);
settings->setGradientBackground(s_settingsState.gradient_background);
settings->setScanlineEnabled(lowSpecOverrides ? realPrefs.scanline_enabled : s_settingsState.scanline_enabled);
@@ -863,7 +873,7 @@ void RenderSettingsPage(App* app) {
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::PushStyleColor(ImGuiCol_Text, material::ErrorVec4());
ImGui::BeginDisabled(true);
std::string lbl = skin.name + TR("grpa_invalid_suffix");
ImGui::Selectable(lbl.c_str(), false);
@@ -898,7 +908,7 @@ void RenderSettingsPage(App* app) {
float cellGap = Layout::spacingLg();
bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale)
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;
const char* cellLabels[4] = { TR("theme"), TR("balance_layout"), TR("language"), TR("clock_format") };
@@ -1025,6 +1035,49 @@ void RenderSettingsPage(App* app) {
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// --- Favorite highlight opacity (gold outline + star button in the Balance address list) ---
// Pure color-alpha preference; not low-spec gated (unlike the transparency sliders below).
{
ImGui::PushFont(body2);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(OnSurfaceMedium()));
ImGui::TextUnformatted(TR("favorite_opacity"));
ImGui::PopStyleColor();
float favSliderW = std::min(std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, contentW), 360.0f * dp);
ImGui::SetNextItemWidth(favSliderW);
{
char favop_fmt[16];
snprintf(favop_fmt, sizeof(favop_fmt), "%.0f%%%%", s_settingsState.favorite_opacity * 100.0f);
if (ImGui::SliderFloat("##FavoriteOpacity", &s_settingsState.favorite_opacity, 0.15f, 1.0f, favop_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
if (app->settings()) app->settings()->setFavoriteOpacity(s_settingsState.favorite_opacity); // live; persisted on release
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_favorite_opacity"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// --- Earnings display format (Balance tab per-address + total earnings figures) ---
{
ImGui::PushFont(body2);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(OnSurfaceMedium()));
ImGui::TextUnformatted(TR("earnings_format"));
ImGui::PopStyleColor();
float efW = std::min(std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, contentW), 360.0f * dp);
ImGui::SetNextItemWidth(efW);
const char* efItems[] = { TR("earnings_format_coin"), TR("earnings_format_coin_fiat"), TR("earnings_format_fiat") };
if (ImGui::Combo("##EarningsFormat", &s_settingsState.earnings_format, efItems, IM_ARRAYSIZE(efItems))) {
s_settingsState.earnings_format = std::max(0, std::min(2, s_settingsState.earnings_format));
if (app->settings()) { app->settings()->setEarningsFormat(s_settingsState.earnings_format); app->settings()->save(); }
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_earnings_format"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// --- Effect toggles (always visible, horizontal wrapping flow) ---
{
ImGui::PushFont(body2);
@@ -1249,9 +1302,14 @@ void RenderSettingsPage(App* app) {
if (c == 1) { rowY += fh + gp; c = 0; } else { c = 1; }
return changed;
};
CB(TrId("save_z_transactions", "save_ztx"), &s_settingsState.save_ztxs);
// These persist immediately on toggle (like the daemon/verbose options below). The old
// code discarded CB()'s return and relied on a Save button that only renders on the About
// tab, so a user who enabled Auto-shield (a money/privacy intent) silently lost it on relaunch.
if (CB(TrId("save_z_transactions", "save_ztx"), &s_settingsState.save_ztxs))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_ztx"));
CB(TrId("auto_shield", "auto_shld"), &s_settingsState.auto_shield);
if (CB(TrId("auto_shield", "auto_shld"), &s_settingsState.auto_shield))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_shield"));
{
// Not implemented: disabled and never shown as "on", even for a stored use_tor=true.
@@ -1276,6 +1334,9 @@ void RenderSettingsPage(App* app) {
if (CB(TrId("stop_external", "stop_ext"), &s_settingsState.stop_external_daemon))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_stop_external"));
if (CB(TrId("auto_consolidate", "auto_consol"), &s_settingsState.auto_consolidate))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_consolidate"));
// (Stratum pool hosting lives in the Node & Security tab — it's a node feature.)
}
if (CB(TrId("verbose_logging", "verbose"), &s_settingsState.verbose_logging)) {
@@ -1292,7 +1353,7 @@ void RenderSettingsPage(App* app) {
ImGui::SetCursorScreenPos(ImVec2(cx, last + gp));
ImGui::PushTextWrapPos((cx + cw) - ImGui::GetWindowPos().x); // wrap to the card content width
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())
ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str());
} else {
@@ -1436,6 +1497,9 @@ void RenderSettingsPage(App* app) {
last = cy + bh; cy += bh + bgp;
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"));
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;
if (fullNode) {
cy += bh + bgp;
@@ -1494,9 +1558,13 @@ void RenderSettingsPage(App* app) {
if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key"));
last = cy + bh; cy += bh + bgp;
if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all"));
last = cy + bh; cy += bh + bgp;
// Export-all only works over the full-node RPC; hide it in lite (which has its own key export
// in the lite wallet section) rather than dead-ending on "Not connected to the daemon".
if (fullNode) {
if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all"));
last = cy + bh; cy += bh + bgp;
}
if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv"));
last = cy + bh;
@@ -1591,20 +1659,20 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(0, Layout::spacingMd());
if (isLocked) {
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::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 {
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->lockWallet();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
ImGui::SameLine(0, Layout::spacingSm());
ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN);
ImGui::TextColored(material::SuccessVec4(), ICON_MD_LOCK_OPEN);
ImGui::PopFont();
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
// when the column is too narrow for three buttons + status.
@@ -1650,7 +1718,11 @@ void RenderSettingsPage(App* app) {
// PIN unlock controls, trailing the auto-lock combo on the same row.
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();
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
@@ -1698,10 +1770,10 @@ void RenderSettingsPage(App* app) {
}
} else {
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::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 {
@@ -2114,9 +2186,10 @@ void RenderSettingsPage(App* app) {
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
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));
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"));
} else {
@@ -2273,8 +2346,10 @@ void RenderSettingsPage(App* app) {
cs.hoverBg = material::WithAlpha(OnSurface(), 30);
cs.tooltip = TR("copy");
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());
Notifications::instance().info(TR("copied_to_clipboard"));
}
}
// Row 2: Wallet size — label left, value RIGHT-aligned.
@@ -2309,6 +2384,20 @@ void RenderSettingsPage(App* app) {
ICON_MD_CALL_MERGE, material::ActionTier::Secondary))
ShieldDialog::showConsolidate();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge"));
ImGui::SameLine();
// Compact reclaims the file space consolidation frees (BDB won't shrink in place).
// It rewrites wallet.dat by restarting the node with -salvagewallet, so it only works
// with the embedded node — disable it for an external dragonxd and explain on hover.
// Enabled whenever this build can run the built-in node. If connected to an external
// node, the confirmation offers to switch to the built-in one first (compactViaEmbeddedSwitch).
const bool compactPossible = app->supportsEmbeddedDaemon();
ImGui::BeginDisabled(!compactPossible);
if (material::ActionButton("##walletcompact", TR("wallet_size_compact"),
ICON_MD_COMPRESS, material::ActionTier::Secondary))
s_settingsState.confirm_compact_wallet = true;
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", TR(compactPossible ? "tt_wallet_size_compact" : "compact_needs_embedded"));
}
// Row 3: folder buttons (their own row so the path gets the full width).
@@ -2537,6 +2626,21 @@ void RenderSettingsPage(App* app) {
if (ui::DaemonUpdateDialog::consumeInstalled())
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 ---
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label"));
@@ -2673,7 +2777,10 @@ void RenderSettingsPage(App* app) {
ImGui::TextUnformatted(TR("transaction_url"));
ImGui::SameLine(urlRowX + urlLblW);
ImGui::SetNextItemWidth(urlInputW);
// These Explorer-tab controls persist on edit — they were the only ones still relying on the
// About-only Save button, which a user on this tab never sees.
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
@@ -2683,6 +2790,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(urlRowX + urlLblW);
ImGui::SetNextItemWidth(urlInputW);
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
ImGui::PopFont();
@@ -2700,10 +2808,12 @@ void RenderSettingsPage(App* app) {
// Checkboxes + Block Explorer button (button wraps to its own row when it won't fit).
const float expRowRight = ImGui::GetCursorScreenPos().x + contentW;
ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees);
if (ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
ImGui::SameLine(0, Layout::spacingLg());
ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices);
if (ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
@@ -2873,8 +2983,10 @@ void RenderSettingsPage(App* app) {
Notifications::instance().success(TR("settings_saved"));
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_settings"));
af.next(material::ActionButtonWidth(TR("reset_to_defaults"), ICON_MD_RESTORE));
if (material::ActionButton("##aboutreset", TR("reset_to_defaults"), ICON_MD_RESTORE, AT::Tertiary)) {
// This only reloads the last-saved values from disk (it doesn't apply factory defaults), so
// label it honestly as "Discard unsaved changes" rather than the misleading "Reset to Defaults".
af.next(material::ActionButtonWidth(TR("discard_changes"), ICON_MD_UNDO));
if (material::ActionButton("##aboutreset", TR("discard_changes"), ICON_MD_UNDO, AT::Tertiary)) {
if (app->settings()) { loadSettingsPageState(app); Notifications::instance().info(TR("settings_reloaded")); }
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reset_settings"));
@@ -2906,7 +3018,7 @@ void RenderSettingsPage(App* app) {
s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip)))
saveSettingsPageState(app->settings());
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"));
}
}
@@ -2935,14 +3047,23 @@ void RenderSettingsPage(App* app) {
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);
// Draw overline label + arrow on top of the invisible button
{
ImFont* ovFont = Type().overline();
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
ImFont* iconFont = Type().iconSmall();
if (!iconFont) iconFont = ovFont;
@@ -2950,6 +3071,14 @@ void RenderSettingsPage(App* app) {
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) {
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -2993,9 +3122,33 @@ void RenderSettingsPage(App* app) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
#endif
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_debug_select"));
ImGui::TextColored(ImVec4(1,1,1,0.35f), "%s", TR("settings_debug_restart_note"));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
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()));
// 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
static const char* debugCats[] = {
@@ -3053,27 +3206,7 @@ void RenderSettingsPage(App* app) {
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// "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"));
}
// (The "restart daemon" prompt was moved up to directly under the restart note above.)
}
// ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ----
@@ -3221,12 +3354,12 @@ void RenderSettingsPage(App* app) {
// Confirmation dialog for deleting blockchain data
if (s_settingsState.confirm_delete_blockchain) {
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::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
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::Separator();
ImGui::Spacing();
@@ -3268,7 +3401,7 @@ void RenderSettingsPage(App* app) {
}
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();
@@ -3287,14 +3420,14 @@ void RenderSettingsPage(App* app) {
} else {
if (s_settingsState.rescan_probe_failed) {
// 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::PopStyleColor();
ImGui::Spacing();
}
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe"));
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_rescan_safe"));
}
ImGui::Spacing();
@@ -3323,12 +3456,12 @@ void RenderSettingsPage(App* app) {
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
if (s_settingsState.confirm_repair_wallet) {
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_repair_wallet_safe"));
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_repair_wallet_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
@@ -3348,15 +3481,48 @@ void RenderSettingsPage(App* app) {
}
}
// Confirm: compact wallet.dat to reclaim the space consolidation freed (rewrite via -salvagewallet,
// which restarts the node and re-scans the chain). A backup of the current wallet is kept.
if (s_settingsState.confirm_compact_wallet) {
// Direct compact when the wallet already runs the built-in node; otherwise the dialog explains it
// will stop the external node and switch to the built-in one first.
const bool embeddedNow = app->canCompactWallet();
if (BeginOverlayDialog(TR("confirm_compact_wallet_title"), &s_settingsState.confirm_compact_wallet, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
ImGui::Spacing();
ImGui::TextWrapped("%s", TR(embeddedNow ? "confirm_compact_wallet_msg" : "confirm_compact_switch_msg"));
ImGui::Spacing();
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_compact_wallet_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool doCancel = false, doConfirm = false;
material::DialogConfirmFooter(TrId("cancel", "compact_wallet_cancel").c_str(),
TrId("compact_wallet_confirm", "compact_wallet_confirm").c_str(),
false, doCancel, doConfirm);
if (doCancel) {
s_settingsState.confirm_compact_wallet = false;
}
if (doConfirm) {
if (embeddedNow) app->compactWallet();
else app->compactViaEmbeddedSwitch();
s_settingsState.confirm_compact_wallet = false;
}
EndOverlayDialog();
}
}
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
if (s_settingsState.confirm_reinstall_daemon) {
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_reinstall_daemon_safe"));
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_reinstall_daemon_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
@@ -3380,7 +3546,7 @@ void RenderSettingsPage(App* app) {
// Confirm: restart daemon (briefly drops the connection to apply changed options)
if (s_settingsState.confirm_restart_daemon) {
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::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
@@ -3407,12 +3573,12 @@ void RenderSettingsPage(App* app) {
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
if (s_settingsState.confirm_lite_redownload) {
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::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
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::Separator();
ImGui::Spacing();
+5 -1
View File
@@ -763,7 +763,11 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
}
// ---- Keyboard navigation ----
if (!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
// Suppress single-key nav while a dialog/overlay (send-confirm, seed backup, unlock, …) or any
// ImGui popup is open — otherwise Arrow/J/K/[ silently switch the tab *behind* the modal.
if (!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl &&
!material::IsOverlayDialogBlockingInput() &&
!ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopup)) {
int idx = static_cast<int>(current);
bool nav = false;
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) || ImGui::IsKeyPressed(ImGuiKey_K)) {
+10 -4
View File
@@ -38,6 +38,12 @@ void RenderAboutDialog(App* app, bool* p_open)
return;
}
// Escape closes the dialog (click-outside and the Close button already work; this matches the FAQ
// dialog's affordance so Escape behaves consistently across modal dialogs).
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
*p_open = false;
}
// Use Body2 font for all dialog text
ImGui::PushFont(Type().body2());
@@ -71,7 +77,7 @@ void RenderAboutDialog(App* app, bool* p_open)
#ifdef DRAGONX_DEBUG
ImGui::Text("%s", TR("about_build_type"));
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
ImGui::Text("%s", TR("about_build_type"));
ImGui::SameLine(valX);
@@ -86,7 +92,7 @@ void RenderAboutDialog(App* app, bool* p_open)
ImGui::Text("%s", TR("about_daemon"));
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();
ImGui::Text("%s", TR("about_chain"));
@@ -107,7 +113,7 @@ void RenderAboutDialog(App* app, bool* p_open)
ImGui::Spacing();
// Credits
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", TR("about_credits"));
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), "%s", TR("about_credits"));
ImGui::Spacing();
ImGui::BulletText("The Hush Developers");
@@ -121,7 +127,7 @@ void RenderAboutDialog(App* app, bool* p_open)
ImGui::Spacing();
// License
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", TR("about_license"));
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), "%s", TR("about_license"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("about_license_text"));
+35 -7
View File
@@ -141,6 +141,14 @@ public:
double newFromBal = s_info.fromBalance - totalDeduct;
double newToBal = s_info.toBalance + amount;
bool amountValid = amount > 0 && totalDeduct <= s_info.fromBalance;
// Mirror the Send tab's guards (this dialog previously enabled Confirm on amount alone, so a
// disconnected / syncing / locked wallet would fail only at submit). These are own-address
// moves, but gating is strictly more restrictive — the transfer logic below is unchanged.
const auto& ws = s_app->getWalletState();
const bool connected = s_app->isConnected();
const bool syncing = ws.sync.syncing;
const bool liteLocked = s_app->liteWallet() && ws.isLocked();
const bool canTransfer = amountValid && !s_sending && connected && !syncing && !liteLocked;
ImGui::Separator();
ImGui::Spacing();
@@ -167,6 +175,19 @@ public:
ImGui::TextWrapped("%s", TR("insufficient_funds"));
ImGui::PopStyleColor();
}
if (amount <= 0.0) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Error()));
ImGui::TextWrapped("%s", TR("send_tooltip_enter_amount"));
ImGui::PopStyleColor();
} else if (amountValid && (!connected || syncing || liteLocked)) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
ImGui::TextWrapped("%s", !connected ? TR("send_tooltip_not_connected")
: syncing ? TR("send_tooltip_syncing")
: TR("transfer_tooltip_locked"));
ImGui::PopStyleColor();
}
if (amountValid && newFromBal < 1e-9) {
ImGui::Spacing();
// Full-balance send: same warning-icon treatment as the de-shielding header above,
@@ -204,13 +225,13 @@ public:
bool outConfirm = false;
bool outClose = false;
DialogActionFooter(s_sending ? sendingLabel : confirmLabel,
amountValid && !s_sending,
canTransfer,
cancelLabel, outConfirm, outClose, btnW);
if (outClose) {
s_open = false;
}
if (outConfirm) {
if (outConfirm && canTransfer) { // re-check live state (the dialog persists across frames)
s_sending = true;
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
amount, s_fee, "",
@@ -296,12 +317,19 @@ private:
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("transfer_sent_desc"));
ImGui::Spacing();
{
char buf[128];
snprintf(buf, sizeof(buf), TR("shield_operation_id"),
s_resultMsg.size() > 40
? (s_resultMsg.substr(0, 20) + "..." + s_resultMsg.substr(s_resultMsg.size() - 10)).c_str()
: s_resultMsg.c_str());
// The result is a TRANSACTION id, not an "Operation ID" — label it correctly and make
// it copyable.
std::string disp = s_resultMsg.size() > 40
? (s_resultMsg.substr(0, 20) + "..." + s_resultMsg.substr(s_resultMsg.size() - 10))
: s_resultMsg;
char buf[160];
snprintf(buf, sizeof(buf), TR("transfer_txid_label"), disp.c_str());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
ImGui::SameLine();
if (ImGui::SmallButton(TR("grpb_copy"))) {
ImGui::SetClipboardText(s_resultMsg.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
}
} else {
ImGui::Spacing();
+267 -44
View File
@@ -13,6 +13,7 @@
#include "address_transfer_dialog.h"
#include "mining_tab_helpers.h"
#include "send_tab.h"
#include "transactions_tab.h"
#include "../../app.h"
#include "../../config/settings.h"
@@ -35,6 +36,8 @@
#include <cstring>
#include <cstdio>
#include <ctime>
#include <chrono>
#include "../../data/earnings_series.h"
#include <cmath>
#include <string>
#include <vector>
@@ -55,7 +58,57 @@ void UpdateBalanceLerp(App* app) {
StepDisplayLerp(s_dispUnconfirmed, state.unconfirmed_balance, dt, kBalanceLerpSpeed, instant);
}
// Total period-earnings for the whole wallet, formatted per the earnings settings. Only shown when the
// global earnings toggle is on, so the default hero/layout appearance is unchanged. Own revision-keyed
// cache (independent of the per-row one in RenderSharedAddressList).
bool FormatTotalEarnings(App* app, std::string& outText, ImU32& outCol) {
using namespace material;
if (!app->settings() || !app->settings()->getEarningsShowAll()) return false;
auto& S = schema::UI();
const auto& state = app->state();
auto* est = app->settings();
static dragonx::data::EarningsCache s_totalEarnCache;
auto iv = (dragonx::data::EarningsInterval)est->getEarningsInterval();
auto md = (dragonx::data::EarningsMode)est->getEarningsMode();
const int fmt = est->getEarningsFormat();
const std::int64_t nowSec = (std::int64_t)std::time(nullptr);
const std::int64_t nowMs = (std::int64_t)std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
dragonx::data::EarningsKey ek;
ek.txRevision = state.tx_revision;
ek.txCount = state.transactions.size();
ek.interval = iv;
ek.mode = md;
ek.periodStart = dragonx::data::earningsPeriodStart(nowSec, iv);
const auto& r = s_totalEarnCache.get(ek, state.transactions, nowSec, nowMs);
const double ev = r.total;
const double fiat = ev * state.market.price_usd;
const bool haveFiat = state.market.price_usd > 0.0;
const int f = haveFiat ? fmt : 0;
char buf[64];
if (f == 0) snprintf(buf, sizeof(buf), "%+.4f", ev);
else if (f == 2) snprintf(buf, sizeof(buf), "%s$%.2f", ev < 0 ? "-" : "+", std::fabs(fiat));
else snprintf(buf, sizeof(buf), "%+.4f ($%.2f)", ev, fiat);
outText = buf;
outCol = ev > 1e-12 ? S.resolveColor("var(--accent-shielded)", Success())
: (ev < -1e-12 ? S.resolveColor("var(--accent-error)", ReadableError())
: OnSurfaceDisabled());
return true;
}
// Render compact hero line: logo + balance + USD + mining on one line
float DrawPendingChip(ImDrawList* dl, ImVec2 pos, float maxRight, ImFont* font, float fontSize) {
using namespace material;
if (s_dispUnconfirmed <= 1e-9) return 0.0f;
char buf[64];
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), s_dispUnconfirmed);
const float w = font->CalcTextSizeA(fontSize, 10000.0f, 0.0f, buf).x;
if (pos.x + w > maxRight) return 0.0f; // would overflow the card — skip rather than overlap
dl->AddText(font, fontSize, pos, WithAlpha(Warning(), 230), buf);
if (IsRectHovered(pos, ImVec2(pos.x + w, pos.y + fontSize))) Tooltip("%s", TR("baltab_pending_tip"));
return w;
}
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs, float heroHeightOverride) {
using namespace material;
char buf[64];
@@ -95,6 +148,30 @@ void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float v
ImGui::Dummy(ImVec2(capFont->CalcTextSizeA(tickerFontSize, 10000, 0, DRAGONX_TICKER).x + Layout::spacingLg(), lineH));
ImGui::SameLine();
// Pending (unconfirmed) chip — so these layouts show it too, labeled, not just the Classic card.
if (s_dispUnconfirmed > 1e-9) {
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), s_dispUnconfirmed);
ImVec2 pp = ImGui::GetCursorScreenPos();
dl->AddText(capFont, tickerFontSize, ImVec2(pp.x, tickerY), WithAlpha(Warning(), 230), buf);
float pw = capFont->CalcTextSizeA(tickerFontSize, 10000, 0, buf).x;
if (material::IsRectHovered(ImVec2(pp.x, tickerY), ImVec2(pp.x + pw, tickerY + tickerFontSize)))
material::Tooltip("%s", TR("baltab_pending_tip"));
ImGui::Dummy(ImVec2(pw + Layout::spacingLg(), lineH));
ImGui::SameLine();
}
// Total period-earnings (left of the USD) when the global earnings toggle is on.
{
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
dl->AddText(capFont, tickerFontSize,
ImVec2(ImGui::GetCursorScreenPos().x, tickerY), earnCol, earnText.c_str());
float ew = capFont->CalcTextSizeA(tickerFontSize, 10000, 0, earnText.c_str()).x;
ImGui::Dummy(ImVec2(ew + Layout::spacingLg(), lineH));
ImGui::SameLine();
}
}
// USD value
const auto& state = app->state();
double usd_value = state.getBalanceUSD();
@@ -163,6 +240,29 @@ void RenderSharedAddressList(App* app, float listH, float availW,
addRows(state.t_addresses, false);
auto rows = BuildAddressListRows(rowInputs, addr_search, s_hideZeroBalances, s_showHidden);
// ---- Inline period-earnings: compute once (cached on tx_revision + interval/mode/period) ----
static dragonx::data::EarningsCache s_earnCache;
const dragonx::data::EarningsResult* earnResult = nullptr;
bool earnShowAll = false;
int earnFormat = 1; // 0=coin 1=coin+fiat 2=fiat
if (app->settings()) {
auto* est = app->settings();
earnShowAll = est->getEarningsShowAll();
earnFormat = est->getEarningsFormat();
auto iv = (dragonx::data::EarningsInterval)est->getEarningsInterval();
auto md = (dragonx::data::EarningsMode)est->getEarningsMode();
const std::int64_t nowSec = (std::int64_t)std::time(nullptr);
const std::int64_t nowMs = (std::int64_t)std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
dragonx::data::EarningsKey ek;
ek.txRevision = state.tx_revision;
ek.txCount = state.transactions.size();
ek.interval = iv;
ek.mode = md;
ek.periodStart = dragonx::data::earningsPeriodStart(nowSec, iv);
earnResult = &s_earnCache.get(ek, state.transactions, nowSec, nowMs);
}
// ---- Toolbar: search + checkboxes + create buttons ----
float avail = ImGui::GetContentRegionAvail().x;
float schemaMaxW = (searchIn.maxWidth >= 0) ? searchIn.maxWidth : 250.0f;
@@ -184,39 +284,65 @@ void RenderSharedAddressList(App* app, float listH, float availW,
}
}
float buttonWidth = ((addrBtn.width > 0) ? addrBtn.width : 140.0f) * dp;
float spacing = ((addrBtn.gap > 0) ? addrBtn.gap : 8.0f) * dp;
float totalButtonsWidth = buttonWidth * 2 + spacing;
float kMinButtonsPosition = std::max(S.drawElement("tabs.balance", "min-buttons-position").size,
S.drawElement("tabs.balance", "buttons-position").size * hs);
ImGui::SameLine(std::max(kMinButtonsPosition,
ImGui::GetWindowWidth() - totalButtonsWidth -
S.drawElement("tabs.balance", "button-row-right-margin").sizeOr(16.0f)));
// ---- Inline period-earnings controls: interval (1H/1D/1M/1Y) + mode (received / net) + a global
// show-all toggle, all drawn as Material segmented controls to match the Receive-tab filter. The
// create-address buttons moved below the list (footer) to make room. ----
if (app->settings()) {
auto* est = app->settings();
ImFont* capF = Type().caption();
ImFont* icoF = Type().iconSmall();
ImDrawList* wdl = ImGui::GetWindowDrawList();
const float segH = ImGui::GetFrameHeight();
bool sharedAddrSyncing = state.sync.syncing && !state.sync.isSynced();
ImGui::BeginDisabled(sharedAddrSyncing);
if (s_generating_z_address) {
char genLabel[64];
snprintf(genLabel, sizeof(genLabel), "%s%s##shared_z", TR("generating"), material::LoadingDots());
TactileButton(genLabel, ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font));
} else if (TactileButton(TR("new_z_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
s_generating_z_address = true;
app->createNewZAddress([](const std::string& addr) {
s_generating_z_address = false;
DEBUG_LOGF("Created new z-address: %s\n", addr.c_str());
});
ImGui::SameLine(0, Layout::spacingLg());
const float rowStartX = ImGui::GetCursorScreenPos().x;
const float rowY = ImGui::GetCursorScreenPos().y;
float x = rowStartX;
// Interval (1H/1D/1M/1Y) — text segments sized to the widest label, like the Receive filter.
const char* ivText[4] = { "1H", "1D", "1M", "1Y" };
float ivCellW = segH;
for (const char* t : ivText)
ivCellW = std::max(ivCellW, capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, t).x + 2.0f * Layout::spacingMd());
const float ivTotalW = ivCellW * 4;
int iv = est->getEarningsInterval();
int ivClk = material::SegmentedControl(wdl, ImVec2(x, rowY), ivTotalW, segH, ivText, 4, iv, capF, "##earniv", dp);
if (ivClk >= 0) { est->setEarningsInterval(ivClk); est->save(); }
if (ImGui::IsMouseHoveringRect(ImVec2(x, rowY), ImVec2(x + ivTotalW, rowY + segH)))
material::Tooltip("%s", TR("tt_earn_interval"));
x += ivTotalW + Layout::spacingMd();
// Mode (received-only vs net) — two icon segments.
const char* modeIcons[2] = { ICON_MD_CALL_RECEIVED, ICON_MD_SWAP_VERT };
float modeCellW = segH;
for (const char* t : modeIcons)
modeCellW = std::max(modeCellW, icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, t).x + 2.0f * Layout::spacingSm());
const float modeTotalW = modeCellW * 2;
int emode = est->getEarningsMode();
int modeClk = material::SegmentedControl(wdl, ImVec2(x, rowY), modeTotalW, segH, modeIcons, 2, emode, icoF, "##earnmode", dp);
if (modeClk >= 0) { est->setEarningsMode(modeClk); est->save(); }
for (int i = 0; i < 2; ++i) {
ImVec2 cMin(x + i * modeCellW, rowY), cMax(cMin.x + modeCellW, rowY + segH);
if (ImGui::IsMouseHoveringRect(cMin, cMax))
material::Tooltip("%s", i == 0 ? TR("tt_earn_mode_inflows") : TR("tt_earn_mode_net"));
}
x += modeTotalW + Layout::spacingMd();
// Global earnings-column toggle — a single matching segment (highlighted when on).
const char* togIcon[1] = { ICON_MD_TRENDING_UP };
float togW = std::max(segH, icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, togIcon[0]).x + 2.0f * Layout::spacingSm());
bool showAll = est->getEarningsShowAll();
int togClk = material::SegmentedControl(wdl, ImVec2(x, rowY), togW, segH, togIcon, 1, showAll ? 0 : -1, icoF, "##earnshowall", dp);
if (togClk == 0) { est->setEarningsShowAll(!showAll); est->save(); }
if (ImGui::IsMouseHoveringRect(ImVec2(x, rowY), ImVec2(x + togW, rowY + segH)))
material::Tooltip("%s", TR("tt_earn_show_all"));
x += togW;
// Reserve the controls' row as one layout item so the cursor drops below it (the segmented
// controls draw absolutely + hit-test with their own InvisibleButtons).
ImGui::SetCursorScreenPos(ImVec2(rowStartX, rowY));
ImGui::Dummy(ImVec2(x - rowStartX, segH));
}
ImGui::SameLine();
if (TactileButton(TR("new_mining_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
app->createNewTAddress([](const std::string& addr) {
DEBUG_LOGF("Created new t-address: %s\n", addr.c_str());
});
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_new_mining_address"));
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -278,6 +404,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
addrScrollMaxY = ImGui::GetScrollMaxY();
ImU32 greenCol = S.resolveColor("var(--accent-shielded)", Success());
ImU32 goldCol = S.resolveColor("var(--accent-transparent)", Warning());
ImU32 earnNegCol = S.resolveColor("var(--accent-error)", material::ReadableError()); // negative net earnings
float rowPadLeft = Layout::cardInnerPadding();
float rowPadRight = Layout::cardInnerPadding();
float rowIconSz = std::max(S.drawElement("tabs.balance", "address-icon-min-size").size,
@@ -285,10 +412,13 @@ void RenderSharedAddressList(App* app, float listH, float availW,
float innerW = ImGui::GetContentRegionAvail().x;
// Theme colors for buttons (resolved once)
ImU32 favGoldFill = S.resolveColor("var(--favorite-fill)", IM_COL32(255, 200, 50, 40));
ImU32 favGoldBorder = S.resolveColor("var(--favorite-border)", IM_COL32(255, 200, 50, 100));
ImU32 favGoldIcon = S.resolveColor("var(--favorite-icon)", IM_COL32(255, 200, 50, 255));
ImU32 favGoldOutline= S.resolveColor("var(--favorite-outline)", IM_COL32(255, 200, 50, 120));
// User-adjustable opacity for the favorite gold visuals (Settings > Appearance). 1.0 =
// theme default (unchanged); scales whatever alpha the skin resolved, so it is skin-agnostic.
const float favOp = app->settings() ? app->settings()->getFavoriteOpacity() : 1.0f;
ImU32 favGoldFill = ScaleAlpha(S.resolveColor("var(--favorite-fill)", IM_COL32(255, 200, 50, 40)), favOp);
ImU32 favGoldBorder = ScaleAlpha(S.resolveColor("var(--favorite-border)", IM_COL32(255, 200, 50, 100)), favOp);
ImU32 favGoldIcon = ScaleAlpha(S.resolveColor("var(--favorite-icon)", IM_COL32(255, 200, 50, 255)), favOp);
ImU32 favGoldOutline= ScaleAlpha(S.resolveColor("var(--favorite-outline)", IM_COL32(255, 200, 50, 120)), favOp);
ImU32 btnFill = S.resolveColor("var(--row-button-fill)", IM_COL32(255, 255, 255, 12));
ImU32 btnFillHov = S.resolveColor("var(--row-button-fill-hover)", IM_COL32(255, 255, 255, 25));
ImU32 btnBorder = S.resolveColor("var(--row-button-border)", IM_COL32(255, 255, 255, 25));
@@ -590,9 +720,33 @@ void RenderSharedAddressList(App* app, float listH, float availW,
}
}
// ---- Line-2 right content widths: USD + optional period-earnings to its left ----
std::string usdText = FormatAddressUsdValue(addr.balance, state.market.price_usd);
float usdW = usdText.empty() ? 0.0f
: capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdText.c_str()).x;
const bool haveFiat = state.market.price_usd > 0.0;
bool earnShown = earnResult && app->settings() &&
(earnShowAll || app->settings()->isAddressEarningsShown(addr.address));
char earnBuf[48]; earnBuf[0] = '\0';
float earnW = 0.0f; ImU32 earnCol = OnSurfaceDisabled();
if (earnShown) {
double ev = dragonx::data::earningsForAddress(*earnResult, addr.address);
double fiat = ev * state.market.price_usd;
int fmt = (!haveFiat) ? 0 : earnFormat; // no price -> coin only
if (fmt == 0) snprintf(earnBuf, sizeof(earnBuf), "%+.4f", ev);
else if (fmt == 2) snprintf(earnBuf, sizeof(earnBuf), "%s$%.2f", ev < 0 ? "-" : "+", std::fabs(fiat));
else snprintf(earnBuf, sizeof(earnBuf), "%+.4f ($%.2f)", ev, fiat);
earnW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, earnBuf).x;
earnCol = ev > 1e-12 ? greenCol : (ev < -1e-12 ? earnNegCol : OnSurfaceDisabled());
if (row.hidden) earnCol = OnSurfaceDisabled();
}
// Left edge of the line-2 right content, so the address truncates before it.
const float line2RightStart = contentRight - usdW
- (earnShown && earnBuf[0] ? (earnW + Layout::spacingMd()) : 0.0f);
// ---- Address (second line) ----
{
float addrAvailW = contentRight - labelX;
float addrAvailW = line2RightStart - Layout::spacingMd() - labelX;
ImVec2 fullAddrSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, addr.address.c_str());
std::string display_addr;
if (fullAddrSz.x <= addrAvailW) {
@@ -620,14 +774,17 @@ void RenderSharedAddressList(App* app, float listH, float availW,
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(balX, cy), balCol, balBuf);
}
// ---- USD value (second line, right-aligned) ----
// ---- Line 2 right: period-earnings (left of) + USD value, right-aligned ----
{
std::string usdText = FormatAddressUsdValue(addr.balance, state.market.price_usd);
if (!usdText.empty()) {
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdText.c_str());
const float line2Y = cy + body2->LegacySize + Layout::spacingXs();
if (earnShown && earnBuf[0]) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(contentRight - usdSz.x,
cy + body2->LegacySize + Layout::spacingXs()),
ImVec2(contentRight - usdW - Layout::spacingMd() - earnW, line2Y),
earnCol, earnBuf);
}
if (!usdText.empty()) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(contentRight - usdW, line2Y),
OnSurfaceDisabled(), usdText.c_str());
}
}
@@ -690,6 +847,10 @@ void RenderSharedAddressList(App* app, float listH, float availW,
SetSendFromAddress(addr.address);
app->setCurrentPage(NavPage::Send);
}
if (ImGui::MenuItem(TR("view_transactions"))) {
SetTransactionsAddressFilter(addr.address);
app->setCurrentPage(NavPage::History);
}
ImGui::Separator();
if (ImGui::MenuItem(TR("set_label"))) {
AddressLabelDialog::show(app, addr.address, row.isZ);
@@ -720,6 +881,15 @@ void RenderSharedAddressList(App* app, float listH, float availW,
if (ImGui::MenuItem(TR("favorite_address")))
app->favoriteAddress(addr.address);
}
// Per-address earnings column toggle (when the global show-all toggle is off).
if (app->settings()) {
const bool shown = app->settings()->isAddressEarningsShown(addr.address);
if (ImGui::MenuItem(shown ? TR("hide_earnings") : TR("show_earnings"))) {
if (shown) app->settings()->hideAddressEarnings(addr.address);
else app->settings()->showAddressEarnings(addr.address);
app->settings()->save();
}
}
// Portfolio membership — toggle this address in each Market-tab portfolio entry.
ImGui::Separator();
if (ImGui::BeginMenu(TR("portfolio_add_to"))) {
@@ -810,7 +980,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
listPanelMin.y, listPanelMax.y, fadeZone, addrScrollY, addrScrollMaxY);
}
// Address count
// Address count + create-address buttons (relocated below the list to free the toolbar row for
// the earnings controls). Count on the left, New Z / New mining right-aligned on the same row.
{
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
char countBuf[128];
@@ -818,6 +989,38 @@ void RenderSharedAddressList(App* app, float listH, float availW,
snprintf(countBuf, sizeof(countBuf), TR("showing_x_of_y"),
(int)rows.size(), totalAddrs);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), countBuf);
float buttonWidth = ((addrBtn.width > 0) ? addrBtn.width : 140.0f) * dp;
float spacing = ((addrBtn.gap > 0) ? addrBtn.gap : 8.0f) * dp;
float totalButtonsWidth = buttonWidth * 2 + spacing;
float rightMargin = S.drawElement("tabs.balance", "button-row-right-margin").sizeOr(16.0f);
ImGui::SameLine(std::max(ImGui::GetCursorPosX(),
ImGui::GetWindowWidth() - totalButtonsWidth - rightMargin));
bool sharedAddrSyncing = state.sync.syncing && !state.sync.isSynced();
ImGui::BeginDisabled(sharedAddrSyncing);
if (s_generating_z_address) {
char genLabel[64];
snprintf(genLabel, sizeof(genLabel), "%s%s##shared_z", TR("generating"), material::LoadingDots());
TactileButton(genLabel, ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font));
} else if (TactileButton(TR("new_z_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
s_generating_z_address = true;
app->createNewZAddress([](const std::string& addr) {
s_generating_z_address = false;
DEBUG_LOGF("Created new z-address: %s\n", addr.c_str());
});
}
ImGui::SameLine();
if (TactileButton(TR("new_mining_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
app->createNewTAddress([](const std::string& addr) {
DEBUG_LOGF("Created new t-address: %s\n", addr.c_str());
});
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_new_mining_address"));
ImGui::EndDisabled();
}
}
@@ -854,7 +1057,27 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
if (count == 0) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));
const bool roomForIcon = ImGui::GetContentRegionAvail().y >= 100.0f * dp;
if (!app->isConnected()) {
// Disconnected: not "empty" and not "loading" — keep a plain caption.
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
TR(app->isLiteBuild() ? "lite_no_wallet" : "not_connected"));
} else {
const std::string txLoadingText = app->transactionRefreshProgressText();
// In lite, transactionRefreshProgressText() is empty and last_tx_update is stamped differently,
// so treat only an active sync as "loading" there.
const bool loadingOrSyncing = !txLoadingText.empty() ||
(state.sync.syncing && !state.sync.isSynced()) ||
(!app->isLiteBuild() && state.last_tx_update == 0);
if (loadingOrSyncing) {
char lbuf[192];
const char* base = !txLoadingText.empty() ? txLoadingText.c_str() : TR("loading_transactions");
snprintf(lbuf, sizeof(lbuf), "%s%s", base, material::LoadingDots());
material::DrawEmptyState(roomForIcon ? ICON_MD_HOURGLASS_EMPTY : nullptr, lbuf);
} else {
material::DrawEmptyState(roomForIcon ? ICON_MD_RECEIPT_LONG : nullptr, TR("no_transactions_yet"));
}
}
} else {
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* capFont = Type().caption();
+12
View File
@@ -8,6 +8,8 @@
#pragma once
#include <string>
#include "imgui.h"
namespace dragonx {
@@ -31,5 +33,15 @@ void RenderSharedAddressList(App* app, float listH, float availW, float glassRou
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
void RenderSyncBar(App* app, ImDrawList* dl, float vs);
// Draws a labeled "Pending +X" chip (unconfirmed balance) at `pos`, via the draw list only, when there
// is unconfirmed balance AND it fits left of `maxRight`. Returns the width drawn (0 if skipped). Adds a
// hover tooltip. Shared by the balance layouts so pending isn't visible in only one of them.
float DrawPendingChip(ImDrawList* dl, ImVec2 pos, float maxRight, ImFont* font, float fontSize);
// Total period-earnings for the whole wallet, formatted per the earnings settings. Returns false
// (and leaves out* untouched) when the global earnings toggle is off, so layouts show it only when
// the user has opted into the earnings view. Shared by RenderCompactHero and the layout totals.
bool FormatTotalEarnings(App* app, std::string& outText, ImU32& outCol);
} // namespace ui
} // namespace dragonx
+12 -1
View File
@@ -1,6 +1,7 @@
#include "balance_recent_tx.h"
#include "../../config/version.h"
#include "../../util/text_format.h"
#include "../../util/i18n.h"
#include <cmath>
#include <cstdio>
@@ -23,12 +24,22 @@ std::string recentTimeAgo(int64_t timestamp)
{
return util::formatTimeAgoShort(timestamp);
}
// Translated type label (the data layer's getTypeDisplay() returns raw English). Never leak a raw RPC
// category: anything unrecognized (including "shield") falls back to a generic "Transaction".
std::string recentTypeLabel(const std::string& type)
{
if (type == "send") return TR("sent_type");
if (type == "receive") return TR("received_label");
if (type == "mined" || type == "generate" || type == "immature") return TR("mined_type");
return TR("tx_type_generic");
}
}
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen)
{
return {
tx.getTypeDisplay(),
recentTypeLabel(tx.type),
util::truncateMiddle(tx.address, addressMaxLen),
formatRecentAmount(tx.type, tx.amount),
recentTimeAgo(tx.timestamp)
+113 -3
View File
@@ -205,6 +205,39 @@ void RenderBalanceTab(App* app)
}
}
// Shared "still catching up" strip, above whichever layout renders, so a syncing node's 0 / stale
// balance is never mistaken for lost funds (the documented "my coins vanished after an update"
// trap). Scoped to the NORMAL catching-up case — connected, a network tip is known, still verifying
// blocks, and not in a rescan / witness rebuild. Offline, no-peers, a busy node and rescans are
// already surfaced by the node-status banner, so this never stacks a second banner on them.
{
namespace m = material;
const auto& st = app->state();
if (st.connected && st.sync.tip_known && st.sync.syncing &&
!st.sync.building_witnesses && !st.sync.rescanning) {
ImFont* ico = m::Type().iconSmall();
ImFont* txt = m::Type().body2();
const float padX = ui::Layout::spacingMd();
const float padY = ui::Layout::spacingSm();
const float availW = ImGui::GetContentRegionAvail().x;
char line[192];
snprintf(line, sizeof(line), TR("balance_catchup_notice"),
static_cast<float>(st.sync.verification_progress) * 100.0f);
const float iconW = ico->LegacySize + padX * 0.5f;
const float wrapW = std::max(1.0f, availW - padX * 2.0f - iconW);
ImVec2 tsz = txt->CalcTextSizeA(txt->LegacySize, FLT_MAX, wrapW, line);
const float h = std::max(ico->LegacySize, tsz.y) + padY * 2.0f;
ImVec2 p0 = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p0, ImVec2(p0.x + availW, p0.y + h),
m::WithAlphaF(m::Warning(), 0.13f), ui::Layout::glassRounding());
dl->AddText(ico, ico->LegacySize, ImVec2(p0.x + padX, p0.y + padY), m::Warning(), ICON_MD_SYNC);
dl->AddText(txt, txt->LegacySize, ImVec2(p0.x + padX + iconW, p0.y + padY),
m::OnSurfaceMedium(), line, nullptr, wrapW);
ImGui::Dummy(ImVec2(availW, h + ui::Layout::spacingSm()));
}
}
// Dispatch by string ID
for (const auto& entry : s_layoutDispatch) {
if (layoutId == entry.id) {
@@ -364,7 +397,7 @@ static void RenderBalanceClassic(App* app)
OnSurfaceMedium(), DRAGONX_TICKER);
cy += sub1->LegacySize + valGap;
// USD value
// USD value (+ total period-earnings to its right when the earnings toggle is on)
{
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0)
@@ -373,6 +406,12 @@ static void RenderBalanceClassic(App* app)
snprintf(buf, sizeof(buf), "$-.-- USD");
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
OnSurfaceDisabled(), buf);
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
float usdW = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + usdW + tickGap * 3.0f, cy), earnCol, earnText.c_str());
}
}
cy += capFont->LegacySize + 2 * dp;
@@ -486,9 +525,10 @@ static void RenderBalanceClassic(App* app)
WithAlpha(Success(), 160), buf);
}
// Unconfirmed badge (top-right corner)
// Pending badge (top-right corner) — labeled so an unconfirmed "+X" doesn't read as a mystery.
bool pendingChipHovered = false;
if (state.unconfirmed_balance > 0.0) {
snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance);
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), state.unconfirmed_balance);
ImVec2 ts = capFont->CalcTextSizeA(
capFont->LegacySize, 10000, 0, buf);
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp;
@@ -501,6 +541,7 @@ static void RenderBalanceClassic(App* app)
dl->AddText(capFont, capFont->LegacySize,
ImVec2(bMin.x + bp, bMin.y + bp * 0.5f),
amberCol, buf);
if (material::IsRectHovered(bMin, bMax)) { pendingChipHovered = true; material::Tooltip("%s", TR("baltab_pending_tip")); }
}
// Hover glow + click to Receive
@@ -508,6 +549,7 @@ static void RenderBalanceClassic(App* app)
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (!pendingChipHovered) material::Tooltip("%s", TR("shielded_card_tooltip"));
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::Receive);
}
@@ -708,6 +750,11 @@ static void RenderBalanceDonut(App* app) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(ImGui::GetCursorScreenPos().x, tickerY),
OnSurfaceMedium(), DRAGONX_TICKER);
DrawPendingChip(dl, ImVec2(ImGui::GetCursorScreenPos().x
+ capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, DRAGONX_TICKER).x
+ Layout::spacingMd(), tickerY),
ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x,
capFont, capFont->LegacySize);
ImGui::NewLine();
// USD value
@@ -715,6 +762,13 @@ static void RenderBalanceDonut(App* app) {
if (usd_value > 0.0) snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else snprintf(buf, sizeof(buf), "$-.-- USD");
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
{ // total period-earnings beside the USD when the earnings toggle is on
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
ImGui::SameLine(0, Layout::spacingMd());
Type().textColored(TypeStyle::Caption, earnCol, earnText.c_str());
}
}
}
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, cGap));
@@ -916,6 +970,13 @@ static void RenderBalanceConsolidated(App* app) {
if (usd_value > 0.0) snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else snprintf(buf, sizeof(buf), "$-.-- USD");
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
{ // total period-earnings to the right of the USD when the earnings toggle is on
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
float usdW = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + usdW + Layout::spacingLg(), cy), earnCol, earnText.c_str());
}
}
// Market badge (top-right)
{
@@ -1291,6 +1352,15 @@ static void RenderBalanceVerticalStack(App* app) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rowEnd.x - amtSz.x - rowPad, cy),
i == 0 ? OnSurface() : rowInfos[i].accent, buf);
if (i == 0) { // total period-earnings, left of the Total row's amount, when the toggle is on
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
float earnW = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, earnText.c_str()).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rowEnd.x - amtSz.x - rowPad - Layout::spacingMd() - earnW, cy),
earnCol, earnText.c_str());
}
}
// Proportion bar (for shielded/transparent rows — fills gap between label and amount)
if (i == 1 || i == 2) {
@@ -1490,6 +1560,15 @@ static void RenderBalanceVertical2x2(App* app) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cellMax.x - amtSz.x - rowPad, cy),
cell.isMarket ? cell.accent : (row == 0 && col == 0 ? OnSurface() : cell.accent), buf);
if (row == 0 && col == 0) { // total period-earnings below the Total cell's amount, toggle on
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
float earnW = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, earnText.c_str()).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cellMax.x - earnW - rowPad, cy + capFont->LegacySize + 2.0f * dp),
earnCol, earnText.c_str());
}
}
// Proportion bar (shielded/transparent)
if (cell.hasBar) {
@@ -1591,12 +1670,24 @@ static void RenderBalanceShield(App* app) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
OnSurfaceMedium(), DRAGONX_TICKER);
DrawPendingChip(dl, ImVec2(ImGui::GetCursorScreenPos().x
+ capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, DRAGONX_TICKER).x
+ Layout::spacingMd(), pos.y + heroSz.y - capFont->LegacySize),
ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x,
capFont, capFont->LegacySize);
ImGui::NewLine();
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0) snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else snprintf(buf, sizeof(buf), "$-.-- USD");
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
{ // total period-earnings beside the USD when the earnings toggle is on
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
ImGui::SameLine(0, Layout::spacingMd());
Type().textColored(TypeStyle::Caption, earnCol, earnText.c_str());
}
}
}
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, cGap));
@@ -1773,12 +1864,24 @@ static void RenderBalanceTimeline(App* app) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
OnSurfaceMedium(), DRAGONX_TICKER);
DrawPendingChip(dl, ImVec2(ImGui::GetCursorScreenPos().x
+ capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, DRAGONX_TICKER).x
+ Layout::spacingMd(), pos.y + heroSz.y - capFont->LegacySize),
ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x,
capFont, capFont->LegacySize);
ImGui::NewLine();
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0) snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else snprintf(buf, sizeof(buf), "$-.-- USD");
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
{ // total period-earnings beside the USD when the earnings toggle is on
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
ImGui::SameLine(0, Layout::spacingMd());
Type().textColored(TypeStyle::Caption, earnCol, earnText.c_str());
}
}
}
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, cGap));
@@ -2145,6 +2248,13 @@ static void RenderBalanceMinimal(App* app) {
else snprintf(buf, sizeof(buf), "$-.-- USD");
ImGui::SameLine(0, Layout::spacingLg());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
{ // total period-earnings beside the USD when the earnings toggle is on
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
ImGui::SameLine(0, Layout::spacingMd());
Type().textColored(TypeStyle::Caption, earnCol, earnText.c_str());
}
}
RenderSyncBar(app, dl, vs);
+18 -5
View File
@@ -45,6 +45,7 @@ public:
s_state = State::Confirm;
s_bootstrap.reset();
s_errorMsg.clear();
s_lastUrl.clear();
s_wasDaemonRunning = false;
}
@@ -282,13 +283,17 @@ private:
Type().textColored(TypeStyle::H6, Success(), TR("bootstrap_success"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("bootstrap_success_desc"));
// For the embedded daemon, tell the user the node will start (and offer the button) instead of
// instructing them to start it by hand. External-daemon users keep the manual-start wording.
const bool embedded = s_app->isUsingEmbeddedDaemon();
ImGui::TextWrapped("%s", TR(embedded ? "bootstrap_success_desc_start" : "bootstrap_success_desc"));
ImGui::Spacing();
ImGui::Spacing();
float btnW = 140.0f * dp;
if (s_wasDaemonRunning) {
if (TactileButton(TR("bootstrap_restart_daemon"), ImVec2(btnW, 0))) {
float btnW = 150.0f * dp;
if (s_wasDaemonRunning || embedded) {
if (TactileButton(s_wasDaemonRunning ? TR("bootstrap_restart_daemon") : TR("bootstrap_start_daemon"),
ImVec2(btnW, 0))) {
s_app->setBootstrapDownloading(false);
s_app->startEmbeddedDaemon();
s_open = false;
@@ -314,9 +319,15 @@ private:
float btnW = 120.0f * dp;
if (TactileButton(TR("retry"), ImVec2(btnW, 0))) {
startDownload("");
startDownload(s_lastUrl); // keep the user's source/mirror choice (was startDownload(""))
}
ImGui::SameLine();
{
const std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
if (s_lastUrl != mirrorUrl && TactileButton(TR("bootstrap_try_mirror"), ImVec2(130.0f * dp, 0)))
startDownload(mirrorUrl);
ImGui::SameLine();
}
if (TactileButton(TR("close"), ImVec2(90.0f * dp, 0))) {
s_app->setBootstrapDownloading(false);
s_open = false;
@@ -336,6 +347,7 @@ private:
s_bootstrap->start(dataDir);
else
s_bootstrap->start(dataDir, url);
s_lastUrl = url; // remember what we started (set only after the guards passed)
s_state = State::Downloading;
s_errorMsg.clear();
s_cancelling = false;
@@ -347,6 +359,7 @@ private:
static inline std::unique_ptr<util::Bootstrap> s_bootstrap;
static inline bool s_wasDaemonRunning = false;
static inline bool s_cancelling = false; // cancel requested; disables the Cancel button until the worker stops
static inline std::string s_lastUrl; // last source started ("" = default); so Retry keeps the mirror choice
static inline std::string s_errorMsg;
};
+726 -42
View File
@@ -63,15 +63,19 @@ int s_msgsel_head = 0; // drag-current byte offset
bool s_msgsel_dragging = false;
// Composer + new-conversation UI state.
char s_compose[512] = "";
char s_compose[16400] = ""; // must be >= kChatBodyMaxBytes + 1 (static_assert'd below)
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
// instead of always appending. -1 = unknown/never-focused => append at the end.
int s_composeCursor = -1;
// On-chain chat body cap in bytes = (512 − len("utf8:"))/2 − secretstream ABYTES (see chat_outgoing.cpp).
// The composer hard-caps input to this; the emoji picker respects it too.
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
// On-chain chat body cap in bytes. Larger messages split the ciphertext across many memo outputs: tier 2
// packs up to kHushChatMaxMessageParts (16) into ONE transaction (~4 KB), and tier 3 spills further across
// several transactions up to kHushChatMaxMessageChunks (64) chunks total (protocol max ~16303: 64 × 255
// cipher bytes − 17 secretstream tag). Kept a touch under that so the composer never produces a body that
// compose would reject as TooLong. The composer hard-caps input here; the emoji picker too.
constexpr int kChatBodyMaxBytes = 16000;
static_assert(sizeof(s_compose) >= kChatBodyMaxBytes + 1, "compose buffer too small for the body cap");
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
@@ -96,12 +100,35 @@ int composeInputCallback(ImGuiInputTextCallbackData* data) {
bool s_show_new_convo = false;
char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
char s_pending_new_zaddr[128] = ""; // cross-tab request from Contacts' "Message" action
bool s_pending_new_set = false;
char s_search[80] = ""; // conversation-list filter (Q8)
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (revive vs block)
std::string s_delete_cid; // conversation targeted by the delete confirm
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
bool s_show_restore = false; // restore-conversations overlay (reverse an on-chain delete)
char s_restore_addr[128] = ""; // paste-a-chat-address buffer in the restore overlay
bool s_show_sensitive = false; // sensitive mark/unmark/setup/unlock overlay
std::string s_sensitive_cid; // target conversation (empty => "unlock the vault only", from the opener)
std::string s_sensitive_name; // peer display name (for the label)
std::string s_sensitive_peeraddr; // peer z-address (carried as the control label, for restore-by-address parity)
bool s_sensitive_unmark = false; // true => the dialog is UNmarking a currently-sensitive conversation
char s_sensitive_pass[128] = ""; // passphrase (setup / unlock)
char s_sensitive_pass2[128] = ""; // confirm passphrase (setup only)
bool s_show_export_confirm = false; // "export as plaintext" confirm overlay
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).
static std::string chatExportPathFor(const std::string& peerName) {
std::string safe;
for (char ch : peerName) safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
if (safe.empty()) safe = "chat";
return util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
}
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
char s_emoji_search[48] = ""; // emoji picker keyword filter
@@ -535,6 +562,7 @@ struct ConvSummary {
std::int64_t lastTs = 0;
int count = 0;
bool hidden = false; // shown only while "Show hidden" is on
bool pendingRequest = false; // unsolicited incoming contact request from a stranger: awaiting Accept/Block
};
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
@@ -576,6 +604,17 @@ void centeredHint(const char* text) {
}
// 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) {
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 origin = ImGui::GetCursorPos();
@@ -748,12 +787,35 @@ void RenderChatTab(App* app)
const auto& store = service.store();
auto& book = app->addressBook();
// Not unlocked / identity not derived yet → nothing to show.
// No identity yet → show WHY, not a blanket "unlock your wallet" (which is wrong for an unlocked
// view-only/legacy wallet that simply can't derive a chat identity, and left it stuck forever).
if (!service.hasIdentity()) {
centeredEmptyState(ICON_MD_LOCK, TR("chat_locked_hint"), nullptr);
switch (app->chatIdentityStatus()) {
case App::ChatIdentityStatus::Preparing:
centeredEmptyState(ICON_MD_HOURGLASS_EMPTY, TR("chat_preparing_hint"), nullptr);
break;
case App::ChatIdentityStatus::Unavailable:
centeredEmptyState(ICON_MD_INFO_OUTLINE, TR("chat_unavailable_hint"), TR("chat_unavailable_sub"));
break;
case App::ChatIdentityStatus::Locked:
default:
centeredEmptyState(ICON_MD_LOCK, TR("chat_locked_hint"), nullptr);
break;
}
return;
}
// Consume a cross-tab "Message this contact" request (from Contacts): open the New-conversation
// dialog prefilled with the z-address. Runs every frame the tab renders, so it fires right after the
// Contacts tab switches us here.
if (s_pending_new_set) {
s_pending_new_set = false;
s_show_new_convo = true;
std::memcpy(s_new_zaddr, s_pending_new_zaddr, sizeof(s_new_zaddr));
s_new_msg[0] = '\0';
sodium_memzero(s_pending_new_zaddr, sizeof(s_pending_new_zaddr));
}
// Resolve the Chat-tab clock once per frame: the chat override wins, else the global format.
if (auto* cst = app->settings()) {
const int ctf = cst->getChatTimeFormat(); // 0=follow global, 1=24h, 2=12h
@@ -783,9 +845,11 @@ void RenderChatTab(App* app)
c.cid = cid;
c.hidden = hidden;
c.count = static_cast<int>(messages.size());
bool sentAny = false;
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
if (m.direction == chat::ChatDirection::Outgoing) sentAny = true;
}
const auto& last = messages.back();
c.lastBody = last.body;
@@ -793,6 +857,12 @@ void RenderChatTab(App* app)
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
c.peerName = (idx >= 0) ? book.entries()[idx].label
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
// Unsolicited: first message is an incoming contact request, we've never replied, and the peer
// isn't a saved contact → show an Accept/Block card instead of treating it as a normal chat.
const auto& first = messages.front();
c.pendingRequest = (idx < 0) && !sentAny && !c.peerZaddr.empty() &&
first.direction == chat::ChatDirection::Incoming &&
first.kind == chat::ChatMessageKind::ContactRequest;
s_convs.push_back(std::move(c));
}
std::sort(s_convs.begin(), s_convs.end(),
@@ -904,6 +974,57 @@ void RenderChatTab(App* app)
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
ImGui::PopStyleColor();
}
// Restore opener — reverse an on-chain "Delete everywhere". Shown when a delete is known from chain
// (works on a fresh seed import, which has no local state) or something is locally blocked; the
// dialog also offers paste-by-address + a quick scan for deletes made on another device.
const int onChainDelCount = (int)app->chatService().onChainDeletedConversations().size();
if (onChainDelCount > 0 || blockedCount > 0) {
if (blockedCount > 0) ImGui::SameLine();
const std::string rl = onChainDelCount > 0
? std::string(TR("chat_restore_manage")) + " (" + std::to_string(onChainDelCount) + ")"
: std::string(TR("chat_restore_manage"));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
if (ImGui::SmallButton(rl.c_str())) s_show_restore = true;
ImGui::PopStyleColor();
}
// Sensitive-vault opener: lock / unlock the local key for sensitive conversations. Shown only once
// a vault exists (it's created on the first "Make sensitive"). While locked it shows the count of
// sensitive conversations currently withheld.
{
const auto vs = app->chatSensitiveVaultState();
if (vs != App::ChatVaultState::NotConfigured) {
if (blockedCount > 0 || onChainDelCount > 0) ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
if (vs == App::ChatVaultState::Unlocked) {
if (ImGui::SmallButton(TR("chat_sensitive_lock_btn"))) {
app->lockChatSensitiveVault();
s_convsKey = ~0ull; // sensitive threads vanish — rebuild the list
}
} else { // Locked
const int sn = app->settings() ? (int)app->settings()->sensitiveChatConversations().size() : 0;
const std::string ul = sn > 0
? std::string(TR("chat_sensitive_unlock_btn")) + " (" + std::to_string(sn) + ")"
: std::string(TR("chat_sensitive_unlock_btn"));
if (ImGui::SmallButton(ul.c_str())) {
s_sensitive_cid.clear(); s_sensitive_name.clear(); s_sensitive_peeraddr.clear();
s_sensitive_unmark = false;
s_sensitive_pass[0] = '\0'; s_sensitive_pass2[0] = '\0';
s_show_sensitive = true; // empty cid => unlock-only
}
}
ImGui::PopStyleColor();
}
}
// "Copy my chat address" — the address others use to reach this user. The conversation header
// only copies a PEER's address, so without this there's no way to hand out your own. Copies on
// click only (chatMyAddress may persist a freshly chosen address).
{
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
if (ImGui::SmallButton(TR("chat_copy_my_address"))) copyMyChatAddress(app);
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
}
const std::string search = s_search;
ImGui::Separator();
if (convs.empty()) {
@@ -970,9 +1091,21 @@ void RenderChatTab(App* app)
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
dl->PopClipRect();
// Pending-request pill (bottom-right) for an unsolicited stranger; the preview is clipped to its left.
float previewRight = mx.x - pad;
if (c.pendingRequest) {
const char* rl = TR("chat_request_pill");
const ImVec2 rs = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, rl);
const float px = 6.0f * dp, py = 1.0f * dp;
const ImVec2 pmax(mx.x - pad, p.y + rowH - pad + py);
const ImVec2 pmin(pmax.x - rs.x - 2.0f * px, pmax.y - rs.y - 2.0f * py);
dl->AddRectFilled(pmin, pmax, material::WithAlpha(material::Warning(), 60), (rs.y + 2.0f * py) * 0.5f);
dl->AddText(metaFont, metaSz, ImVec2(pmin.x + px, pmin.y + py), material::Warning(), rl);
previewRight = pmin.x - pad;
}
// Preview (bottom, clipped to the text column, muted).
const std::string preview = previewOf(c.lastBody);
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(std::max(textX, previewRight), mx.y), true);
dl->AddText(metaFont, metaSz, ImVec2(textX, p.y + rowH - pad - metaSz),
material::OnSurfaceMedium(), preview.c_str());
dl->PopClipRect();
@@ -1077,7 +1210,7 @@ void RenderChatTab(App* app)
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
// shown whenever there's an address to save the contact under; the settings "notch" gear is
// always the rightmost icon.
const int nBtns = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename)
const int nBtns = 6 + (hasAddr ? 1 : 0); // export, mute, hide, sensitive, delete, settings (+rename)
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
@@ -1177,23 +1310,9 @@ void RenderChatTab(App* app)
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base; a.tooltip = TR("chat_export_warn");
if (material::IconButton("##hdr_export", ICON_MD_FILE_DOWNLOAD, ifont, ImVec2(ib, ib), a)) {
std::string content = "DragonX chat export\n";
content += sel->peerName + " <" + sel->peerZaddr + ">\n";
content += "cid: " + sel->cid + "\n\n";
for (const auto& m : store.conversation(sel->cid)) {
const bool out = (m.direction == chat::ChatDirection::Outgoing);
content += "[" + formatTime(m.timestamp) + "] " +
(out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n";
}
std::string safe;
for (char ch : sel->peerName)
safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
if (safe.empty()) safe = "chat";
const std::string path = util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
else
Notifications::instance().error(TR("chat_export_failed"));
// The export writes DECRYPTED plaintext — confirm first instead of writing on one click.
s_export_cid = sel->cid;
s_show_export_confirm = true;
}
bx += ib + gap;
}
@@ -1234,6 +1353,24 @@ void RenderChatTab(App* app)
}
bx += ib + gap;
}
// Sensitive — per-conversation local-key encryption. Opens a dialog that (by vault state)
// sets up / unlocks the vault and marks or unmarks this conversation.
{
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base;
const bool isSens = app->chatService().isConversationSensitive(sel->cid);
a.tooltip = isSens ? TR("chat_sensitive_unmark_tip") : TR("chat_sensitive_mark_tip");
if (material::IconButton("##hdr_sensitive",
isSens ? ICON_MD_LOCK : ICON_MD_ENHANCED_ENCRYPTION, ifont, ImVec2(ib, ib), a)) {
s_sensitive_cid = sel->cid;
s_sensitive_name = sel->peerName;
s_sensitive_peeraddr = sel->peerZaddr;
s_sensitive_unmark = isSens;
s_sensitive_pass[0] = '\0'; s_sensitive_pass2[0] = '\0';
s_show_sensitive = true;
}
bx += ib + gap;
}
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
{
@@ -1264,13 +1401,22 @@ void RenderChatTab(App* app)
// same row. Hidden while renaming (the field takes the whole span).
if (hasAddr && !renaming) {
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) {
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),
material::WithAlpha(material::Primary(), 220), ICON_MD_LOCK);
if (ImGui::IsMouseHoveringRect(ImVec2(ax, ly), ImVec2(ax + lockW, ly + icoSz)))
material::Tooltip("%s\n%s", TR("chat_verify_key"), keyFingerprint(sel->peerPubKey).c_str());
lockHov ? material::Primary() : material::WithAlpha(material::Primary(), 220),
ICON_MD_LOCK);
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;
}
// Click-to-copy compact address.
@@ -1297,6 +1443,42 @@ void RenderChatTab(App* app)
ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi));
ImGui::Separator();
// Stranger contact-request card: Accept (add to contacts) or Block (delete + block). Normal
// ImGui flow, so ##ChatMessages below (sized from the remaining content height) shrinks to fit.
if (sel->pendingRequest) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
ImGui::TextUnformatted(TR("chat_request_title"));
ImGui::PopStyleColor();
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", TR("chat_request_body"));
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
if (ImGui::SmallButton(TR("chat_request_accept"))) {
data::AddressBookEntry e;
e.address = sel->peerZaddr;
e.label = sel->peerName;
book.addEntry(e); // book.revision() bumps → the request flag clears on the next rebuild
Notifications::instance().info(TR("chat_request_accepted"));
}
ImGui::SameLine();
if (ImGui::SmallButton(TR("chat_request_block"))) {
const std::string cid = sel->cid, nm = sel->peerName; // copy before deleteConversation
if (app->chatService().deleteConversation(cid, /*block=*/true)) {
if (app->settings()) {
app->settings()->setChatBlocked(cid, nm, true);
app->settings()->setChatHidden(cid, false);
app->settings()->save();
}
if (s_selected_cid == cid) s_selected_cid.clear();
Notifications::instance().info(TR("chat_blocked_toast"));
} else {
Notifications::instance().error(TR("chat_delete_failed"));
}
}
ImGui::Separator();
}
const float dp = Layout::dpiScale();
// Small message-list inset (the ##ChatThread WindowPadding above already holds content off
// the glass edge; keep a little here so bubbles don't hug the scrollbar).
@@ -1305,6 +1487,13 @@ void RenderChatTab(App* app)
const float origSpacingX = ImGui::GetStyle().ItemSpacing.x;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f * dp, 8.0f * dp));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(origSpacingX, 0.0f));
// Thicker, clearly-visible scrollbar for the message thread: the global one is a thin 6px with a
// ~6% grab that's hard to see and grab. Pushed for this child only (popped after EndChild below).
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 12.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 6.0f * dp);
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, material::WithAlpha(material::OnSurface(), 55));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, material::WithAlpha(material::OnSurface(), 95));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, material::WithAlpha(material::OnSurface(), 135));
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y),
ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollWithMouse);
material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below
@@ -1628,7 +1817,10 @@ void RenderChatTab(App* app)
}
}
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();
} else if (s_msgmenu_index == static_cast<int>(mi)) {
s_msgmenu_index = -1; // popup closed
@@ -1667,8 +1859,15 @@ void RenderChatTab(App* app)
const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE;
const float gs = icfSz * 0.82f;
const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, glyph);
dl->AddText(icf, gs, ImVec2(bmax.x - gsz.x, bmax.y + 1.0f * dp),
material::WithAlpha(material::OnSurface(), sending ? 95 : 135), glyph);
const ImVec2 gpos(bmax.x - gsz.x, bmax.y + 1.0f * dp);
dl->AddText(icf, gs, gpos,
material::WithAlpha(material::OnSurface(), sending ? 170 : 200), glyph);
// Explain the glyph on hover — a check means "broadcast to the network", not "delivered".
const float hpad = 4.0f * dp;
if (ImGui::IsWindowHovered() &&
ImGui::IsMouseHoveringRect(ImVec2(gpos.x - hpad, gpos.y - hpad),
ImVec2(gpos.x + gsz.x + hpad, gpos.y + gsz.y + hpad)))
material::Tooltip("%s", TR(sending ? "chat_status_sending" : "chat_status_sent"));
ImGui::Dummy(ImVec2(0.0f, gs + 2.0f * dp)); // reserve the status glyph (no ItemSpacing now)
}
// Failed send → right-aligned "not sent" + Retry (kept — it's actionable).
@@ -1680,6 +1879,12 @@ void RenderChatTab(App* app)
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
ImGui::TextUnformatted(TR("chat_send_failed"));
ImGui::PopStyleColor(); ImGui::PopFont();
if (ImGui::IsItemHovered()) {
std::string tip = m.fail_reason.empty()
? std::string(TR("chat_failed_generic"))
: std::string(TR("chat_failed_generic")) + "\n" + m.fail_reason;
material::Tooltip("%s", tip.c_str());
}
ImGui::SameLine();
if (ImGui::SmallButton(TR("chat_retry"))) {
// A failed contact request must be re-sent as a request (it has no peer key
@@ -1753,11 +1958,59 @@ void RenderChatTab(App* app)
}
}
ImGui::EndChild();
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
ImGui::PopStyleColor(3); // ScrollbarGrab + Hovered + Active
ImGui::PopStyleVar(4); // WindowPadding + ItemSpacing + ScrollbarSize + ScrollbarRounding
// Larger-messages tier 3: while a big incoming message is still arriving across transactions,
// show a subtle centred progress line (it surfaces as an ordinary bubble once every part lands).
{
int rcv = 0, tot = 0;
for (const auto& pr : app->chatService().pendingLargeMessages())
if (pr.conversation_id == sel->cid) { rcv = pr.received; tot = pr.total; break; }
if (tot > 0) {
const float dp2 = Layout::dpiScale();
ImGui::Dummy(ImVec2(0.0f, 2.0f * dp2));
char buf[96];
std::snprintf(buf, sizeof buf, "%s %d / %d %s",
TR("chat_receiving_large"), rcv, tot, TR("chat_parts_label"));
ImFont* f = ImGui::GetFont();
const ImVec2 tsz = f->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, buf);
const float availX = ImGui::GetContentRegionAvail().x;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (availX - tsz.x) * 0.5f));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(buf);
ImGui::PopStyleColor();
}
}
// 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 {
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
}
}
ImGui::EndChild();
@@ -1796,6 +2049,17 @@ void RenderChatTab(App* app)
// Fraction of the on-chain cap used, for the radial gauge (drawn inside the input, below).
const float frac = kBodyMaxBytes > 0 ? static_cast<float>(used) / kBodyMaxBytes : 0.0f;
// How this message chunks on-chain (mirrors buildOutgoingMessage): the hex ciphertext is
// (plaintext + the 17-byte secretstream tag) * 2; ≤512 hex fits one memo output, otherwise it
// splits into 510-hex chunks (512 minus a 2-char part index). Up to kHushChatMaxMessageParts (16)
// chunks ride ONE transaction (tier 2); beyond that the message spills across several (tier 3).
const int chatMsgParts = [&]() -> int {
const std::size_t hexLen = (static_cast<std::size_t>(used) + 17) * 2;
return hexLen <= 512 ? 1 : static_cast<int>((hexLen + 509) / 510); // ceil(hexLen/510)
}();
// How many transactions those chunks need (tier 3 is >1): the real cost the user asked to see.
const int chatMsgTxns = (chatMsgParts + chat::kHushChatMaxMessageParts - 1) / chat::kHushChatMaxMessageParts;
// Input row: [emoji] [input] [Send]. The input's TOP is pinned 10px below the message box and
// it grows DOWNWARD as it gets taller (the thread above shrinks), so the row bottom stays put.
const float rowY = boxMax.y + composerAreaH - composerBoxH;
@@ -1906,14 +2170,43 @@ void RenderChatTab(App* app)
gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
thick * 0.6f, fillCol, 10);
}
// Always-visible indicator beside the gauge once the message spans >1 memo output: the part
// count while it still fits one transaction (tier 2), or the TRANSACTION count once it spills
// across several (tier 3 — the real cost the user cares about). Full explanation on hover.
if (chatMsgParts > 1 && !overCap) {
ImFont* cf = ImGui::GetFont();
const float cfsz = std::max(10.0f, lineH * 0.42f);
char buf[32];
if (chatMsgTxns > 1)
std::snprintf(buf, sizeof buf, "%d %s", chatMsgTxns, TR("chat_txns_label"));
else
std::snprintf(buf, sizeof buf, "%d %s", chatMsgParts, TR("chat_parts_label"));
const ImVec2 tsz = cf->CalcTextSizeA(cfsz, FLT_MAX, 0.0f, buf);
gdl->AddText(cf, cfsz, ImVec2(ringC.x - ringR - 6.0f * tdp - tsz.x, ringC.y - tsz.y * 0.5f),
material::WithAlpha(material::OnSurface(), chatMsgTxns > 1 ? 205 : 150), buf);
}
// Exact count on hover (progressive disclosure; over-cap prefixes a label).
const float hitR = ringR + thick;
if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
ImVec2(ringC.x + hitR, ringC.y + hitR))) {
std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) tip = std::string(TR("chat_len_over")) + " " + tip;
if (overCap) {
tip = std::string(TR("chat_len_over")) + " " + tip;
} else if (chatMsgTxns > 1) {
tip += " \xC2\xB7 " + std::to_string(chatMsgParts) + " " + TR("chat_parts_label") +
" \xC2\xB7 " + std::to_string(chatMsgTxns) + " " + TR("chat_txns_label");
} else if (chatMsgParts > 1) {
tip += " \xC2\xB7 " + std::to_string(chatMsgParts) + " " + TR("chat_parts_label");
}
ImGui::BeginTooltip();
ImGui::TextUnformatted(tip.c_str());
if (chatMsgParts > 1 && !overCap) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 24.0f);
ImGui::TextUnformatted(TR(chatMsgTxns > 1 ? "chat_parts_explain_multi" : "chat_parts_explain"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
ImGui::EndTooltip();
}
}
@@ -1931,7 +2224,7 @@ void RenderChatTab(App* app)
// When Send is disabled *because the message is over the cap*, explain why on hover — the
// gauge went red but the numeric reason is no longer always-visible.
if (overCap && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s %d / %d", TR("chat_len_over"), used, kBodyMaxBytes);
material::Tooltip("%s %d / %d", TR("chat_len_over"), used, kBodyMaxBytes);
ImGui::PopStyleColor(3);
if (submit && s_compose[0] != '\0' && !overCap) {
@@ -1977,17 +2270,27 @@ void RenderChatTab(App* app)
const std::string activeHash = app->activeWalletScopeId(); // per-wallet contact scope
int shown = 0;
for (const auto& e : book.entries()) {
if (e.address.empty() || e.address[0] != 'z') continue; // chat requires a z-address
if (e.address.empty()) continue;
// Respect the same per-wallet scope the Contacts tab enforces — don't leak another
// wallet's scoped contact into this wallet's picker (global + legacy fail open).
const bool visible = e.isGlobal() ||
(e.scope.rfind("w:", 0) == 0 ? (!activeHash.empty() && e.scope == activeHash) : true);
if (!visible) continue;
++shown;
const std::string item = e.label + " " + shorten(e.address, 16, 8);
if (ImGui::Selectable(item.c_str())) {
std::strncpy(s_new_zaddr, e.address.c_str(), sizeof(s_new_zaddr) - 1);
s_new_zaddr[sizeof(s_new_zaddr) - 1] = '\0';
// Show transparent contacts too, but DISABLED with a reason — chat needs a shielded
// address, so silently hiding them left the user wondering where a contact went.
if (dragonx::util::isShieldedAddress(e.address)) {
++shown;
if (ImGui::Selectable(item.c_str())) {
std::strncpy(s_new_zaddr, e.address.c_str(), sizeof(s_new_zaddr) - 1);
s_new_zaddr[sizeof(s_new_zaddr) - 1] = '\0';
}
} else {
ImGui::BeginDisabled();
ImGui::Selectable(item.c_str(), false);
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", TR("chat_contact_needs_z"));
}
}
if (shown == 0) {
@@ -2028,6 +2331,113 @@ void RenderChatTab(App* app)
}
}
// ---- Export-as-plaintext confirm (unencrypted file on disk) ----
if (s_show_export_confirm) {
const ConvSummary* ex = nullptr;
for (const auto& c : convs) if (c.cid == s_export_cid) { ex = &c; break; }
if (!ex) {
s_show_export_confirm = false;
s_export_cid.clear();
} else {
material::OverlayDialogSpec ov;
ov.title = TR("chat_export_confirm_title");
ov.p_open = &s_show_export_confirm;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 500.0f;
ov.idSuffix = "chatexport";
if (material::BeginOverlayDialog(ov)) {
const float edp = Layout::dpiScale();
const std::string path = chatExportPathFor(ex->peerName);
ImGui::PushTextWrapPos(0.0f);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
ImGui::TextWrapped("%s", TR("chat_export_confirm_body"));
ImGui::PopStyleColor();
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
ImGui::TextUnformatted(TR("chat_export_confirm_path"));
ImGui::PopStyleColor();
ImGui::TextWrapped("%s", path.c_str());
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
const float egap = ImGui::GetStyle().ItemSpacing.x;
float wExp = std::max(110.0f * edp, ImGui::CalcTextSize(TR("chat_export_confirm_btn")).x + 24.0f * edp);
float wCan = std::max(90.0f * edp, ImGui::CalcTextSize(TR("chat_cancel")).x + 24.0f * edp);
material::BeginOverlayDialogFooter(wExp + wCan + egap, false);
if (material::TactileButton(TR("chat_export_confirm_btn"), ImVec2(wExp, 0))) {
// Build + write exactly as the old inline export did (format byte-identical).
std::string content = "DragonX chat export\n";
content += ex->peerName + " <" + ex->peerZaddr + ">\n";
content += "cid: " + ex->cid + "\n\n";
for (const auto& m : store.conversation(ex->cid)) {
const bool out = (m.direction == chat::ChatDirection::Outgoing);
content += "[" + formatTime(m.timestamp) + "] " +
(out ? std::string(TR("chat_you")) : ex->peerName) + ": " + m.body + "\n";
}
if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
else
Notifications::instance().error(TR("chat_export_failed"));
s_show_export_confirm = false;
s_export_cid.clear();
}
ImGui::SameLine(0, egap);
if (material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0))) {
s_show_export_confirm = false;
s_export_cid.clear();
}
material::EndOverlayDialog();
}
}
}
// ---- 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) ----
if (s_show_delete_confirm) {
const float dp = Layout::dpiScale();
@@ -2073,6 +2483,57 @@ void RenderChatTab(App* app)
s_delete_cid.clear(); s_delete_name.clear();
};
// "Delete everywhere": block locally (instant, persistent — no sync flicker) AND broadcast a
// self-addressed Control(del) so other devices + a fresh seed import hide it too. Carries the
// peer z-address as the control label so Restore can match it by address.
auto doDeleteEverywhere = [&]() {
const std::string cid = s_delete_cid, name = s_delete_name;
std::string peerAddr;
for (const auto& m : app->chatService().store().conversation(cid)) {
if (!m.peer_zaddr.empty()) { peerAddr = m.peer_zaddr; break; }
}
if (!app->chatService().deleteConversation(cid, /*block=*/true)) {
Notifications::instance().error(TR("chat_delete_failed"));
s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear();
return;
}
if (app->settings()) {
app->settings()->setChatBlocked(cid, name, true);
app->settings()->setChatHidden(cid, false);
app->settings()->save();
}
app->sendChatControl(cid, "del", peerAddr.empty() ? name : peerAddr);
if (s_selected_cid == cid) s_selected_cid.clear();
Notifications::instance().info(TR("chat_delete_everywhere_toast"));
s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear();
};
// Full-width on-chain delete above the local footer, gated on a fully-synced wallet (so a
// conversation can't flicker in/out mid-sync while the delete signal is still catching up).
// Full node requires tip_known (isSynced); lite never sets it, so use its own "sync complete
// + at tip" signal there — otherwise the button would be permanently disabled in lite.
const auto& syncSt = app->state().sync;
const bool synced = app->isLiteBuild()
? (!syncSt.syncing && syncSt.blocks > 0 && syncSt.blocks >= syncSt.headers - 2)
: syncSt.isSynced();
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnError()));
ImGui::BeginDisabled(!synced);
const bool doEvery = material::TactileButton(TR("chat_delete_everywhere"),
ImVec2(ImGui::GetContentRegionAvail().x, 0));
ImGui::EndDisabled();
ImGui::PopStyleColor(4);
if (doEvery) doDeleteEverywhere();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(synced ? TR("chat_delete_everywhere_note") : TR("chat_delete_needs_sync"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
auto textW = [&](const char* t) {
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
};
@@ -2144,6 +2605,215 @@ void RenderChatTab(App* app)
}
}
// ---- Restore conversations (reverse an on-chain "Delete everywhere") ----
if (s_show_restore) {
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_restore_title");
ov.p_open = &s_show_restore;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "chatrestore";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_restore_desc"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Restore: clear any local block/hide here AND broadcast Control("res") so every device un-hides.
// The messages re-import from the chain on the next scan (the reducer no longer hides the cid).
auto doRestore = [&](const std::string& cid, const std::string& label) {
if (cid.empty()) return;
if (app->settings()) {
app->settings()->setChatBlocked(cid, "", false);
app->settings()->setChatHidden(cid, false);
app->settings()->save();
}
app->forgetChatConversationSeen(cid);
app->sendChatControl(cid, "res", label.empty() ? cid : label);
Notifications::instance().info(TR("chat_restore_toast"));
};
// Chain-derived deleted conversations — present even on a wallet freshly restored from seed.
const auto deleted = app->chatService().onChainDeletedConversations();
if (deleted.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(TR("chat_restore_none"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
} else {
std::string restoreCid, restoreLabel;
for (const auto& d : deleted) {
ImGui::PushID(d.first.c_str());
const std::string label = d.second.empty() ? shorten(d.first, 10, 6) : shorten(d.second, 12, 6);
const float bw = ImGui::CalcTextSize(TR("chat_restore_btn")).x +
ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp;
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label.c_str());
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw);
if (material::TactileButton(TR("chat_restore_btn"), ImVec2(bw, 0))) {
restoreCid = d.first; restoreLabel = d.second;
}
ImGui::PopID();
}
if (!restoreCid.empty()) doRestore(restoreCid, restoreLabel);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Separator();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Paste the chat (z) address to restore a conversation not in the list (matched against the
// address each delete signal carries; otherwise the input is treated as a raw conversation id).
ImGui::TextUnformatted(TR("chat_restore_paste_label"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##chatrestoreaddr", TR("chat_restore_paste_hint"),
s_restore_addr, sizeof(s_restore_addr));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
const float gap = Layout::spacingSm();
const float wPaste = std::max(120.0f * dp, ImGui::CalcTextSize(TR("chat_restore_btn")).x + 44.0f * dp);
const float wScan = std::max(120.0f * dp, ImGui::CalcTextSize(TR("chat_restore_scan")).x + 44.0f * dp);
if (material::TactileButton(TR("chat_restore_btn"), ImVec2(wPaste, 0))) {
std::string input(s_restore_addr);
while (!input.empty() && (input.front() == ' ' || input.front() == '\n' || input.front() == '\t')) input.erase(input.begin());
while (!input.empty() && (input.back() == ' ' || input.back() == '\n' || input.back() == '\t')) input.pop_back();
if (!input.empty()) {
std::string cid, label;
for (const auto& d : deleted) { if (d.second == input) { cid = d.first; label = d.second; break; } }
if (cid.empty()) { cid = input; label = input; } // not in the list — treat as a raw cid
doRestore(cid, label);
s_restore_addr[0] = '\0';
}
}
ImGui::SameLine(0, gap);
if (material::TactileButton(TR("chat_restore_scan"), ImVec2(wScan, 0))) {
app->noteWalletMutation(); // re-run the change-gated scans → re-harvest control memos
Notifications::instance().info(TR("chat_restore_scanning"));
}
material::EndOverlayDialog();
}
}
// ---- Sensitive: setup / unlock / mark / unmark (per-conversation local-key encryption) ----
if (s_show_sensitive) {
const float dp = Layout::dpiScale();
const auto vs = app->chatSensitiveVaultState();
const bool unlockOnly = s_sensitive_cid.empty();
const bool needSetup = (vs == App::ChatVaultState::NotConfigured);
const bool needUnlock = (vs == App::ChatVaultState::Locked);
material::OverlayDialogSpec ov;
ov.title = unlockOnly ? TR("chat_sensitive_unlock_title")
: s_sensitive_unmark ? TR("chat_sensitive_unmark_title")
: TR("chat_sensitive_mark_title");
ov.p_open = &s_show_sensitive;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "chatsensitive";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
if (unlockOnly) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_sensitive_unlock_body"));
ImGui::PopStyleColor();
} else if (s_sensitive_unmark) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_sensitive_unmark_body"));
ImGui::PopStyleColor();
} else {
ImGui::TextUnformatted(TR("chat_sensitive_warn_1"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
ImGui::TextUnformatted(TR("chat_sensitive_warn_lose"));
ImGui::PopStyleColor();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_sensitive_warn_dir"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::TextUnformatted(TR("chat_sensitive_warn_hist"));
ImGui::PopStyleColor();
}
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (needSetup) {
ImGui::TextUnformatted(TR("chat_sensitive_setup_label"));
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##senspass", TR("chat_sensitive_pass_hint"), s_sensitive_pass,
sizeof(s_sensitive_pass), ImGuiInputTextFlags_Password);
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##senspass2", TR("chat_sensitive_pass2_hint"), s_sensitive_pass2,
sizeof(s_sensitive_pass2), ImGuiInputTextFlags_Password);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
} else if (needUnlock) {
ImGui::TextUnformatted(TR("chat_sensitive_unlock_label"));
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##senspass", TR("chat_sensitive_pass_hint"), s_sensitive_pass,
sizeof(s_sensitive_pass), ImGuiInputTextFlags_Password);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
}
const char* actLabel =
needSetup ? TR("chat_sensitive_btn_setup")
: needUnlock ? (unlockOnly ? TR("chat_sensitive_btn_unlock")
: s_sensitive_unmark ? TR("chat_sensitive_btn_unlock_unmark")
: TR("chat_sensitive_btn_unlock_mark"))
: (unlockOnly ? TR("chat_sensitive_btn_unlock")
: s_sensitive_unmark ? TR("chat_sensitive_btn_unmark") : TR("chat_sensitive_btn_mark"));
auto closeWipe = [&]() {
std::memset(s_sensitive_pass, 0, sizeof(s_sensitive_pass));
std::memset(s_sensitive_pass2, 0, sizeof(s_sensitive_pass2));
s_show_sensitive = false;
s_sensitive_cid.clear(); s_sensitive_name.clear(); s_sensitive_peeraddr.clear();
};
const float gap2 = Layout::spacingSm();
const float wAct = std::max(160.0f * dp, ImGui::CalcTextSize(actLabel).x + 40.0f * dp);
const float wCan = std::max(90.0f * dp, ImGui::CalcTextSize(TR("chat_cancel")).x + 32.0f * dp);
material::BeginOverlayDialogFooter(wAct + wCan + gap2, /*drawSeparator=*/false);
const bool doAct = material::TactileButton(actLabel, ImVec2(wAct, 0));
ImGui::SameLine(0, gap2);
const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));
if (doAct) {
bool ok = true;
if (needSetup) {
const std::string p1(s_sensitive_pass), p2(s_sensitive_pass2);
if (p1.empty() || p1 != p2) {
Notifications::instance().error(TR("chat_sensitive_err_pass")); ok = false;
} else if (!app->createChatSensitiveVault(p1)) {
Notifications::instance().error(TR("chat_sensitive_err_create")); ok = false;
}
} else if (needUnlock) {
const std::string p1(s_sensitive_pass);
if (p1.empty() || !app->unlockChatSensitiveVault(p1)) {
Notifications::instance().error(TR("chat_sensitive_err_unlock")); ok = false;
}
}
if (ok) {
if (!unlockOnly) {
if (s_sensitive_unmark) {
app->unmarkChatConversationSensitive(s_sensitive_cid);
Notifications::instance().info(TR("chat_sensitive_toast_unmarked"));
} else {
app->markChatConversationSensitive(s_sensitive_cid, s_sensitive_peeraddr);
Notifications::instance().info(TR("chat_sensitive_toast_marked"));
}
} else {
Notifications::instance().info(TR("chat_sensitive_toast_unlocked"));
}
s_convsKey = ~0ull; // sensitive threads appear/disappear — rebuild the list
closeWipe();
}
}
if (doCancel) closeWipe();
material::EndOverlayDialog();
}
}
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
if (s_show_chat_settings) {
material::OverlayDialogSpec ov;
@@ -2199,6 +2869,8 @@ void ResetChatTab()
sodium_memzero(s_compose, sizeof(s_compose));
sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr));
sodium_memzero(s_new_msg, sizeof(s_new_msg));
sodium_memzero(s_pending_new_zaddr, sizeof(s_pending_new_zaddr));
s_pending_new_set = false;
sodium_memzero(s_rename_buf, sizeof(s_rename_buf));
s_selected_cid.clear();
s_scroll_to_cid.clear();
@@ -2213,11 +2885,16 @@ void ResetChatTab()
s_show_blocked = false;
// 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).
// 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_convsHidden = 0;
s_convsKey = ~0ull;
s_threadCid.clear();
s_threadRev = ~0ull;
for (auto& m : s_threadMsgs) { wipeStr2(m.body); wipeStr2(m.fail_reason); wipeStr2(m.peer_zaddr); }
s_threadMsgs.clear();
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
@@ -2225,6 +2902,13 @@ void ResetChatTab()
s_msgmenu_index = -1;
}
void OpenChatNewConversation(const std::string& zaddr)
{
std::strncpy(s_pending_new_zaddr, zaddr.c_str(), sizeof(s_pending_new_zaddr) - 1);
s_pending_new_zaddr[sizeof(s_pending_new_zaddr) - 1] = '\0';
s_pending_new_set = true;
}
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
{
auto* st = app ? app->settings() : nullptr;
+8
View File
@@ -4,6 +4,8 @@
#pragma once
#include <string>
namespace dragonx {
class App;
@@ -44,5 +46,11 @@ void RenderChatSettingsControls(App* app, float contentWidth = 0.0f, bool drawCa
*/
void ResetChatTab();
/**
* @brief Ask the Chat tab to open its New-conversation dialog prefilled with this z-address (consumed
* on the next RenderChatTab frame). Used by the Contacts tab's "Message" action.
*/
void OpenChatNewConversation(const std::string& zaddr);
} // namespace ui
} // namespace dragonx
+87 -2
View File
@@ -6,6 +6,7 @@
// tab completion, daemon log display, and color-coded output.
#include "console_tab.h"
#include <sodium.h> // sodium_memzero — scrub masked secret-result plaintext
#include "console_command_executor.h"
#include "console_command_reference.h"
#include "console_input_model.h"
@@ -298,7 +299,26 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
{
std::string result;
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
@@ -547,7 +567,21 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
if (ImGui::IsItemHovered()) {
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();
// Commands reference button — shown whenever the backend offers a reference table (full-node
@@ -1537,9 +1571,35 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
focus_input_pending_ = false;
}
namespace {
// Is `firstLower` (a lowercased command name) flagged destructive in the command reference? The
// `destructive` flag was previously consulted only by the reference dialog's Run button; this lets the
// typed/pasted path honor it too. Scans the full-node or lite command set per the active executor.
bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
{
const auto& cats = fullNode ? consoleCommandCategories() : liteConsoleCommandCategories();
for (const auto& cat : cats) {
for (int i = 0; i < cat.count; ++i) {
std::string n = cat.commands[i].name ? cat.commands[i].name : "";
std::transform(n.begin(), n.end(), n.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
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;
}
} // namespace
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
{
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
// log and the recall history. The real `cmd` below is still executed unredacted.
@@ -1561,6 +1621,8 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
// pending state. Member (not a function-local static) so clear() can also cancel it — otherwise a
// toolbar/context-menu clear between the two 'stop's would leave a stale arm and skip the warning.
if (first != "stop") stop_confirm_pending_ = false;
// A pending destructive-command confirmation is armed on the EXACT line; any different input cancels it.
if (destructive_confirm_cmd_ != cmd) destructive_confirm_cmd_.clear();
auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); };
if (first == "clear" || first == "cls") {
// View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history).
@@ -1586,7 +1648,17 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
// Full node connects to a daemon; the lite backend opens a wallet — word the error per variant.
addLine(exec.hasRpcReference() ? TR("console_not_connected") : TR("console_not_connected_lite"),
ConsoleChannel::Error);
} else if (IsDestructiveConsoleCommand(first, exec.hasRpcReference()) &&
destructive_confirm_cmd_ != cmd) {
// Fund-moving / key-revealing commands (dumpprivkey, z_sendmany, z_mergetoaddress, …) get the
// same two-step confirm as 'stop' when typed or PASTED directly — previously the destructive
// flag was honored only by the reference dialog, so a pasted command ran instantly.
destructive_confirm_cmd_ = cmd;
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
} else {
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);
}
return true;
@@ -1853,6 +1925,9 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
ImGui::SameLine();
if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
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';
run_confirm_cmd_ = nullptr;
show_commands_popup_ = false;
@@ -2164,8 +2239,17 @@ void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& m
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()
{
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
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
model_.clear();
@@ -2185,6 +2269,7 @@ void ConsoleTab::clear()
wrap_cache_.reset();
vis_flat_valid_ = false;
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
destructive_confirm_cmd_.clear(); // likewise cancel a pending destructive-command confirmation
addLine(TR("console_cleared"), ConsoleChannel::Info);
}
+14
View File
@@ -20,6 +20,8 @@
#include <vector>
#include <deque>
#include <mutex>
#include <functional>
#include <chrono>
namespace dragonx {
namespace ui {
@@ -66,6 +68,13 @@ public:
*/
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).
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
void requestInputFocus() { focus_input_pending_ = true; }
@@ -161,6 +170,11 @@ private:
int history_index_ = -1;
char input_buffer_[4096] = {0};
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
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)
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
+23
View File
@@ -15,6 +15,10 @@ const char* const kSecretConsoleCommands[] = {
"importprivkey", "importwallet", "importmulti",
"z_importkey", "z_importviewingkey", "z_importwallet",
"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) {
@@ -67,6 +71,25 @@ bool ConsoleCommandCarriesSecret(const std::string& cmd)
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)
{
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.
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
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
std::string RedactConsoleCommand(const std::string& cmd);
+37 -9
View File
@@ -17,6 +17,9 @@
#include "../../util/texture_loader.h"
#include "../../util/platform.h"
#include "image_picker.h"
#include "transactions_tab.h"
#include "chat_tab.h" // OpenChatNewConversation — Contacts "Message" action
#include "../sidebar.h"
#include "../layout.h"
#include "imgui.h"
#include <filesystem>
@@ -530,6 +533,20 @@ void RenderContactsTab(App* app)
s_show_edit_dialog = true;
s_focus_edit_field = true;
};
auto doViewTransactions = [&]() {
if (!selValid()) return;
SetTransactionsAddressFilter(book.entries()[s_selected_index].address);
app->setCurrentPage(ui::NavPage::History);
};
// Message a contact in Chat — only z-contacts (chat needs a shielded address to carry encrypted
// memos). Mirrors doViewTransactions: hand the address to the Chat tab and switch there.
auto doMessage = [&]() {
if (!selValid()) return;
const auto& e = book.entries()[s_selected_index];
if (!isShieldedAddr(e.address)) return;
OpenChatNewConversation(e.address);
app->setCurrentPage(ui::NavPage::Chat);
};
// Add/edit form — a modal popup layered over the tab, with a live contact preview and an avatar
// picker (default Z/T badge, a Material icon, or a custom image via the in-app image picker).
@@ -1367,7 +1384,7 @@ void RenderContactsTab(App* app)
// Label (line 1) + muted address (line 2) — reserve trailing room for the globe + actions.
const float actHit = icoF->LegacySize + 8.0f * dp; // per-action square hit area
const float globeW = entry.isGlobal() ? (icoF->LegacySize + 8.0f * dp) : 0.0f;
const float trailW = 3.0f * actHit + 6.0f * dp + globeW;
const float trailW = 4.0f * actHit + 8.0f * dp + globeW; // copy / view-tx / edit / delete
const float cy = mn.y + rowH * 0.5f;
const float tx = (s_contactsShape == 2) ? (mn.x + tabW + pad)
: (mn.x + pad + avR * 2.0f + pad);
@@ -1399,10 +1416,11 @@ void RenderContactsTab(App* app)
rightX -= gs.x + 8.0f * dp;
}
if (rowHovered || selected) {
const char* actGlyph[3] = { ICON_MD_CONTENT_COPY, ICON_MD_EDIT, ICON_MD_DELETE };
const char* actTip[3] = { TR("copy_address"), TR("edit"), TR("delete") };
// Order (left->right): copy, view-transactions, edit, delete. Drawn right-to-left.
const char* actGlyph[4] = { ICON_MD_CONTENT_COPY, ICON_MD_RECEIPT_LONG, ICON_MD_EDIT, ICON_MD_DELETE };
const char* actTip[4] = { TR("copy_address"), TR("view_transactions"), TR("edit"), TR("delete") };
float ax = rightX - actHit; // rightmost action (delete), just left of the globe
for (int a = 2; a >= 0; --a) {
for (int a = 3; a >= 0; --a) {
ImGui::SetCursorScreenPos(ImVec2(ax, cy - actHit * 0.5f));
ImGui::PushID(a + 100);
const bool aClk = ImGui::InvisibleButton("##act", ImVec2(actHit, actHit));
@@ -1411,12 +1429,13 @@ void RenderContactsTab(App* app)
if (aClk) {
s_selected_index = static_cast<int>(i);
if (a == 0) doCopy();
else if (a == 1) openEdit();
else if (a == 1) doViewTransactions();
else if (a == 2) openEdit();
// Delete mutates book.entries() (removeEntry -> erase) — deferring it past the
// row loop avoids iterating a vector we shrink mid-loop (OOB on later rows).
else rowDeleteRequested = true;
}
const bool armedDel = (a == 2 && s_confirm_delete_idx == static_cast<int>(i));
const bool armedDel = (a == 3 && s_confirm_delete_idx == static_cast<int>(i));
const ImU32 acol = armedDel ? material::ReadableError()
: aHov ? material::OnSurface()
: material::OnSurfaceMedium();
@@ -1454,10 +1473,19 @@ void RenderContactsTab(App* app)
// Shared right-click context menu (opened by either view's row right-click; acts on the selection).
if (openContextMenu) ImGui::OpenPopup("##contactCtx");
if (ImGui::BeginPopup("##contactCtx")) {
if (ImGui::MenuItem(TR("copy_address"))) doCopy();
if (ImGui::MenuItem(TR("edit"))) openEdit();
if (ImGui::MenuItem(TR("copy_address"))) doCopy();
if (ImGui::MenuItem(TR("view_transactions"))) doViewTransactions();
#if DRAGONX_ENABLE_CHAT
{
const bool canMsg = selValid() && isShieldedAddr(book.entries()[s_selected_index].address);
if (ImGui::MenuItem(TR("contacts_message"), nullptr, false, canMsg)) doMessage();
if (!canMsg && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", TR("contacts_message_needs_z"));
}
#endif
if (ImGui::MenuItem(TR("edit"))) openEdit();
ImGui::Separator();
if (ImGui::MenuItem(TR("delete"))) doDelete();
if (ImGui::MenuItem(TR("delete"))) doDelete();
ImGui::EndPopup();
}
+11 -1
View File
@@ -445,11 +445,20 @@ private:
static void renderDone(float, const Progress& p) {
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"));
ImGui::Spacing();
ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str());
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"));
ImGui::Spacing();
if (s_app->isUsingEmbeddedDaemon()) {
@@ -459,6 +468,7 @@ private:
}
ImGui::Spacing();
if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false;
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_later_hint"));
} else {
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
+50 -8
View File
@@ -269,7 +269,7 @@ static void fetchBlockDetail(App* app, int height) {
auto hashResult = rpc->call("getblockhash", {height});
std::string hash = hashResult.get<std::string>();
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]() {
applyBlockDetailResult(result, error);
};
@@ -383,19 +383,27 @@ static void navigateToHash(App* app, const std::string& hash) {
worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb {
json blockResult, txResult;
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 {
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
blockResult = rpc->call("getblock", {hash});
gotBlock = true;
} catch (...) {}
} catch (const std::exception& e) { noteErr(e); }
if (!gotBlock) {
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
txResult = rpc->call("getrawtransaction", {hash, 1});
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;
if (gotBlock) {
int h = blockResult.value("height", -1);
@@ -407,6 +415,8 @@ static void navigateToHash(App* app, const std::string& hash) {
s_tx_loading = false;
s_expanded_tx_idx = 0;
s_show_detail_modal = false;
} else if (rpcFailed) {
s_search_error = std::string(TR("explorer_lookup_failed")) + ": " + util::friendlyRpcError(rpcErr);
} else {
s_search_error = TR("explorer_hash_not_found");
}
@@ -424,7 +434,15 @@ static void performSearch(App* app, const std::string& query) {
try { h = std::stol(query); } catch (...) { return; }
int tip = app->getWalletState().sync.blocks;
if (h < 1) h = 1;
if (tip > 0 && h > tip) h = tip;
if (tip > 0 && h > tip) {
// Say the height is beyond the tip instead of silently snapping to the top of the list.
char msg[128];
snprintf(msg, sizeof(msg), TR("explorer_beyond_tip"),
(int)std::min<long>(h, 2147483647L), tip);
s_search_error = msg;
s_search_target_height = -1; // don't re-anchor; leave the list where it is
return;
}
s_search_target_height = static_cast<int>(h);
s_recent_page = 0;
s_search_error.clear();
@@ -828,7 +846,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
std::map<int, BlockSummary> cachedBlocks;
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
@@ -872,7 +891,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
}
std::map<int, BlockSummary> nextCachedBlocks;
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) {
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
@@ -939,7 +958,30 @@ static void renderRecentBlocks(App* app, float availWidth) {
if (blocks.empty()) {
ImGui::SetCursorPosY(Layout::spacingMd());
ImGui::TextDisabled("%s", fuzzyMode ? TR("explorer_no_results") : TR("loading"));
// Say WHY the list is empty — offline, syncing, or a genuine search miss — instead of an
// endless "Loading" that also shows when the node is disconnected or still catching up.
const auto& st = app->getWalletState();
auto* erpc = app->rpc();
char emptyBuf[128];
const char* msg = TR("loading");
ImU32 col = OnSurfaceMedium();
if (fuzzyMode) {
msg = TR("explorer_no_results");
} else if (!st.connected || !erpc || !erpc->isConnected()) {
msg = TR("explorer_empty_not_connected"); col = Warning();
} else if (st.sync.syncing) {
const int behind = st.sync.headers - st.sync.blocks;
if (st.sync.tip_known && behind > 0) {
snprintf(emptyBuf, sizeof(emptyBuf), TR("explorer_empty_syncing"), behind);
msg = emptyBuf;
} else {
msg = TR("explorer_empty_syncing_unknown");
}
col = Warning();
}
ImGui::PushStyleColor(ImGuiCol_Text, col);
ImGui::TextUnformatted(msg);
ImGui::PopStyleColor();
} else {
for (size_t i = 0; i < blocks.size(); i++) {
const auto& row = blocks[i];
+15 -5
View File
@@ -9,6 +9,7 @@
#include "../../rpc/rpc_worker.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/export_filename.h"
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
@@ -93,7 +94,10 @@ void ExportAllKeysDialog::render(App* app)
ImGui::Spacing();
m::LabeledInput(TR("output_filename"), "##Filename", s_filename, sizeof(s_filename));
ImGui::Spacing();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("file_save_location"));
// Show the real, resolved config directory (not a hardcoded ~/.config path, which was wrong on
// Windows/macOS) so the user knows where this plaintext key file lands before they export it.
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s %s",
TR("file_save_location"), util::Platform::getConfigDir().c_str());
ImGui::EndDisabled();
// Progress / result.
@@ -114,7 +118,7 @@ void ExportAllKeysDialog::render(App* app)
} else if (!s_status.empty()) {
ImGui::Spacing();
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();
}
@@ -157,8 +161,11 @@ void ExportAllKeysDialog::render(App* app)
if (s_include_t) {
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
if (app->worker()) {
app->worker()->post([rpc = app->rpc(), z_addrs, t_addrs, filename]() -> rpc::RPCWorker::MainCb {
@@ -232,7 +239,10 @@ void ExportAllKeysDialog::render(App* app)
Notifications::instance().error(TR("export_keys_write_fail"));
} else if (exported < total) {
snprintf(buf, sizeof(buf), TR("export_keys_partial"), exported, total);
s_status = buf;
// A partial export still wrote a plaintext key file to disk — show WHERE,
// so the user can find it to move or delete it (previously only a full
// success revealed the path, leaving partial-export files orphaned).
s_status = std::string(buf) + "\n" + filepath;
snprintf(buf, sizeof(buf), TR("export_keys_partial_toast"), exported, total);
Notifications::instance().warning(buf);
} else {
+108 -7
View File
@@ -6,7 +6,9 @@
#include "../../app.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/export_filename.h"
#include "../../data/transaction_csv.h"
#include "transactions_tab.h"
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
@@ -18,6 +20,8 @@
#include <string>
#include <ctime>
#include <algorithm>
#include <cctype>
namespace dragonx {
namespace ui {
@@ -27,12 +31,34 @@ static bool s_open = false;
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_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()
{
s_open = true;
s_saved_path.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
std::time_t now = std::time(nullptr);
@@ -74,14 +100,72 @@ void ExportTransactionsDialog::render(App* app)
if (!m::BeginOverlayDialog(ov)) return;
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();
// Filename + save-location hint.
m::LabeledInput(TR("output_filename"), "##Filename", s_filename, sizeof(s_filename));
ImGui::Spacing();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("file_save_location"));
// Show the real, resolved config directory (the old hardcoded ~/.config path was wrong on Windows/macOS).
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s %s",
TR("file_save_location"), util::Platform::getConfigDir().c_str());
// Result — success shows the saved path as a copy field; failure shows a red line.
if (!s_saved_path.empty()) {
@@ -92,6 +176,8 @@ void ExportTransactionsDialog::render(App* app)
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
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()) {
ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f);
@@ -107,14 +193,29 @@ void ExportTransactionsDialog::render(App* app)
if (doExport) {
s_saved_path.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"));
} else {
std::string configDir = util::Platform::getConfigDir();
std::string filepath = configDir + "/" + s_filename;
// Build in memory, then write atomically (temp + fsync + rename): a disk-full /
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast.
const std::string csv = data::formatTransactionsCsv(state.transactions);
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
// the config dir by a pasted path).
std::string safeName = util::sanitizeExportFilename(s_filename, ".csv", "dragonx_transactions.csv");
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)) {
s_error = TR("export_tx_file_fail");
Notifications::instance().error(TR("export_tx_file_fail"));
+16 -6
View File
@@ -121,9 +121,9 @@ void KeyExportDialog::render(App* app)
ImGui::Text("%s", key_label);
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()) {
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.
ImGui::Spacing();
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) {
// 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.)
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()) {
// Show button to fetch key
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_show_key = wantViewing; // viewing keys are less sensitive
} 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);
s_fetching = false;
@@ -197,7 +207,7 @@ void KeyExportDialog::render(App* app)
s_key = key;
s_show_key = false; // Don't show by default
} else {
s_error = error;
s_error = util::friendlyRpcError(error);
}
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
s_fetching = false;
@@ -223,7 +233,7 @@ void KeyExportDialog::render(App* app)
s_key = key;
s_show_key = true; // Viewing keys are less sensitive
} else {
s_error = error;
s_error = util::friendlyRpcError(error);
}
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
s_fetching = false;
+37 -2
View File
@@ -31,6 +31,7 @@
#include <algorithm>
#include <cmath>
#include <ctime>
#include <chrono>
#include <unordered_set>
namespace dragonx {
@@ -1430,7 +1431,7 @@ static void mktDrawPairSelector(App* app, const std::vector<data::ExchangeInfo>&
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
if (!market.last_updated.empty()) {
ImGui::SameLine(0, 12);
ImGui::SameLine(0, 12.0f * Layout::dpiScale());
snprintf(buf, sizeof(buf), TR("market_updated"), market.last_updated.c_str());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
@@ -1496,7 +1497,18 @@ static void mktDrawPriceHero(const MktCtx& cx)
// ---- HERO PRICE (large, prominent) ----
ImFont* h3 = Type().h3();
std::string priceStr = FormatPrice(heroPrice);
ImU32 priceCol = Success();
// Stale-price cue: the aggregate price is kept on a failed/old refresh (it's never cleared), so a
// green number can be hours out of date. Flag it amber when the last refresh failed or the
// aggregate is >=5 min old. Only when the hero IS the aggregate (an exchange pair has its own feed).
const bool haveFetch = market.last_fetch_time.time_since_epoch().count() != 0;
const long ageMin = haveFetch
? (long)std::chrono::duration_cast<std::chrono::minutes>(
std::chrono::steady_clock::now() - market.last_fetch_time).count()
: 0;
const bool usingAggregate = (heroPrice == market.price_usd);
const bool priceStale = usingAggregate && haveFetch &&
(!market.price_error.empty() || ageMin >= 5);
ImU32 priceCol = priceStale ? Warning() : Success();
DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx0, cy), priceCol, priceStr.c_str());
// Ticker label after price
@@ -1519,6 +1531,29 @@ static void mktDrawPriceHero(const MktCtx& cx)
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp);
dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf);
// Stale "as of Nh ago" caption + hover tooltip, right of the change badge (cursor saved/restored
// so the custom draw-list layout below is undisturbed).
if (priceStale) {
char age[64];
if (ageMin >= 60) snprintf(age, sizeof(age), TR("market_price_stale_hour"), (int)(ageMin / 60));
else snprintf(age, sizeof(age), TR("market_price_stale_min"), (int)std::max(1L, ageMin));
ImVec2 asz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, age);
float ax = bMax.x + Layout::spacingMd();
float limit = cardMax.x - pad - 260.0f * dp; // reserve the trade pill + chart toggles
if (ax + asz.x < limit) {
dl->AddText(capFont, capFont->LegacySize, ImVec2(ax, bMin.y + badgePadV), Warning(), age);
ImVec2 sv = ImGui::GetCursorScreenPos();
ImGui::SetCursorScreenPos(ImVec2(ax, bMin.y));
ImGui::InvisibleButton("##PriceStale", ImVec2(asz.x, bMax.y - bMin.y));
if (ImGui::IsItemHovered()) {
std::string tip = market.price_error.empty()
? std::string(TR("market_price_fetch_failed")) : market.price_error;
material::Tooltip(TR("market_price_stale_tip"), tip.c_str());
}
ImGui::SetCursorScreenPos(sv);
}
}
// (Divider is drawn in the chart block, below the interval buttons.)
// ---- TRADE BUTTON (top-right of card) ----
+38 -4
View File
@@ -579,6 +579,9 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
std::string curVer = s_live_miner_ver;
if (curVer.empty()) curVer = app->poolMiningInstalledVersion();
if (curVer.empty()) curVer = app->settings()->getXmrigVersion();
// No miner installed at all → pool mining can't run. Make this a prominent
// "Download miner" call-to-action instead of a subtle grey "Update" pill.
const bool minerMissing = curVer.empty();
// Version state: subtle GREEN + "xmrig releases" when up to date, subtle ORANGE +
// "Update <latest>" when an update is available, neutral when either version is unknown.
auto verNum = [](std::string s) -> std::vector<int> {
@@ -609,7 +612,9 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
const ImU32 subtleOrange = IM_COL32(214, 158, 74, 255);
char xbtn[64];
if (upToDate)
if (minerMissing)
snprintf(xbtn, sizeof(xbtn), "%s", TR("xmrig_download_miner"));
else if (upToDate)
snprintf(xbtn, sizeof(xbtn), "%s", TR("xmrig_releases"));
else if (!s_xmrig_latest_tag.empty())
snprintf(xbtn, sizeof(xbtn), "%s %s", TR("xmrig_update_short"), s_xmrig_latest_tag.c_str());
@@ -629,18 +634,21 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
bool xhov = ImGui::IsItemHovered();
bool xclk = ImGui::IsItemClicked();
ImU32 xpill = minerBusy ? WithAlpha(OnSurface(), 12)
: (xhov ? StateHover() : WithAlpha(OnSurface(), 28));
: (minerMissing ? WithAlpha(Primary(), xhov ? 70 : 45)
: (xhov ? StateHover() : WithAlpha(OnSurface(), 28)));
dl->AddRectFilled(ImVec2(xbtnX, xbtnY), ImVec2(xbtnX + xbtnW, xbtnY + xbtnH),
xpill, xbtnH * 0.3f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(xbtnX + xpadX, xbtnY + (xbtnH - xlblSz.y) * 0.5f),
minerBusy ? OnSurfaceDisabled()
: (upToDate ? subtleGreen : outdated ? subtleOrange : OnSurfaceMedium()),
: (minerMissing ? Primary()
: (upToDate ? subtleGreen : outdated ? subtleOrange : OnSurfaceMedium())),
xbtn);
if (xhov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", minerBusy ? TR("xmrig_stop_mining_first")
: TR("xmrig_update_title"));
: (minerMissing ? TR("xmrig_download_miner_tip")
: TR("xmrig_update_title")));
}
if (xclk && !minerBusy) XmrigDownloadDialog::show(app);
@@ -949,6 +957,8 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
material::Tooltip("%s", isMiningActive ? TR("mining_stopping_tooltip") : TR("mining_starting_tooltip"));
else if (isSyncing && !s_pool_mode)
material::Tooltip(TR("mining_syncing_tooltip"), state.sync.verification_progress * 100.0);
else if (!s_pool_mode && !poolStillRunning && !app->isConnected())
material::Tooltip("%s", TR("mining_not_connected_tooltip")); // was falling through to "Start Mining" on a dead button
else if (poolBlockedBySolo)
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
else if (poolNeedsPayout)
@@ -981,6 +991,30 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
ImGui::Dummy(ImVec2(availWidth, 0));
// Persistent error caption (a crashed/AV-blocked/failed miner otherwise leaves a button that
// "did nothing" once its toast faded). Normal ImGui flow, OUTSIDE the hand-drawn button card.
const std::string& merr = app->miningLastError();
if (!merr.empty() && !app->isMiningToggleInProgress()) {
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushTextWrapPos(cardMin.x + availWidth);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Error()));
ImGui::TextWrapped("%s", merr.c_str());
ImGui::PopStyleColor();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()));
ImGui::TextUnformatted(TR("mining_error_retry_hint"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
#ifdef _WIN32
if (app->miningErrorIsAntivirus()) {
ImGui::SameLine(0, Layout::spacingSm());
if (ImGui::SmallButton(TR("mining_error_av_help"))) app->showAntivirusHelp();
}
#endif
ImGui::SameLine(0, Layout::spacingSm());
if (ImGui::SmallButton(TR("mining_error_dismiss"))) app->clearMiningError();
}
ImGui::Dummy(ImVec2(0, gap));
}
+72 -12
View File
@@ -10,6 +10,7 @@
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../config/settings.h"
#include "../../util/pool_registry.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
@@ -68,15 +69,38 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
minedAllTimeCount = earn.allTimeCount;
const std::vector<MiningEarningsRecentTx>& recentMined = earn.recent;
// Est. Daily = expected reward for YOUR own hashrate share of the network. In pool mode this uses
// your local miner rate (pool_mining.hashrate_10s), NOT the pool's aggregate rate: pool payouts are
// share-proportional, so your expected daily is the same solo-equivalent value (a rough estimate,
// before the pool fee). Using the pool's total rate here would show the POOL's earnings, not yours. (M-05)
double estHashrate = s_pool_mode ? state.pool_mining.hashrate_10s : mining.localHashrate;
// Est. Daily is a statistical long-run AVERAGE of your reward for your hashrate share of the
// network -- NOT a steady income. Actual rewards arrive at random (Poisson): often nothing for
// long stretches, then a lump. We feed EstimateHoursToBlock a TRAILING-AVERAGE hashrate (not an
// instantaneous / 10s snapshot) so a spike or a momentary dip doesn't swing the figure, and in
// pool mode we subtract the known pool fee so the number is net. Pool payouts are share-
// proportional, so your expected daily equals this solo-equivalent value (using the pool's
// aggregate rate would show the POOL's earnings, not yours). (M-05 / A+B)
double estHashrate;
if (s_pool_mode) {
const auto& pm = state.pool_mining;
estHashrate = pm.hashrate_15m > 0.0 ? pm.hashrate_15m
: pm.hashrate_60s > 0.0 ? pm.hashrate_60s
: pm.hashrate_10s; // 15m avg preferred; fall back as it warms up
} else {
estHashrate = mining.localHashrate;
if (mining.hashrate_history.size() >= 10) { // enough samples -> trailing mean
double sum = 0.0;
for (double h : mining.hashrate_history) sum += h;
estHashrate = sum / (double)mining.hashrate_history.size();
}
}
double est_hours_2 = EstimateHoursToBlock(estHashrate, mining.networkHashrate, mining.difficulty);
double estDailyBlocks = (est_hours_2 > 0) ? (24.0 / est_hours_2) : 0.0;
double blockReward = schema::UI().drawElement("business", "block-reward").size;
double estDaily = estDailyBlocks * blockReward;
// Pool fee: compile-time known fee for the configured pool (<0 = unknown/synthetic pool -> gross).
double estFeePct = -1.0;
if (s_pool_mode) {
const util::KnownPool* kp = util::findKnownPoolByUrl(state.pool_mining.pool_url);
if (kp && kp->feePercent >= 0.0) estFeePct = kp->feePercent;
}
if (s_pool_mode && estFeePct > 0.0) estDaily *= (1.0 - estFeePct / 100.0);
bool estActive = isMiningActive && estDaily > 0;
ImU32 greenCol2 = Success();
@@ -167,33 +191,37 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
};
snprintf(valBuf, sizeof(valBuf), "+%.4f", minedToday);
snprintf(subBuf2, sizeof(subBuf2), "(%d txn)", minedTodayCount);
snprintf(subBuf2, sizeof(subBuf2), TR("mining_txn_count"), minedTodayCount);
char todayVal[64], todaySub[64];
strncpy(todayVal, valBuf, sizeof(todayVal));
strncpy(todaySub, subBuf2, sizeof(todaySub));
char yesterdayVal[64], yesterdaySub[64];
snprintf(yesterdayVal, sizeof(yesterdayVal), "+%.4f", minedYesterday);
snprintf(yesterdaySub, sizeof(yesterdaySub), "(%d txn)", minedYesterdayCount);
snprintf(yesterdaySub, sizeof(yesterdaySub), TR("mining_txn_count"), minedYesterdayCount);
char allVal[64], allSub[64];
snprintf(allVal, sizeof(allVal), "+%.4f", minedAllTime);
snprintf(allSub, sizeof(allSub), "(%d txn)", minedAllTimeCount);
snprintf(allSub, sizeof(allSub), TR("mining_txn_count"), minedAllTimeCount);
char estVal[64];
if (estActive)
snprintf(estVal, sizeof(estVal), "~%.4f", estDaily);
else
snprintf(estVal, sizeof(estVal), "%s", TR("grpc_na"));
// Disclose in pool mode that Est. Daily is a rough solo-equivalent (before the pool fee), so the
// number isn't silently mismatched to its plain "Est. Daily" label. (M-05)
const char* estSub = (s_pool_mode && estActive) ? TR("mining_est_daily_pool_sub") : nullptr;
// Flag that Est. Daily is a long-run AVERAGE in both modes (so it doesn't read as realized
// income), and whether it is net of the pool fee. Tooltip below carries the full variance note.
const char* estSub = !estActive ? nullptr
: (s_pool_mode && estFeePct > 0.0) ? TR("mining_est_daily_sub_net")
: TR("mining_est_daily_sub");
// Neutral (not green/"income") colour for the estimate — it's a probability, not money earned.
const ImU32 estCol = estActive ? OnSurfaceMedium() : OnSurfaceDisabled();
EarningsEntry entries[] = {
{ TR("mining_today"), todayVal, todaySub, greenCol2 },
{ TR("mining_yesterday"), yesterdayVal, yesterdaySub, OnSurface() },
{ TR("mining_all_time"), allVal, allSub, OnSurface() },
{ TR("mining_est_daily"), estVal, estSub, estActive ? greenCol2 : OnSurfaceDisabled() },
{ TR("mining_est_daily"), estVal, estSub, estCol },
};
for (int ei = 0; ei < numCols; ei++) {
@@ -223,6 +251,27 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
ImVec2(centerX - valSz.x * 0.5f, valY), entries[ei].col, entries[ei].value);
}
}
// Hover tooltip over the Est. Daily column (last column): explain the Poisson variance and,
// in pool mode, whether the figure is net of the fee.
{
float estColX = cardMin.x + pad + (numCols - 1) * colW;
ImVec2 estMin(estColX, ey);
ImVec2 estMax(estColX + colW, cardMin.y + earningsRowH);
if (material::IsRectHovered(estMin, estMax)) {
char tip[512];
if (s_pool_mode && estFeePct > 0.0) {
char feeNote[160];
snprintf(feeNote, sizeof(feeNote), TR("tt_mining_est_daily_fee_fmt"), estFeePct);
snprintf(tip, sizeof(tip), "%s\n%s", TR("tt_mining_est_daily"), feeNote);
} else if (s_pool_mode && estFeePct < 0.0) { // fee unknown (synthetic/unmatched pool)
snprintf(tip, sizeof(tip), "%s\n%s", TR("tt_mining_est_daily"), TR("tt_mining_est_daily_nofee"));
} else { // solo, or a known 0%-fee pool (net == gross)
snprintf(tip, sizeof(tip), "%s", TR("tt_mining_est_daily"));
}
material::Tooltip("%s", tip);
}
}
}
// === Separator between earnings & details ===
@@ -275,6 +324,11 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
// -- Mining Address --
dl->AddText(capFont, capFont->LegacySize, ImVec2(col3X, cy), OnSurfaceMedium(), TR("mining_mining_addr"));
{
ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, TR("mining_mining_addr"));
if (material::IsRectHovered(ImVec2(col3X, cy), ImVec2(col3X + lblSz.x, cy + lblSz.y)))
material::Tooltip("%s", TR("mining_addr_explainer"));
}
std::string mining_address = "";
for (const auto& addr : state.addresses) {
if (addr.type == "transparent" && !addr.address.empty()) {
@@ -302,6 +356,12 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
ImVec2(Layout::spacingMd(), 4 * dp))) {
Notifications::instance().info(TR("mining_address_copied"));
}
} else {
// No mining (transparent) address yet — explain where rewards go instead of a blank.
dl->AddText(capFont, capFont->LegacySize, ImVec2(col3X + valOffX, cy), OnSurfaceMedium(), TR("mining_addr_none"));
ImVec2 emSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, TR("mining_addr_none"));
if (material::IsRectHovered(ImVec2(col3X + valOffX, cy), ImVec2(col3X + valOffX + emSz.x, cy + emSz.y)))
material::Tooltip("%s", TR("mining_addr_none_tip"));
}
}
+71 -14
View File
@@ -120,7 +120,10 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
CancelMiningBenchmark(app, false); // don't leave a pool benchmark running after switching to solo (L-11)
app->stopPoolMining();
}
if (soloHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (soloHov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_solo_tip"));
}
}
ImGui::SetCursorScreenPos(poolMin);
@@ -136,11 +139,15 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
if (soloMiningAvailable && poolHov && !soloMiningActive) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (poolHov && soloMiningActive && !s_pool_mode) {
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
} else if (poolHov && !soloMiningActive) {
material::Tooltip("%s", TR("mining_pool_tip"));
}
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
ImGui::Dummy(ImVec2(totalW, 0));
bool payoutBadThisFrame = false; // set when the inline payout error is drawn; reserves a row below
// Pool URL + Worker inputs inline next to toggle (pool mode only)
if (s_pool_mode && soloMiningActive) {
// Solo mining is active — show disabled message instead of inputs
@@ -422,6 +429,24 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
}
}
// Persistent inline payout error — a hover-only tooltip is missed by anyone who doesn't hover,
// so outline the field in red and draw a short reason below it (draw-list only; no layout calls
// here, the cursor is hand-positioned). The field height is reserved at the end of the function.
{
const ImVec2 wMin = ImGui::GetItemRectMin();
const ImVec2 wMax = ImGui::GetItemRectMax();
const std::string wk(s_pool_worker);
if (!wk.empty() && wk != "x" && !util::isValidRecipientAddress(wk)) {
payoutBadThisFrame = true;
dl->AddRect(wMin, wMax, Error(), 4.0f * dp, 0, 1.5f * dp);
const char* msg = util::isTransparentAddress(wk)
? TR("mining_payout_needs_shielded_short") : TR("mining_payout_invalid_short");
std::string shown = material::TruncateToWidth(msg, capFont, capFont->LegacySize, wrkGroupW);
dl->AddText(capFont, capFont->LegacySize, ImVec2(wMin.x, wMax.y + 1.0f * dp),
ReadableError(), shown.c_str());
}
}
// --- Worker: Dropdown arrow button ---
ImGui::SameLine(0, 0);
{
@@ -436,21 +461,51 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
}
}
// --- Worker: Bookmark button ---
// --- Worker: Generate (when empty) / Bookmark button ---
// A new wallet has no z-address, and a pool payout MUST be shielded. When the field is empty
// show a one-click "generate a shielded address" button in place of the (useless-when-empty)
// bookmark, instead of only a hover hint pointing at the Receive tab.
ImGui::SameLine(0, 0);
{
std::string currentWorker(s_pool_worker);
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolWorkers(), currentWorker);
material::IconButtonStyle st;
st.color = alreadySaved ? Primary() : OnSurfaceMedium();
st.hoverBg = StateHover();
st.bgRounding = 4.0f * dp;
st.tooltip = alreadySaved ? TR("mining_already_saved") : TR("mining_save_payout_address");
if (material::IconButton("##SaveWorkerAddr", alreadySaved ? ICON_MD_BOOKMARK : ICON_MD_BOOKMARK_BORDER,
Type().iconSmall(), ImVec2(iconBtnW, inputFrameH2), st)
&& !currentWorker.empty() && currentWorker != "x" && !alreadySaved) {
app->settings()->addSavedPoolWorker(currentWorker);
app->settings()->save();
const bool noAddr = currentWorker.empty() || currentWorker == "x";
if (noAddr) {
material::IconButtonStyle st;
st.color = Primary();
st.hoverBg = StateHover();
st.bgRounding = 4.0f * dp;
st.tooltip = TR("mining_generate_z_address");
if (material::IconButton("##GenZAddr", ICON_MD_ADD, Type().iconSmall(),
ImVec2(iconBtnW, inputFrameH2), st)) {
if (!app->isLiteBuild() && !app->isConnected()) {
ui::Notifications::instance().warning(TR("toast_not_connected_daemon"));
} else {
char* wp = &s_pool_worker[0]; const size_t wn = sizeof(s_pool_worker);
app->createNewZAddress([app, wp, wn](const std::string& addr) {
if (addr.empty()) {
ui::Notifications::instance().error(TR("mining_generate_z_failed"));
return;
}
snprintf(wp, wn, "%s", addr.c_str()); // wp → the static s_pool_worker storage
app->settings()->setPoolWorker(addr);
app->settings()->save();
ui::Notifications::instance().success(TR("mining_generate_z_ok"));
});
}
}
} else {
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolWorkers(), currentWorker);
material::IconButtonStyle st;
st.color = alreadySaved ? Primary() : OnSurfaceMedium();
st.hoverBg = StateHover();
st.bgRounding = 4.0f * dp;
st.tooltip = alreadySaved ? TR("mining_already_saved") : TR("mining_save_payout_address");
if (material::IconButton("##SaveWorkerAddr", alreadySaved ? ICON_MD_BOOKMARK : ICON_MD_BOOKMARK_BORDER,
Type().iconSmall(), ImVec2(iconBtnW, inputFrameH2), st)
&& !alreadySaved) {
app->settings()->addSavedPoolWorker(currentWorker);
app->settings()->save();
}
}
}
@@ -607,7 +662,9 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
// Ensure cursor Y is at toggle bottom regardless of pool input widgets,
// so the cards below stay at the same position in both solo and pool modes.
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
ImGui::Dummy(ImVec2(0, gap * 0.5f));
// Reserve a row for the inline payout error so it doesn't overlap the card below (cards shift down
// transiently while the error is shown).
ImGui::Dummy(ImVec2(0, gap * 0.5f + (payoutBadThisFrame ? capFont->LegacySize + 2.0f * dp : 0.0f)));
}
} // namespace ui
+19 -4
View File
@@ -243,11 +243,26 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
{
char uptBuf[64];
int64_t up = pm.uptime_sec;
if (up <= 0) snprintf(uptBuf, sizeof(uptBuf), "N/A");
else if (up < 3600) snprintf(uptBuf, sizeof(uptBuf), "%lldm %llds", (long long)(up / 60), (long long)(up % 60));
else snprintf(uptBuf, sizeof(uptBuf), "%lldh %lldm", (long long)(up / 3600), (long long)((up % 3600) / 60));
if (up <= 0) snprintf(uptBuf, sizeof(uptBuf), "%s", TR("grpc_na"));
else if (up < 3600) snprintf(uptBuf, sizeof(uptBuf), TR("mining_uptime_ms"), (long long)(up / 60), (long long)(up % 60));
else snprintf(uptBuf, sizeof(uptBuf), TR("mining_uptime_hm"), (long long)(up / 3600), (long long)((up % 3600) / 60));
metricRow(TR("mining_uptime"), uptBuf, OnSurface());
}
// Connection status — replaces the old transient "Connecting" banner, with a timeout so a bad URL /
// blocked port flips from "Connecting" to "Can't reach pool" instead of spinning on red forever.
{
static constexpr int kPoolConnectTimeoutSec = 30;
const bool shares = pm.accepted + pm.rejected > 0;
const bool ok = pm.connected || shares || pm.hashrate_10s > 0;
const bool failed = pm.xmrig_running && !ok && pm.running_since > 0 &&
(std::time(nullptr) - pm.running_since) >= kPoolConnectTimeoutSec;
const char* stateText; ImU32 col;
if (ok) { stateText = TR("mining_status_connected"); col = Success(); }
else if (failed) { stateText = TR("mining_pool_unreachable"); col = Error(); }
else if (pm.xmrig_running) { stateText = TR("mining_status_connecting"); col = Warning(); }
else { stateText = TR("mining_status_stopped"); col = OnSurfaceDisabled(); }
metricRow(TR("mining_status_label"), stateText, col);
}
y += gap * 0.5f;
// The pool list = official pools ∪ user-saved favorites ∪ the current custom pool.
@@ -397,7 +412,7 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
const float mRowH = sub1->LegacySize + 7 * dp;
const float listMinH = (capFont->LegacySize + 10 * dp) * 2.6f; // ~2–3 rows before scroll
float leftContentH = pad * 0.75f + toggleH + gap
+ mRowH * 4.0f + gap * 0.5f
+ mRowH * 5.0f + gap * 0.5f // 4 metric rows + the connection-status row
+ (ovFont->LegacySize + 4 * dp) + listMinH + pad * 0.75f;
float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad);
+3 -18
View File
@@ -335,24 +335,9 @@ static void RenderMiningTabContent(App* app)
isMiningActive, s_pool_mode, availWidth, glassSpec, sHdr, gapOver);
// ================================================================
// POOL CONNECTION STATUS — inline indicator (pool mode, no log)
// ================================================================
if (s_pool_mode && state.pool_mining.log_lines.empty() && state.pool_mining.xmrig_running) {
ImFont* iconFont = Type().iconSmall();
const char* dotIcon = ICON_MD_CIRCLE;
ImU32 dotCol = state.pool_mining.connected ? WithAlpha(Success(), 200) : WithAlpha(Error(), 200);
const char* statusText = state.pool_mining.connected
? (state.pool_mining.pool_url.empty() ? TR("mining_connected") : state.pool_mining.pool_url.c_str())
: TR("mining_connecting");
ImVec2 pos = ImGui::GetCursorScreenPos();
ImVec2 dotSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, dotIcon);
dl->AddText(iconFont, iconFont->LegacySize, pos, dotCol, dotIcon);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(pos.x + dotSz.x + 4 * dp, pos.y + (dotSz.y - capFont->LegacySize) * 0.5f),
OnSurfaceMedium(), statusText);
ImGui::Dummy(ImVec2(availWidth, capFont->LegacySize + 4 * dp));
}
// (Pool connection status now lives as a persistent, timeout-aware row in RenderLeftPoolCard
// (mining_stats.cpp) — the old transient "Connecting" banner that vanished once logs arrived and
// sat on red forever on failure was removed.)
}
} // namespace ui
+25 -3
View File
@@ -33,6 +33,7 @@ using namespace material;
// Track selected peer for ban action
static int s_selected_peer_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
static std::string ExtractIP(const std::string& addr)
@@ -626,7 +627,10 @@ void RenderPeersTab(App* app)
if (!app->isConnected()) {
material::DrawEmptyState(ICON_MD_CLOUD_OFF, TR("not_connected"));
} else if (state.peers.empty()) {
material::DrawEmptyState(ICON_MD_WIFI_TETHERING, TR("peers_no_connected"));
// Connected to the daemon but zero peers — mirror the node-status banner's actionable
// hint so this isn't just a bare "No connected peers" dead-end.
material::DrawEmptyState(ICON_MD_WIFI_TETHERING, TR("peers_no_connected"),
TR("node_banner_no_peers_detail"));
} else {
const float dp = ui::Layout::dpiScale();
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg();
@@ -772,9 +776,11 @@ void RenderPeersTab(App* app)
ImGui::Separator();
if (ImGui::MenuItem(TR("copy_address"))) {
ImGui::SetClipboardText(peer.addr.c_str());
ui::Notifications::instance().info(TR("copied_to_clipboard"));
}
if (ImGui::MenuItem(TR("peers_copy_ip"))) {
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
ui::Notifications::instance().info(TR("copied_to_clipboard"));
}
ImGui::Separator();
if (ImGui::MenuItem(TR("peers_ban_24h"))) {
@@ -888,6 +894,7 @@ void RenderPeersTab(App* app)
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
if (ImGui::MenuItem(TR("copy_address"))) {
ImGui::SetClipboardText(banned.address.c_str());
ui::Notifications::instance().info(TR("copied_to_clipboard"));
}
if (ImGui::MenuItem(TR("peers_unban"))) {
app->unbanPeer(banned.address);
@@ -928,10 +935,25 @@ void RenderPeersTab(App* app)
// ================================================================
if (s_show_banned && !state.bannedPeers.empty()) {
ImGui::BeginDisabled(!app->isConnected());
if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button"))) {
app->clearBans();
if (!s_confirm_clear_bans) {
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();
} else {
s_confirm_clear_bans = false; // no stale confirm across tab toggles / after bans clear
}
ImGui::EndChild(); // ##PeersScroll
+7 -1
View File
@@ -11,6 +11,7 @@
#include "../material/draw_helpers.h"
#include "../layout.h"
#include "../theme.h"
#include "../notifications.h"
#include "imgui.h"
namespace dragonx {
@@ -73,7 +74,9 @@ void QRPopupDialog::render(App* app)
const float addrBoxW = ImGui::CalcTextSize(chunkedAddr.c_str()).x + addrPadX * 2.0f + 2.0f;
// The BlurFloat overlay card insets content by 28px per side (draw_helpers BeginOverlayDialog); add
// that (+ a little slack) so the address box fits on one line under its label instead of wrapping.
const float wantW = addrBoxW + 28.0f * 2.0f + 12.0f;
// Scale the overlay inset (BeginOverlayDialog insets 28 LOGICAL px/side) into the same scaled space
// as addrBoxW before the /dp conversion below, so the address box doesn't wrap at HiDPI.
const float wantW = addrBoxW + (28.0f * 2.0f + 12.0f) * dp;
const float maxW = 0.85f * ImGui::GetMainViewport()->Size.x;
const float cardW = (wantW < maxW ? wantW : maxW) / dp;
material::OverlayDialogSpec ov;
@@ -138,6 +141,9 @@ void QRPopupDialog::render(App* app)
material::DialogActionFooter(TR("copy_address"), true, TR("close"), doCopy, doClose, btnW);
if (doCopy) {
ImGui::SetClipboardText(s_address.c_str());
// Confirm the copy — a silent copy here risks a stale clipboard being pasted into an
// exchange withdrawal. Matches the Receive tab's feedback.
Notifications::instance().info(TR("address_copied"));
}
if (doClose) {
close();
+8 -4
View File
@@ -20,6 +20,7 @@
#include "../../wallet/lite_wallet_controller.h"
#include "../../ui/widgets/qr_code.h"
#include "../sidebar.h"
#include "transactions_tab.h"
#include "../layout.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
@@ -332,10 +333,11 @@ static void RenderAddressDropdown(App* app, float width) {
} else {
s_generating_address = true;
if (s_addr_type_filter != 2) {
app->createNewZAddress([](const std::string& addr) {
app->createNewZAddress([app](const std::string& addr) {
s_generating_address = false;
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 {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_shielded_created"));
@@ -399,10 +401,11 @@ static void RenderMiningAddressNotice(App* app, const WalletState& state, float
s_cached_qr_data.clear();
} else {
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;
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 {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_shielded_created"));
@@ -535,6 +538,7 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0)) {
if (!tx.address.empty()) SetTransactionsAddressFilter(tx.address);
app->setCurrentPage(ui::NavPage::History);
}
}
+61 -19
View File
@@ -32,6 +32,7 @@ static uintptr_t s_qr_texture = 0;
static int s_qr_width = 0;
static int s_qr_height = 0;
static bool s_uri_dirty = true;
static bool s_creating_address = false; // true while a Create-shielded-address RPC is in flight
// Helper to build payment URI
static std::string buildPaymentUri()
@@ -48,7 +49,8 @@ void RequestPaymentDialog::show(const std::string& address)
s_label[0] = '\0';
s_payment_uri.clear();
s_uri_dirty = true;
s_creating_address = false;
// Only a shielded address can be paid (DragonX is a private chain): never prefill a mining address.
if (!address.empty() && !util::isTransparentAddress(address)) {
strncpy(s_address, address.c_str(), sizeof(s_address) - 1);
@@ -67,6 +69,7 @@ void RequestPaymentDialog::show(const std::string& address)
void RequestPaymentDialog::hide()
{
s_open = false;
s_creating_address = false;
s_payment_uri.clear();
if (s_qr_texture != 0) {
FreeQRTexture(s_qr_texture);
@@ -100,17 +103,55 @@ void RequestPaymentDialog::render(App* app)
ImGui::Spacing();
// Address selection
ImGui::Text("%s", TR("request_receive_address"));
std::string addr_display = s_address[0] ? s_address : TR("request_select_address");
if (addr_display.length() > static_cast<size_t>(zAddrLbl.truncate)) {
addr_display = addr_display.substr(0, zAddrFrontLbl.truncate) + "..." + addr_display.substr(addr_display.length() - zAddrBackLbl.truncate);
}
ImGui::SetNextItemWidth(-1);
if (ImGui::BeginCombo("##Address", addr_display.c_str())) {
// Z-addresses
if (!state.z_addresses.empty()) {
const bool noShielded = state.z_addresses.empty();
if (noShielded) {
// Nothing to request to yet — offer to create a shielded address instead of dead-ending.
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
ImGui::TextWrapped("%s", TR("request_no_shielded"));
ImGui::PopStyleColor();
ImGui::Spacing();
if (s_creating_address) {
ImGui::BeginDisabled();
material::TactileButton(TR("generating"), ImVec2(0, 0), S.resolveFont(actionBtn.font));
ImGui::EndDisabled();
} else {
ImGui::BeginDisabled(!app->isConnected());
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
if (app->isConnected()) {
s_creating_address = true;
app->createNewZAddress([app](const std::string& addr) {
s_creating_address = false;
if (addr.empty()) {
Notifications::instance().error(app->lastAddressError().empty()
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
} else {
Notifications::instance().success(TR("new_shielded_created"));
// Pre-select the new address if the dialog is still open and none chosen.
if (s_open && s_address[0] == '\0') {
strncpy(s_address, addr.c_str(), sizeof(s_address) - 1);
s_address[sizeof(s_address) - 1] = '\0';
s_uri_dirty = true;
}
}
});
}
}
ImGui::EndDisabled();
}
} else {
ImGui::Text("%s", TR("request_receive_address"));
std::string addr_display = s_address[0] ? s_address : std::string(TR("request_select_address"));
if (addr_display.length() > static_cast<size_t>(zAddrLbl.truncate)) {
addr_display = addr_display.substr(0, zAddrFrontLbl.truncate) + "..." + addr_display.substr(addr_display.length() - zAddrBackLbl.truncate);
}
if (s_address[0]) {
std::string l = app->getAddressLabel(s_address);
if (!l.empty()) addr_display = l + " " + addr_display;
}
ImGui::SetNextItemWidth(-1);
if (ImGui::BeginCombo("##Address", addr_display.c_str())) {
ImGui::TextDisabled("%s", TR("request_shielded_addrs"));
for (size_t i = 0; i < state.z_addresses.size(); i++) {
const auto& addr = state.z_addresses[i];
@@ -118,19 +159,20 @@ void RequestPaymentDialog::render(App* app)
if (label.length() > static_cast<size_t>(zAddrLbl.truncate)) {
label = label.substr(0, zAddrFrontLbl.truncate) + "..." + label.substr(label.length() - zAddrBackLbl.truncate);
}
if (ImGui::Selectable(label.c_str(), addr.address == s_address)) {
std::string clbl = app->getAddressLabel(addr.address);
std::string shown = (clbl.empty() ? label : (clbl + " " + label)) + "##" + addr.address;
if (ImGui::Selectable(shown.c_str(), addr.address == s_address)) {
strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1);
s_uri_dirty = true;
}
}
// No mining (transparent) addresses: they only receive mining rewards, so a request to one
// could never be paid.
ImGui::EndCombo();
}
// No mining (transparent) addresses: they only receive mining rewards, so a request to one
// could never be paid.
ImGui::EndCombo();
}
ImGui::Spacing();
// Amount (optional)
+17 -2
View File
@@ -8,6 +8,7 @@
#include <cstdio>
#include <string>
#include <vector>
#include <sodium.h>
#include "imgui.h"
#include "../layout.h" // Layout::dpiScale()
@@ -15,19 +16,31 @@
namespace dragonx {
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.
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
{
std::vector<std::string> words;
words.reserve(32); // avoid reallocation stranding un-wiped moved-from copies in freed heap
std::string cur;
for (char c : phrase) {
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 {
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;
}
@@ -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());
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
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) {
char cell[96];
@@ -52,6 +66,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
ImGui::TextUnformatted(cell);
if ((i % 4) != 3 && i + 1 < words.size())
ImGui::SameLine(((i % 4) + 1) * colStride);
sodium_memzero(cell, sizeof(cell));
}
}
+133 -74
View File
@@ -14,6 +14,8 @@
#include "../../util/address_validation.h"
#include "../../util/i18n.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 "../layout.h"
#include "contact_picker.h"
@@ -77,7 +79,6 @@ static constexpr double FEE_LOW = DRAGONX_DEFAULT_FEE * 0.5;
static constexpr double FEE_NORMAL = DRAGONX_DEFAULT_FEE;
static constexpr double FEE_HIGH = DRAGONX_DEFAULT_FEE * 2.0;
static bool s_clear_confirm_pending = false;
// Source dropdown preview string
static std::string s_source_preview;
@@ -268,9 +269,13 @@ static void RenderSourceDropdown(App* app, float width) {
const char* tag = isZ ? "[Z]" : "[T]";
std::string trunc = util::truncateMiddle(addr.address,
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
snprintf(buf, sizeof(buf), "%s %s — %s",
tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
std::string lbl = app->getAddressLabel(addr.address);
if (!lbl.empty())
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
else
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
s_source_preview = buf;
} else if (s_from_address[0] != '\0') {
// A pre-filled source that the dropdown list doesn't carry (zero-balance / view-only / list still
@@ -313,9 +318,13 @@ static void RenderSourceDropdown(App* app, float width) {
const char* tag = isZ ? "[Z]" : "[T]";
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
snprintf(buf, sizeof(buf), "%s %s — %s",
tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
std::string lbl = app->getAddressLabel(addr.address);
if (!lbl.empty())
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
else
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(buf, isCurrent)) {
@@ -324,9 +333,12 @@ static void RenderSourceDropdown(App* app, float width) {
addr.address.c_str());
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s\n%s",
addr.address.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
if (!lbl.empty())
material::Tooltip("%s\n%s\n%s", lbl.c_str(), addr.address.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
else
material::Tooltip("%s\n%s", addr.address.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
}
ImGui::PopID();
}
@@ -335,6 +347,20 @@ static void RenderSourceDropdown(App* app, float width) {
ImGui::EndCombo();
}
ImGui::PopFont();
// Warn when the chosen source is a transparent address: spending from it is not private on this
// ac_private chain (sender, amount and recipient become visible on-chain).
if (app->isConnected() && s_from_address[0] != '\0') {
bool srcT = (s_selected_from_idx >= 0 && s_selected_from_idx < (int)state.addresses.size())
? (state.addresses[s_selected_from_idx].type != "shielded")
: IsValidTransparentAddr(s_from_address);
if (srcT) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
Type().textWrapped(TypeStyle::Caption, TR("send_transparent_source_warn"));
ImGui::PopStyleColor();
}
}
}
// ============================================================================
@@ -761,33 +787,32 @@ void RenderSendConfirmPopup(App* app) {
float addrCardH = std::max(schema::UI().drawElement("tabs.send", "confirm-addr-card-min-height").size, schema::UI().drawElement("tabs.send", "confirm-addr-card-height").size * popVs);
float popGlassRound = Layout::glassRounding() * S.drawElement("tabs.send", "confirm-glass-rounding-ratio").size;
// FROM card
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("from_upper"));
// Address cards: show the FULL address (wrapped), not a middle-truncated preview — this is the
// one screen meant to catch a swapped recipient — plus the address-book label when the address
// matches a saved contact ("TO · Alice"), the strongest human-readable check against sending
// to the wrong place. The card grows to fit the wrapped text, and the dialog auto-sizes to it.
const float addrWrapW = popW - Layout::spacingMd() * 2.0f;
auto drawAddrCard = [&](const char* hdrKey, const char* addr) {
std::string hdr = TR(hdrKey);
std::string label = app->getAddressLabel(addr);
if (!label.empty()) { hdr += " \xC2\xB7 "; hdr += label; } // "·" separator
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), hdr.c_str());
ImVec2 cMin = ImGui::GetCursorScreenPos();
ImVec2 cMax(cMin.x + popW, cMin.y + addrCardH);
ImVec2 txtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, addrWrapW, addr);
float cardH = std::max(addrCardH, txtSz.y + Layout::spacingSm() * 2.0f);
ImVec2 cMax(cMin.x + popW, cMin.y + cardH);
GlassPanelSpec gs; gs.rounding = popGlassRound;
DrawGlassPanel(popDl, cMin, cMax, gs);
popDl->AddText(body2, body2->LegacySize,
ImVec2(cMin.x + Layout::spacingMd(), cMin.y + Layout::spacingSm()),
Success(), util::truncateMiddle(s_from_address, (int)S.drawElement("tabs.send", "confirm-addr-trunc-len").size).c_str());
ImGui::Dummy(ImVec2(popW, addrCardH));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
Success(), addr, nullptr, addrWrapW);
ImGui::Dummy(ImVec2(popW, cardH));
};
// TO card
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("to_upper"));
ImVec2 cMin = ImGui::GetCursorScreenPos();
ImVec2 cMax(cMin.x + popW, cMin.y + addrCardH);
GlassPanelSpec gs; gs.rounding = popGlassRound;
DrawGlassPanel(popDl, cMin, cMax, gs);
popDl->AddText(body2, body2->LegacySize,
ImVec2(cMin.x + Layout::spacingMd(), cMin.y + Layout::spacingSm()),
Success(), util::truncateMiddle(s_to_address, (int)S.drawElement("tabs.send", "confirm-addr-trunc-len").size).c_str());
ImGui::Dummy(ImVec2(popW, addrCardH));
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
}
drawAddrCard("from_upper", s_from_address);
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
drawAddrCard("to_upper", s_to_address);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// Fee tier selector
{
@@ -892,11 +917,16 @@ void RenderSendConfirmPopup(App* app) {
// since Review. Don't broadcast a now-invalid transaction.
const auto& liveState = app->getWalletState();
double liveAvail = GetAvailableBalance(app);
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 ||
(s_amount + s_fee) > liveAvail) {
if (!app->isConnected() || liveState.sync.syncing ||
!liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 ||
!util::canAfford(s_amount, s_fee, liveAvail)) {
s_tx_status = TR("send_cannot_send_now");
s_status_success = false;
s_status_timestamp = ImGui::GetTime();
// The status renders on the form BEHIND this popup, so surface it visibly and close the
// popup — otherwise the user is stuck re-clicking a button that silently does nothing.
Notifications::instance().warning(TR("send_cannot_send_now"));
s_show_confirm = false;
}
// A locked encrypted lite wallet can't spend; prompt to unlock instead of sending
// (the backend would otherwise reject with "Wallet is locked").
@@ -927,11 +957,27 @@ void RenderSendConfirmPopup(App* app) {
s_amount = 0.0;
s_memo[0] = '\0';
s_send_max = false;
} else {
s_tx_status = std::string(TR("send_error_prefix")) + result;
} 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().error(std::string(TR("send_tx_failed")) + result);
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 {
// 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.
const std::string friendly = util::friendlyRpcError(result);
s_tx_status = std::string(TR("send_error_prefix")) + friendly +
(friendly != result ? "\n\n" + result : std::string());
s_result_txid.clear();
s_status_success = false;
Notifications::instance().error(std::string(TR("send_tx_failed")) + friendly);
}
}
);
@@ -1003,18 +1049,36 @@ static void RenderActionButtons(App* app, float width, float vScale,
}
bool can_send = app->isConnected() &&
!state.sync.syncing &&
state.sync.sendTipReady(app->isLiteBuild()) &&
is_valid_address &&
s_amount > 0 &&
s_from_address[0] != '\0' &&
total <= available &&
util::canAfford(s_amount, s_fee, available) &&
sourceSpendable &&
!s_sending;
// Network fee + running total on the compose form — previously the fee was only revealed in the
// confirm popup, so the user couldn't see it (or the total) before committing to Review.
if (s_amount > 0.0) {
char feeLine[160];
snprintf(feeLine, sizeof(feeLine), "%s %.8f %s \xC2\xB7 %s %.8f %s",
TR("send_network_fee_label"), s_fee, DRAGONX_TICKER,
TR("send_total_label"), total, DRAGONX_TICKER);
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), feeLine);
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}
float btnGap = Layout::spacingMd();
float cancelBtnW = std::max(schema::UI().drawElement("tabs.send", "cancel-btn-min-width").size, width * S.drawElement("tabs.send", "cancel-btn-width-ratio").size);
float sendBtnW = width - cancelBtnW - btnGap;
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).
if (state.sync.syncing || (app->isConnected() && !state.sync.sendTipReady(app->isLiteBuild()))) {
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}
// ---- Send button ----
if (!can_send) {
// The disabled fill must contrast the surface: a white overlay reads on dark skins but vanished
@@ -1039,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"));
else if (state.sync.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')
material::Tooltip("%s", TR("send_tooltip_select_source"));
else if (is_mining_addr)
@@ -1047,7 +1113,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
else if (s_amount <= 0)
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"));
else if (!sourceSpendable)
material::Tooltip("%s", TR("send_tooltip_view_only"));
@@ -1063,38 +1129,20 @@ static void RenderActionButtons(App* app, float width, float vScale,
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.send", "cancel-btn-border-size").size);
char clearId[64];
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("cancel"), suffix);
// This button never cancels an in-progress send (there is none here) — it clears the form. Relabel it
// honestly, disable it when the form is empty, and drop the redundant confirm (ClearFormWithUndo
// already offers a timed Undo). The "##Cancel" id suffix is kept so the widget id stays stable.
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("send_clear_form"), suffix);
ImGui::BeginDisabled(!FormHasData());
if (TactileButton(clearId, ImVec2(cancelBtnW, btnH), S.resolveFont(S.button("tabs.send", "clear-button").font))) {
if (FormHasData()) {
s_clear_confirm_pending = true;
} else {
ClearFormWithUndo();
}
ClearFormWithUndo();
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", TR("send_clear_form_tip"));
ImGui::PopStyleVar(); // FrameBorderSize
ImGui::PopStyleColor(3);
// Clear confirmation popup
char confirmClearId[32];
snprintf(confirmClearId, sizeof(confirmClearId), "##ConfirmClear%s", suffix);
if (s_clear_confirm_pending) {
ImGui::OpenPopup(confirmClearId);
s_clear_confirm_pending = false;
}
if (ImGui::BeginPopup(confirmClearId)) {
ImGui::Text("%s", TR("send_clear_fields"));
ImGui::Spacing();
if (TactileButton(TR("send_yes_clear"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-yes-width").size * dp, 0), S.resolveFont("button"))) {
ClearFormWithUndo();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (TactileButton(TR("send_keep"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-keep-width").size * dp, 0), S.resolveFont("button"))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// Undo button (shown briefly after clear)
if (s_undo_timestamp > 0.0) {
double undoElapsed = ImGui::GetTime() - s_undo_timestamp;
@@ -1252,9 +1300,9 @@ void RenderSendTab(App* app)
RenderSyncBanner(state);
bool sendSyncing = state.sync.syncing && !state.sync.isSynced();
ImGui::BeginDisabled(sendSyncing);
// The form stays EDITABLE while syncing so the user can pre-fill it; only the Review/Send button is
// gated (can_send includes !syncing, and the confirm step re-checks live state), so nothing can be
// sent until the wallet is synced.
ImVec2 sendAvail = ImGui::GetContentRegionAvail();
float hs = Layout::hScale(sendAvail.x);
float vScale = Layout::vScale(sendAvail.y);
@@ -1311,7 +1359,6 @@ void RenderSendTab(App* app)
// Zero balance CTA
if (RenderZeroBalanceCTA(app, dl, formW)) {
ImGui::EndGroup();
ImGui::EndDisabled(); // sendSyncing guard
ImGui::EndChild();
return;
}
@@ -1386,7 +1433,9 @@ void RenderSendTab(App* app)
Type().textColored(TypeStyle::Caption, Error(), TR("send_mining_addr_short"));
if (vt && ImGui::IsItemHovered())
material::Tooltip("%s", TR("send_mining_addr_blocked"));
} else if (!checkPreview) {
} else if (!checkPreview && ImGui::GetActiveID() != ImGui::GetID("##ToAddr")) {
// Defer the "invalid" error until the field loses focus, so it doesn't flash red
// from the first keystroke while the user is still typing the address.
ImGui::SameLine();
Type().textColored(TypeStyle::Caption, Error(), TR("invalid_address"));
}
@@ -1499,6 +1548,7 @@ void RenderSendTab(App* app)
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
// 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_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)
{
@@ -1524,6 +1574,7 @@ void RenderSendTab(App* app)
if (s_amount < 0) s_amount = 0;
if (market.price_usd > 0)
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)
if (market.price_usd > 0 && s_amount > 0) {
@@ -1588,14 +1639,17 @@ void RenderSendTab(App* app)
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
// 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);
Type().textColored(TypeStyle::Caption, Error(), buf);
}
}
// ---- MEMO (shielded only) ----
if (is_valid_z || s_to_address[0] == '\0') {
// ---- MEMO (shielded only; shown disabled + explained for non-shielded recipients) ----
{
// Keep the memo visible (and the typed text intact) even for a transparent recipient, with an
// explainer, instead of silently hiding the field and dropping what the user wrote.
const bool memoDisabled = (s_to_address[0] != '\0' && !is_valid_z);
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
{
ImVec2 divPos = ImGui::GetCursorScreenPos();
@@ -1610,8 +1664,10 @@ void RenderSendTab(App* app)
float memoInputH = std::max(S.drawElement("tabs.send", "memo-min-height").size, S.drawElement("tabs.send", "memo-base-height").size * vScale);
ImGui::PushItemWidth(colW);
if (memoDisabled) ImGui::BeginDisabled();
ImGui::InputTextMultiline("##Memo", s_memo, sizeof(s_memo),
ImVec2(colW, memoInputH));
if (memoDisabled) ImGui::EndDisabled();
ImGui::PopItemWidth();
// The Sapling memo limit is in BYTES (multi-byte UTF-8 fills it faster than visible chars);
@@ -1621,6 +1677,10 @@ void RenderSendTab(App* app)
bool memoAtCap = memo_len + 1 >= memoMax;
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
if (memoDisabled) {
const char* mk = s_memo[0] ? "send_memo_will_not_be_sent" : "send_memo_requires_shielded";
Type().textColored(TypeStyle::Caption, Warning(), TR(mk));
}
}
// Divider before action buttons
@@ -1688,7 +1748,6 @@ void RenderSendTab(App* app)
RenderRecentSends(state, formW, capFont, app);
ImGui::EndGroup();
ImGui::EndDisabled(); // sendSyncing guard
// Round to nearest pixel to prevent sub-pixel oscillation (vibration)
// when content like "recent sends" elapsed-time text changes width each frame
float measuredH = ImGui::GetCursorPosY() - contentStartY;
-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
+122 -16
View File
@@ -41,6 +41,8 @@ static bool s_advanced = false; // Advanced (fee + batch size)
static bool s_confirm = false; // inline "confirm before moving funds" phase
static bool s_operation_pending = false;
static bool s_op_terminal = false; // async op reached success/failed — freeze inputs
enum class OpOutcome { None, Success, Failed, Uncertain };
static OpOutcome s_op_outcome = OpOutcome::None; // tags the terminal status for colour (display only)
static std::string s_operation_id;
static std::string s_status_message;
static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds)
@@ -68,6 +70,7 @@ static void resetTransient()
++s_poll_session;
s_operation_pending = false;
s_op_terminal = false;
s_op_outcome = OpOutcome::None;
s_confirm = false;
s_status_message.clear();
s_operation_id.clear();
@@ -201,6 +204,25 @@ static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_am
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
// Shield-coinbase source helpers. "*" = all spendable mining (transparent) addresses; else one address.
// Balances are DISPLAY totals (may include immature/non-coinbase), so the UI wording says "up to".
static double miningBalance(const WalletState& st, const char* from) {
if (std::strcmp(from, "*") == 0) {
double sum = 0.0;
for (const auto& a : st.t_addresses) if (a.isSpendable()) sum += a.balance;
return sum;
}
const int i = addressIndexByString(st.t_addresses, from);
return i >= 0 ? st.t_addresses[i].balance : 0.0;
}
static int miningAddrCount(const WalletState& st, const char* from) {
if (std::strcmp(from, "*") == 0) {
int n = 0; for (const auto& a : st.t_addresses) if (a.isSpendable() && a.balance > 0.0) ++n; return n;
}
const int i = addressIndexByString(st.t_addresses, from);
return (i >= 0 && st.t_addresses[i].balance > 0.0) ? 1 : 0;
}
// A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap).
static void wrappedCaption(ImU32 color, const char* text)
{
@@ -232,6 +254,7 @@ static void submitOperation(App* app)
if (!app->worker()) return; // before the pending state, so a missing worker can't wedge it
s_operation_pending = true;
s_op_terminal = false;
s_op_outcome = OpOutcome::None;
s_status_message = TR("shield_submitting");
s_last_poll = ImGui::GetTime();
@@ -253,8 +276,9 @@ static void submitOperation(App* app)
app->trackOperation(s_operation_id);
} else {
s_operation_pending = false; s_op_terminal = true;
s_status_message = std::string(TR("shield_error_prefix")) + error;
Notifications::instance().error(std::string(TR("shield_send_failed")) + error);
s_op_outcome = OpOutcome::Failed;
s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error);
Notifications::instance().error(std::string(TR("shield_send_failed")) + util::friendlyRpcError(error));
}
};
});
@@ -309,13 +333,15 @@ static void submitOperation(App* app)
app->trackOperation(s_operation_id);
} else if (uncertain) {
s_operation_pending = false; s_op_terminal = true;
s_op_outcome = OpOutcome::Uncertain;
s_confirm = false; // don't leave Confirm armed for a merge that may still be running
s_status_message = TR("merge_submit_uncertain");
Notifications::instance().warning(TR("merge_submit_uncertain"));
} else {
s_operation_pending = false; s_op_terminal = true;
s_status_message = std::string(TR("shield_error_prefix")) + error;
Notifications::instance().error(std::string(TR("merge_send_failed")) + error);
s_op_outcome = OpOutcome::Failed;
s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error);
Notifications::instance().error(std::string(TR("merge_send_failed")) + util::friendlyRpcError(error));
}
};
});
@@ -340,6 +366,7 @@ static void submitPerAddress(App* app)
s_confirm = false;
s_status_message.clear(); // an earlier "Into one" error / uncertain text belongs to that op
s_op_terminal = false;
s_op_outcome = OpOutcome::None;
}
}
@@ -384,6 +411,7 @@ static void pollOperation(App* app)
// client popped it with z_getoperationresult). It registers an op before returning its id,
// so this is no race. The outcome can't be confirmed; stop polling.
s_operation_pending = false; s_op_terminal = true;
s_op_outcome = OpOutcome::Uncertain;
s_status_message = TR("send_status_unconfirmed");
Notifications::instance().warning(TR("send_status_unconfirmed"));
return;
@@ -393,6 +421,7 @@ static void pollOperation(App* app)
? op["status"].get<std::string>() : std::string();
if (status == "success") {
s_operation_pending = false; s_op_terminal = true;
s_op_outcome = OpOutcome::Success;
s_status_message = TR("shield_completed");
Notifications::instance().success(TR("shield_merge_done"));
} else if (status == "failed" || status == "cancelled" || status == "canceled") {
@@ -401,6 +430,7 @@ static void pollOperation(App* app)
op["error"]["message"].is_string())
msg = op["error"]["message"].get<std::string>();
s_operation_pending = false; s_op_terminal = true;
s_op_outcome = OpOutcome::Failed;
s_status_message = std::string(TR("shield_op_failed")) + msg;
// No toast: the op is tracked (App::trackOperation) and the shared opid poller raises the
// one failure toast, whether this dialog is open or not.
@@ -523,9 +553,12 @@ void ShieldDialog::render(App* app)
if (s_scan.phase == services::ScopePhase::Loaded && listed == 0) {
wrappedCaption(material::Warning(), TR("merge_keep_each_none"));
} else if (listed > 0) {
const float eachPad = 10.0f * dp; // inner padding (the floating overlay leaves ambient 0)
const float listH = std::min(static_cast<float>(listed), static_cast<float>(maxRows)) * rowH +
ImGui::GetStyle().WindowPadding.y * 2.0f;
if (ImGui::BeginChild("##ConsolidateEach", ImVec2(-1, listH), ImGuiChildFlags_Borders)) {
eachPad * 2.0f;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(eachPad, eachPad));
if (ImGui::BeginChild("##ConsolidateEach", ImVec2(-1, listH),
ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding)) {
for (const auto& g : s_groups) {
if (g.notes < 2) continue;
const bool worth = services::worthConsolidating(g, s_fee);
@@ -549,6 +582,7 @@ void ShieldDialog::render(App* app)
}
}
ImGui::EndChild();
ImGui::PopStyleVar(); // ##ConsolidateEach inner padding
}
ImGui::Spacing();
}
@@ -556,8 +590,32 @@ void ShieldDialog::render(App* app)
// ── Shield coinbase: from address ───────────────────────────────────────────────────────────
if (!isMerge && !opInFlight) {
material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address));
ImGui::TextDisabled("%s", TR("shield_wildcard_hint"));
// Pick the mining (transparent) address to shield from a dropdown with balances, instead of a
// free-text box with a "*" wildcard. "*" shields all of them.
ImGui::TextUnformatted(TR("shield_from_address"));
const bool allFrom = std::strcmp(s_from_address, "*") == 0;
const std::string fromLbl = allFrom ? std::string(TR("shield_all_mining_addrs"))
: (app->getAddressLabel(s_from_address).empty() ? shortAddr(s_from_address)
: app->getAddressLabel(s_from_address));
ImGui::SetNextItemWidth(-1);
if (ImGui::BeginCombo("##FromAddr", fromLbl.c_str())) {
if (ImGui::Selectable(TR("shield_all_mining_addrs"), allFrom))
strncpy(s_from_address, "*", sizeof(s_from_address));
for (const auto& a : state.t_addresses) {
if (!a.isSpendable()) continue;
const std::string lbl = app->getAddressLabel(a.address);
char item[192];
std::snprintf(item, sizeof(item), "%s (%s DRGX)",
lbl.empty() ? shortAddr(a.address).c_str() : lbl.c_str(),
fmtAmt(a.balance).c_str());
const bool sel = std::strcmp(s_from_address, a.address.c_str()) == 0;
if (ImGui::Selectable(item, sel)) {
strncpy(s_from_address, a.address.c_str(), sizeof(s_from_address) - 1);
s_from_address[sizeof(s_from_address) - 1] = '\0';
}
}
ImGui::EndCombo();
}
ImGui::Spacing();
}
@@ -666,16 +724,29 @@ void ShieldDialog::render(App* app)
std::max(1, p.batchesEstimated), p.notesMerged);
wrappedCaption(material::OnSurfaceMedium(), cap);
if (!p.stopping) {
if (const char* key = services::runWaitKey(run.lastWait())) wrappedCaption(material::OnSurfaceMedium(), TR(key));
// The pause notice (waiting for a batch gap / sync / a send) toggles on and off every batch.
// Reserve a fixed two-line slot for it so its coming and going doesn't change the content
// height and make the auto-height, vertically-centred modal jump around during the run.
const float waitTop = ImGui::GetCursorPosY();
if (const char* key = services::runWaitKey(run.lastWait()))
wrappedCaption(material::OnSurfaceMedium(), TR(key));
const float waitSlotH = ImGui::GetTextLineHeightWithSpacing() * 2.0f;
if (ImGui::GetCursorPosY() < waitTop + waitSlotH) ImGui::SetCursorPosY(waitTop + waitSlotH);
}
}
ImGui::ProgressBar(static_cast<float>(p.batchesDone) / static_cast<float>(std::max(1, p.batchesEstimated)),
ImVec2(-1, 0));
const auto& addrs = run.addresses();
// Inner padding for the bordered list: this floating overlay pops its content padding, so the
// ambient WindowPadding is 0 and a bordered child's text would otherwise touch the border. Push a
// small padding (and reserve it in the height) so the rows sit inset.
const float listPad = 10.0f * dp;
const float runListH = std::min(static_cast<float>(addrs.size()), static_cast<float>(maxRows)) * rowH +
ImGui::GetStyle().WindowPadding.y * 2.0f;
listPad * 2.0f;
bool leftovers = false;
if (ImGui::BeginChild("##ConsolidateRuns", ImVec2(-1, runListH), ImGuiChildFlags_Borders)) {
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(listPad, listPad));
if (ImGui::BeginChild("##ConsolidateRuns", ImVec2(-1, runListH),
ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding)) {
for (const auto& a : addrs) {
const std::string label = app->getAddressLabel(a.group.address);
const std::string name = label.empty() ? shortAddr(a.group.address) : label;
@@ -711,9 +782,21 @@ void ShieldDialog::render(App* app)
}
}
ImGui::EndChild();
if (leftovers) {
ImGui::Spacing();
wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_run_again"));
ImGui::PopStyleVar(); // ##ConsolidateRuns inner padding
{
// The "run again" hint appears partway through a run (once an address has leftover notes). While
// the run is live, reserve its measured height up front so it appearing doesn't shift the modal.
const float loTop = ImGui::GetCursorPosY();
if (leftovers) {
ImGui::Spacing();
wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_run_again"));
}
if (live) {
const float loH = ImGui::GetStyle().ItemSpacing.y +
ImGui::CalcTextSize(TR("merge_keep_each_run_again"), nullptr, false,
ImGui::GetContentRegionAvail().x).y;
if (ImGui::GetCursorPosY() < loTop + loH) ImGui::SetCursorPosY(loTop + loH);
}
}
if (!live) { // below the rows: merge_keep_each_some_failed says "see the list above"
std::string endLine;
@@ -746,10 +829,18 @@ void ShieldDialog::render(App* app)
// ── Live progress / status ──────────────────────────────────────────────────────────────────
if (!s_status_message.empty() && !(isMerge && run.hasRun())) { // the single-op path's message, not the run's
if (s_operation_pending && !s_op_terminal)
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str());
const bool inFlight = s_operation_pending && !s_op_terminal;
const ImU32 stCol = inFlight ? material::Warning()
: s_op_outcome == OpOutcome::Success ? material::Success()
: s_op_outcome == OpOutcome::Failed ? material::Error()
: s_op_outcome == OpOutcome::Uncertain ? material::Warning()
: material::OnSurfaceMedium();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(stCol));
if (inFlight)
ImGui::Text("%s%s", s_status_message.c_str(), material::LoadingDots()); // spinner while scanning
else
ImGui::TextWrapped("%s", s_status_message.c_str());
ImGui::PopStyleColor();
ImGui::Spacing();
}
@@ -814,9 +905,24 @@ void ShieldDialog::render(App* app)
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
ImGui::TextWrapped("%s", cb);
ImGui::Spacing();
} else if (s_confirm && !isMerge) {
// Shield-coinbase preview: amount / address count / fee / destination (no amount/count was shown before).
const bool allFrom = std::strcmp(s_from_address, "*") == 0;
const std::string toLbl = app->getAddressLabel(s_to_address);
char cb[360];
std::snprintf(cb, sizeof(cb), TR("shield_confirm_fmt"),
fmtAmt(miningBalance(state, s_from_address)).c_str(),
allFrom ? miningAddrCount(state, s_from_address) : 1,
s_utxo_limit,
toLbl.empty() ? shortAddr(s_to_address).c_str() : toLbl.c_str(),
fmtAmt(s_fee).c_str());
ImGui::TextWrapped("%s", cb);
ImGui::Spacing();
}
bool can_submit = haveDest && connected && !syncing;
// Block a shield from an empty source or one with no funds (the free-text box allowed both).
if (!isMerge && (s_from_address[0] == '\0' || miningBalance(state, s_from_address) <= 0.0)) can_submit = false;
const bool scopeLoaded = s_scan.phase == services::ScopePhase::Loaded;
if (isMerge && scopeLoaded && srcCount() == 0) can_submit = false;
if (perAddress) can_submit = connected && !syncing && scopeLoaded && !selectedGroups().empty();
+11 -8
View File
@@ -10,6 +10,7 @@
#include "../theme.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "../notifications.h"
#include "imgui.h"
#include <ctime>
@@ -57,16 +58,16 @@ void TransactionDetailsDialog::render(App* app)
ImVec4 type_color;
std::string type_display;
if (tx.type == "receive") {
type_color = ImVec4(0.3f, 0.8f, 0.3f, 1.0f);
type_color = material::SuccessVec4();
type_display = TR("tx_received");
} 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");
} else if (tx.type == "generate" || tx.type == "mined") {
type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f);
type_display = TR("tx_mined");
} 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");
} else {
type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
@@ -78,11 +79,11 @@ void TransactionDetailsDialog::render(App* app)
// Confirmations badge
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) {
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 {
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();
@@ -93,9 +94,9 @@ void TransactionDetailsDialog::render(App* app)
ImGui::Text("%s", TR("amount_label"));
ImGui::SameLine(lbl.position);
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 {
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
@@ -126,6 +127,7 @@ void TransactionDetailsDialog::render(App* app)
ImGui::SameLine();
if (material::StyledButton((std::string(TR("grpb_copy")) + "##TxID").c_str(), ImVec2(copyBtn.width, 0), S.resolveFont(copyBtn.font))) {
ImGui::SetClipboardText(tx.txid.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
ImGui::Spacing();
@@ -152,6 +154,7 @@ void TransactionDetailsDialog::render(App* app)
ImGui::SameLine();
if (material::StyledButton((std::string(TR("grpb_copy")) + "##Addr").c_str(), ImVec2(copyBtn.width, 0), S.resolveFont(copyBtn.font))) {
ImGui::SetClipboardText(tx.address.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
}
+107 -18
View File
@@ -3,6 +3,7 @@
// Released under the GPLv3
#include "transactions_tab.h"
#include "../notifications.h"
#include "transaction_details_dialog.h"
#include "export_transactions_dialog.h"
#include "transactions_tab_helpers.h"
@@ -36,6 +37,11 @@ namespace ui {
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) {
return std::string(TR(key)) + "##" + id;
}
@@ -45,6 +51,17 @@ static bool containsIgnoreCase(const std::string& str, const std::string& search
return dragonx::util::containsIgnoreCase(str, search);
}
// Pending "view transactions for this address" request. Set by SetTransactionsAddressFilter()
// from Contacts / the Balance address list / the Receive tab, consumed on the next
// RenderTransactionsTab frame to pre-fill the search box (see the consume block below).
static std::string s_pending_addr_filter;
static bool s_pending_addr_filter_set = false;
void SetTransactionsAddressFilter(const std::string& address) {
s_pending_addr_filter = address;
s_pending_addr_filter_set = true;
}
// A display-ready transaction that may be a merged autoshield pair.
// For non-merged entries, send_idx or recv_idx is -1.
struct DisplayTx {
@@ -87,7 +104,7 @@ static void DrawTxIcon(ImDrawList* dl, const std::string& type,
} else if (type == "receive") {
icon = ICON_MD_CALL_RECEIVED;
} else if (type == "shield") {
icon = ICON_MD_CALL_MADE;
icon = ICON_MD_SHIELD; // a shield, not an outgoing arrow — it's an internal privacy move
} else {
icon = ICON_MD_CONSTRUCTION;
}
@@ -174,6 +191,9 @@ void RenderTransactionsTab(App* app)
ImU32 greenCol = Success();
ImU32 redCol = Error();
ImU32 goldCol = Warning();
// Neutral colour for a shielding move (your own funds going transparent→private): neither the
// red of a spend nor the green of income, so a shield never reads as "money leaving".
ImU32 shieldCol = schema::UI().resolveColor("var(--accent-shielded)", OnSurfaceMedium());
std::string txLoadingText = app->transactionRefreshProgressText();
bool txLoading = !txLoadingText.empty();
@@ -199,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
// (a sort-combo change re-sorts without regrouping).
static history::TxidGroups s_groups;
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows
if (s_groups_key != contentKey) {
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_structure_revision starts at 1 and only grows
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_key = contentKey;
s_groups_key = structKey;
}
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
@@ -237,11 +258,9 @@ void RenderTransactionsTab(App* app)
if (send_i >= 0 && recv_i >= 0) {
isShieldLeg[send_i] = true;
isShieldLeg[recv_i] = true;
// The list shows the merged shield under the "Sent" filter, so count it there too —
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded
// (receive-leg) amount, which is what the merged row displays.
sendCount++;
sendTotal += std::abs(state.transactions[recv_i].amount);
// A shield moves your OWN funds transparent→private — it isn't a spend, so it is NOT
// counted toward the Sent card/total (and no longer appears under the Sent filter).
// It merges into one neutral "Shielded" row, visible under "All".
}
}
@@ -313,6 +332,15 @@ void RenderTransactionsTab(App* app)
// Search & Filter bar
// ================================================================
static char search_filter[128] = "";
// Consume a pending "view transactions for this address" request: pre-fill the search box
// with the address and reset the type filter to All so no matching row is hidden. The
// changed search_str resets pagination/expansion via filterInputsChanged() below.
if (s_pending_addr_filter_set) {
strncpy(search_filter, s_pending_addr_filter.c_str(), sizeof(search_filter) - 1);
search_filter[sizeof(search_filter) - 1] = '\0';
type_filter = 0;
s_pending_addr_filter_set = false;
}
float searchMaxW = (searchInput.maxWidth >= 0) ? searchInput.maxWidth : 300.0f;
float searchRatio = (searchInput.widthRatio >= 0) ? searchInput.widthRatio : 0.30f;
float searchWidth = std::min(searchMaxW * hs, availWidth * searchRatio);
@@ -388,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
// the refresh service, but we re-sort here to apply the "pending first" ordering.
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 bool s_display_cache_valid = false;
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.
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_cache.clear();
std::vector<DisplayTx>& display_txns = s_display_cache;
@@ -497,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_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
@@ -523,13 +570,14 @@ void RenderTransactionsTab(App* app)
for (size_t i = 0; i < display_txns.size(); i++) {
const auto& dtx = display_txns[i];
if (type_filter != 0) {
if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue;
if (type_filter == 1 && dtx.display_type != "send") continue; // Sent = real spends; shields aren't sends
if (type_filter == 2 && dtx.display_type != "receive") continue;
if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue;
if (type_filter == 4 && !chatStore.hasChatTxid(dtx.txid)) continue; // chat-only
}
if (!search_str.empty()) {
if (!containsIgnoreCase(dtx.address, search_str) &&
!containsIgnoreCase(dtx.from_address, search_str) &&
!containsIgnoreCase(dtx.txid, search_str) &&
!containsIgnoreCase(dtx.memo, search_str) &&
!(dtx.is_shield && containsIgnoreCase(std::string("shielded"), search_str))) {
@@ -543,6 +591,35 @@ void RenderTransactionsTab(App* app)
s_filtered_search = search_str;
s_filtered_chat_rev = chatRev;
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;
filtered_count = static_cast<int>(filtered_indices.size());
@@ -701,7 +778,7 @@ void RenderTransactionsTab(App* app)
ImU32 iconCol;
const char* typeStr;
if (shieldedDisplay) {
iconCol = redCol; typeStr = TR("sent_type");
iconCol = shieldCol; typeStr = TR("shielded_self_type");
} else if (tx.display_type == "receive") {
iconCol = greenCol; typeStr = TR("recv_type");
} else if (tx.display_type == "send") {
@@ -729,7 +806,7 @@ void RenderTransactionsTab(App* app)
float cy = rowPos.y + Layout::spacingMd();
// Icon
DrawTxIcon(dl, shieldedDisplay ? "send" : tx.display_type,
DrawTxIcon(dl, shieldedDisplay ? "shield" : tx.display_type,
cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
// Type label
@@ -748,7 +825,7 @@ void RenderTransactionsTab(App* app)
OnSurfaceMedium(), addr_display.c_str());
// Amount (right-aligned, first line)
ImU32 amtCol = (tx.amount >= 0) ? greenCol : redCol;
ImU32 amtCol = shieldedDisplay ? shieldCol : ((tx.amount >= 0) ? greenCol : redCol);
if (tx.amount >= 0)
snprintf(buf, sizeof(buf), "+%.8f", tx.amount);
else
@@ -778,7 +855,12 @@ void RenderTransactionsTab(App* app)
{
const char* statusStr;
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();
} else if (tx.confirmations < 10) {
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
@@ -849,9 +931,11 @@ void RenderTransactionsTab(App* app)
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
ImGui::SetClipboardText(tx.address.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
if (ImGui::MenuItem(TR("copy_txid"))) {
ImGui::SetClipboardText(tx.txid.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
ImGui::Separator();
if (ImGui::MenuItem(TR("view_on_explorer"))) {
@@ -894,7 +978,10 @@ void RenderTransactionsTab(App* app)
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
ImGui::TextWrapped("%s", tx.txid.c_str());
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()));
// Memo
@@ -916,10 +1003,12 @@ void RenderTransactionsTab(App* app)
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.txid.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
ImGui::SameLine();
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.address.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
ImGui::SameLine();
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
+35
View File
@@ -4,16 +4,51 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
#include <cstddef>
namespace dragonx {
class App;
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
* Shows transaction history
*/
void RenderTransactionsTab(App* app);
/**
* @brief Pre-filter the Transactions (History) tab by an address.
*
* Sets the History search box to @p address and resets the type filter to All on the next
* render, then the caller navigates to the History tab. Used by the "View transactions"
* actions in Contacts, the Balance address list, and the Receive tab. Because shielded /
* received-from attribution is incomplete, this shows transactions *involving* the address,
* not a guaranteed-complete set.
*/
void SetTransactionsAddressFilter(const std::string& address);
} // namespace ui
} // namespace dragonx

Some files were not shown because too many files have changed in this diff Show More