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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:35:32 -05:00
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
DanSandClaude Opus 4.8 e40c6c0a7c perf(market): clip off-screen rows in the portfolio editor
The portfolio-editor address list drew every row every frame inside the
fade-scroll child. It now runs through an ImGuiListClipper; the fixed
step (the InvisibleButton height plus item spacing) keeps each row's
cursor exactly where the unclipped loop put it, so the scroll extent for
the fade shader, the check marks, the selection and the "selected rows
float to the top" ordering are all unchanged.

Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, including skin switch and resize with the dialog open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 16:55:07 -05:00
DanSandClaude Opus 4.8 4d95ef68f6 perf(contacts): clip off-screen rows in the Cards and List views
The contacts Cards/List row loop submitted every contact every frame.
It now runs through an ImGuiListClipper, so only the on-screen rows are
built; the fixed step (row height plus the item spacing pushed above)
matches the per-row cursor exactly, so the scroll extent, fade, hover
actions, selection and keyboard navigation are unchanged. Table view is
left unclipped (its rows can wrap).

Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, in both Cards and List views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 16:55:07 -05:00
DanSandClaude Opus 4.8 3f85953452 perf(ui): stop backdrop effects requesting frames behind a modal
While a blur overlay (a modal dialog or the lock screen) covers the
window, the theme backdrop effects kept requesting animation frames
even though they are not visible, so the app never went idle and
re-rendered continuously.

The ambient animation predicate now drops the backdrop-effects term
when a blur overlay was active last frame. Reduce Motion and the
unfocused/hidden throttle were already handled, and the overlay's own
moving elements (the lock screen's unlock dots, a spinner) still request
frames through the progress term, so nothing on screen freezes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:36:43 -05:00
DanSandClaude Opus 4.8 5a3d36904c test(litebe): make the retire test deterministic under the change-gate
The change-gated refresh worker only runs `balance` on a full-refresh
cycle, so testLiteWalletControllerRetire could no longer reliably park
the worker inside a blocked `balance` (it passed only by timing). Bump
the fake scanned height to force a full-refresh cycle, which enters the
now-blocked `balance` deterministically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:36:43 -05:00
DanSandClaude Opus 4.8 ef14518089 perf(litebe): change-gate the idle lite refresh worker
The background 2 s refresh worker re-queried and re-copied the whole
wallet every cycle (a fresh TLS refresh plus a multi-megabyte JSON
build, copies and DOM parse), even when nothing had changed, so CPU and
the network never went idle.

The worker now skips the full refresh and the re-publish on a cycle that
is provably unchanged, while never letting the UI show stale confirmed
balance or transactions:

- The lite bridge keeps a monotonic mutation epoch, bumped after any
  command not on a read-only allow-list, so a local change made on any
  thread (send, shield, import, new address, encrypt/unlock/lock,
  console) is detected at one choke point.
- Each cycle the worker runs one cheap sync probe (one connection, the
  mandatory block ingest) and a local height read, and refreshes only
  when the scanned height advanced, the mutation epoch moved, the
  encryption state changed, a sync is in progress, or a ~10 s heartbeat
  elapsed; otherwise it skips. Direct/synchronous callers are not gated.
- A failed probe backs off instead of running a second sync, so a dead
  server is not retried back-to-back.

Confirmed funds can never be stale beyond one cycle (every confirmed
change advances the height or bumps the epoch); only unconfirmed mempool
rows can lag up to the heartbeat, and the spend path re-queries the
backend directly rather than reading the gated model. Average idle cost
drops from three data connections per cycle to about one.

Tests: a bridge mutation-epoch test and a worker change-gate test
(skip-when-unchanged; refresh on height advance, local mutation and
encryption change) against the fake backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:00:26 -05:00
DanSandClaude Opus 4.8 39292b1b1f feat(litebe): salted Argon2id KDF for new encrypted wallets
New Lite wallets now encrypt their seed and keys with a salted Argon2id
key instead of the unsalted double-SHA256 of the passphrase, which was
cheap to brute-force from a stolen wallet file.

- A shared derive_wallet_key() replaces double-SHA256 across encrypt,
  unlock, remove-encryption and seed recovery. KDF 0 is the old
  double-SHA256 (unchanged); KDF 1 is Argon2id (moderate: 3 passes,
  256 MiB), using sodiumoxide's existing pwhash -- no new dependency.
- The KDF id, salt and parameters are stored in the wallet. A wallet is
  written at version 10 only while it is actually Argon2id-encrypted;
  every pre-existing wallet (plaintext or double-SHA256) is read and
  written byte-for-byte as version 9.
- New-wallets-only: a freshly created or restored wallet encrypts with
  Argon2id; a wallet read from disk never changes its KDF, and removing
  encryption never changes it. Set DRAGONX_LITE_KDF=legacy to keep new
  wallets on double-SHA256.
- An older build (or SilentDragonXLite) opening a version-10 wallet
  fails cleanly with "don't know how to read wallet version 10" before
  parsing anything, never a silent misread; the seed phrase stays the
  cross-wallet recovery path.

Compatibility break (by design, opt-in): a wallet newly encrypted with
Argon2id cannot be opened by older ObsidianDragon or SilentDragonXLite.

Tests: the existing encryption tests now exercise the Argon2id path,
plus the version-10 header, legacy byte-identity and derive_wallet_key
parameter bounds. Validated by the lite oracle suite and the old-vs-new
serialization differential (legacy wallets byte-identical; version-10
round-trips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 10:37:34 -05:00
DanSandClaude Opus 4.8 6d4ed96f6d fix(litebe): make HD t-address derivation atomic under scan
Deriving the next HD transparent address read the key count under a
read lock, released it, derived the address, and only then took a
write lock to append it. Between reading the position and appending,
another caller - for instance a scan deriving an address at the same
time - could read the same position, derive the same address, and
append it too, leaving a duplicate transparent key in the wallet.

- The whole read-modify-write now runs under a single write guard, so
  the position a caller derives from reflects every key already
  added. The slow seed stretch is computed once before the guard is
  taken, so holding the lock does not block on it.
- As a further guard, derivation skips a position whose address is
  already present and advances to the next, so no path can produce a
  duplicate even if positions were ever to overlap.
- The starting index is computed exactly as before (it still counts
  imported keys too), so a wallet with no contention derives the same
  addresses in the same order.

Uncontended derivation produces the same addresses in the same order,
verified against the differential harness. The wallet file format is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 b49f85bcbe perf(litebe): take a read lock for wallet serialization in do_save
Saving the wallet serializes it to a temporary file under the sync
lock, but it took a write guard on the wallet to do so. Serialization
only reads wallet state, so the write guard needlessly excluded other
readers for the whole duration of the write, buffering, and flush.

- The save path now takes a read guard to serialize. A read guard
  still excludes writers, so nothing can mutate the wallet mid-write;
  it only lets other readers proceed in parallel. The surrounding
  sync lock still serializes saves against syncs, and the atomic
  temp-file-then-rename sequence is unchanged.

The serialized bytes are identical, verified against the differential
harness. The wallet file format is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 0bdea68337 perf(litebe): memoize per-note viewing-key decode on wallet open
Opening a wallet reads each shielded note from disk, and every note
record embeds a 169-byte extended full viewing key that was decoded
from scratch for every note. That decode is an elliptic-curve parse;
a wallet with many notes sharing a handful of keys paid for it once
per note, which dominated open time.

- Note reads now go through a small content-addressed cache keyed on
  the exact 169 raw key bytes. The first note with a given key
  decodes it; the rest reuse the result. Because the cache is keyed
  on the raw bytes and the decoder is deterministic, the cached key
  is identical to a fresh parse.
- The cache is thread-local and naturally bounded by the few distinct
  keys a wallet holds, so it adds no cross-thread state and cannot
  grow without bound.

The bytes on disk are read exactly as before - this only avoids
re-parsing identical bytes - so the loaded wallet is byte-identical,
verified against the differential harness. The wallet file format is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 c045e56d4e perf(litebe-sync): bound the gRPC block channel with backpressure
Each block range is streamed over one gRPC connection into a channel
that a worker thread drains, encodes, and scans. That channel was
unbounded, so when the server delivered blocks faster than the
scanner consumed them the backlog piled up in memory with no ceiling
- on a fast link against a large range it could grow without limit.

- The channel is now a bounded synchronous channel holding at most
  2048 blocks. When it fills, the fetch side waits, applying
  backpressure up the stream instead of buffering endlessly, so peak
  memory is capped no matter how far ahead the server runs.
- The batch size is untouched: it still streams a whole range over a
  single connection, which is what keeps the connection count low
  enough to avoid socket-buffer exhaustion on Windows. Only the
  in-flight depth between fetch and scan is now bounded.

The same blocks are fetched and scanned in the same order, so the
resulting wallet is byte-identical, verified against the differential
harness. The wallet file format is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 be69c9bafe fix(litebe-sync): bound the reorg recovery window to the max depth
When sync hit a reorg it stepped the scan back to just before the
invalidated block, then set the top of the re-scan range to that
point plus the full forward batch size. That window is far larger
than any reorg can be: the batch size is tuned for forward streaming
(tens of thousands of blocks), while a reorg is capped at the maximum
reorg depth and the pass aborts once it is exceeded. So the recovery
pass re-fetched and re-scanned a large span of already-good blocks
before it could resume moving forward.

- The recovery range now reaches only the maximum reorg depth (plus
  one) above the rewind point, which is the most a reorg can ever
  disturb. Forward syncing still uses the full batch size; only the
  pass immediately after a reorg is bounded this way.
- The set of blocks that ultimately get scanned is unchanged - the
  next pass picks up from where recovery stops - so the resulting
  wallet is identical, just reached with far less redundant fetching
  and scanning.

The wallet file format is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 d9dae45c39 fix(lite): flush the wallet save on quit before _Exit
On quit the lite controller's destructor never runs, because main()
ends the process with _Exit(). Any state the background refresh had
scanned since the last save was lost: the destructor is where that
final save would normally happen, and it was being skipped on the
one path that always runs.

- shutdown() now calls a new flushOnExit() that persists once on the
  way out, bounded to 4 s so the existing 15 s shutdown watchdog
  budget still holds. On timeout it abandons the save thread and lets
  _Exit follow.
- It is a plain save through the same backend path, which writes a
  temp file and renames it into place. That cannot corrupt the wallet
  if it is cut off or abandoned, and it does not change the wallet
  file format - it only persists sooner on the quit path.
- It reuses the destructor's guards and also skips when a wallet op
  is already in progress: shutdown() has already waited for ops to go
  idle, and a still-pending op holds the sync lock and issues its own
  save, so flushOnExit would only block the budget on a save that
  cannot take the lock.

Tests cover a save on the open, synced, idle path (exactly once), a
blocked save returning within the budget, and the retired and
never-opened guards skipping the save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:51:34 -05:00
DanSandClaude Opus 4.8 e2210cf5d3 fix(lite): refresh the sidebar unconfirmed badge on confirmations
The sidebar unconfirmed-tx badge was recomputed only when the tx list
changed, keyed on last_tx_update plus the list size. That missed the
one case that matters most: a transaction confirming without the list
count changing. last_tx_update did not move for a same-second content
change, and lite never stamped it at all, so the badge stuck on a
stale 0-conf count until some unrelated refresh happened to shift the
list size.

- The badge now keys on tx_revision, which the state bumps on every
  content change - including a confirmation flip at an unchanged tx
  count - and which lite stamps too, so the count tracks confirmations
  on both the full node and the lite wallet.
- The sentinel starts at 0, which tx_revision never takes (it starts
  at 1), so the first frame always computes once.
- The recompute still runs only when the revision actually moved, so
  the per-frame work the previous guard avoided is still avoided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:51:34 -05:00
DanSandClaude Opus 4.8 25ad23aee4 fix(tx): de-duplicate transparent rows across listtransactions pages
Transparent history is gathered by skip-paging listtransactions. The
underlying list can shift between the per-page RPCs - a new
transaction arriving pushes one page's tail into the next page's head
- so a row could be collected twice and show up as a duplicate in
History.

- Pages are now de-duplicated on the daemon's stable per-output
  identity: txid, send/non-send class, and vout. Keying on the class
  rather than the exact category means a coinbase that matures
  (immature to generate) between two fetches is still recognised as
  the same row; keeping vout means two equal-value outputs of one
  transaction stay distinct. No real transaction row is dropped or
  duplicated.
- Rows with no txid or vout ("move") are never keyed and pass through
  (they are dropped downstream anyway), so the de-dup fails open.
- The raw page size still drives the stop condition and the next-run
  row hint, independent of how many rows were unique.

Tests cover a shifted page whose head repeats the prior page's last
row (dropped), a sibling output of the same tx (kept), and an
un-keyable row (kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 6a6ac8cf99 fix(tx): list transactions with account "*" so labelled rows show
listtransactions was called with the default "" account, which
returns only rows in the unnamed account. Transactions tied to a
labelled address were left out of the History tab entirely.

- All three listtransactions call sites now pass "*", which spans
  every account, so labelled-address rows appear alongside the rest.
- With "*" the daemon also emits "move" accounting rows that have no
  txid, address or vout. Those are not wallet transactions, so they
  are skipped and never become blank History rows. No real
  transaction row is dropped by this filter.

Tests cover that a "move" row is skipped while a labelled receive row
is kept, and that the transparent collectors now page with "*".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 998788d351 perf(rpc): move the result subtree out of the parse tree
parseRpcResult copied the "result" node out of the parsed reply,
because indexing the JSON object yields an lvalue reference. On large
replies - full transaction lists, unspent-note sets - that duplicated
the entire subtree for no reason, and it also meant a secret-bearing
result then existed in two trees at once.

- The result node is now moved out of the parse tree instead of
  copied, so the secret lives only in the returned value that the
  calling code already wipes. The leftover result slot is moved-from
  (null), so the existing secret-scrub of the discarded tree still
  runs harmlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 16371c5879 fix(rpc): bypass the env proxy for loopback RPC and local stats
Loopback HTTP was still subject to the process environment's proxy
settings (http_proxy / all_proxy). That routed the local daemon's
basic-auth credentials and wallet data - and the miner's 127.0.0.1
stats API - through whatever proxy the user's shell happened to
export, and could break the connection outright.

- Local daemon RPC now sets curl's no-proxy to "*" when the host is
  loopback, so it always connects directly. This also overrides a
  no_proxy list. Remote daemons keep full proxy support unchanged.
- The xmrig stats fetch against the local miner API does the same.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 d9143f0e18 fix(rpc): keep the worker alive on non-std throws; atomic flags
The RPC worker loop only caught std::exception. A task that threw
anything else - a non-std exception propagating out of a posted job -
escaped the loop and tore down the worker thread, so every later RPC
silently went unanswered.

- The worker loop now also has a catch-all that swallows non-std
  throws and keeps the thread running. Such a job still loses its
  main-thread callback, exactly as the std::exception path already
  did; the only change is that the worker survives.
- RPCClient's connected_ and warming_up_ flags are now atomic. They
  are read from the UI thread while the worker updates them, so the
  plain bools were a data race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 7e7804e577 perf(daemon): skip the UI-thread datadir lock wait
Starting the daemon polls for an already-running node holding the
datadir, sleeping up to three 100 ms intervals to let a shutting-down
node clear out. When a start runs on the UI thread, that is up to 300 ms
of blocking sleep on the render thread.

- The lock-wait poll count is now a parameter. A start invoked on the UI
  thread passes 0, which does a single check and never sleeps; every
  other caller keeps the three-poll wait. The default is unchanged, so
  background starts behave exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 8df0fe1734 perf(app): run miner shutdown off the UI thread
At quit the UI thread drained and joined the mining-control thread and
then called the miner's blocking stop with a 3 s budget. Both ran on the
UI thread, so the window could stall for up to that long before the
shutdown screen rendered.

- Quit now only signals the mining-control thread (no join) on the UI
  thread. The blocking teardown — join the control thread, then stop the
  miner — moves to a background thread, so xmrig still has a single
  driver but the UI thread never blocks on it.
- On the daemon-stop path that teardown runs first on the shutdown
  thread, so the miner is fully stopped before the daemon stop begins.
  On the disconnect-only quit paths it runs on its own thread, which
  the final shutdown joins before tearing xmrig down, so there is never
  a second concurrent join of the control thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 30f5243dd1 perf(daemon): back off the monitor poll when idle
The daemon monitor thread drained output and then slept a fixed short
interval on every tick, waking several times a second for the life of
the node even long after startup, when the node emits almost nothing.
Stopping it also meant waiting out the current sleep before it saw the
stop flag.

- The monitor now waits on a condition variable instead of a plain
  sleep, so a stop request wakes it immediately rather than after the
  interval. The stop flag is set under the monitor's mutex so the wake
  cannot be lost.
- The poll interval stays fast for the first 30 s after the thread
  starts and for 3 s after any output, and otherwise doubles per idle
  tick up to a 500 ms cap. Any new output drops it straight back to
  fast, so responsiveness during startup, rescan and shutdown is
  unchanged while a quiet node is polled far less often. The interval
  choice is a pure function.

Tests cover the interval function across the warmup window, the
recent-data window, the reset on new output, the doubling, and the cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 b407b8927a perf(miner): abort xmrig stat fetches on stop
The miner's stats and pool-API fetches are blocking HTTP calls on the
monitor thread with multi-second timeouts. When the miner was stopped,
stop() still had to wait out an in-flight request before the monitor
thread could join.

- Both fetches now install a curl progress callback that aborts the
  transfer as soon as a stop is requested, so a request in flight ends
  promptly instead of running down its timeout.
- An abort triggered by stop is treated as a clean cancellation, not a
  fetch failure, so it is not logged or counted against the failure
  warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 60b3ad5d5a fix(miner): close the xmrig stdout fd after the monitor join
Stopping the miner closed the stdout pipe before joining the monitor
thread, and the UI thread's stats poll also drained stdout. Either path
could read or close a descriptor the other side was still using, so a
read could land on a closed or already-reused fd.

- stop() now joins the monitor thread first and only then closes the
  process handle and the stdout pipe, so nothing is draining the pipe
  when it is closed.
- The stdout drain is serialized under its own mutex, and the UI
  thread's stats poll no longer drains stdout at all: the monitor thread
  is the sole reader. The process handle, pid and fds are atomic so they
  can be observed safely from the memory probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 614dfbbc5b fix(daemon): let DRAGONX.conf dbcache override the pinned value
The wallet pins a -dbcache value on the daemon command line. A
command-line flag overrides the same key in DRAGONX.conf, so a user who
set their own dbcache in the conf never got it: the wallet's pin always
won.

- Before spawning, a normal start now reads DRAGONX.conf once and, if it
  sets dbcache, drops the wallet's own -dbcache pin from the arguments
  so the user's value takes effect. An isolated start keeps its own
  datadir and conf and is left alone.
- The conf is now read a single time for both this check and the
  existing rpcthreads check instead of twice.

Tests cover dropping the pin when the conf sets dbcache (in various
forms), and leaving the default arguments untouched when it is
commented out, empty, or sets something else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 a6b7b966b0 fix(daemon): reset the console offset to the watermark on restart
When the daemon was restarted, the output-parse offset was reset to 0.
The daemon's output buffer is retained across runs, so the next poll
re-read the whole retained tail of the previous run and fed it to the
busy tracker as if it were fresh. A witness rebuild or rescan line left
over from the old run could then be mistaken for activity in the new
one.

- On restart the offset is now reset to the absolute end of the output
  at that moment, captured on the lifecycle thread after the stop and
  before the start, so the UI resumes parsing only the new run's output
  rather than replaying the old tail.
- The capture is handed to the UI thread through a pending flag and
  applied there before the next read, both on the normal poll and when
  skipping the backlog, so the offset is only ever moved on the thread
  that owns it.

Tests cover reading from the captured watermark (only the new run is
seen) versus the old reset-to-0 behaviour (the previous run replays).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 e9042f091e refactor(daemon): share the output ring-window helper
The embedded daemon and the xmrig manager each kept their own copy of
the console output ring: append new bytes, trim the front to a cap at a
newline boundary, and hand a reader everything since its offset. The two
copies had drifted. The daemon tracked an absolute byte total so a
reader's offset stayed valid across a front-trim, while the miner keyed
its reader off the live buffer size, so a trim could silently rewind the
console feed.

- The append/trim and the since-offset logic now live in one small
  header, outputWindowSince and appendBounded, included by both. The
  trim keeps the newline-boundary cut and the guard that stops a lone
  trailing newline from erasing the whole buffer.
- The xmrig manager now carries the same absolute byte total, so its
  reader resolves against the window the same way and survives a trim
  (and a process restart that clears the buffer but keeps the total).

Tests cover feeding the ring well past the trim point while a reader
polls each chunk (the feed is continuous and never dies at the trim),
the lone-trailing-newline guard, and a cleared buffer with a retained
total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 e77b960cc5 build: embed ui.toml and banlist with INCBIN instead of a configure loop
ui.toml and the default banlist were embedded by a CMake function that
read each file and emitted a C array header one byte at a time in a
configure-time loop. That is slow to configure, and because the result
is a header, editing either resource recompiled every translation unit
that included it.

- Both files are now embedded with INCBIN from a small generated
  translation unit, the same pattern already used for the fonts. There
  is no generated header, so editing a resource re-assembles one tiny
  object file instead of triggering a wide recompile, and configure does
  no per-byte work.
- Consumers include a hand-written header declaring the symbols and
  always use the embedded data (the __has_include fallback is gone). The
  data is not NUL-terminated, so every consumer passes an explicit size.
- The old EmbedResources.cmake script is removed, and the generated unit
  is added to the app and test builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 3fd52fbdaf perf(chat): ingest a message batch in one transaction
Ingesting a scan's worth of chat messages persisted each new message
with its own append() call, so a batch of fresh messages opened and
committed one SQLite transaction per row.

- A new appendBatch persists many messages in a single transaction,
  using the same INSERT OR IGNORE dedup key as append() and preserving
  input order so load()'s rowid ordering is unchanged.
- Every message is serialized and encrypted before the transaction
  opens, so a message with a non-UTF-8 body is skipped on its own
  without leaving a transaction open; the valid siblings still persist.
- Ingest collects the newly stored messages and writes them once after
  the loop instead of per message. It is inert (returns 0) while locked
  or on empty input.

Tests cover a batch insert, dedup on re-insert, empty input, bad-UTF-8
isolation with a valid sibling and a following append(), insert order,
and the locked no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 1416654385 perf(tx-cache): skip Argon2id for unencrypted wallets
Opening the transaction-history cache always derived its encryption key
with Argon2id, even for an unencrypted wallet. For those wallets the
passphrase is just a value derivable from the database itself, so the
~50-100 ms Argon2id stall bought no additional secrecy and ran on the
UI thread at open.

- Unencrypted wallets now derive the cache key with a cheap,
  domain-separated BLAKE2b KDF over the wallet identity, keyed by the
  per-wallet salt. The secret is derivable from the DB, so this is
  security-equivalent for the unencrypted case and avoids the stall.
- Passphrase-encrypted wallets are unchanged: they keep the Argon2id
  key derivation. The two paths use separate domain labels, so the
  fast-path key and the Argon2id key never collide for the same DB and
  salt; a cache written under one cannot be read under the other.

Tests cover the fast-path round-trip and reopen, and assert that an
Argon2id unlock of the same DB produces a different key and fails to
read the fast-path snapshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 3554ba1e2d perf(noise): tile the resident noise texture on GL instead of a copy
Drawing the tiled background noise built a viewport-sized pre-tiled
texture and re-created it whenever the viewport changed, copying the
256x256 noise tile across the whole screen in memory.

- On GL the draw now samples the resident 256x256 tile directly with
  GL_REPEAT, computing UVs from the screen rect over the tile size with
  the phase anchored at the viewport origin. The result is pixel-
  identical to the old pre-tiled texture (same generator, same origin)
  with no per-viewport copy.
- DX11 keeps the pre-tiled viewport-sized texture, since its sampler is
  CLAMP and cannot repeat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 aa486266c6 perf(texture): cap and downscale theme images on load
Theme images were decoded straight to RGBA at their full source size
and uploaded to a GPU texture as-is. A large or over-sized image wasted
VRAM and could exceed the GL maximum texture size, and each code path
also hand-managed the decoded pixel buffer with its own free calls.

- Loading from file and from memory now goes through the shared
  size-aware decoder with a 2048-pixel cap on the long side. An image
  larger than that is downscaled with its aspect ratio kept; an image
  whose area is absurd is rejected; a small image is left untouched.
- The decoder owns the pixel buffer for the life of the upload, so the
  manual free calls are gone.

Tests cover downscaling a wide image to a 2048 long side with the aspect
kept, rejecting an over-area image, and leaving an under-cap image as-is,
plus the fit-dims math at the theme-art cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 8c5bd485dd fix(lite): probe servers without joining the thread on the UI thread
Adding a lite server from the network tab restarted the background
server probe immediately. Starting a probe while one is still running
joins the previous worker thread, and that join happened on the UI
thread, so the frame could stall until the outstanding probe finished.

- Adding a server now sets a dirty flag instead of restarting the probe
  inline; the next frame starts a fresh probe once the current pass has
  finished, so the UI thread never blocks on a join.
- The probe's libcurl transfers honor the cancel flag through a progress
  callback and abort cleanly, so a probe in flight stops promptly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 8168e26d93 perf(updater): read the downloaded archive in one bounded allocation
Before verifying a downloaded archive, both updaters slurped it into a
string with an istreambuf_iterator, which grows the buffer repeatedly
and reallocates as it reads a multi-megabyte file. It also placed no
upper bound on the size it would read into memory.

- A shared readFileExact helper sizes the file first and reads it in a
  single allocation, failing closed if the file is missing, empty,
  larger than a caller-supplied cap, or comes up short. A truncated read
  can never reach the SHA-256 / signature verifier.
- Both updaters use it with the existing max-archive cap.

Tests cover a good read, a read exactly at the cap, over the cap, an
empty file, and a missing file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 e685d425ed fix(updater): cancel the release-metadata request on dialog close
The daemon and miner updaters fetch release metadata with a blocking
HTTP GET that ignored the updater's cancel flag. Closing the dialog set
the flag, but an in-flight metadata request kept running to completion
on the worker thread, so the dialog could not tear down promptly on a
slow or stalled connection.

- The metadata GET now passes the updater's cancel flag through to the
  HTTP helper, so closing the dialog aborts the request in progress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:02:59 -05:00
DanSandClaude Opus 4.8 2396ed33db fix(settings): tolerate wrongly-typed values instead of quarantining
Several settings were read with json's value()/get<T>() helpers that
assume the stored type. A hand-edited settings file, or one re-saved
after an inf/NaN float was written back as JSON null, could therefore
hold a value of the wrong type and throw during load. The whole load is
wrapped so a throw quarantines the entire settings file to a
.corrupt- copy, meaning one bad field reset every unrelated setting.

- ui_opacity is accepted only when it is actually a number, and it is
  clamped to the same 0.3..1.0 range the setter enforces; the legacy
  reduced_transparency migration likewise runs only when that key is a
  boolean.
- A non-string name in a blocked chat conversation, and a non-string
  scope in a portfolio entry, now fall back to an empty string instead
  of throwing.

The file loads with the offending field defaulted, and the rest of the
settings survive.

Tests cover ui_opacity as a string/null/int/float/bool, a non-string
blocked-conversation name and portfolio scope, and the legacy
reduced_transparency migration, asserting no quarantine file is written.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:02:59 -05:00
DanSandClaude Opus 4.8 f47528de53 fix(startup): move the single-instance lock to a private per-user file
The single-instance lock lived in /tmp, which is world-writable. Creating
it with mode 0666 there gets masked to 0644, so a second local user
cannot open it O_RDWR and is wrongly blocked from running the wallet at
all. A world-writable directory also lets another user pre-create or
symlink the lock path.

- The lock now lives in a per-user directory: $XDG_RUNTIME_DIR when it is
  an absolute directory the user owns, otherwise the per-user, per-variant
  config directory that startup has already created. Never /tmp.
- It is created mode 0600 with O_NOFOLLOW, and after opening we fstat it
  and refuse anything that is not a regular file we own, rejecting a
  planted symlink or a foreign-owned file.
- The lock file is no longer unlinked on release; flock drops on close,
  and leaving the file in place avoids an unlink/relock race (a stale
  file is harmless).

Tests cover acquiring and conflicting on the lock, the 0600 mode, that
release leaves the file in place, and that a symlinked lock path is
refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 bf5cb39566 fix(bootstrap): restrict download redirects to https
Both the HEAD size probe and the archive download followed redirects
with no restriction on where they could go. A redirect could therefore
downgrade the transfer to plain http or point at a file:// URL, defeating
the TLS verification the initial https request was relying on.

- Redirects on both requests are now limited to https, so a redirect can
  no longer leave TLS or reach a local path.
- The limit is set with the string-based curl option where available and
  the protocol-mask option on older libcurl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 667c89ba51 fix(bootstrap): resume downloads without discarding a valid archive
Resuming a partial download leaned entirely on the remote size from a
HEAD request. When that size could not be determined the code played it
safe by deleting the partial file and starting over, so a flaky HEAD (or
an offline mirror) threw away gigabytes that were fine to resume. It also
could not tell a real size from an error page, and if the server answered
a ranged request with the whole file (200, not 206) libcurl could append
it to the partial and silently corrupt the archive.

- The HEAD now returns transport status, HTTP code, and size together,
  and a size counts as authoritative only on a 2xx reply with a positive
  Content-Length.
- A pure helper decides what to do from the existing bytes and that
  authoritative flag: start fresh, truncate a stale oversized file, reuse
  a complete one, or resume with a ranged GET. When the size is not
  authoritative the partial is kept and a ranged GET is attempted rather
  than discarded.
- A ranged request that comes back as 200 instead of 206 now restarts
  cleanly from scratch instead of appending to the partial.
- The free-space pre-check runs only when the size is authoritative, and
  a failed download reports how many bytes were kept for the retry.

Tests cover the resume-decision helper across the fresh, stale, complete,
resuming, and non-authoritative cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 4d9d528566 fix(bootstrap): keep the archive unless a checksum truly mismatches
Any verification failure deleted the downloaded archive and forced a
full multi-GB re-download on the next attempt. But most failures are not
a corrupt archive: a missing or unreadable checksum sidecar, or a fetch
blip while pulling the checksum, would throw away a perfectly good file.
On top of that the checksum parser took the first token of whatever came
back, so an HTTP error page could be mistaken for a digest.

- The archive is now removed only on a genuine digest mismatch. A
  missing sidecar, an unusable one, or a transient fetch error keeps the
  file so the next attempt can reuse or resume it; the no-checksum case
  still fails closed and keeps the archive.
- The checksum parser now accepts a token only if it is exactly the
  expected length and all hex, lowercasing it; anything else (an error
  page, a truncated or wrong-width digest) is rejected.
- The small-file fetch moved onto the shared capped HTTP helper, and MD5
  is consulted only as a fallback when no SHA-256 is published.

Tests cover the checksum parser across good, wrong-length, non-hex, and
error-page inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 b3a1a9e654 fix(bootstrap): report verify progress correctly past 2 GiB
The checksum and MD5 passes measured the file for their progress bar
with fseek/ftell. On Windows a long is 32-bit, so ftell overflows on
any archive larger than 2 GiB and hands back a wrong (often negative)
size, which makes the verify progress percentage meaningless on exactly
the multi-gigabyte downloads this code exists for.

- Both passes now take the size from std::filesystem::file_size, which
  is 64-bit on every platform, through a small helper that returns 0 if
  the size can't be read.
- No seek is performed just to size the file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 e1003cba43 perf(migrate): gate the seed-migration confirming balance scan
While the seed migration sits on the Confirming gate it polled the
sweep's confirmations and a full wallet balance together on every pass.
That balance call is the expensive one, and it only matters once the
sweep is mined and we do not yet have a post-mine figure, yet it ran
every poll even mid-sync when it cannot move the gate.

- The confirmation count is still read on every poll.
- The balance scan now runs only when it can actually advance the adopt
  gate: the sweep is mined, no post-mine balance has been recorded yet,
  and no sync is in progress. The manual Refresh button forces it
  regardless.
- The decision lives in a small pure helper, and the poller snapshots
  the main-thread state it needs rather than reading members off the
  worker thread.
- The Confirming screen now claims a leftover remainder (and offers the
  re-sweep button) only once the balance has actually been measured; while
  it is still unknown it keeps waiting instead of prompting on a sentinel.

Tests cover the gate helper across the forced, syncing, and mined cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 19:24:12 -05:00
DanSandClaude Opus 4.8 f1b0ae5ce7 fix(net): cap market and bootstrap HTTP response bodies
Several HTTP fetches read a remote body into memory without a tight
bound. The price and exchange-ticker calls shared one large 16 MiB
metadata cap, and the bootstrap checksum download had no size limit at
all, so a hostile or MITM'd server streaming an unbounded (and
Content-Length-free) response could grow memory far past anything these
endpoints legitimately return.

- The HTTP GET helper now takes a per-call byte cap and redirect limit.
  The write callback refuses bytes past the cap, which aborts the
  transfer, and the same value is set as curl's max file size.
- The price call is capped at 256 KiB and the ticker list at 2 MiB,
  each sized to its real payload rather than the shared default.
- The bootstrap checksum fetch caps its body at 64 KiB in both the
  write callback and curl's max file size.

Tests cover the sink accept/abort boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 19:24:12 -05:00
DanSandClaude Opus 4.8 edbb98d9ec perf(chat): single-pass watermark and move-append on chat load
Loading chat history did more work than it needed to. Seeding the unread
watermark walked every conversation and copied each one's full message
list out of the store just to read the last timestamp, and the database
reload copied every message into the store one at a time with no
reservation.

- The watermark now comes from one pass over the stored messages that
  returns the latest timestamp per conversation, with no per-
  conversation copy or sort.
- The store gained a move-append and a reserve, so the reload pre-sizes
  its vector and dedup set once and moves each row in instead of copying
  it. The dedup key is computed before the move.

Behaviour is unchanged: the watermark values and the blocked-conversation
filter on reload are the same as before.

Tests cover the per-conversation latest-timestamp pass and move-append.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 19:24:12 -05:00
DanSandClaude Opus 4.8 2a535c844d fix(security): overwrite key/PIN files with fsync before deleting them
Two secure-delete paths overwrote a sensitive file and then unlinked it,
but neither reliably forced the overwrite to stable storage. The
plaintext key export used a buffered stream with only a flush, and the
PIN vault removal opened a second descriptor to fsync only on non-
Windows. On a crash, or on Windows, the zeroing write could stay in the
OS cache and the original bytes could survive on disk.

Both now go through one shared best-effort secure-delete helper:

- It overwrites the file in place in chunks, never truncating first, so
  the original blocks are the ones rewritten.
- It flushes the data to the device before unlinking (full fsync on
  macOS, write-through on Windows), then removes the file.
- It is safe on a missing file and reports whether the file is gone.

This is still best-effort: SSD wear-levelling and copy-on-write or
journaling filesystems may retain copies.

Tests exercise the overwrite-and-remove helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 19:24:12 -05:00
DanSandClaude Opus 4.8 63a422a615 fix(security): only a real wrong passphrase counts toward unlock lockout
A failed wallet unlock was always treated as a wrong guess: the lockout
counter was bumped and the back-off curve armed no matter why the RPC
failed. A busy or warming-up node, a transport timeout, or a wallet that
is not actually encrypted would all eat an attempt and could push a user
into a lockout without a single mistyped passphrase.

The failed call is now classified before it is acted on:

- Only the daemon's "passphrase incorrect" error counts as a wrong
  guess and advances the lockout.
- A warming-up or unreachable node, a reply timeout, or a full queue is
  reported as a transient busy-node condition with no attempt charged.
- A "wallet is not encrypted" reply refreshes the local encryption state
  instead of penalising the user.
- Any unknown error surfaces its message without touching the counter.

The PIN path got the same treatment: when the vault decrypts correctly
but the unlock RPC then fails for a non-credential reason, the attempt
is no longer counted.

Tests cover the classification of each failure kind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 19:24:12 -05:00
DanSandClaude Opus 4.8 100d76b88f fix(input): handle the first Alt+wheel while idle in the wait path
Alt + mouse wheel adjusts the font scale, but it was only handled in the
poll loop that runs after the app wakes. When the app was idle it blocks
in a single timed wait, and that first wheel event went straight to
ImGui, scrolling the window instead of changing the scale until a later
event arrived.

- The Alt+wheel handling is pulled into a shared helper used by both the
  idle wait path and the poll loop, so the first event while idle is
  consumed and triggers a redraw.
- Behaviour is otherwise unchanged: the visual scale updates smoothly and
  the font atlas is rebuilt once scrolling stops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 fa1ba97286 perf(win): skip DWM blur/acrylic when the window starts opaque
The window always asked DWM for a full-window blur region and, on
Windows 11, an acrylic backdrop. When the user runs with an opaque
window (low-spec mode or full opacity) that desktop blur is never seen
but DWM still composites it every frame.

- An early read of the saved settings decides whether the window starts
  opaque before the backdrop is configured.
- When it does, the full-window blur region is left as SDL's zero-area
  default and the backdrop type is set to none instead of acrylic.

Windows-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 53da160546 fix(window): defer caption-button window ops out of the ImGui frame
The custom minimize / maximize / restore buttons are drawn from inside
app.render(), while an ImGui frame is open. Calling SDL to change the
window state there pushes resize events synchronously, which re-enters
the resize watcher and can nest a new frame inside the open one.

- A click now records the requested op instead of acting on it, and the
  window state change is applied once the frame has closed, after the
  pacing block so the follow-up redraw is not dropped.

Windows-only; it rides the borderless caption controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 49240facff perf(resize): dedupe the live-resize watcher, early-out DX11 resize
The live-resize event watcher redrew for every resize event, and each
resize tore down and recreated the DX11 render target even when the
pixel size had not actually changed. A single drag fires both a RESIZED
and a PIXEL_SIZE_CHANGED event reporting the same final size, so the
work ran twice.

- The watcher keys off the real pixel size and returns early when it has
  not moved since the last pass, collapsing the duplicate event pair into
  one redraw. It also bails out when called from inside an open ImGui
  frame, which the caption buttons can trigger.
- The DX11 resize now checks the swap chain's current dimensions up
  front and does nothing when the size already matches and the target is
  live, skipping the destroy/recreate churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 13059412ce fix(log): share one handle between stdout and stderr on Windows
On Windows stdout and stderr were reopened onto the same log path with
two separate freopen() calls. Each stream then had its own file handle
and its own write offset into the one file, so the two writers
overwrote each other and interleaved into a garbled log.

- stdout is reopened (truncating the file per run), stderr is pointed at
  the same underlying handle via _dup2 so both share one offset and
  append in order.
- stderr is first given a valid descriptor, since a GUI-subsystem
  process may start with no fd 2, and is left unbuffered so crash
  diagnostics are not lost.

Also corrects a stale comment that named the old log file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 45d8bdccf0 perf(startup): drop the unused SDL gamepad subsystem and nav flag
Startup initialised SDL's gamepad subsystem and turned on ImGui gamepad
navigation, but the wallet is never driven by a gamepad. Initialising
the subsystem enumerates attached controllers and loads the mapping
database for nothing.

- SDL now starts with the video subsystem only.
- ImGui keeps keyboard navigation; the gamepad navigation flag is
  removed from both the DX11 and the OpenGL setup paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 a24ffea86e perf(startup): let Windows relax the system timer resolution
On Windows SDL3 raises the global timer resolution with
timeBeginPeriod(1) and holds it for the whole process lifetime, which
keeps the system on a 1 ms tick and costs power for no benefit here.

- A startup hint now asks SDL to leave the system timer alone. Nothing
  in the app needs sub-15 ms waits: vsync paces drawing, the idle wait
  is 200 ms, and background sleeps are 50 ms or longer.

The hint is Windows-only in effect and is ignored elsewhere; an
environment override that turns it off is harmless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 5d9bd1688c fix(security): clear the secret clipboard on a monotonic clock
A secret copied to the clipboard auto-clears after a timeout, but the
deadline was stored as an ImGui::GetTime() value. That clock only
advances while frames are being drawn, so while the app idled (no
redraws) it effectively stood still and the pending clear could be put
off well past the intended window.

- The clear deadline is now a steady_clock time_point, which keeps
  counting whether or not the UI is rendering.
- The pump uses the armed hash as its "something is pending" flag rather
  than a sentinel deadline value, and compares the real wall time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 f3f85d602d fix(mining): stamp hashrate history and label the chart by real time
The hashrate sparkline guessed the age of its oldest sample from the
sample count, labelling the left edge "5m ago", "1m ago" or "start"
regardless of how fast samples actually arrived. Solo and pool samples
come in at different intervals, and a slow or stalled poll made those
labels wrong.

- Each hashrate sample now carries a wall-clock timestamp, kept in
  lockstep with the value as the history fills and rolls over, for both
  the solo and pool histories.
- The chart's left-edge label is derived from the oldest sample's actual
  timestamp, falling back to "start" only when no stamp is available.

Tests: a mining refresh records a timestamp per sample, and the
timestamp history stays the same length as the value history once the
history is capped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:30 -05:00
DanSandClaude Opus 4.8 2c9e99c022 perf(daemon): memoize the external debug.log miss
When following the debug.log of a node this session did not launch, every
poll re-read the last 2 MB of the file and re-scanned it for the process
banner. If that node logs to its console instead, the banner is never
there, so the full re-scan repeated on every poll for the life of the
process.

- A miss is now remembered by pid and file size: if nothing has changed
  since the last miss, or the file is growing but the backoff has not
  elapsed, the scan is skipped.
- The retry schedule re-scans quickly during a fresh-node grace window
  (the banner flushes a beat after the pid appears) and then backs off
  from 4 s, doubling to a 60 s cap. The memo is cleared on a successful
  tie and when the followed process goes away.

Tests: the retry delay is fast within the grace window and then steps
4, 8, 16 s up to the 60 s cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:30 -05:00
DanSandClaude Opus 4.8 1cd4ea4026 fix(security): throttle and wipe the import dialog clipboard preview
Hovering the Paste button in the import-key dialog read the OS clipboard
every frame to draw a ghost preview of the key, and the trimmed
plaintext was rebuilt each frame and never wiped. SDL's clipboard read
is a synchronous round-trip, and the pasted value is a secret private or
viewing key.

- The clipboard is now read only on hover entry and then at most twice a
  second, and the classified preview is cached rather than recomputed
  every frame.
- The cached preview is a dedicated secret buffer: it is wiped the moment
  it stops being shown, wiped on secret wipe, and the transient trimmed
  copy is zeroed right after it is classified.
- Trimming moves to a shared clipboard helper shared with the paste path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:30 -05:00
DanSandClaude Opus 4.8 d601f786e2 perf(lite): cache walletExists() to stop per-frame FFI
While no lite wallet is open the startup auto-open check called
walletExists() every frame. That call crosses into the backend and
touches the filesystem (and prints), so it ran a disk stat on every
frame the app idled at the first-run prompt.

- walletExists() now caches its result for about two seconds on the main
  thread, and the cache is invalidated whenever an open or lifecycle
  result is consumed, since the wallet file may have changed.
- The auto-open condition is reordered so the cheap timer checks gate the
  call, and the click-time overwrite decision uses a new
  walletExistsFresh() that bypasses the cache.

Tests: the cache returns the first queried value within the TTL and the
fresh read re-queries the backend and refreshes the cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:30 -05:00
DanSandClaude Opus 4.8 29c1674094 fix(mining): honour the pool Refresh button in Manual mode
The pool auto-balance step returned early whenever the pool selector was
in Manual mode, so a pending manual Refresh of the pool display stats was
dropped and the button did nothing unless Auto-balance was active.

- A pending refresh is now serviced in Manual mode too, re-querying the
  pool display stats without auto-switching the pool or restarting xmrig.
- Auto-balance behaviour is unchanged; the manual path is display-only,
  so it runs with no benchmark gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:30 -05:00
DanSandClaude Opus 4.8 95605dd7e9 fix(ui): stop global shortcuts auto-repeating; skip invalid skins
The global keyboard shortcuts tested keys without disabling key-repeat,
so holding one down fired it every frame: the theme cycle, balance and
market layout cycles, the effects and background toggles, the settings
and sidebar-collapse keys all re-triggered on a held key. Cycling themes
also stepped onto skins that had failed to load and wrote the broken
pick into settings.

- Every one-shot shortcut now checks for a fresh key press only, so a
  held key fires once.
- Theme cycling skips over invalid skins and only applies, persists, and
  shows the toast when a skin actually loads; if none can load, settings
  are left untouched and no toast appears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:29 -05:00
DanSandClaude Opus 4.8 96352ce9ad fix(daemon): guard start/connect during shutdown
Once a shutdown was under way the app could still launch or restart the
full node and keep running the RPC connection state machine. That could
spawn a daemon process on the way out and surface a spurious connection
failure toast as the window tore down.

- Starting the embedded daemon now bails out immediately while a
  shutdown is in progress, so the node is never (re)launched on exit.
- The connect attempt returns early during shutdown, so there is no
  start/connect try and no stray failure toast on the exit screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:29 -05:00
DanSandClaude Opus 4.8 bbe2bf94dc fix(export): write the transaction CSV atomically and report failures
The transaction export streamed rows straight into an ofstream. A write
that failed partway, on a full disk or removable media, left a truncated
CSV on disk behind a success toast. The CSV formatting also lived inline
in the dialog with no test coverage.

- The CSV is now built in memory and written atomically (temp file plus
  rename), so a write failure is reported with an error toast and leaves
  no partial file.
- Formatting moves into a reusable helper, and the timestamp conversions
  use the thread-safe localtime variants and tolerate a conversion
  failure rather than dereferencing a null result.

Tests: field escaping neutralises formula-injection prefixes and quotes
special characters, and the full CSV has the expected header and rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 f76d71fede fix(chat): drop a redundant save and report a real contact-save result
Renaming or adding a contact from the chat tab called the address-book
mutator and then saved again, even though the mutator already persists.
It also reported success unconditionally, so a failed rename still
showed a success toast.

- The redundant explicit save is removed; the mutator's own save stands.
- A rename now shows success only when the mutator succeeds and an error
  toast when it does not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 2a3e88c8dc fix(network): do not re-pin a lite server while a connect is in progress
Pinning a lite server while a connection to it was already being
established tore the in-flight connect down and restarted it, slowing
the very connection the user was waiting on.

- Pinning is now skipped while a connect is already underway, so the
  existing attempt is left to finish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 b4ff9a10a2 fix(request): clear the stale payment URI, show it only for an address
The request-payment dialog kept its generated payment URI across opens
and closes. A dialog opened for an address with no payable shielded
address could still show the previous request's URI, and the string
lingered after the dialog closed.

- The payment URI is cleared when the dialog opens, when it closes, and
  on the close cleanup path.
- The URI field is shown only when there is both a URI and a real
  address to pay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 76daf9619d perf(send): poll the clipboard for the paste hint only when it helps
The send tab read the OS clipboard every frame the paste button was
hovered, to preview a pasted address. SDL's clipboard read is a
synchronous round-trip that can stall, and the preview can only ever
latch when the recipient field is empty, so most of those reads were
wasted work.

- The clipboard is now read only while the button is hovered and the
  recipient field is empty, polled once on hover entry and then at most
  twice a second.
- Trimming the clipboard text moves to a shared helper that also bounds
  the copy so a pathological multi-megabyte clipboard is ignored.

Tests: the trim helper handles null, surrounding whitespace, an
all-whitespace string, and the length bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 3f82af99c4 refactor(market): resolve exchange/pair selection through one helper
The chart fetch and the Market tab both resolved the saved exchange and
pair names into the registry, with their own near-identical scans and
slightly different fallbacks. Keeping two copies risked the chart and
the chips disagreeing about which pair was selected.

- A single resolver maps the saved names to a registry index, matching
  the exchange name (any exchange when none is saved) then the pair
  display name, and falling back to the registry's first pair, the UI
  default. The chart fetch and the Market tab both call it.
- The Market tab now derives its selection indices from the saved names
  against the current registry every frame, so the indices stay correct
  when the live exchange list replaces the fallback; a steady-state fast
  path skips the scan once the indices already name the saved selection.

Tests: the resolver returns the right index for a matched exchange/pair,
an empty-exchange match, and the first-pair fallback on misses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 ce00621a4a perf(market): precompute the address-picker selection flag once
The portfolio address picker's sort comparator looked up each address in
the selected-address set on every comparison, two hash lookups per call,
and the per-row draw repeated the lookup again. On a long address list
that is a lot of redundant hashing every frame.

- Each filtered candidate now carries its selected flag, looked up once
  when the list is built. The comparator reads the stored flag, so the
  sort produces the same ordering with no lookups of its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 a631e4421a refactor(contacts): share case-insensitive compare, truncation helpers
The contacts list carried its own lowercase-and-compare sort, its own
lowercase substring search, and an inline linear middle-truncation loop
for fitting an address to a column width. These duplicated logic that
belongs in the shared text helpers.

- A new allocation-free case-insensitive compare replaces the per-sort
  lowercasing; it lowercases ASCII per byte and leaves bytes >= 0x80
  alone, so UTF-8 labels sort exactly as before.
- Search now uses the shared case-insensitive contains helper.
- Middle-truncation to a pixel width moves to a shared template that
  binary-searches the fit (about log2(n) width measurements instead of
  up to n) while producing the same result as the old linear shrink.

Tests: the compare matches lowercase-then-compare across ASCII, UTF-8
bytes and length ties, and the fit helper matches the old linear loop
across lengths and widths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 32ea4d0524 fix(mining): keep the thread benchmark running while the tab is hidden
The thread benchmark state machine was advanced from the Mining tab's
render path, so it only progressed while that tab was visible and the
window was on screen. Hiding the tab or minimizing the window froze the
benchmark mid-sweep.

- The benchmark now advances from the main update loop, ungated by
  connectivity, so it keeps running when the tab is hidden or the window
  is minimized.
- Its clock is driven from a steady wall-clock reading rather than the
  render delta, and each step is clamped like the other logic timers so
  a suspend/resume cannot leap a benchmark phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 fae5ef05b9 fix(mining): restore the miner after a benchmark only if it was running
Cancelling a thread benchmark always stopped and restarted the pool
miner at its pre-benchmark thread count, even when the user had not been
mining before the benchmark began. That silently started the miner on a
plain cancel or a switch to solo mode.

- A cancel now always drops the candidate-thread miner, and restarts the
  pool miner only when the user was mining before the benchmark, read
  from a snapshot taken at the start rather than the live running state
  (the benchmark sweep transiently stops the miner between candidates,
  so a live check would drop the restore).
- Callers that must not restore the miner (leaving solo mode) pass the
  new restore flag as false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 13989cf268 fix(notifications): prune expired toasts and skip invisible draws
The status-bar notifications only expired the front of the queue, so an
expired newer toast could sit in front of a still-live older one and
hide it from view. The liveness check that drives continuous repaints
also disagreed with what render() actually showed.

- Expired toasts are now pruned wherever they sit in the queue, not just
  at the front, so the newest remaining toast is always a live one.
- The visible toast is the newest live entry, skipping an expired newer
  one. The liveness check reports active while a toast is visible plus
  one trailing frame, so the final faded-out state is painted once and
  then repaints stop; it self-expires if render() is skipped.

Tests: pruning removes expired entries from anywhere in the queue, the
visible toast skips an expired newest entry, and liveness tracks it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 c5b325a752 perf(charts): fill chart areas in one linear pass
The filled sparklines and the market price chart built their area fill
with PathFillConcave, which runs a per-scanline pass over the whole
filled region. For a simple area under an x-monotone curve that work is
unnecessary.

- A new helper fills the area between an x-monotone polyline and a
  horizontal baseline directly as a triangle strip, in one pass over the
  points. The market sparklines, the market price chart and the mining
  hashrate sparkline all use it. The fill is flat and non-anti-aliased;
  the anti-aliased curve drawn on top hides the stepped top edge, and
  the faint side and bottom edges are imperceptible at these alphas.

Tests: the strip's vertex and index counts, index bounds, and that its
triangle area matches the trapezoid area under the polyline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 969718e03a perf(theme): drop the redundant acrylic grain under glass cards
Glass cards draw their own grain layer over the surface overlay, but the
acrylic primitive beneath them was also drawing its grain. That lower
layer sits under the overlay at well below one grey level, so it is
invisible and only costs an extra noise pass per card.

- The card now asks the acrylic primitive for zero grain and keeps its
  own visible grain layer, removing the redundant pass with no visible
  change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 7a9b29f358 fix(effects): stop retrying scroll-fade shader init after it fails
If the scroll-edge fade shader failed to compile or link, the init path
left its ready flag clear and tried again on the very next frame. On a
driver that rejects the shader that meant a fresh compile attempt every
frame, for the life of the program.

- A failed init is now latched and treated as final until destroy(), so
  the compile is attempted once, the failure is logged once, and the
  fades stay disabled without further retries. Destroying the shader
  clears the latch so a later re-init can try again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 968d6d3875 fix(effects): capture the acrylic backdrop at framebuffer size on HiDPI
The acrylic effect blits its backdrop from the default framebuffer using
the logical viewport size as the source rectangle. On a HiDPI display
the framebuffer is larger than the logical size, so the blur sampled
only the lower-left corner of the screen and the glass showed the wrong
region stretched across each panel.

- The capture blit source now uses the real framebuffer pixel size
  (logical size times ImGui's DisplayFramebufferScale, truncated the
  same way the GL backend does), while the capture and blur targets stay
  at logical size. The framebuffer size is tracked separately from the
  viewport size so quality or buffer-lifetime resets cannot lose it, and
  moving the window between a Retina and a non-Retina display redoes the
  capture. DX11 is unaffected; its scale is one.
- The debug screenshot sweep captures at framebuffer size too, so HiDPI
  captures are no longer cropped.

Tests: the framebuffer-extent helper matches the GL backend's
truncation and falls back to the logical size when the scale is unknown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 fdd95aa457 refactor(effects): drop the unused composite acrylic shader
The acrylic material carried a full composite shader — vertex and
fragment source, compile and link helpers, the program handle and seven
uniform locations — that nothing ever invoked. The acrylic is composited
on the ImGui draw list, not through this program.

- Remove the dead shader source, its compile/link helpers, the program
  and uniform fields, and the compile/link/cleanup steps in init and
  shutdown. No behaviour change; init no longer builds a program that is
  never bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 75e0c5048e fix(theme): give the menu acrylic the card blur radius
Every panel class samples one shared blur texture, so a menu asking for
a different blur radius than cards could not actually get it. All it did
was force a second full-screen blur pass each frame as the radius
flipped between the card and menu values, and let whichever class drew
last decide the radius for everything.

- Setting the acrylic theme now copies the card blur radius onto the
  menu class, so menus and cards share one radius and the blur runs
  once per frame. The first-use default path goes through the same
  setter so it is covered too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 cd83038de5 perf(effects): cull off-screen card effects and sample border by arc
Glass cards scrolled out of view still ran their full effect path, and
the rainbow border always used a segment count derived from a fixed
per-pixel budget, so wide cards drew far more line segments than their
slowly changing gradient needed.

- A glass panel whose rectangle lies wholly outside the draw list's clip
  rect now returns early, so a scrolled-out card draws nothing (its
  border and ember passes are skipped along with the fill).
- The rainbow border is split into segments by perimeter arc length:
  straight edges get one segment per ~32 dp, corner arcs get a few
  chords each to stay round, and a fully transparent border is skipped
  outright. A wide card now draws tens of segments instead of a couple
  hundred, with no visible change.

Tests: the segment fractions are ascending and bounded, each corner arc
keeps at least three chords, and the rect-cull margin test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 fbc429f914 perf(effects): keep the theme animation clock in double precision
The theme effects stored their animation time in a 32-bit float taken
from the ImGui clock. After a few days of uptime a float can no longer
resolve a single frame step, so every oscillation, drift and sweep began
to stutter, snapping forward a frame at a time instead of moving
smoothly.

- The clock is now held as a double, and the phase/oscillator maths runs
  through a small set of shared helpers that reduce the argument in
  double before returning a float. A float clock at 30 days steps by a
  quarter second per ULP; the double clock stays frame-accurate.
- Hue cycling, the rainbow and gradient borders, shimmer, specular
  glare, glow pulse, edge trace, embers, the sandstorm and the viewport
  wash all use the shared helpers, so their timing no longer drifts with
  uptime.

Tests: the helpers advance smoothly after seven days, pin the legacy
float breakdown at 30 days, and stay in range for negative offsets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 944fafa1c7 perf(explorer): fetch each block with one RPC and back off on failure
Each recent block was fetched with two round trips (getblockhash then
getblock), and a height that failed, such as one briefly above the tip
during a reorg, was refetched on every frame.

- getblock is now called once with the height directly, falling back to
  the getblockhash plus getblock pair only when an older daemon answers
  "not found" for the short string; any other error is a real answer.
- A failed fetch backs off with a doubling wait (2 s up to 32 s) before
  any new fetch is scheduled, and a successful fetch resets the backoff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 09:24:51 -05:00
DanSandClaude Opus 4.8 ebbeb846e8 feat(explorer): indexed block search with a bounded cache
The explorer block cache searched with a LIKE query that had to escape
wildcards, grew without any limit, and retried a failed open on every
frame, including a two-second busy wait on the UI thread.

- Search now uses an instr() substring test on a lowercased needle, and
  only checks the height text when the query is all digits, since a
  non-numeric needle can never appear inside a decimal height.
- The table is pruned to the highest 20000 blocks, once per open and
  periodically as blocks are stored, so the file stops growing at its
  high-water mark.
- A failed open is negative-cached (30 s by default) instead of retried
  every frame, and the busy timeout drops to 250 ms.
- When the cache database is unavailable or refuses a write, the block
  spills to a small bounded in-memory map that both the list and search
  read, so a spilled block never appears in one but not the other; the
  spill is cleared when the chain tip regresses.
- A batch of blocks is written under one savepoint instead of one commit
  per block.

Tests: case-insensitive hash and height search, the negative-cache and
spill paths with tip-regression eviction, and batch store plus prune.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 09:24:51 -05:00
DanSandClaude Opus 4.8 c3f981b52e fix(history): keep the open detail row across paging and filtering
The inline detail panel was tracked by the filtered list index. The
display list is rebuilt and re-sorted on every data change, sort switch
or filter edit, so a stored index pointed at a different transaction
after the rebuild, and a page reset was triggered by any change in the
row count, bouncing the view back to page 1.

- The open row is now tracked by a stable content key (txid, display
  type, address and the amount's exact bits) so the right row stays open
  across rebuilds.
- The page resets and the open row collapses only when a filter input
  changes (type, sort or the full search text). A new or removed row is
  data and now just clamps the current page.
- The open row is also cleared on page navigation and when the list
  empties, so a wallet switch does not resurrect an old expansion.

Adds pure helpers rowKey/filterInputsChanged to
transactions_tab_helpers.h.

Tests: rowKey distinguishes each field and separates +0.0 from -0.0, and
filterInputsChanged latches each input change but not the first frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 09:24:51 -05:00
DanSandClaude Opus 4.8 a5e4fd5c02 perf(history): group transaction rows by txid once per change
The History tab built a txid-to-indices map twice when its inputs
changed, once for the summary-card totals and once for the display list,
each a full pass over every transaction with string-keyed hashing.

- The rows are now grouped by txid a single time whenever the
  transaction list changes, into an index-only structure (first/next
  chains with no string copies), and both the summary pass and the
  display pass reuse it. A sort-combo change re-sorts without regrouping.

Adds a pure, unit-testable groupByTxid helper in
transactions_tab_helpers.h.

Tests: the chains match a reference map over a random id stream, and the
empty and single-row cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 09:24:51 -05:00
DanSandClaude Opus 4.8 01b59d24db perf(image-picker): cull off-screen thumbnail cells
The image picker laid out and drew every cell in the directory and image
grids, including the many cells scrolled out of view.

- A cell whose rectangle is not visible now reserves the same space with
  a dummy of the cell size, so the scroll extent is unchanged, and skips
  the hit-test and draw.

No visible change; only off-screen cells are skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 09:24:51 -05:00
DanSandClaude Opus 4.8 3ca7a5c90e perf(explorer): cull off-screen rows in the block detail list
The block detail modal drew every transaction row of a block, running
the hit-test, the draw and a hash truncation for each one, even when the
row sat far outside the scroll viewport.

- A row entirely outside the scroll clip now keeps its layout slot (so
  the scroll extent is unchanged) but skips the hit-test, draw and hash
  truncation. The expanded row is always laid out so its inline detail
  panel still appears.
- The old "showing first 100 of N" footer is dropped, since every row is
  now cheap to lay out regardless of how many a block has.

No visible change; only off-screen rows are skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 09:24:51 -05:00
DanSandClaude Opus 4.8 b8a4a11f90 fix(console): keep JSON folding active under channel filters
JSON folding in the console was bypassed in any filtered view, including
when only a channel toggle (daemon, RPC or app) was active. A collapsed
block silently expanded the moment a channel was toggled.

- Folding is now kept active unless the view is text-filtered or
  errors-only, which are flat so that every match stays reachable.
  Channel toggles keep folding, since JSON blocks are atomic per channel.
- The fold triangles and the collapsed " ... }" summary are drawn only
  in the fold-aware view, so the glyph and the rendered block can never
  disagree.

User-visible: JSON results stay foldable while channel filters are
applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00
DanSandClaude Opus 4.8 c1b289cc27 perf(console): cull off-screen wrapped rows
A long wrapped console line could extend far past the viewport, yet
every sub-row was still measured and drawn.

- Sub-rows that fall entirely outside the clip rect are now skipped. The
  per-row ordinal still advances for a culled row, so scanline parity is
  unchanged.

No visible change; only off-screen rows are skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00
DanSandClaude Opus 4.8 f7ed1ae115 perf(console): tail-cap large RPC results without a full copy
A large RPC result was fully split into lines and copied before being
handed to the console, even though only the last few lines could survive
the model's line cap.

- The formatter now takes a line cap and keeps only the last N lines,
  located by a backward newline scan, so a huge result is never fully
  split or copied. JSON classification still keys off the first
  character of the whole result.
- It works over a string view and moves line text through, and fold
  spans are computed over the formatted lines directly instead of a
  separate vector of copies.
- getline semantics are preserved: a trailing newline adds no empty
  line and an empty result yields no lines.

Tests: getline parity for empty, trailing-newline and blank-line inputs,
the backward tail cap, and JSON classification and fold spans over a
capped result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00
DanSandClaude Opus 4.8 116a997a3a fix(console): bound the pending queue by lines and bytes
The console's pending, not-yet-drained queue was unbounded, so a hidden
Console page that kept receiving verbose daemon-log chunks could grow
without limit.

- Pending lines are now bounded by both a line count and a byte budget.
  The oldest pending lines are dropped once either is exceeded, while
  the newest line is always kept so one huge line is never lost.
- A formatted RPC result is ingested as one block under a single lock,
  so lines from other producers can never interleave inside it.

The front-sequence accounting is also corrected: a pending line dropped
by the cap never entered the visible buffer, so it no longer advances
the front sequence. Only a front eviction or a clear does.

Tests: the line and byte caps keep the newest lines, a single oversized
line survives, a batch appends contiguously, and the front sequence
advances only on a front eviction or clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00
DanSandClaude Opus 4.8 70e96f75f0 perf(console): case-insensitive filtering without per-line copies
Console text filtering and match highlighting lowercased a full copy of
every line, and of every drawn segment, on each pass.

- A new alloc-free ASCII case-insensitive substring search folds both
  sides on the fly, so neither the filter test nor the highlight scan
  allocates a lowercased copy per line any more.
- Both call sites use it; the case-insensitive match behaviour is
  unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00
DanSandClaude Opus 4.8 6a24355c79 fix(console): release busy gate when the fast worker stops
When the daemon disconnects, teardown stops the fast RPC worker without
draining its queued main-thread callbacks. The console busy gate was
released inside that callback, so a command whose callback was discarded
left the input disabled for the rest of the session.

- The busy gate now drops from an RAII guard at the end of the worker
  job itself, so it is released however the job ends: success, error, or
  a worker torn down undrained.
- The result is published to the console before the gate drops, so a
  freed-up console always has the output queued.
- An unknown, non-std::exception failure is now caught and reported
  instead of escaping the job.

User-visible: the Console input can no longer stay stuck disabled after
a reconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00
DanSandClaude Opus 4.8 5376681af5 fix(schema): parse node-status banner and layout UI sections
The UI-schema loader walked a hardcoded list of flat TOML sections and
left [banners.*] and [layout] out, so those sections in ui.toml were
silently dropped. The node-status error strip at the top of the content
column could not be styled from the schema at all.

- The flat-section list is now one shared constant used by the file,
  string and overlay loaders, and it includes banners and layout.
- The node-status banner is themeable from ui.toml like every other
  section; a note in the toml records that these parse as flat sections.

No visible change unless ui.toml overrides those sections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00
DanSandClaude Opus 4.8 8255923f33 fix(settings): scope portfolio groups by wallet identity
Market-tab portfolio groups were tied to a wallet by a hash of its
address set. That hash changed whenever an address was added, so a
group built for a wallet could silently drop out of view once the
wallet gained a new address.

- Groups are now scoped by the wallet's stable id, which is persisted
  and never derived from the address set, so adding an address can no
  longer orphan a group.
- An older build's address-hash scope fails open (the group stays
  visible) and is migrated once per wallet per session to the stable id.
  A legacy group is claimed when its old hash matches, when one of its
  own addresses belongs to this wallet, or when only one wallet is
  known; anything ambiguous is left legacy and stays visible.
- Scope filtering waits until the wallet's addresses are loaded, so a
  group never flashes an empty 0.0000 balance and Add stays disabled
  until the wallet is resolved. This works on Lite, which never stamps
  an address-update time.

Behaviour: an older build opened after this change hides groups tagged
with a stable "w:" scope (they are not deleted and return on the next
upgrade) - worth a release note.

Tests: group visibility across global, stable and legacy scopes with the
wallet resolved and not; the migration rules (hash match, address
overlap, single-wallet claim, ambiguous left alone, idempotent); and a
save/load round trip where an address-set change cannot orphan the scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 c3ffc97b31 fix(ui): finish a stranded bootstrap even when its dialog is closed
A Settings bootstrap was only wrapped up inside the dialog's own draw
path. If the dialog finished downloading, verifying or extracting while
the user had clicked away or minimized the window, completion was never
processed: the connect and reconcile gate stayed held and the
post-bootstrap rescan was never armed.

- Completion is now a pure decision driven from a pump() that both the
  per-frame update and the dialog's render call, so a finished bootstrap
  is handled every tick whether or not the dialog is drawn.
- With the dialog open the behaviour is unchanged: it advances to the
  Done or Failed screen and those screens still own the gate. With the
  dialog closed, pump() releases the gate itself and shows a success or
  failure toast; a user-cancelled run is released silently.

User-visible: a bootstrap started from Settings now completes, arms its
rescan and reports its result even if the dialog is closed or the window
is minimized when it finishes.

Tests: the completion decision for an unfinished worker, a completed run
with the dialog open and closed, a failed run, and a user-cancelled run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 d4339be94b fix(ui): keep a chosen send-from address from auto-select overwrite
The Send tab auto-selected the highest-balance address on first load.
When another tab had already pre-filled the source with "Send from this
address", that first-load pick overwrote the user's choice, and the
dropdown's selected index could drift out of step with the address that
would actually be debited when the address list changed.

- The send-from string is now the single source of truth. The one-shot
  auto-pick only fills an empty source and never replaces one the user
  or another tab set, and the list index is re-derived from the string
  each frame so it cannot desync.
- An explicit "Send from this address" choice latches the auto-pick off,
  so the first visit cannot undo it.
- A chosen source the dropdown does not list (zero balance, view-only,
  or the list still loading) now previews the address that will be
  debited instead of the "Select a source" placeholder.

User-visible: a send-from address chosen from another tab survives the
first load and later refresh ticks.

Tests: the source resolver keeps a pre-fill on first visit, fills an
empty source with the best spendable address, keeps a source missing
from a short list with index -1, latches nothing while disconnected or
with an empty list, and re-derives a stale index from the string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 579accd0c6 fix(ui): validate shielded addresses with the correct RPC
The Validate address dialog chose its RPC from the first character of
the input, then asked the transparent-only validateaddress for every
case. That RPC is Base58-only, so a valid Sapling (z) address came back
isvalid=false and was reported invalid.

- The input is now classified with the checksum-verified recognizers,
  and a shielded address is sent to z_validateaddress while a
  transparent (mining) address keeps validateaddress.
- Anything that is not an address - a typo, garbage, or a pasted
  viewing/spending key - is judged invalid on the spot and never sent to
  the node, so a key pasted into the field is not leaked to an RPC.
- On Lite there is no node to ask, so the dialog shows the checksum
  verdict alone and hides the "owned by this wallet" row.

Tests: validateRpcMethod maps shielded to z_validateaddress, transparent
to validateaddress, and a key or empty string to no RPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 abc82136c4 fix(util): use the POSIX daemon binary name, scan versions off-thread
The installed-daemon lookup built the binary path from the Windows
resource key "dragonxd.exe", which never exists on Linux or macOS, so
the installed full node was never found there. The version scan also
read the whole binary, now around 200 MB, into memory on the UI thread.

- A new DAEMON_BINARY_NAME gives the daemon's file name on this platform
  ("dragonxd" on POSIX, "dragonxd.exe" on Windows); the old resource
  constant stays only as the embedded-table key. The installed daemon is
  now found on POSIX.
- The version-stamp scan streams the file in 4 MiB chunks with bounded
  memory, carrying bytes across each boundary so a stamp straddling it is
  neither cut short nor missed, and stops at the first match. The result
  is cached by path, size and mtime, so only a changed binary is
  rescanned.
- Settings stats the binary on the UI thread but runs the streamed scan
  on a worker, guarded so a new scan is not started while one is still
  running (the future blocks in its destructor), and the version fills
  in when it lands.

Tests: the POSIX path and binary name, a version stamp in a small file,
a stamp straddling the 4 MiB chunk boundary, and a file with no stamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 21dc874988 fix(core): refresh transaction history on the Mining tab
The Mining tab shows earnings derived from transaction history, but it
was not treated as a wallet-data page, so newly mined or paid-out
earnings did not appear until the user left the tab and came back. A
coinbase row also changes category in place (immature becomes generate
at maturity, or orphan on a reorg), and the Recent merge kept both the
old and the new row, double-counting the earnings.

- The Mining tab now gets the cheap between-blocks Recent poll: one
  listtransactions per new block, bounded by the 15 s transaction-age
  timer, once history has loaded. It never runs a full routine scan and
  never pre-empts an owed dirty cycle; tracked sends still take their
  normal cycle on any page.
- The shielded-payout probe runs only while the pool miner is running,
  since pool payouts are shielded receives while a solo miner's earnings
  are transparent coinbase rows.
- The Recent merge drops the previous row of the other coinbase category
  when a coinbase transitions, so maturing or reorged rewards are no
  longer counted twice. A non-coinbase row sharing the txid is left
  alone.

Behaviour change: mining earnings update on the Mining tab per block
instead of only on tab re-entry, and maturing or reorged coinbase rows
no longer double-count.

Tests: the per-block poll gate (one poll per block, due only after
history loads, held while dirty or addresses are dirty), the Mining-page
cycle shaping, and the coinbase transition merge for immature to
generate, generate to orphan, and a shared-txid non-coinbase row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 fbe09c6a4c fix(security): follow the daemon's lock state on Set-PIN and expiry
Setting a PIN always unlocked the wallet with a 5 s probe and then
re-locked it, even when the wallet was already unlocked, so a PIN check
silently re-locked an unlocked wallet. And once the daemon's absolute
unlock window ran out the GUI still showed "unlocked", so the next send
failed with nothing on screen explaining it.

- The Set-PIN check now decides on the main thread: an unlocked wallet
  is verified with the time it has left (capped at seven days) and is
  not re-locked, so the GUI and the daemon keep agreeing; a locked
  wallet still uses the 5 s probe and re-locks. Either way the daemon's
  lock state is re-read afterwards, which self-heals any drift.
- When the daemon's unlock window has expired but the GUI still says
  "unlocked", a bounded reconcile (once per expiry, 30 s back-off)
  re-reads getwalletinfo so the lock screen and the transaction-cache
  lock follow the daemon. Lite wallets, which have no daemon timer, are
  left alone.

Behaviour change: with auto-lock disabled the daemon still re-locks at
its window, and the GUI now lands on the lock screen instead of
appearing unlocked until a send fails.

Tests: the Set-PIN verify plan (unlocked keeps its remaining time and
skips the re-lock, locked or near-expiry falls back to the 5 s probe and
re-lock, long windows are capped) and the unlock-expiry predicate across
encrypted, locked, no-deadline and not-yet-expired cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 2fce9a7860 fix(security): reconcile the encryption-pending flag on every connect
The wizard persists an "encryption requested" flag so a quit or crash
before the wallet is actually encrypted is not silent. That flag was
only reconciled on the slow-path getwalletinfo refresh, so it could be
left stale, and the clear-and-warn logic lived inline in two places.

- A single reconcileEncryptionPending now runs on every connect and on
  every encryption-state refresh, keyed on the parsed getwalletinfo
  reply. It clears the flag whenever the wallet is encrypted, however it
  got there (deferred request, Settings, another client), and warns once
  per session when encryption was requested but the wallet is not
  encrypted and nothing is pending or in flight.
- A failed or warm-up (-28) RPC decides nothing, so a 15 s timeout that
  forces encrypted=false can no longer mis-clear or mis-warn.
- The flag is also cleared at encryptwallet success and when the user
  removes encryption, so neither path leaves a stale flag that would
  warn on the next connect. No settings format change.

Tests: the pure decision helper over the flag, RPC-ok, encrypted,
deferred-pending, in-flight and already-warned combinations (clear when
encrypted, warn once when lost, nothing on a failed RPC or no flag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 0aaf33ecf0 fix(core): drive auto-lock idle from real input, not widget focus
The auto-lock idle clock was reset whenever any ImGui item was active,
so a text field left focused (a half-filled send form, say) counted as
continuous activity and kept the wallet unlocked indefinitely, well past
the configured idle timeout.

- The idle clock is now fed by real input events (key, text, mouse,
  touch, pen, gamepad button) as they are ingested on the main thread,
  plus a still-held mouse button so a drag or scrollbar grab that emits
  no new event still counts. A focused but untouched widget no longer
  does.
- A new header-only IdleTracker owns the clock and never moves backwards,
  so an event stamped before the previous one cannot shorten the idle
  time.

Behaviour change: a form left focused and idle past the timeout now
auto-locks. The form's buffers are app state and survive the lock and
unlock, so nothing typed is lost.

Tests: a new IdleTracker case covers idle time growing with no events
(enough to trip a 900s lock), activity resetting it, the clock never
moving backwards, and a query before the last activity reading zero
rather than a negative value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 1ea584311e fix(lite): honor the backend sync result with retry and a failed state
A failed backend sync was silently swallowed: the reply was never
inspected, so a sync that errored out left the wallet showing a
"syncing" state that never completed and never reported a problem.

- The sync and rescan replies are now parsed. On failure the partial
  progress is saved and the sync is retried with exponential back-off
  (5s doubling up to 60s, six attempts in all), then an explicit failed
  state is published: done, sync not complete, and the backend's error
  text carried through.
- A failed rescan is retried as a plain sync, because a rescan has
  already cleared the wallet and re-running it would throw away the
  partial progress just saved.
- A terminal error, such as a reorg deeper than the wallet can unwind,
  stops the retries immediately instead of waiting out the attempts.
- Recovery is observed, not guessed: once a later refresh's server-bound
  reads all succeed, the failed state clears itself.

Behaviour change: a sync failure now surfaces to the user as a failed
state with a message, instead of a frozen "syncing", and the client
retries on its own with back-off.

Tests: a parser case for the success, failure, and terminal replies, and
fake-backend controller cases for retrying after a failure, giving up
after the attempt cap, a terminal reorg stopping at once, a failed
rescan retrying as sync, cancel stopping the back-off, and the failed
state self-healing on a good refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 3f7af2d028 fix(lite-backend): fix t-scan rewind underflow and retry the owed scan
When a sync pass's transparent (t-address) scan failed, the backend
mishandled it three ways: the retry computed its start height as the
block height minus the batch size, an unsigned subtraction that wrapped
to a huge value for low heights; the block range left un-scanned was
not remembered, so a transient failure left a permanent hole in the
transparent history that nothing ever revisited; and the block times
needed to date the transactions in that range were discarded.

- The owed range and the block times seen so far are now kept and folded
  into the next pass, which resumes from the lower of its own first block
  and any owed range.
- The scan start never subtracts, so it can no longer underflow.
- A t-address scan owed by an earlier failed pass is paid on the next
  sync even when no new block is pending.

Behaviour change: a transparent scan that fails is retried in-process on
the next sync rather than being lost. The owed range lives only in
memory and is never written to the wallet file, so the file format is
unchanged and remains interchangeable with other builds. The debt is
lost if the process exits or the server is switched before a retry
completes; Re-download blocks (rescan) recovers it, as today.

Tests: library tests on the extracted t-scan seam cover a failed pass
keeping its range and block times, a later success clearing the debt,
and the scan start never exceeding the pass start across the batch-size
boundary (the old formula wrapped there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:22 -05:00
DanSandClaude Opus 4.8 5ae5e5213b fix(lite): sort lite transactions newest-first to match the UI panels
The lite backend lists transactions oldest-first, but every consumer of
the transaction list assumes newest-first and takes a prefix of it, so
the Recent Transactions / Recent Sends / Recent Received panels and the
CSV export showed the oldest records instead of the latest.

- The UI copy is now sorted pending (0-conf) first, then newest-first by
  timestamp, the same order as the History tab's default.
- The sort is stable, so several records that share a txid (a send entry
  plus its per-note entries, or same-block transactions) keep a
  deterministic order across refreshes. That keeps the order-sensitive
  change check reporting "unchanged" and avoids bumping the transaction
  revision every couple of seconds.

Tests: a new case covers the pending-first, newest-first ordering, a
stable order within one txid, and the revision staying unchanged when
the identical model is re-applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:22 -05:00
DanSandClaude Opus 5.5 e7f084e3eb fix(bootstrap): harden archive names, the cancel window and quit wait
The bootstrap preflight's protected names could be bypassed by names
that resolve onto them, and its cancel and quit paths had two timing
gaps.

- Entry names are judged as the file system resolves them: '\' is a
  separator, "." components are dropped and each component loses its
  trailing dots and spaces, as Win32 does ("./wallet.dat" and
  "DRAGONX.conf." name protected files). The whole archive is refused
  for an absolute or drive-rooted name, a ".." component, any ':'
  (drive letter or NTFS alternate data stream, "DRAGONX.conf::$DATA"),
  an 8.3 short name ("DRAGON~1.CON"), a component of only dots or
  spaces (other than "."), and any byte >= 0x80 or ASCII control
  character: macOS folds or ignores some Unicode (U+017F, U+212A,
  U+200C) onto a protected name, and the published bootstrap archive
  uses plain ASCII names. A refused name is echoed
  with \xNN escapes. A member is written at the path it was checked
  under, and the protected-name check runs on the resolved name.
- The user's Cancel (Settings dialog and first-run wizard) goes through
  requestCancel(). It is refused once the old chain data starts being
  replaced, and the start of that window re-checks a cancel under the
  same lock, so a click lands before the old chain is touched or not at
  all.
- Quit cancels the wizard's and the Settings bootstrap when the
  shutdown starts; the HEAD and checksum requests now end at once on
  cancel, and a cancelled HEAD keeps the partial download. A run
  cancelled before the window leaves the old chain as it was. A run
  inside it stops extracting and removes its partial chain while the
  shutdown screen shows "Cleaning up...", so it ends with no chain data
  (wallet.dat untouched; Retry reuses the zip, or the node resyncs).
  That wait is bounded: at most 2 minutes from the start of the
  shutdown, then 3 s in shutdown(), after which a worker still running
  is left to the process exit. Only a cleanup that overruns that cap
  can leave a partial chain, which dragonxd then answers with its
  reindex offer.

Tests: refusal of the Windows-resolved, non-ASCII and control-character
names with the escaped echo; the protected-name list incl. the resolved
forms and a planted -wallet file, conf and database/ that stay
byte-identical; the user-cancel window; the bounded quit wait and
safeToAbandon(); a cancel ends a HEAD request that never answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 a5185737e6 refactor(app): retire logo textures through the deferred-destroy queue
ensureLogoTexture() still freed the chat emoji, header logo and coin logo
textures it replaced at once, while every other theme-driven replacement
already goes through retireTexture() and is freed at the next preFrame().
It runs at the top of render(), so nothing referenced them yet; this is
hygiene, so that the same rule covers every replaced texture.

- ensureLogoTexture(): the three direct DestroyTexture() calls become
  retireTexture(). Cost: the old textures live one extra frame on a theme
  or accent change.
- setGradientTexture() (unused today) retires the texture it replaces
  instead of dropping it.
- No drain at shutdown: the process exits and the live logo and gradient
  textures are not freed at exit either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 a581f42a33 fix(settings): disable the unimplemented Tor option and tell its users
The "Use Tor" checkbox was saved but never read: no build routed any
traffic (node, RPC, price/update/bootstrap/pool HTTP or Lite gRPC)
through Tor, so users who ticked it believed they were anonymous.

- The checkbox is disabled and always shown unticked, on the Settings
  page and in the legacy Settings window. Its tooltip says it is not
  available and gives the alternatives: for the node, proxy=127.0.0.1:9050
  in DRAGONX.conf with Tor running; otherwise a system-wide VPN/Tor (Lite
  builds get the short version). The "Use Tor" label is kept.
- Behaviour change: a stored use_tor=true is cleared once at startup
  (Settings::consumeLegacyUseTor + a synchronous save before the async
  writer and any Settings page state), with a 20 s warning in the user's
  language. The cleared key is the "already told" flag, so the notice
  never repeats. No settings format change.
- 3 new strings (tt_tor_unavailable, tt_tor_unavailable_lite,
  tor_never_applied_notice) in all 8 languages; the tooltip wraps at
  tor-tooltip-wrap-width from ui.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 ae66b54694 fix(miner): keep an updated miner, don't re-extract the bundled one
Builds that embed xmrig (Windows release) re-extracted the bundled miner
whenever its size differed from the file on disk. An "Update miner..."
install always differs, so the next start silently put the bundled build
back while settings still showed the updated version.

- The bundled miner is now placed only when it is missing, like the node
  binaries (kXmrigRefreshOnSizeMismatch = false in the provision table).
  Params and asmap still follow the bundled size.
- Behaviour change: a wallet upgrade no longer refreshes a miner the user
  never updated; "Update miner..." is the way to get a newer build.
- The uncalled needsXmrigExtraction() is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 81733f5603 fix(rpc): run rescans and key imports without the 30 s timeout
A rescan or key import holds the node's cs_main for the whole wallet
scan. On the shared clients it hit the 30 s curl timeout, reported a
failure that wasn't one, abandoned a daemon RPC slot and flipped the app
into "node busy".

- One long wallet RPC at a time (rescan, importprivkey, importaddress,
  importwallet, z_importkey, z_importviewingkey, z_importwallet) runs on
  its own ungated client with no client timeout (rpc/long_call_lane.h).
  It can be aborted from any thread, and an abort is never lost, even
  one that lands before the client opens.
- The GUI rescan, key import and sweep use it. The sweep's fund-moving
  body is unchanged; only the client it calls differs. runtimeRescan()
  returns bool, and a refused post-bootstrap reconcile stays armed.
- The console routes rescan and the import commands through the lane;
  its input stays usable meanwhile, and a refusal is printed like any
  other result. RPCClient gains callRaw(.., timeoutSec).
- Restart, wallet switch, delete-chain, bootstrap (now also from
  Settings) and the other node-stopping flows are refused while it runs,
  with a message saying a key import or rescan is still running; quit
  asks for confirmation. The lane is aborted at quit.
- A second import or rescan is refused before the key is copied anywhere.
  An import cut off after the request reached the node (a lost reply,
  or a proxy's 502/504 while the node still imports) says the key may
  already be in the wallet and to run Settings -> Rescan. Only the
  node's own HTTP-layer refusals (401, 503, work queue full, unhandled
  request) say that the key was not imported. A pure
  classifyLongCallCutOff() makes that choice.
- New strings in all 8 languages; console results stay English.

Tests: the long-method list and the console's routing; one lane at a
time; an abort lands whether it comes before the client opens or during
the call; a call with no client timeout outlasts a 3 s scan, where a 1 s
gated client times out and marks the node busy; classifyLongCallCutOff()
for every failure kind with and without a sent request, and from raw
HTTP replies (502, 504, a plain 500 -> cut off; 401, 503, queue full,
unhandled -> refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 b3a57b07ad test(fake-daemon): add slow rescan and key-import methods
fake_dragonxd now answers rescan, importprivkey, z_importkey and
z_importviewingkey, so the GUI's long wallet RPCs can be reproduced
without a real node.

- Each one holds cs_main for FAKE_RESCAN_SECS (default 0). The lock-free
  getrescaninfo reports the scan while it runs, and, like v1.3.0, the
  scan stops early when a shutdown is requested.
- Argument checks, error codes and return values follow dragonxd
  (z_importkey returns null, importprivkey the t-address, the default
  "whenkeyisnew" skips a known key, z_importviewingkey needs its zaddr).
  Keys are only prefix-checked and never stored or logged; an imported
  spending key gets a made-up address that the address lists then show.
- FAKE_ENCRYPTED=1 / FAKE_WALLET_PASSPHRASE: an encrypted wallet that
  starts locked, with walletpassphrase (-14 on a wrong passphrase),
  walletpassphrasechange, walletlock and getwalletinfo unlocked_until;
  the imports answer -13 while it is locked. As in dragonxd, the right
  passphrase with a non-integer timeout unlocks first and only then
  fails (-1): the wallet stays unlocked with no relock timer of its
  own. A timer pending from an earlier unlock keeps running; otherwise
  only walletlock locks it again. Without FAKE_ENCRYPTED, those three
  methods answer -15 like dragonxd.
- FAKE_METHODS_ALL call events show keys and passphrases as <redacted>.

A plain run is unchanged: the same 63 GUI calls before and after, and
identical replies to every one of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 78e38570a8 fix(bootstrap): check archive and free space before deleting chain data
The bootstrap removed blocks/, chainstate/ and notarizations/ first and
only then opened the archive. An unsafe entry, a full disk or a corrupt
member therefore left no chain, or a half-written one for dragonxd to
start on.

- Preflight before anything is deleted: every archive entry must be
  safe (no absolute path, no "..") and readable, and the disk must hold
  the extracted size plus max(256 MiB, 5%), counting what removing the
  old chain frees (a symlinked chain dir counts 0). Free space counts as
  unknown only when the probe errors, the capacity is 0 or the
  available size is -1; a known disk with 0 bytes free is a refusal.
  The old chain is kept on every preflight failure.
- Protected members are skipped, compared case-insensitively: any path
  component starting with "wallet", any "database" component, a .cookie
  or *.conf file, and top-level *.dat files other than fee_estimates,
  peers and banlist. The number of non-critical member failures is
  logged.
- The removal of the old chain is verified (a locked file fails the run
  instead of mixing old and new chain files). If the extraction fails or
  is aborted, the partial chain is removed again before the failure is
  published; isDone() turns true only after the worker's last
  filesystem change.
- Cancel is disabled while the old chain data is being replaced, and
  quit aborts a running bootstrap.
- A complete zip from an earlier attempt is reused on Retry; its
  checksum is re-verified. A download that cannot fit fails early, and a
  disk-full write error says so.

Tests: the space rule (boundaries, unknown vs. full disk, saturation);
planArchive sizes, zip-slip, garbage and truncated zips; the
protected-name list; reclaimableBytes with symlinks; each preflight
refusal keeps the old chain and wallet.dat byte-identical; a corrupt
critical member removes the partial chain before the failure is
published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 70d8b0f687 fix(daemon): supervise a daemon restarted after a crash
After an unexpected exit the monitor thread returned on its own but
stayed joinable, and start() only spawned a monitor when none was
joinable. A node restarted after a crash (no stop() in between) then ran
unwatched: a second crash was never detected, its output was never
drained, and the dead run's stdout pipe leaked into the next fork.

- start() joins a monitor that returned on its own (only when joinable,
  at the top, after the "already running" early returns), closes the
  dead run's stdout pipe (Windows: process and pipe handles), and always
  starts a new monitor for the process it launched. crash_count_ is kept.
- New lifecycle_mutex_ serializes start(), beginStop(), stop()'s flag
  writes and joins, and endStop(), so two threads can never join the same
  monitor. The monitor thread and the state callback never take it; a
  beginStop() racing a start() waits for it to return (its longest wait
  is the ~300 ms datadir-lock poll).
- start() is refused (returns false, state unchanged) while a stop is in
  progress, i.e. between beginStop() and endStop().
- The daemon tests now start the stand-in only in isolation (temp
  -datadir override, port check skipped): they no longer write the real
  ~/.hush/DRAGONX/obsidiandragon-dragonxd.pid or tail the real debug.log.
  New tests: restart after a crash is supervised, a restarted run stops
  cleanly, no fd leak over crash+restart cycles (Linux), and start() is
  refused while stopping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 f6e5038043 fix(security): don't penalise a correct PIN after a passphrase change
A passphrase change left the PIN vault sealing the old passphrase. The
next PIN unlock opened the vault, walletpassphrase answered RPC -14, and
the generic catch ran the lockout curve on a correct PIN, while Settings
kept showing the PIN as active.

- Passphrase change: when the active wallet's vault exists at completion
  (same vault path as at dispatch, passphrase actually changed), the
  vault is marked out of date for the session and kept on disk: PIN entry
  is no longer offered, a warning toast says so, and Settings shows
  "PIN out of date" with a tooltip on how to fix it. The Change
  Passphrase dialog notes beforehand that a PIN will go out of date.
- PIN unlock: RPC -14 after the vault opened is treated as "PIN out of
  date": passphrase entry, a message, no attempt counted, no lockout, and
  the vault is not deleted (-14 is only the daemon's answer; the vault may
  hold the only copy of a passphrase that opens an older backup). After a
  restart Settings shows the PIN as active until the first PIN attempt.
- A malformed vault (zero-filled by a removeVault() whose unlink failed,
  truncated, unknown version) is treated as missing: passphrase entry,
  "PIN file unreadable", no penalty. New SecureVault::isWellFormed().
- Race guards: Set/Change/Remove PIN are disabled while a passphrase
  change runs, and Change Passphrase while a PIN operation runs.
- Setting or removing the PIN, or switching wallets, clears the mark;
  setting a PIN offers PIN entry again. Change PIN keeps the mark (the
  vault still seals the same passphrase).
- Pure decisions in data/pin_vault_policy.h, unit-tested. New strings in
  all 8 languages.

No vault, wallet or settings format change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 62a5577491 fix(sweep): keep the UI sweep away from real wallet data
The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.

- Preflight: a full sweep is refused (toast + log line) while a real
  workflow is in flight: a pending seed_migration_* setting or open
  migrate dialog, a wallet switch, an encryption/import workflow, an
  encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
  The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
  save/load latched while a demo-contacts surface is shown), WalletIndex
  and Settings (active wallet file, contacts view mode, pool URL/worker)
  for the whole sweep. Settings::sweepEnd() is the last statement of
  clearDemoWalletData and flushes once only if a save was suppressed; a
  quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
  zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
  persisted id or "" and never mints one, so the Contacts tab cannot
  move legacy-scoped contacts to a throwaway id. The only address-book
  write a sweep can still cause is the one-time legacy re-attach to the
  wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
  under the temp dir (exclusive create, a pre-existing path or symlink
  is refused), removed at sweep end. The teardown that deleted those six
  wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
  conversations and a fabricated demo chat identity are reset at sweep
  end; the seed-backup surface always shows the demo phrase and a real
  z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
  language files.

Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.

Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 3ac46abe1a fix(security): compile the UI sweep only into dev builds
The screenshot sweep and the full UI sweep are dev tools. The full sweep
injects demo data into the live wallet state and the real data and
config dirs, and the tab sweep screenshots real balances and addresses.
Both were compiled into every build: the Settings buttons were
reachable in full-node releases, and the DRAGONX_FULL_SWEEP env hook
worked in every build, Lite releases included.

A default-OFF option alone would fail open: dev and release builds
share build/linux (and build/mac), so a release configured without
--clean would inherit a cached ON from the last dev configure.

- CMake: option(DRAGONX_ENABLE_UI_SWEEP ... OFF), passed to
  ObsidianDragon and ObsidianDragonTests as a compile definition.
- build.sh passes it explicitly: ON in the dev build, OFF in every
  release configure (Linux, Windows, macOS osxcross and native).
- Packaging guard: while the cache says ON, dragonx_release depends on
  dragonx_release_sweep_guard, which fails, so a hand-configured ON
  tree cannot package a release.
- startSweepImpl is a no-op unless the option is ON (an undefined macro
  counts as 0, so it fails closed). The enabled branch logs the
  sentinel "[Sweep] DRAGONX_UI_SWEEP_COMPILED_IN", so a release can be
  checked with `strings <bin> | grep -c DRAGONX_UI_SWEEP_COMPILED_IN`
  (must be 0).
- main.cpp: the DRAGONX_FULL_SWEEP hook is compiled out (autoSweep is
  false). It must be: with the sweep a no-op, the hook would quit the
  app once its warm-up frames ran out.
- Settings > Node > Debug options: both sweep buttons, open-dir, the
  seed-demo-chat button and the current-theme checkbox are compiled only
  into dev builds. The daemon debug categories are unchanged.

No i18n change: the existing strings stay in the tables.

Verified with an isolated release (DRAGONX_BUILD_ROOT/DRAGONX_RELEASE_ROOT
in a scratch dir) whose tree was first configured by hand with ON:
build.sh --linux-release without --clean leaves the cache OFF, and the
sentinel count is 0 in the built binary, the zip and the AppImage.
With the cache set back to ON by hand, --target dragonx_release fails
at the guard before compiling anything. Dev trees configured ON keep
the sweep (sentinel present; a one-surface DRAGONX_FULL_SWEEP run
against the fake daemon captured and exited cleanly).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 eac0993512 fix(lite): require a confirmed 8+ character passphrase to encrypt
The Lite "Encrypt wallet" field and the Create/Restore passphrase had no
confirmation and no minimum length; only an empty passphrase was
refused. Encrypting locks the wallet at once, and a locked wallet cannot
reveal its seed, so a typo there could cost a user without a seed backup
their funds. The full-node encrypt dialog already requires 8 characters
and a confirmation.

This applies to new passphrases only. Opening and unlocking are
unchanged: an existing wallet with a shorter passphrase still opens and
unlocks.

- A pure liteEncryptPassphraseValid(pass, confirm): at least 8 bytes and
  equal to the confirmation. It takes string_views, so the page's secret
  buffers are never copied.
- liteLifecyclePassphraseAcceptable(op, pass, confirm) gates the Settings
  create/open/restore request. Open is never checked, because its
  passphrase field also unlocks an existing wallet. Create and Restore
  are checked only when a passphrase was entered; an empty one still
  creates an unencrypted wallet.
- Encrypt gets a Confirm field. The button stays disabled until the
  passphrase is valid, and a hint says why (too short, with the count,
  or not matching).
- Create/Restore show a Confirm row once a passphrase is typed, with the
  same hint. Validate stays disabled while the passphrase is invalid, so
  a click never wipes a typed seed over a passphrase typo. The request
  builder re-checks the rule.
- The confirmation buffers are wiped with their passphrase: after a run
  or an encrypt, and when the Confirm row is hidden.
- Once the wallet is closed, the Settings page wipes the Encrypt,
  Confirm and Decrypt passphrase buffers whole every time, also when no
  secret was revealed; before, closing the wallet wiped them only while
  clearing a revealed backup or secret. They are zeroed in full because
  an emptied field can still hold the old tail past its first NUL.
- New copy under Encrypt: "Anyone with the wallet file can try
  passphrases offline. Back up your seed phrase first and use a long
  passphrase."
- i18n: three new keys (lite_confirm_label, tt_lite_confirm_pass,
  lite_encrypt_offline_warning) in i18n.cpp and the 8 language files,
  added additively. The hints reuse the wizard's wiz_pass_too_short and
  wiz_pass_mismatch. No new CJK glyphs, so the subset font is unchanged.

Tests: the validator rejects empty, short (7) and mismatched or
unconfirmed passphrases, and accepts 8 or more with a match. The request
gate always accepts Open, even "abc" with no confirmation, accepts
Create/Restore without a passphrase, and applies the rule to them
otherwise. An encrypted, locked wallet still opens and unlocks through
the controller with the 3-character passphrase "abc".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 e52c9e1b92 fix(lite): classify imported keys before calling the backend
importKeyCore guessed the key type from its first character, then
retried a failed import with the other command. That retry is what sent
shielded keys to the backend's transparent-key decoder, which panicked
on them and poisoned the wallet lock. A WIF typo that was still
valid base58 also reached the backend unchecked.

- The key is classified first, and exactly one backend command runs, or
  none:
  - secret-extended-key-... and zxview... go to "import";
  - a Base58Check WIF with version 0xBC or 0x80 and the compressed flag
    goes to "timport";
  - everything else is refused before any backend call.
  There is never a retry with the other command. A testnet (0xEF) WIF
  is not accepted, because the Lite app is mainnet-only.
- New fixed, untranslated reasons: "uncompressed transparent keys are
  not supported by the Lite wallet", a full-node incoming viewing key
  (zivks) is explained as such, and anything else is "unrecognized key
  format". The key is never echoed.
- The backend's refusal while the wallet is encrypted now maps to "wallet
  is encrypted - remove encryption to import a private key". It is
  checked before "locked", because unlocking would not help.
- base58Decode reserves its buffers up front, and it and
  decodeBase58Check wipe their copies of the decoded bytes with
  sodium_memzero on every return path, since the input can be a private
  key.
- exportSeed rejects the all-zero-entropy phrase (23 x "abandon" + "art").
  A backend built before the seed-export fix returns it for a wallet
  whose lock failed halfway (seed wiped, wallet still unlocked); it is
  never a real backup and must not seed a chat identity.
- An open that the backend recovered from the .bak ("OK: recovered from
  backup; damaged file kept as <name>") is written to the lite log.

Tests: the retry control now expects one "import" and no "timport", and
"typo-key" makes no backend call; the controller's transparent-import
test uses a real compressed WIF. A new classifier test covers both WIF
versions, the shielded and viewing keys, a single call on failure, the
uncompressed, zivks, testnet, bad-flag and bad-checksum refusals, the
encrypted reason and its lite-log line, the zero-entropy seed and the
recovery log line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 b6cffdf942 fix(lite-backend): catch panics at the wallet-open C entry points
litelib_initialize_new, litelib_initialize_new_from_phrase,
litelib_initialize_existing and litelib_check_server_online had no
catch_unwind (only litelib_execute did), so any panic below them
unwound across the C ABI into the GUI. With rustc 1.63 that is
undefined behaviour; in practice the process aborts with "fatal runtime
error: failed to initiate panic, error 5". The simplest trigger is a
malformed user-entered server URL: get_server_or_default unwraps the
http::Uri parse before anything else runs.

- ffi_guard (next to error_cstring) runs a body under catch_unwind and
  turns a panic into "Error: Rust panic in <name>", which the C++ bridge
  already treats as a failed call. The three init entry points keep
  their extern signatures and #[no_mangle] and call ffi_guard; their old
  bodies are now private *_impl functions.
- litelib_check_server_online wraps its body the same way and returns
  false on a panic.
- litelib_initialize_existing opens the wallet through
  read_from_disk_reporting. After a recovery from the .bak it returns
  "OK: recovered from backup; damaged file kept as <name>" instead of
  "OK". The C++ side treats any non-empty, non-error string as a
  successful open, so this is ABI-compatible.

No ABI or symbol change: the exported symbol inventory
(lite-backend-symbols.txt) is identical. panic = "unwind" stays in the
release profile, and every guard depends on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 31304a1a63 fix(lite-backend): durable wallet saves, non-destructive backup recovery
do_save flushed the temp wallet and renamed it over the wallet with no
fsync of the temp file, the .bak or the directory, so after a power cut
the rename could be on disk while the data was not, leaving a torn
wallet. The readers then panicked on malformed bytes instead of
returning an error (the gzip Decoder and WalletTx unwraps in
LightWallet::read, the version asserts in data.rs and walletzkey.rs,
from_utf8/diversifier unwraps, and vec![0; len] on a corrupt length
prefix, which can abort the process outright), so read_from_disk's .bak
fallback was never reached. And the fallback itself was destructive: it
copied the .bak over the wallet before reading it, on ANY error, so the
damaged file was lost, a transient I/O error replaced a good newer
wallet with an older .bak, and a wallet written by a newer app ("Don't
know how to read wallet version") was overwritten too, dropping imported
keys that are not in the seed.

- do_save: sync_all the temp file (removed on error), copy the .bak,
  tighten and sync it through a write handle (FlushFileBuffers needs
  write access on Windows), rename, sync the copy-fallback destination,
  then fsync the directory on unix. No MoveFileEx write-through on
  Windows.
- read_from_disk: each parse runs in read_wallet_file under
  catch_unwind (panic = "unwind" is kept for release builds). The .bak
  is used only when the wallet fails with InvalidData, UnexpectedEof or
  InvalidInput, and it is read first, so a bad backup leaves both files
  as they were. A damaged wallet whose header says encrypted is not
  replaced by a plaintext .bak (only an older build leaves one): the
  open fails with an explanation and nothing changes. Otherwise the
  damaged wallet is moved (or, failing that, copied) to
  <wallet>.dat.corrupt-<unix secs>, with a -N suffix while that name is
  taken (a dangling symlink counts as taken), and is never deleted. The
  recovered wallet is tightened as before.
- read_from_disk_reporting returns the same client plus "recovered from
  backup; damaged file kept as <name>" after a recovery, for the FFI;
  read_from_disk wraps it. Both stages are also written to stderr,
  since the logger is not initialised yet when the wallet is opened.
- Readers return Err: a newer wallet version is ErrorKind::Unsupported
  (was InvalidData), and so is a newer WalletTx or key version;
  Decoder::new and WalletTx::read propagate with ?; a bad Utxo version,
  a negative or non-UTF-8 or non-R Utxo address, non-UTF-8 metadata
  addresses, an invalid diversifier and a viewing key with no default
  address are InvalidData. Every length-prefixed string and address is
  read through read_bytes_bounded (take(len).read_to_end, no cap, std's
  own short-read error), so a corrupt length is never allocated up
  front.

Flagged behaviour changes:
- fsync order: tmp, then .bak, then rename, then the dir (unix); saves
  at exit, encrypt and decrypt still run on the UI thread and get
  slower on slow disks;
- the readers return Err instead of panicking;
- the .bak fallback runs only for corrupt files, and the damaged file
  is kept as .dat.corrupt-<ts> (every copy is kept; one from a
  plaintext era keeps its seed, inside the 0700 dir);
- a wallet with a newer format errors (Unsupported) and is never
  replaced;
- a damaged encrypted wallet whose backup predates encryption is not
  opened.
Wallet format v9 is unchanged; valid files read and write byte for byte
as before. No ABI or symbol change.

Tests: a wallet whose parse panicked recovers from the .bak, the
garbage is kept as .dat.corrupt-<ts> and the report names it, and the
next open reports nothing; a newer-version wallet is Unsupported and
both files are unchanged (this fails when only the version error kind is
reverted); an unreadable (0000) wallet is a PermissionDenied error that
changes nothing either; a corrupt wallet with
a corrupt or no .bak changes nothing; a damaged encrypted wallet with a
pre-encryption plaintext .bak is refused with both files unchanged and
nothing moved aside; a recovery never overwrites an earlier
.dat.corrupt-<ts> or follows a dangling symlink; every truncation of a
wallet and of a WalletTx is an Err, crafted Utxo, metadata, note and key
records are the expected Err kinds, and a u64::MAX length returns at
once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 39333f80b7 fix(lite-backend): keep wallet files and logs private to the owner
On Linux and macOS the Lite data dir (~/.silentdragonxlite) was created
with create_dir_all (0755 under the default umask) on every
get_zcash_data_path() call, even by the startup wallet_exists() probe.
do_save wrote the wallet through File::create (0644) and renamed it into
place, and fs::copy gave the .bak the same mode, so any local account
could read the wallet file, and the plaintext seed with it when the
wallet was not encrypted. The debug log was 0644 and its roller pattern
was relative, so rolled logs landed in the process's working directory.

A crash-leftover .dat.tmp was truncated and reused: an fd another
account had opened on it read the next wallet. And after encrypt(), the
first save copied the previous, plaintext wallet to .bak, where the seed
stayed until the next save, or for good if the app quit right away.

- New helpers, each an item-level #[cfg(unix)] / #[cfg(not(unix))]
  pair: create_private_dir_all (0700 components), tighten (strips only
  the group/other bits of an existing dir or file, keeps the owner bits,
  never follows a symlink), create_private_file_new (unlinks a stale
  file, then create_new with 0600), and a once-per-process warning when
  a chmod fails (drvfs, FAT, NFS).
- get_zcash_data_path creates the default data dir 0700 and tightens an
  existing one (for test/regtest also its parent). A caller-supplied
  data_dir is created as before and never chmodded, and the params dir
  is left alone.
- do_save writes a fresh 0600 temp file and tightens the .bak after the
  copy. At the plaintext -> encrypted transition (the wallet is
  encrypted and the on-disk header says plaintext) the .bak is copied
  from the new, encrypted file. wallet_header_encrypted reads the flag
  byte for versions 4 and 6-9 only (v5 is gzip) and returns None when it
  cannot tell, which keeps the old behaviour.
- read_from_disk tightens a wallet restored from .bak, and the wallet
  and .bak after every successful read.
- get_log_config pre-creates the log 0600 on unix and anchors the
  roller in the data dir; a data path containing "{}" keeps the old
  relative pattern. log4rs re-creates the log with the umask mode after
  a 5 MB roll, so the 0700 data dir is the lasting control.

Flagged behaviour changes:
- on unix, the data dir is 0700 and existing files lose their
  group/other bits (owner bits kept, symlinks and a caller-supplied
  data_dir untouched); a shared group or a backup user under another uid
  loses read access;
- a fresh 0600 temp file is used;
- the first encrypted save backs up the encrypted file;
- the log rolls inside the data dir (old rolled logs in former working
  directories are not cleaned up).
On Windows the file modes are unchanged; the encrypted first .bak and
the log-roll location apply there too. Wallet format v9 is unchanged;
no ABI or symbol change.

Tests: the wallet and .bak are 0600 after every save and after an open
from 0644, and a wallet restored from a loose .bak is 0600, while the
caller's 0755 data dir is unchanged; a stale 0644 .dat.tmp is replaced
by a new inode and an fd held on it still reads the stale bytes; tighten
keeps owner bits (0555 -> 0500), ignores the other kind and symlinks,
create_private_dir_all makes every component 0700, and
create_private_file_new never reuses or follows a stale file or link;
after encrypt the first save's .bak is encrypted, byte-identical to the
wallet and unlocks to the seed; the header probe answers only for v4 and
v6-v9; the log is 0600, the roller pattern and a real roll stay in the
data dir, and a "{}" path falls back. An opt-in test
(DRAGONX_LITE_SCRATCH_HOME=1, temp HOME) checks the default data dir,
its testnet parent, a symlinked data dir and the params dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 a5db2e9a3d fix(lite-backend): never hang or panic a sync on malformed server data
The lightwalletd server controls the compact ciphertext length, the
block, prev-block and tx hash lengths, and (through the chain) the full
txs' epks. Each of these could wedge or poison a sync:

- try_sapling_compact_note_decryption asserts a 52-byte ciphertext. A
  shorter (or full 580-byte) one panicked a pool job in
  scan_output_internal while the original sender stayed alive, so the
  collector waited forever with sync_lock, txs.write() and a wallet read
  guard held.
- scan_taddress_txids kept its own sender alive too: one panicking
  t-address job (scan_full_tx) left crx.iter().take(n) blocked forever.
- CompactBlock::hash()/prev_hash() assert 32 bytes when no header is
  sent, and every vtx hash is copied into a [u8; 32], one of them under
  txs.write(): a 31-byte tx hash poisoned the wallet for good, and a bad
  hash in the block's txid list panicked after the witnesses had moved.
- scan_full_tx and scan_full_mempool_tx unwrapped as_prime_order() on
  the epk. Consensus rejects only a small-order epk, so a mixed-order
  one can be mined: an own tx then failed every sync, and the mempool
  receiver thread died silently.

Changes:
- append_leaf(node: Node, ..) commits a leaf to the existing, block and
  new witnesses and the tree; the epk-error path, the new
  short-ciphertext path and the normal path all use it (a
  behaviour-neutral extraction).
- A compact ciphertext under 52 bytes is skipped with its cmu still
  committed; a longer one is truncated to 52 bytes, which is exact (the
  compact plaintext is those bytes and the cmu re-check validates it).
- Each decryption job catches a panic and sends exactly one message;
  drop(tx) after the dispatch, and the collector logs a vanished or
  panicked job and treats the output as not ours.
- scan_taddress_txids runs through the private seam
  scan_taddress_txids_with(job): catch_unwind per job, drop(ctx), and a
  count check, so a dead job is an error, never a hang or a silent
  partial success.
- scan_block_with_pool checks the block, prev-block and vtx hash lengths
  right after the parse and returns Err(-1), as for an unparsable block,
  before any change (Err(height) would refetch the same block forever
  under sync_lock).
- scan_full_tx and scan_full_mempool_tx skip an output whose epk is not
  in the prime-order subgroup (one check per output, used by every
  decryption and the outgoing recovery).
- The mempool receiver thread catches a panic of scan_full_mempool_tx.

Flagged behaviour changes:
- a compact ciphertext under 52 bytes is skipped (its cmu is still
  committed); one over 52 bytes is truncated;
- pool-job panics are reported (logged; the output is treated as not
  ours, which a rescan repairs) instead of hanging the sync;
- bad hash lengths return Err(-1) before any change;
- outputs with a non-prime-order epk are skipped;
- the mempool thread survives a bad tx.
Wallet format v9 is unchanged; no ABI or symbol change. Honest data
takes the same path as before (an old-vs-new run over generated honest
chains is byte-identical).

Tests (each on its own thread under a 15 s watchdog):
0/51/52/53/580-byte ciphertexts in one block on the inline and deferred
witness paths (the 51-byte notes are skipped, the 53- and 580-byte notes
are found, the tree and every witness match the reference); a 31-byte tx
hash, an empty block hash, a 31-byte prev hash and a 33-byte foreign tx
hash each return Err(-1) with the txs lock usable and
last_scanned_height, trees and witnesses unchanged; a mixed-order epk in
scan_full_tx and scan_full_mempool_tx does not panic and decrypts
nothing while the real epk still decrypts; a t-address job panicking for
1 of 3 addresses is an Err. The oracle generator gains short
(0..=51-byte) and full (580-byte) ciphertext outputs without an extra
random draw, so each seed keeps its sequence of blocks, values and
keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 55431cd606 fix(lite-backend): make lock/unlock atomic, check passphrase to decrypt
lock() zeroed the seed first and then locked the keys one by one. A key
with no encrypted copy (an import into an encrypted wallet by an older
build) failed halfway: the seed was all zeros, the earlier keys were
wiped, and the wallet still reported unlocked. Seed export then showed
the all-zero-entropy phrase ("abandon ... art") as the backup.

unlock() had the mirror problem. When the passphrase was right but a
later key failed (a zaddress/fvk mismatch, a bad sealed key), the keys
decrypted so far stayed in memory while the wallet reported locked, and
write() saves a locked wallet as is, so the next save put them in
plaintext into the encrypted file. unlock also unwrapped the nonce and
the sealed key, so a malformed or foreign file panicked under the wallet
write lock and poisoned it.

remove_encryption() on an unlocked wallet never looked at the
passphrase: any string, even "", removed the encryption for good, and
the next save wrote the seed and every key in plaintext.

- WalletTKey/WalletZKey get can_lock(), which mirrors lock()'s
  precondition. lock() checks every key first and changes nothing when
  one cannot be locked; the keys are locked next and the seed is zeroed
  last.
- unlock() runs the t-key loop, then the z-key loop; on any error it
  re-locks every key that can be locked before it returns. This drops
  only plaintext copies that can be re-derived or re-decrypted.
- The nonce and enc_key unwraps in LightWallet::unlock and in
  WalletTKey/WalletZKey::unlock return "Wallet encryption data is
  corrupt" (InvalidData) instead.
- get_seed_phrase() returns "" when the wallet is encrypted and its seed
  is all zeros.
- remove_encryption() opens the sealed seed with the given passphrase
  first, locked or unlocked, before it changes anything.

Flagged behaviour changes:
- lock() checks every key before it changes anything;
- a failed unlock re-wipes;
- corrupt encryption data returns an error, not a panic;
- seed export never returns the all-zero phrase;
- remove_encryption verifies the passphrase in both lock states (a wrong
  passphrase now fails with "Decryption failed. Is your password
  correct?").
Wallet format v9 is unchanged; no ABI or symbol change. The KDF is
unchanged; upgrading it is left for later.

Tests: lock is atomic for an unsealed t-key and an unsealed imported
z-key (seed, unlocked flag and keys unchanged, then a clean lock and
unlock); a failed unlock leaves no t-key or z-key secret in memory or in
the written file, and with the z-key's real derivation index restored
the same passphrase unlocks every key; a key with no sealed copy, a
missing nonce or a nonce of the wrong length is an error in WalletTKey,
WalletZKey and LightWallet, and remove_encryption refuses it too;
remove_encryption with a wrong or empty passphrase fails locked and
unlocked, and the right one round-trips an unencrypted v9 file; an
encrypted wallet with a zero seed exports "".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 20b63bf45d fix(lite-backend): validate WIFs, refuse key import while encrypted
import_taddr decoded a WIF with bs58::decode(..).unwrap(), a [2..66]
slice of its hex and SecretKey::from_slice(..).unwrap(), all under the
wallet write guard that do_import_tk holds. A z-key, a typo or any short
string panicked there and poisoned the wallet until restart, and the
checksum was never verified, so a mistyped WIF that was still valid
base58 imported a different key.

A private key imported into an encrypted wallet was stored without an
enc_key. The import then failed in do_save's lock(), which had already
zeroed the seed while the wallet stayed "unlocked": seed export showed
the all-zero-entropy phrase.

- decode_taddr_wif replaces the old decode: Base58Check with the
  checksum verified, version 0xBC (the config prefix) or 0x80, and the
  0x01 compressed flag. It decodes into a buffer it owns and wipes it
  with sodiumoxide::utils::memzero on every path, including a failed
  checksum. Errors are fixed strings that never contain the key.
- import_taddr and add_imported_sk refuse while the wallet is encrypted,
  as add_taddr and add_zaddr already do.
- do_import_tk and do_import_sk check is_encrypted() before the
  "locked" check, so a locked wallet does not send the user to unlock
  and retry.

Flagged behaviour changes:
- Base58Check, version 0xBC/0x80 and the compressed flag are required;
- uncompressed and foreign-network WIFs are rejected with fixed
  messages (an uncompressed key used to import under the compressed
  address, where its funds are not);
- no panic and no poisoned lock;
- private-key import (t and z) is refused while the wallet is
  encrypted, with a reason that says to remove encryption, import, then
  encrypt again; viewing keys still import;
- the key is never echoed.
No ABI, symbol or wallet format change.

Tests: garbage, z-keys, bad checksums, a transposed or flipped
character, a zero scalar, a wrong flag, uncompressed, 0x05 and 0xEF WIFs
are rejected with their message and leave tkeys usable; the wallet's own
export, a 0x80 WIF and the published key-1 vector still import; the
encrypted refusal in the wallet and the client, locked and unlocked,
leaves lock, save and seed export intact; a viewing key still imports
into an encrypted wallet; a bad timport leaves the wallet lock unpoisoned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 026d0e92a6 fix(lite-backend): serialize clear and rescan with a running sync
clear_state_from wiped the wallet's blocks, txs and mempool maps without
taking sync_lock. The console `clear` and `rescan` commands run
concurrently with the refresh worker's sync (litelib_execute drops the
client mutex before a command runs), so a clear could land after a sync
had already taken its nullifier snapshot. scan_block then unwrapped a
note that was no longer in the wallet and panicked under the txs write
guard, which poisons the wallet until restart.

- clear_state_from now takes sync_lock first (lock order sync_lock ->
  wallet, as in do_save and do_sync). No caller holds sync_lock, so it
  cannot self-deadlock; do_rescan holds no lock when it clears.
- clear_state reads the birthday into a local before calling
  clear_state_from. Before, the wallet read guard was a temporary that
  lived across the call, which read-locks the wallet again: with a
  writer (a save, an import, a new address) queued in between, the
  writer-preferring RwLock on Linux and Windows could deadlock. Holding
  it while waiting for sync_lock would also have inverted the lock order
  against do_save.
- scan_block resolves each spent note with and_then instead of three
  unwraps. A spend of a note that is no longer in the wallet logs a
  warning and is skipped.

Flagged behaviour changes:
- console clear/rescan and the GUI rescan wait for an in-flight sync or
  save to finish instead of racing it;
- the recursive wallet read lock that could deadlock a concurrent save
  is gone;
- a spend of a vanished note now warns and is skipped instead of
  panicking; it is counted after the next rescan.
No ABI, symbol or wallet format change.

Tests: clear_state_from waits for sync_lock, clear_state holds no wallet
guard while it waits, neither leaks the lock, and a 200x clear/write
loop finishes within a 20 s watchdog (a regression guard only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 0a72b47ca6 fix(lite-backend): verify TLS for the t-address scan and mempool monitor
The transparent-address txid scan in the sync loop and the mempool
monitor both hard-coded no_cert = true, so they connected to the
lightwalletd through get_client's NoCertificateVerification path in
every Lite build, while the app always passes dangerous = false. Block
sync, info, broadcast and the full-tx fetcher already honour
LightClientConfig::no_cert_verification. An active man in the middle on
those two channels could hide or invent transparent balance, UTXOs and
pending transactions (signing stays local, so no funds can be taken).

Both calls now take the setting from the config:
- the sync loop passes self.config.no_cert_verification to
  scan_taddress_txids;
- start_mempool_monitor reads config.no_cert_verification once and
  passes it to monitor_mempool.

Flagged behaviour change: a lightwalletd with an invalid or self-signed
certificate now fails the t-address stage (do_sync retries, then returns
the error) and the mempool monitor (it logs and retries), as block sync
already does. `dangerous` is the only bypass. No ABI, symbol or wallet
format change.

A tripwire test checks that lightclient.rs never again hard-codes the
bypass for either call; its needles are joined at runtime so the test
cannot match its own source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 1848025a95 build(lite-backend): fat LTO and one codegen unit for release builds
The qtlib wrapper's Cargo.toml, the workspace root of the backend build,
had no release profile, so libsilentdragonxlite.a was built with cargo's
defaults: no LTO and 16 codegen units, with the objects of every
dependency copied into the archive.

The release profile now sets opt-level 3, fat LTO and one codegen unit.
panic stays "unwind", written out with a comment: litelib_execute turns
a panic into an error through catch_unwind, and the full-tx fetcher and
the threadpool turn scan-job panics into errors by unwinding. There is
no strip setting: rustc does not link a staticlib, and build.sh strips
the final executable.

Measured, building the same source both ways:
- archive: Linux 126.7 -> 71.5 MB with 1580 -> 1 Rust object files;
  Windows 105.9 -> 65.8 MB with 1560 -> 1;
- stripped ObsidianDragonLite: Linux 84,283,856 -> 75,366,184 bytes
  (-8.9 MB, -10.6%); Windows exe 87,436,288 -> 79,379,968 bytes
  (-8.1 MB, -9.2%). On Linux .text went from 20.0 to 14.8 MB and
  .eh_frame from 2.67 to 1.19 MB;
- exported symbols unchanged (blake3_PW and the 8 litelib_* functions);
- a forced panic in the send command's JSON parsing still returns
  "Error: Rust panic in litelib_execute", and the next call works;
- lite_smoke --restore-recent, --keys and --encrypt pass with identical
  output apart from chain tip and birthday; restore CPU time is
  unchanged within noise (115.8-117.2 s before, 111.7-118.8 s after)
  and peak RSS dropped from 38.5 to 31.6 MB;
- backend build time: Linux 70 -> 118 s, Windows 54 -> 102 s.

This has no effect on releases until the backend artifacts are rebuilt:
build.sh links the prebuilt build/lite-backend/<platform> archives,
which are still the old non-LTO ones (the Windows one also predates the
recent backend commits). Rebuild them with
scripts/build-lite-backend-artifact.sh from a clean, committed tree.

Not verifiable here: on Windows only the link was checked, with no panic
probe, smoke run, sync or send (inlining can grow stack frames, and
mingw threads get about 2 MB of stack); the macOS artifact was not built
(CARGO_PROFILE_RELEASE_LTO=thin is the fallback if fat LTO is a problem
there); and a send, with its proving, needs a funded wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 1bf803f107 fix(app): free the replaced gradient texture and defer logo frees
reloadThemeImages() replaced gradient_tex_ in each of its three branches
(image loaded, load failed, no background) without freeing the old
texture, so every skin activation, including the one at startup, leaked
one texture.

The logo and coin textures were freed immediately instead, although the
current frame's draw lists can still reference them: the window backdrop
records its images at the start of the frame and skin switches happen
mid-frame (Settings, Ctrl+arrow keys), and the screenshot sweep switches
skins after RenderDrawData() but before the secondary viewports are
rendered. That hands the renderer a freed texture.

Textures replaced mid-frame are now retired to a queue (retireTexture()
also zeroes the member) that the next preFrame() frees first, after the
previous frame's draw data has been rendered. The old gradient is
retired at the top of reloadThemeImages(), and the logo and coin where
they used to be freed. ensureLogoTexture() still frees directly, since
it runs at the top of render(), before this frame draws a logo.
Shutdown does not drain the queue.

Measured with a temporary live-texture counter over a screenshot sweep
of all 10 skins: before, 6, 7, ... 15 live textures (one more per skin);
now a constant 5. The first screenshot after each skin switch is
pixel-identical to before on all 10 skins, and the full sweep matches
within noise.

Not verifiable here: DX11 at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 261883ca21 perf(acrylic): release blur render targets while the effect is skipped
Turning blur off (blur 0), switching to low-spec mode or enabling
reduced transparency after the acrylic targets existed left the capture
FBO and both blur ping-pong targets allocated for the rest of the
session, although nothing draws with them while the effect is skipped.

They are now released at a frame boundary and rebuilt on demand:
- ImGuiAcrylic::ReleaseIdleBuffers() runs right after app.preFrame(),
  before NewFrame(), when the previous frame's draw data has been
  rendered, and once in the resize-watcher frame (GL and DX11). While
  the effect is skipped it frees the targets; when the effect is wanted
  again it reallocates them once, with no per-frame retry if that fails;
- resize() while skipped only records that a reallocation is needed;
- the release checks the GL names (and each DX11 texture) directly, so a
  half-allocated ping-pong pair is freed too; DX11 also resets the
  ping-pong index;
- the capture paths and applyBlur() return early without a capture
  buffer, which would otherwise blit the default framebuffer onto
  itself;
- each release logs "released targets (effect skipped)".

With blur on, the path and the output are unchanged (screenshot sweeps
within noise). After re-enabling, the first frame draws the tinted
fallback until a fresh capture exists.

Measured with software GL, toggling every 30 frames for 1200 frames: the
targets were released exactly one frame after each switch-off (22 of 22)
and rebuilt on each switch-on (21 of 21), with no GL or framebuffer
errors. While off at 1280x720, with a fixed malloc mmap threshold, the
process holds 4.62 MB less (the 1280x720 RGBA8 capture plus two 320x180
RGBA16F blur targets) and RssAnon is about 3.1-3.5 MB lower. With
glibc's default threshold the freed memory stays in the heap and RssAnon
does not visibly drop.

Known issue: every off/on pair now frees and reallocates those targets.
Under software GL (llvmpipe) with the default glibc allocator the freed
texture memory is not reused: toggling every 2 frames grew RssAnon from
49 to 906 MB over 240 pairs, every 8 frames from 56 to 179 MB, and every
30 frames it levels off near 120 MB. It is not a GL object leak (with a
fixed mmap threshold the mapped bytes stay flat), and on a hardware
driver (WSLg d3d12) RssAnon stayed between 67.5 and 74.9 MB under the
2-frame churn (94.3 MB without the release). Users reach fast toggling
by holding Ctrl+Shift+Down (key repeat, about 20 Hz) or by dragging the
blur slider back and forth across 0. A short delay before releasing
would avoid it if software-rendered users matter.

Not verifiable here: the DX11 release and reallocation at runtime, and
macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 f970cae193 perf(gl): drop the unused depth/stencil buffers from window and FBOs
The GL window asked for a 24-bit depth and 8-bit stencil buffer, and
every acrylic Framebuffer attached a DEPTH24_STENCIL8 renderbuffer the
size of its color texture. Nothing uses them: no code enables depth or
stencil tests (the ImGui GL3 backend disables both), every blit and
readback is color-only, and only Framebuffer::clear() also cleared the
depth buffer, which nothing read. The window and every FBO, including
the full-size capture FBO, carried that memory, and the FBO
renderbuffers were reallocated on every resize.

Now:
- the window requests depth 0 and stencil 0 explicitly (SDL3 requests a
  depth buffer by default when the attributes are simply removed);
  alpha 8 stays for the transparent window;
- Framebuffer has no renderbuffer, and clear() clears color only;
- a permanent startup log line,
  "GL default framebuffer: depth=N stencil=N", reports what the driver
  provided; "(no attachment)" marks a failed query, which is how some
  drivers report a buffer that does not exist.

The log shows depth=0 stencil=0 both offscreen and on WSLg X11, where
"Window transparency enabled by compositor" is still logged, with no
framebuffer-incomplete errors. Screenshot sweeps at 1280x720 and
2560x1440, and a glass run with ui_opacity 0.6 and blur on (so panels go
through the FBO blur), match the previous build within noise. The
driver-side memory saving itself was not measured. DX11 is unaffected:
its swap chain has no depth buffer.

Not verifiable here: macOS (an NSOpenGL 3.2 core context with a
transparent window).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 73f7f33a4b perf(skins): skip redundant skin parsing, base reloads and theme writes
Skin handling did more work than it needed:
- the bundled-skin scan fully parsed each of the 9 bundled skin files to
  read name, author, dark and images from their first 327-345 bytes. The
  build copies the rest of ui.toml into every skin, so each file is
  51-54 KB;
- setActiveSkin() reloaded ui.toml right after main() had loaded it, a
  second full parse with nothing merged in between;
- in the Windows single-file exe, every getBundledSkinsDirectory() call
  (skin refresh, the dragonx entry, each image-dir lookup) re-extracted
  all embedded themes with a blind overwrite: more than once per launch,
  and again on skin changes.

Now:
- bundled skins are parsed only up to the first table header after
  [theme] other than [theme.images]. Anything unusual (no [theme],
  images defined later, a parse error in the slice) falls back to the
  full parse, and user skins are still parsed in full;
- UISchema tracks whether its state is exactly the base (the last change
  was a base load and no hot-reload is pending), and setActiveSkin()
  skips reloadBase() in that case;
- the bundled-skins directory is resolved once per process (re-checked
  with exists()), and theme extraction rewrites a file only when its
  bytes differ, through the new resources::writeFileIfDifferent(). It
  compares bytes, not sizes, so a same-length palette edit still
  updates.

Measured on the Lite build (median of 5 runs):
- bundled-skin scan: 15 -> 1 ms;
- from "Initializing ObsidianDragon" to the activated skin: 29 -> 12 ms;
- ui.toml is loaded once per launch instead of twice, and the light skin
  merges its overlay exactly once.

A Python mirror of the header-slice parse agrees with a full parse on
name, author, dark and images for all 18 source and build-expanded skin
files, and full screenshot sweeps over all 10 skins match the previous
build within noise. New unit tests cover writeFileIfDifferent(): missing
file, identical bytes (mtime untouched), same-length change, and a
longer file truncated.

Behaviour changes:
- a bundled skin with a TOML error after its header is now listed
  instead of skipped; the error surfaces only when it is selected;
- deleting a single file inside the extracted bundled-themes dir
  mid-session is repaired only at the next launch;
- the "Extracted N embedded bundled themes" count includes files that
  were already current.

Not verifiable here: Linux has no embedded themes, so the new
writeFileIfDifferent() ran only in the unit tests and the extraction
path was only compiled, in the Windows cross-build. That a second
Windows launch leaves the theme files' mtimes untouched is still to be
checked on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 b50d5ac31f perf(startup): build the font atlas once at the final ui.toml sizes
main() built the font atlas before App::init() loaded ui.toml, so the
first build used the hard-coded fallback sizes (H1 24 px, Body2 12 px).
App::init() then rebuilt for a saved font scale above 1.0, still before
the schema load and so still at the fallback sizes. Only the color-emoji
rebuild requested for the first frame reached the ui.toml sizes, so
color-emoji users built the atlas twice at font scale 1.0 and three
times at 1.5.

Users with chat_emoji_color=false never got that last rebuild. They
built the atlas once or twice and ran the whole session at the fallback
sizes, e.g. Body2 at 12 px instead of 15 and H1 at 24 px instead of 20.

Now:
- App::loadBaseUiSchema(), the ui.toml load moved out of App::init()
  unchanged, runs in main() before both InitImGui paths (DX11 and GL),
  together with the saved font scale and emoji style from the early
  settings read. It is idempotent, so the call left in App::init() is a
  no-op;
- the two rebuild triggers in App::init() are gone;
- Typography records what the atlas was built from (the 15 schema sizes,
  the schema's font scale, the effective emoji style and the schema
  generation), and preFrame() rebuilds once when they differ. While the
  schema generation and the emoji style are unchanged this costs two
  compares per frame.

Measured on the Lite build (color and mono emoji, font scale 1.0 and
1.5, English and Japanese): every run builds the atlas exactly once, at
the ui.toml sizes times the scale (H1 20 px and Body2 15 px at 1.0,
30 px and 22 px at 1.5), and ui.toml is loaded before the window and GL
context are created. The packaged Lite zip and AppImage log one atlas
build instead of two. Color-emoji screenshot sweeps match the previous
build within noise.

Visible changes:
- users with chat_emoji_color=false had been rendering the whole UI at
  the fallback font sizes; they now get the designed ui.toml sizes, so
  every mono-emoji screen differs from before and matches the
  color-emoji layout;
- a user skin whose [fonts] differ from ui.toml now rebuilds the atlas
  once when it is activated or left; on a runtime switch the atlas used
  to keep the old sizes until a restart. With a test skin that sets
  Body2 to 18 px, startup builds twice and ends at 18 px; switching to a
  bundled skin rebuilds once, switching among the 9 bundled skins
  rebuilds nothing (they carry an identical copy of [fonts]), and
  switching back rebuilds once;
- editing ui.toml while running still gives exactly one rebuild.

Not verifiable here: the Windows DX11 path with the schema loaded before
its first FreeType build, macOS, and the interactive font-scale slider,
Alt+scroll and emoji toggle, which were checked by reading the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 e0f9d36780 perf(typography): use font blobs in place and share identical faces
Typography copied every font blob into a fresh heap buffer for each face
it added (IM_ALLOC + memcpy, FontDataOwnedByAtlas = true): the Ubuntu
weight, the CJK subset merge, the emoji merge, and the icon and pickaxe
fonts. The blobs are INCBIN symbols in read-only memory that live for
the whole process, so the copies (16 faces, each with its own CJK copy
and, for 8 of them, an emoji copy) only cost memory. Styles with the
same weight and size also built separate, identical ImFonts.

Now:
- every font is added straight from the embedded data with
  FontDataOwnedByAtlas = false. ImGui 1.92 frees only data it owns, so
  the blobs survive atlas clears and DPI or skin reloads;
- load() walks a style table and shares the ImFont between styles with
  the same (weight, scaled size, emoji) key. Emoji is part of the key,
  so no style gains or loses the emoji fallback. With the current
  ui.toml sizes H3/H4, H6/ButtonLg and Body1/Overline share a face: 13
  ImFonts instead of 16 (12 text faces plus Mono), CJK merged 13 times
  instead of 16 and emoji 7 times instead of 8.

Measured on the Lite build, RssAnon 15 s after start, median of 3 runs:
- color emoji: 108.6 -> 78.8 MiB (-29.8 MiB);
- monochrome emoji: 102.8 -> 78.6 MiB (-24.2 MiB).

RssFile is also 2.4-5.1 MiB lower, because the blobs are no longer read
end to end for copying. Full screenshot sweeps (610 screens per run; color
and mono emoji at 1280x720 in English and Japanese, color at 2560x1440)
match the previous build within run-to-run noise. The only content
differences are live values such as the mining tab's RAM readout (233 MB
before, 201 MB after).

Also fixed: when AddFontFromMemoryTTF failed, ImGui had already freed
the owned copy and the caller freed it again, a latent double free;
those IM_FREE calls are gone. The emoji-merge failure log now prints the
blob size instead of passing the float font size to %u.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 1f162ec830 build(i18n): embed language JSON only on Windows, add lookup fallbacks
CMake turned every res/lang/*.json into an xxd header that i18n.cpp
compiled in as a fallback, on every platform. Only the Windows
single-file exe needs it: the Linux zip and AppImage and the macOS .app
always ship res/lang, and loadLanguage() reads the loose file first. On
Linux the embedded copy added about 1.5 MB to the binary, and every
translation edit recompiled i18n.cpp.

A new option, DRAGONX_EMBED_LANG, defaults to ON for WIN32 and OFF
elsewhere:
- ON without xxd is now a configure error (it used to warn, then fail to
  compile i18n.cpp on the missing headers);
- i18n.cpp compiles the embedded includes and the fallback only when ON;
- outside Windows, loadLanguage() also tries
  <exe>/../share/<app>/res/lang (the cmake --install layout and the
  full-node AppImage) and <exe>/../Resources/res/lang (the macOS .app
  binary run directly), so those layouts find the loose files whatever
  the working directory; before, only the embedded copy covered that.

Measured:
- full-node Linux binary: .data 1,699,696 -> 168,592 bytes; stripped
  19,983,136 -> 18,456,128 bytes (-1.53 MB);
- with the ImGui demo stubs, the Linux zips and AppImages are
  0.50-0.55 MB smaller, with identical file lists including all 8
  language files;
- a Lite build started from an unrelated working directory loads the
  ja.json next to the binary (2422 strings), and the packaged Lite zip
  and AppImage load ja.json with no new not-found or failed lines;
- Windows keeps the embedded copy: the 8 language headers are generated
  and the ja.json bytes are in the exe.

A new test loads "de" from the loose files and checks a translated
string; with bin/res/lang moved away exactly its two assertions fail.

Behaviour changes on Linux and macOS: a missing or corrupt res/lang now
falls back to English instead of the embedded copy, and a translation
edit needs only the copy_langs step instead of a recompile.

Not verifiable here: the macOS .app in a non-English language, and the
Windows single-file exe at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 04ad1462d5 build(win-release): drop the unused loose xmrig.exe from Windows zips
Both Windows zips shipped a loose xmrig.exe next to the wallet exe. The
exe already embeds xmrig (HAS_EMBEDDED_XMRIG), and getXmrigPath() uses,
and re-extracts when needed, the copy under %APPDATA%\<app>\dragonx; it
looks next to the exe only when that extraction fails.

build_release_win now copies it only when the generated header does not
define HAS_EMBEDDED_XMRIG, or there is no header.

Measured: each Windows zip loses exactly one entry, xmrig.exe; with the
ImGui demo stubs, the full-node zip went from 167,515,430 to 164,554,177
bytes.

The loose dragonxd, dragonx-cli, dragonx-tx, wallet-rebuild helper,
params and asmap stay in the full-node zip. The daemon copy under
%APPDATA% is written once and never refreshed, so without the loose copy
existing zip users would silently run their first-install daemon.
Dropping them needs a migration first.

Not verifiable here: mining from an unzipped Windows build without the
loose xmrig.exe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 abb63d9441 build(win-release): stop embedding Sapling params/asmap in the Lite exe
The Windows release embeds its resources into the exe through a
generated INCBIN header. The Lite build got the same param and asmap
entries as the full node, so ObsidianDragonLite.exe carried
sapling-spend.params, sapling-output.params and asmap.dat (about 53 MB)
that it never reads: the SDXL backend embeds its own copy of the params,
and Lite already skips extracting full-node-only resources.

build_release_win now writes the param and asmap INCBINs only for the
full node (should_bundle_full_node_assets). Lite gets null/zero stubs
for those three symbols and still embeds images, themes and xmrig. Two
fixes make that safe:
- getEmbeddedResource() stopped at the first table entry with null data,
  so with the params stubbed out the Lite lookup ended before xmrig:
  mining would report itself available but never find the embedded
  binary. It now walks to the end of the table and skips null payloads.
  This also fixes a full-node build without asmap, whose lookup ended at
  the null asmap entry and hid the daemon and xmrig entries.
- full and Lite builds share build/windows. When the full-node build
  cannot find the params, the generated header is now deleted, so a Lite
  header left behind by an earlier Lite build is not compiled into it
  (the node would then never start, because the startup job would take
  the embedded-extraction path and never copy the loose params).

Measured on release builds:
- ObsidianDragonLite.exe: 159,788,544 -> 106,120,192 bytes (-53.7 MB, of
  which about 0.28 MB is the ImGui demo stubs);
- the Lite header has no param or asmap INCBINs and the resource object
  holds only the xmrig payload; a sample of sapling-spend.params occurs
  once in the exe instead of twice (the remaining copy is the
  backend's);
- the full-node header is byte-identical to before.

Not verifiable here (Windows runtime): the Lite exe starting without
embedded params, and mining through the embedded xmrig. Lite installs
from older versions may still hold about 53 MB of extracted params and
asmap in %APPDATA%\ObsidianDragonLite\dragonx; nothing deletes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 70eacd90ad build(imgui): compile imgui_demo.cpp to its empty stubs
The ImGui demo window cannot be opened in the wallet (its show flag is
never set), but imgui_demo.cpp was compiled and linked in full.

It is now compiled with IMGUI_DISABLE_DEMO_WINDOWS, which reduces it to
empty stubs. ShowDemoWindow, ShowStyleEditor, ShowAboutWindow,
ShowUserGuide and ShowStyleSelector stay defined, and imgui.h declares
them unconditionally, so a per-file define is ODR-safe.
IMGUI_DISABLE_DEBUG_TOOLS is deliberately not set: it changes imgui.h
declarations and would drop ImGui's duplicate-ID highlight.

Measured:
- imgui_demo.cpp.o: 755,392 -> 1,704 bytes;
- stripped full-node Linux binary: 20,267,680 -> 19,983,136 bytes
  (-284,544);
- full-node Windows release exe: 282,112 bytes smaller;
- the "Dear ImGui Demo" string is gone and the stubs are still defined.

With Makefile generators the per-file define lands in the target's
flags.make, so each existing build tree recompiles the whole app once
(153 files, about 71 s at -j8 here). No runtime or visual change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 8b094595b1 build(cmake): exclude HushChatFixtureCheck from the default build
HushChatFixtureCheck is a dev-only CLI for the HushChat fixture files.
Nothing runs it (no ctest entry, not linked into the app or the tests),
yet every dev and test build compiled and linked it.

It is now EXCLUDE_FROM_ALL, with a usage note next to the target:

    cmake --build build/linux --target HushChatFixtureCheck

A dry run of `all` no longer mentions it (4 lines before) while
ObsidianDragonTests stays in `all`, and building the target explicitly
still produces a working binary (`--help` exits 0). The fixture template
and the chat sources are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 e65be1d24b build(cmake): add a dragonx_release target that builds only what ships
Release builds ran a plain `cmake --build .`, which builds `all`: on top
of the app and the wallet-rebuild helper, every --*-release run also
compiled HushChatFixtureCheck, ObsidianDragonTests where BUILD_TESTING
is on (Linux, macOS) and, in Lite backend trees, lite_smoke and
lite_send_smoke. None of them ship.

A new `dragonx_release` custom target depends on ObsidianDragon plus
dragonx-wallet-rebuild, copy_langs and copy_themes where those targets
exist, and the three release paths in build.sh build only that target.
The dev build (./build.sh) still builds `all`.

BUILD_TESTING is deliberately left alone: release and dev builds share
build/<platform>, and writing BUILD_TESTING=OFF into that cache would
leave ctest running a stale test binary.

Measured:
- a dry run of dragonx_release schedules none of the test, smoke-tool or
  fixture-CLI builds that `all` schedules, in a full-node, a
  Lite+backend and a mingw tree; the wallet-rebuild helper is still
  built;
- all four release builds (Linux and Windows, full node and Lite) log
  the dragonx_release target and no ObsidianDragonTests build, and the
  Linux zip and AppImage file lists are unchanged.

Consequences: a release run no longer refreshes the test binary or the
lite smoke tools in build/<platform>/bin, so run ./build.sh or
`cmake --build` before ctest, and build the Windows smoke tools with
`--target lite_smoke lite_send_smoke`. If a release step starts copying
another CMake-built file out of bin/, its target must be added to
dragonx_release.

Not verifiable here: macOS --mac-release (the .app should still contain
the app, dragonx-wallet-rebuild and Contents/Resources/res).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 25ceb2cfe1 build(build.sh): add DRAGONX_BUILD_ROOT/DRAGONX_RELEASE_ROOT overrides
The release functions always built in build/<platform> and wrote to
release/<platform>. On Linux and macOS build/<platform> is also the dev
build tree, so a release run reconfigures it (--linux-release turns a
Lite+backend dev tree into a full-node one), and building a baseline to
compare a change against meant overwriting the existing release/
artifacts.

build_release_linux, build_release_win and build_release_mac now take
their build and output roots from DRAGONX_BUILD_ROOT and
DRAGONX_RELEASE_ROOT, defaulting to build/ and release/ as before:

    DRAGONX_BUILD_ROOT=/tmp/rel-build DRAGONX_RELEASE_ROOT=/tmp/rel \
        ./build.sh --win-release

With both unset nothing changes. The dev build (./build.sh) and the lite
backend artifact dir (build/lite-backend/<platform>) do not read them.

A full release set (Linux and Windows, full node and Lite) built into
override roots left the release/ listings (file sizes and timestamps)
and the dev tree's CMakeCache.txt unchanged (same checksums before and
after).

Known gaps:
- the end-of-run summary still prints release/ as the output dir;
- configuring a Windows build still writes res/ObsidianDragon.manifest
  into the source tree, so the last configured variant's version ends up
  there;
- a fresh build root re-downloads the FetchContent dependencies and
  appimagetool, so it needs network access;
- the macOS paths changed the same way but were not run here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:06 -05:00
DanSandClaude Opus 5.5 22e3f104d6 fix(lite-backend): don't panic when a reorg lands during a send
After broadcasting, the send path looked up the spent notes' and
UTXOs' transactions with unwrap. A reorg while the transaction was
being proved could remove one of them, so the backend panicked and
poisoned the wallet lock although the transaction was already
broadcast. The lookups are now `if let` with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:06 -05:00
DanSandClaude Opus 5.5 7c0afd06a8 fix(lite-backend): record the mined height of mempool-first transactions
When the mempool monitor saw a transaction first, its wallet entry kept
the monitor's height (often 0) after the transaction was mined, because
scanning the block reused the entry without updating its block. A later
reorg between the two heights then left a phantom spend of our own
send and a change note with an empty witness list until a rescan.

Scanning a block now sets the entry's block to the block height. This
applies to every monitor-first transaction, including payments received
from others: their reported block height, the verified/spendable timing
that depends on it, and the wallet birthday now use the real mined
height instead of the monitor's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:06 -05:00
DanSandClaude Opus 5.5 24a90ae532 fix(lite-backend): keep witnesses current for notes in a pending send
Notes spent by a send still in the mempool stopped getting witness
updates. After a restart (pending flags are not saved), or a reorg
that un-mined the send, those notes were spendable again but with
stale witnesses, so the next send from them used an anchor that is not
on chain and failed until a rescan. Deterministic note selection made
this sticky: the stale note was picked every time it was the best fit.

Witnesses now advance for every note that is not spent on chain,
pending or not. Witness clearing still keys on the mined spend only,
so pending notes are never cleared. The wallet file format is
unchanged; an old build reading a file written while a send was
pending can now spend from it (verified offline).

The oracle reloads and reorgs inside the pending window and compares
every unspent note's witnesses byte for byte; reverting the fix fails
9 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:06 -05:00
DanSandClaude Opus 5.5 db8d5359a4 fix(lite-backend): don't panic on a reorg while a send is pending
invalidate_block unwrapped a note's `spent` field for notes that only
had `unconfirmed_spent` set, so any reorg while one of our sends was
still in the mempool panicked the backend (and poisoned the wallet
lock). The branch could only panic or do nothing; it is gone, and a
reorg now leaves `unconfirmed_spent` as it is: the send can still be
mined on the new branch, because the chain checks the anchor's root,
not its height.

The oracle no longer steers reorgs around pending sends: it now
reorgs above, across and below the sent tip, including popping the
anchor block, and the reproducer runs by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:06 -05:00
DanSandClaude Opus 5.5 c233d4d503 perf(lite-backend): fetch full transactions over one shared gRPC channel
At the end of a sync the wallet fetches the full transaction of each new
wallet tx, together with every other transaction in the blocks that
contain them, as decoys. Every txid got its own pool job that built a
fresh Tokio runtime (one thread per CPU), a new rustls config and a new
TCP + TLS + HTTP/2 connection for a single GetTransaction call, so a
restore or rescan opened one connection per fetched transaction. The
first error returned from the sync while the remaining jobs kept
fetching and scanning in the background, and a stalled connection, an
unparsable transaction or a panicking scan could hang the sync forever
with its lock held.

fulltx_fetch.rs now works through the same (txid, height)-sorted,
de-duplicated list on the sync's runtime, with as many workers as the
sync pool has threads (2-8, the same fan-out as before), over one gRPC
channel that is dialed lazily and re-dialed on retry. Parsing and
scanning stay on the sync pool, and the call returns once the whole
batch has finished.

Behaviour changes:
- a failure on a decoy no longer fails the sync; it fails only when one
  of the wallet's own txs could not be fetched and scanned;
- new error texts "Error reading transaction: ...", "full tx scan job
  panicked" and "fetched only X of Y wallet txs", all after the existing
  "Error fetching all txns for memos: " prefix;
- each tx gets up to 3 fetch attempts with a 120 s timeout each, where
  the old code could wait forever; a request that legitimately takes
  longer now fails;
- nothing keeps running in the background after the sync returns;
- all GetTransaction calls share one TLS/HTTP-2 connection (up to one
  dial per worker at the start, plus a re-dial per retry); the request
  list and its order are unchanged;
- if the network silently drops packets mid-batch, every attempt waits
  out its timeout until 16 consecutive failed txs stop the batch: up to
  about 12 minutes with 8 workers (48 minutes with 2) with the sync lock
  held, where the old code gave up at its first failed connection.

Which txs are requested, in what order, how often they are retried and
when the batch stops do not depend on which txs are the wallet's own;
only the final result does. A server that keeps failing one tx can see
that result through the sync's retry, but it could already tell, because
an own tx that failed is requested again on the next sync without its
decoys; nothing new leaks.

tests/fulltx_fetch.rs drives the engine with a fake fetcher: the old
request list, each item fetched and scanned once, bounded concurrency, a
request sequence independent of ownership, retries, timeouts, the
breaker, panicking and unparsable scans, and a saved wallet
byte-identical to the old serial fill. The gRPC fetcher itself is not
covered by these tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 ba08cab95a perf(lite-backend): share unchanged witness states between blocks
Each scanned block deep-copied the newest witness of every note with
history to start that block's entry, although a block without shielded
outputs changes no witness, and spent or pending notes never change
theirs. A note's 100-entry list was 100 separate copies, mostly
identical.

The list now holds Arc-shared witnesses. The per-block duplicate is an
Arc::clone, and Arc::make_mut copies the newest entry only for unspent,
non-pending notes and only in blocks with at least one shielded output.
Every entry is still written in full, so the file is unchanged, and a
spend takes its own copy of the witness. A loaded wallet is not
de-duplicated, so the saving builds up over the next 100 blocks.

No output change: the saved bytes are identical; notes use less memory.
With 1000 live notes x 100 witnesses (a 70 MB file), RssAnon after 100
blocks without outputs is 18 MiB instead of 189 MiB and such a block
scans in 1.0 ms instead of 2.1-2.7 ms; when every block has an output,
the live bytes stay about the same.

The witness tests, the 1000-seed oracle and a differential run against
the unmodified backend (every witness entry and the whole saved file
after every operation; only the cleared lists and the chosen spend notes
may differ) all pass, and the measured wallet saves to the same SHA-256
as with the old build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 6f1ae874fb feat(lite-backend): select spend notes deterministically, max 250 inputs
send_to_address walked the wallet's notes in HashMap order and took them
until the target was covered, cloning the witness and spending key of
each note it visited, zero-value notes included. So which notes were
spent, how many and which address got the change were random: a send
could prove zero-value notes (each spend is a Groth16 proof of about
0.5-2 s plus 384 bytes) and far more notes than needed, and a send
needing 251-489 notes ground through minutes of proving to build a
transaction whose flat fee is below DragonX's default minimum relay
rate.

Selection is now a pure function (note_selection.rs) over the value and
nullifier of the eligible non-zero notes:
- the smallest single note that covers the target; otherwise the fewest
  notes, largest first, with the last pick swapped for the smallest note
  that still covers. Ties break on the nullifier, so map order no longer
  matters;
- at most 250 spends, and fewer when the estimated size would pass
  190,000 bytes (the consensus limit is 200,000);
- the notes cover the whole target whenever they can; only when they
  cannot do the keyed UTXOs, which are still spent in full, reduce what
  the notes must cover;
- only the chosen notes get their witness and key cloned.

Behaviour changes:
- which notes are spent, and so which address receives the change, is
  deterministic: the smallest single covering note, otherwise the fewest
  notes, largest first;
- zero-value notes are never spent;
- new errors before any proving: "Too many inputs: ..." (more than 250
  spends needed; it states the maximum sendable amount in DRGX) and
  "Transaction too large: ..." (it suggests shielding transparent funds
  first). Neither contains "Insufficient", so the
  GUI does not retry them. Sends that need 251-489 notes, which succeed
  today after minutes of proving, are refused;
- UTXOs alone fund a send only when no eligible non-zero note exists, or
  when the size budget leaves no room for a shielded input; then, as for
  a wallet without notes before, the change goes to the first z-address
  and is listed as a receive. Otherwise the smallest non-zero note is
  still spent, so the change is recorded as change;
- the "Insufficient verified funds" text is unchanged, but its "have"
  amount no longer counts UTXOs whose key the wallet lacks;
- duplicate nullifiers (corrupt state only) are de-duplicated;
- new info log: "Selected N of M spendable notes".

A note left with a stale witness by the pre-existing pending-send bug is
now picked every time it is the best fit, rather than by chance.

Pure tests check that the selection never uses more notes than the old
walk (3000 random cases, shuffled), that the count is minimal (brute
force) and independent of order, and the cap, size budget and fallbacks;
wallet tests cover best fit, a chat-fee send, the cap refused before
proving and UTXO funding. In the 1000-seed oracle every send verifies
offline at anchor offsets 0 and 4 and the largest send dropped from 7
inputs to 2; a differential run against the unmodified backend has
identical anchors and byte-identical non-selection state apart from the
cleared lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 965f36cecb perf(lite-backend): batch a block's witness appends across threads
The block scan appended each shielded output's leaf to the witness of
every live (unspent, not pending) note as it went, one witness at a time
on the sync thread, while holding the wallet's txs write lock: live
notes x outputs appends per block, all on one core.

With 16 or more live notes, the scan now collects the block's leaves in
output order and appends them to those witnesses once, at the end of the
block, still under the same lock and before anything reads them. When
that is at least 128 appends, the witnesses are split into disjoint
chunks over min(8, cores, live notes / 8) scoped threads; otherwise the
appends run inline. Every witness receives the same leaves in the same
order, including a leaf whose epk does not parse; an output whose cmu
does not parse still adds nothing. The hashing work is the same, only
spread over cores; the speed-up has not been benchmarked yet.

No output change. Each qualifying block starts up to min(8, cores)
short-lived threads.

Tests check that the threaded helper matches the plain double loop byte
for byte for 0-64 witnesses, and that the deferred path handles bad-epk
and bad-cmu outputs. The 1000-seed oracle passes, as do the suite and
125 oracle seeds pinned to 2 cores, and a differential run against the
unmodified backend is byte-identical, apart from the lists cleared two
commits earlier, for wallets below and above 16 live notes and blocks of
0, 1 and 50 outputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 e7ebc568b0 perf(lite-backend): trim per-note witness history in place
The block scan replaced each note's witness list with the result of
split_off(), which allocates a new buffer on every call: every block,
each note with history paid an allocation and, once its list was full, a
copy of all 100 entries.

The duplicate of the newest witness is now pushed and, when the list
exceeds 100 entries, the oldest is drained in place; notes with no
history are skipped. The list keeps the same entries in the same order,
so nothing in the output changes.

The witness tests and the oracle pass, and a differential run against
the unmodified backend is byte-identical (every witness entry and the
whole saved file after every operation) apart from the lists the
previous commit clears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 7f99fbb9c6 perf(lite-backend): drop witness history of long-spent notes
Every note keeps a list of up to 100 witnesses, one per block, so that a
reorg can roll it back; a full list takes roughly 40-90 KB of the wallet
file. The block scan kept duplicating and trimming that list for every
note that had one, spent notes included, although a spent note never
reads its history again once its spend can no longer be rolled back. So
every note the wallet ever spent kept costing memory, per-block scan
time and file size.

Once the block window is full (101 blocks), a spent note whose spending
transaction's block is below the window's first block now gets an empty
witness list. This runs before each block's witness update and once when
a wallet is loaded. Such a spend can only be undone by a reorg that pops
the whole window, and that already empties every witness list, so reorgs
behave exactly as before.

Behaviour changes:
- notes whose spend lies below a full reorg window lose their witness
  history in memory, at load and in the next save; existing large
  wallets shrink on their first save (which, like every save, copies the
  previous file to wallet.dat.bak);
- the wallet format (v9) is unchanged; old builds and SilentDragonXLite
  read an empty list as an unspendable note, which a spent note already
  is;
- new info log at load: "Dropped witness history of N notes whose spend
  is deeper than 100 blocks".

The oracle now also accepts an empty list for a spent note that was
cleared before a later partial reorg.

New witness tests compare every block's lists with a model of the old
algorithm, including reorgs across the clear point and an old file with
full lists, which loads and re-saves to the new bytes. The 1000-seed
oracle run passes (12,207 reorgs, 993 sends verified offline), and a
differential run against the unmodified backend is byte-identical apart
from exactly the cleared lists; the old build reads the new files, and
17 sends it built from them verified offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 6de9b5cc90 perf(lite-backend): borrow embedded Sapling params instead of copying
Every LightClient copied the Sapling spend and output parameters, which
rust-embed already compiles into the binary, onto the heap: 51,551,256
bytes (49.2 MiB) of private memory per live client, whether or not it
ever sent anything.

LightClient::sapling_output and sapling_spend are now Cow<'static, [u8]>
and borrow the embedded image. set_sapling_params still stores an owned
copy, with its is-empty guard, hash checks and disk write unchanged. The
prover receives the same bytes.

Behaviour changes:
- about 49 MiB less anonymous memory per live LightClient; the wallet
  file and JSON are unchanged;
- the first send or shield pages the 51.5 MB of embedded params in from
  the binary, where client creation used to copy them (an estimated
  0.3-1 s from the AppImage);
- the public fields sapling_output and sapling_spend change type from
  Vec<u8> to Cow<'static, [u8]>: a Rust API change, with no C ABI or
  exported-symbol change.

tests/sapling_params.rs checks that a restored, a new and a reloaded
client all borrow the embedded image (same pointer, pinned lengths and
SHA-256), that set_sapling_params keeps its guard and hash checks, and
that the borrowed params parse like a heap copy. An ignored test
measures 576 KiB of RssAnon growth for a second client, against 46.5 MiB
with the old heap copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 bd4c06dfd0 perf(lite-backend): encode note addresses from pk_d instead of deriving
note_address() re-derived a note's payment address from its viewing key
and diversifier (a group hash, an ivk hash and a 252-bit scalar
multiplication) for every note in `notes` and `list` and for each new
note in the block scan. The z-balance sums did the same derivation in
their address filter, so `balance`, which sums four balances for each of
Z z-addresses, cost Z x (N + 2U) derivations for N notes, U of them
unspent. do_list_notes also encoded up to Z + Z^2 addresses to find the
spendable ones and cloned that set once per transaction.

Now:
- note_address() encodes the note's stored pk_d with its diversifier.
  Both decryption and wallet load build that pk_d from the same key and
  diversifier, so the string is byte-identical;
- the three z-balance sums share one address filter. The per-address
  sums behind `balance` compare (diversifier, pk_d) with each z-address
  directly; any other address string keeps the old exact string compare;
- those sums work from a copy of the z-addresses whose lock is released
  before any note is read, so the txs-then-zkeys lock order that sync
  uses is unchanged;
- do_list_notes builds the spendable-address set with Z encodes and
  borrows it.

No output changes: balances and the notes/list/balance JSON are
byte-identical. The address filter no longer contains an unwrap (it
could not fail).

Tests compare against verbatim copies of the old functions over
randomized chains (note addresses, also after a reload; every filtered
balance for own, diversified, foreign and junk address strings; the
per-address balances; the spendable set), and a concurrency test fails
with a deadlock if the zkeys lock is held across the sums. A
differential run against the unmodified backend compared per-address
balances and the JSON after every operation, through reorgs and reloads,
and the old build re-saves the new files byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 d28cc0da31 test(lite-backend): add a seeded chain oracle and offline spend verifier
No existing test checks that a send's anchor, nullifiers and proofs are
valid, or that note witnesses stay right through reorgs and reloads, and
that is what the following witness and note-selection changes touch.

oracle_tests.rs drives a LightWallet through its public entry points
over seeded random fake chains that start at real mainnet checkpoints:
receives to HD, diversified, imported, view-only and foreign keys,
zero-value and dust notes, outputs with an unparseable epk, compact
spends with change, real sends that are mined later or never (mempool
expiry), reorgs of 1-100 blocks in one call or block by block (re-mining
spends on the new branch), and save/reload.

An independent reference keeps its own commitment tree, every block's
root and every note's true witness at each height. After each step it
checks the tree and block window, every note's fields and spent state,
witness-list lengths, every retained witness of a live note byte for
byte (sampled ones also by root, position and auth path), the balances
and the mempool. Sends are captured in the broadcast closure, never
sent, and verified offline: the anchor must be the reference root at tip
minus the anchor offset, every nullifier must belong to an eligible
note, and the Sapling verifier must accept every spend and output proof
and the binding signature (the first send of a run also checks that it
rejects a wrong anchor and a wrong nullifier).

The default run (20 seeds x 300 blocks) is part of the normal suite; a
1000-seed x 400-block run and a 20-seed x 5000-block soak are #[ignore].
Every run prints its seed, and a single seed can be re-run.

Two pre-existing bugs are documented as #[ignore] reproducers and kept
out of the green runs:
- a reorg while a send is pending panics the backend (invalidate_block
  unwraps `spent` on a note that only has `unconfirmed_spent`);
- a pending send's notes stop receiving witness updates, so after a
  restart (the send never mined) or a reorg that undoes the block that
  mined it, they are spendable again with stale witnesses, and later
  sends from them fail until a rescan.

On the unchanged production code the default and full runs pass (1000
seeds: 12,194 reorgs up to 100 deep, 5,893 reloads, 993 proving sends
verified offline at anchor offsets 0 and 4) and the reproducers fail as
described.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 8895038c40 test(lite-backend): port the core-crate tests to the current API
The lite backend core's in-crate tests no longer compiled (119 errors in
lightwallet/tests.rs and the lightclient and walletzkey test modules):
they still used the old LightWallet::new and send_to_address signatures,
the removed extfvks/extsks/zaddress fields and the old new_from_phrase
and do_send arities. So `cargo test` built nothing and no backend change
could be checked against them.

Port them, and the commands and checkpoints tests, to the current API,
touching only #[cfg(test)] code:
- LightWallet::new takes 4 arguments, send_to_address 7 (with an
  explicit default fee, returning (txid, raw tx)), new_from_phrase 5 and
  do_send 2; keys are read through zkeys and tkeys;
- every config uses the "main" chain: creating a wallet on the "test"
  chain panics in base58_pubkey_address, so LightClient::unconnected
  (which hard-codes "test") is no longer used;
- every LightClient lives in a temporary data dir, so a test cannot
  write to the real ~/.silentdragonxlite or ~/.zcash-params;
- ten send_to_address recipient lists garbled by an upstream change in
  2020 are restored from the upstream history.

Expectations that contradicted deliberate current behaviour are updated;
none is ignored:
- a pending send's mempool entry sits at the tip, not tip+1, and
  zbalance no longer counts a note with a pending spend;
- keys cannot be added to an encrypted wallet, so the encrypted-receive
  tests derive their second address before encrypting;
- the t-address vectors are m/44'/141'/0'/0/{0,1}, checked against an
  independent BIP32 derivation; the z-address vectors record the current
  output;
- test_addresses and test_checkpoints follow the current address count
  and checkpoint tables.

All 43 tests pass on the unchanged production code (three runs) and
leave the real wallet and params directories untouched; that list is the
regression baseline for the following commits. They cover balances,
address derivation, bookkeeping, the mempool, encryption, serialization
and rollback, but none verifies a proof, an anchor or a witness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 3f13d79ccb perf(ui): memoize the node-status banner ellipsis
While the node-status banner shows, its detail line was fitted every
frame by popping one character at a time and re-measuring the whole
"·  " + detail + "…" string: O(L^2) per frame in the detail length L,
which adds up on the ~1.5 KB Windows crash report. The pop also left a
dangling UTF-8 lead byte before the "…", so a cut in non-ASCII text drew
replacement characters in place of the ellipsis.

- util::textfit::ellipsizeTail builds the same string in O(L + m log m),
  m being the length of the line the cut lands in: ImGui measures
  multi-line text as its widest line, so the old loop could only stop
  inside the first line that overflows on its own. Byte-identical for
  ASCII; the cut now lands on a code-point boundary.
- EllipsisFitCache keeps the result until the detail, budget, font size,
  font (by FontId) or rasterizer density changes, so an unchanged frame
  costs one string compare.
- When ImGui would draw a line wider than the per-line split predicts
  (a malformed byte joined with the following newline, as in CP-1252
  output from the Windows daemon), the old whole-string loop is used for
  that text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 8c0a22b7bc fix(ui): cut ellipsis truncation on UTF-8 code-point boundaries
TruncateToWidth, the middle "front...back" ellipsis for addresses, pool
URLs, paths, labels and the status bar, cut at byte offsets: a
multi-byte character at either cut was split into invalid UTF-8 and
drawn as replacement characters (e.g. a translated Cyrillic or CJK
status). It also tried front lengths from n/2 down until one fit,
building and measuring a new string for each: up to O(n^2) per
truncated label per frame.

util::textfit::middleEllipsis keeps the same contract and is
byte-identical for ASCII, but snaps the front cut down and the back cut
up to code-point boundaries, and finds the largest fitting front length
by bisection into one reused buffer: O(log n) measures for single-line
text. Text containing a newline keeps the linear scan, because its
measured width (the widest line) does not grow steadily with the cut.

text_fit.h also gains largestFittingPrefix, a bisection over code-point
boundaries; its first caller is the node-status banner in a following
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 e025a314d1 perf(ui): memoize typography specs on the schema generation
Every Typography text, textWrapped and textColored call rebuilt all 15
type specs to read one uppercase flag: 28 ui.toml element lookups, 13
of them copying the element with its string map. With M such labels on
screen, that was 28M lookups and 13M copies per frame.

The specs are now built once per UI-schema generation, which every
ui.toml load, hot reload and skin or theme switch bumps. DPI, font
scale and language are not inputs, so nothing else invalidates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 1ced49d622 perf(console): extend the filtered view incrementally
With any console filter on, each batch of new output rescanned all N
lines to rebuild the visible set. A text filter lowercase-copied the
needle and every line to test it: O(total characters) and 2N
allocations per batch.

When the filter is unchanged and the model only appended or evicted
lines since the last pass, the visible indices drop the evicted lines
and shift down, and only the new lines are tested. The result equals
a full rescan, since line text and channel never change and the
filtered view has no folds. A filter change, Clear, or a model window
that doesn't follow on from the last one still rescans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 ee0a486676 perf(console): wrap only new console lines via a per-line cache
Each batch of new output re-wrapped all N visible lines glyph by glyph,
allocating a fresh segment vector per line; a line evicted at the
10,000-line cap shifted every index, so nothing could be kept. And on
every frame, idle or not, seeding the scanline shading summed the wrap
rows of every line above the view.

- The model numbers its lines and the view keeps one wrap per line,
  aligned by that number, so an eviction drops a prefix instead of
  forcing a rebuild. New output wraps only the new lines; the layout is
  then a prefix sum over the N visible lines, with no measuring and no
  per-line allocation.
- The wraps are kept while the wrap width, line height, font size, font
  (by ImFont::FontId) and rasterizer density are exactly unchanged. A
  line-spacing change only re-sums heights; filters and fold toggles
  reuse the cached wraps. A resize drag still re-wraps every visible
  line per frame, as before, but reuses their storage.
- The scanline seed is read from the prefix sum: O(1) per frame.

Heights, offsets and segments are bit-identical to the old rebuild. A
font reload at an unchanged size (the colour-emoji toggle) now re-wraps
the console at once; before, it waited for the next line of output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 c2c2a8325b perf(overview): clip the address list to the visible rows
The Overview address list built all N rows on every frame (icons,
labels, balances, buttons, an InvisibleButton and a context-menu
check per address) and allocated a vector of N row positions, however
few rows fit on screen; the edge mask then walked all their vertices.

An ImGuiListClipper now submits only the rows that intersect the
list's clip rect, plus:
- one guard row on each side, so dividers and anti-aliased edges at
  the clip boundary are drawn as before;
- the row being dragged and the row whose context menu is open, so
  drag-to-reorder and the menu keep working when they scroll away;
- the rows keyboard navigation targets (kept by the clipper itself).

Before each row the cursor is put back exactly where the unclipped
loop left it, so the density-scaled spacing, which depends on the
cursor position, and every row's geometry are unchanged. Row tops are
computed from the index, so the per-frame vector is gone, and the
scroll extent is the same. The row model (sort, filter) is still
rebuilt over all addresses each frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 bb973a1007 perf(ui): skip the scroll-edge mask when no edge fades
ApplyScrollEdgeMask, which fades a scroll area's content at its top
and bottom edges, walked every vertex the area emitted on every frame
and tested both edges per vertex, even when the content fit without
scrolling and no vertex could ever be written. It now returns before
the walk when neither edge fades.

The fade maths moved unchanged into scroll_edge_fade.h and is tested
against the old inline expression, so rendering is identical. All six
callers benefit without an edit: the Overview address list, Mining's
recent blocks, Console, History, Explorer and Peers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 88fa13dd1a perf(mining): sample system RAM at most once a second
The Mining tab's memory bar read used system RAM and the wallet's own
memory on the UI thread every frame: synchronous OS queries, on Linux
an open and parse of /proc/meminfo and /proc/self/status per frame.
Both are now sampled at most once a second; the first frame after
more than a second idle resamples at once. Total RAM was already
cached and daemon RAM already comes from a background sampler.

The used-RAM and wallet-memory readings in the bar and its tooltip now
refresh at most once a second; what they show is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 888a41ddc2 perf(mining): memoize the earnings card on tx_revision
The Mining tab's earnings card walked all N wallet transactions on
every frame to total Today / Yesterday / All time and pick the 4 most
recent rows: four string compares per transaction plus a memo search
on receives. It also ran localtime and mktime every frame to find
local midnight.

The pass is now a pure function behind a one-slot cache keyed on
WalletState::tx_revision, the transaction count, the filter pill, the
Solo/Pool mode, solo support and local midnight, with a 2 s maximum
age as a safety net. It reruns only when one of those changes or the
2 s age runs out, instead of every frame. The totals are bit-identical
(same loop, same summation order), and crossing local midnight still
moves Today into Yesterday at once.

Local midnight is recomputed only when the clock second changes,
through the shared util::localTime; a failed localtime now yields a
zeroed struct instead of an uninitialised one. The "x ago" labels and
Est. Daily are still computed per frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 3069151e7f fix(chat): bound the selection copy range to the message body
The copy button of a text selection in a chat bubble copied
body.substr(start, len) with byte offsets kept from earlier frames.
Those offsets can outlive a thread change (an older message inserted
above shifts the message indices, so the selection can land on a
shorter body), and a start past the end made substr throw
std::out_of_range out of the chat render; the main loop catches it,
but the rest of that frame's UI is dropped.

The copy now runs only when the start is not past the end of the body.
substr already clamps the length, so the copied text is the same in
every case that did not throw before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 4a95d9ecb7 perf(chat): lay out and draw only the visible chat thread rows
Every frame the open chat thread handled all N messages: grouping flags
from two day keys each, a measuring layout of every body, then the
draw, which laid every body out a second time and ran the bubble, hover
and popup code, however few rows were on screen.

The thread now keeps a layout cache: grouping flags, each body's
wrapped size (keyed by message, so only new or re-wrapped bodies are
measured) and each row's height, with prefix offsets. A frame binary-
searches the visible range and submits those rows plus any failed send
(Retry stays reachable with Tab), the row being drag-selected and the
row whose context menu is open; one spacer stands in for each skipped
run, and scrolling to the newest message works as before.

- Rows are rebuilt when the chat store changes or when the wrap width,
  fonts, font size, DPI scale, rasterizer density or compact mode
  change. Fonts are keyed by FontId, so a font reload at the same size
  (the colour-emoji toggle) re-measures. A rebuild is O(N), about
  0.5-1 ms at 3000 messages.
- Grouping flags are recomputed on a data change and once a minute,
  together with util::refreshTimezone(), so a system timezone change
  reaches chat timestamps within a minute.

Rows above the visible range are now one spacer of their summed
heights, so the visible rows no longer move by a pixel each time a row
above them crosses the top of the app window. Debug builds log once if
a cached wrap width or row height ever disagrees with the live layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 a69380817e perf(chat): use reentrant local time for chat timestamps
The open chat thread called std::localtime at least twice per message
per frame (two day keys for grouping), and more for day separators and
time labels. On glibc every call re-checks the timezone with a stat of
/etc/localtime. The chat time helpers now use util::localTime() from
the new util/local_time.h: localtime_r (localtime_s on Windows), with
no per-call timezone check and no shared static result buffer.

glibc's localtime_r reads the timezone only once, so local_time.h also
adds util::refreshTimezone() (tzset) for caches to call when they
rebuild. Nothing calls it yet; its first caller is the chat thread's
grouping-flags cache in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 e45f4c7d90 perf(history): key History caches on tx_revision and memoize the filter
Every frame the History tab:

- hashed the txid, amount, confirmations, time, type and address of
  all N transactions (about 0.6-5 ms at 5k-20k) to decide whether its
  totals and its merged, sorted row list were stale;
- copied every chat txid into a fresh set;
- ran the type and search filter over all N rows, with up to three
  case-insensitive substring searches each.

The totals and the row list now key on tx_revision (the row list also
on the sort mode), the Message badge and the Chat filter use the chat
store's txid index, and the filtered rows are rebuilt only when the row
list, the type filter, the search text or, under the Chat filter, the
chat store changes. A frame is now a few compares plus one txid lookup
per row of the current page. An unchanged full-node refresh no longer
rebuilds the totals and the row list either: the old fingerprint folded
in the refresh time.

On lite, a change to only a memo or a sender address now shows at once.
The old fingerprint left those fields out and relied on a refresh time
that lite never sets, so such a row stayed stale until something else
in the list changed, such as the next block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 d3fa09128e perf(chat): look up chat txids through a revision-keyed index
The History tab copies every chat txid into a fresh set each frame to
badge and filter chat transactions: M string allocations for M chat
messages. ChatStore gains hasChatTxid(), an O(1) lookup in a txid index
that is rebuilt only after the store changed (append, delivery update,
erase, clear) and never built for an empty store. clear() also drops
the index, so no chat txids linger after a lock. The History tab moves
to it in the next commit.

The chat note-buffer pump, which runs every frame while chat is idle,
built the whole conversation-id list just to see whether the store was
empty; it now asks store().empty(), O(1) instead of O(M).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 860916d203 perf(history): add a transactions revision counter to WalletState
WalletState had no change signal for its transaction list, so a UI
cache had to fingerprint all N transactions every frame to notice an
update. It now carries tx_revision, a counter that only increases;
clear() on disconnect bumps it instead of resetting it, so a cache
built before a reconnect or wallet switch can never match again.

- Every writer of the list bumps it: the full-node refresh, loading the
  cached history, adding, completing and removing a pending send, a
  mining-address relabel, the lite refresh and the screenshot demo
  rows.
- The full-node refresh and the lite refresh (every ~2 s) swap in the
  new list only when it differs field by field, the amount compared
  bit-exactly because -0.0 and +0.0 print differently. An unchanged
  refresh keeps the list and the revision; the compare costs O(N) per
  refresh, not per frame.

Nothing reads the counter yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 4a2a1bdd54 perf(image-picker): decode thumbnails off the UI thread
The image picker decoded up to 6 full-size images per frame on the UI
thread to make its thumbnails (1.5-2.7 s per frame with 12 MP photos,
until the visible grid filled) and read every .gif/.webp a second time
just to see whether it animates. Thumbnails were freed only on navigate
or close, so a large folder and every hovered animation (up to ~20 MB
each) stayed resident.

Thumbnails now decode on util::StartImageDecode workers (at most 2 at
once, so the top of the visible grid fills first) and the UI thread
uploads up to 8 small textures per frame. The animation badge comes from
the same decode, with no second read. Hover previews get their own
2-slot lane, and the picker asks for a frame every 100 ms while anything
is pending; under idle pacing a deferred thumbnail used to wait for input
or the 2 s tick.

Behaviour changes:
- thumbnails and hover frames are LRU-capped at 64 MiB per folder;
  evicted ones re-decode when they are needed again;
- leaving an animated thumbnail before its preview finishes decoding
  cancels it (re-hover restarts it); a finished preview is kept;
- animated WebP files get a real thumbnail instead of the placeholder;
- images above the decoder's limits show the placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 3af7b0ef44 perf(contacts): decode avatars off the UI thread
The Contacts tab read, decoded and downscaled custom avatars inside the
draw call, two per frame, rows scrolled out of view included: a 12 MP
photo froze the window for 0.25-0.45 s, a long GIF for up to 1.5 s, and
the avatar cache was never evicted. "Add image" decoded the picked file
and copied it inside the picker's confirm callback, freezing it again.

Avatars now decode on util::StartImageDecode workers (at most 2 at once).
The UI thread only uploads finished frames, at most about 4 MB per UI
frame, and asks for a frame every 100 ms while anything is pending;
under idle pacing a deferred avatar used to wait for input or the 2 s
tick.

Behaviour changes:
- an avatar scrolled out of view is not decoded until it scrolls in
  (the badge shows for 0.1-0.3 s the first time);
- avatar textures are LRU-capped at 128 MiB; the least recently drawn
  go first, never one drawn on-screen in the previous Contacts frame,
  and they re-decode on scroll-back;
- an animated avatar starts playing once all its frames are uploaded;
- an import is validated and copied on a worker: the avatar appears
  0.1-0.5 s after confirming and Save/Add stays disabled until it does;
  a result only lands in the dialog session that started it;
- the copy goes to a temporary file renamed over the target (retried
  briefly), so a crash or a failed copy never leaves a truncated avatar
  or removes the existing library image;
- imports get the decoder's limits, and an animated WebP can now be
  imported (it always failed with "couldn't be loaded").

Applying an import also no longer destroys an avatar texture the same
frame has already drawn (a use-after-free on DX11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 c6fc1ac168 fix(images): cap and stream image decoding in a GL-free decoder
Image decoding lived in the GL texture file and had no limits: a file of
any size was read whole, stb and libwebp allocated whatever the header
claimed (a WebP alone up to 1 GB), and a GIF was decoded at full size,
every frame, before the 300-frame cap. A 720p 150-frame GIF avatar cost
0.2-1.5 s and 140-370 MB on the UI thread. stb's GIF loader also read
before the start of its buffer on disposal-3 frames, and when a long GIF
ran out of memory it freed the frame delays that the caller then freed
again (an abort).

Decoding, and the stb implementation, now live in the GL-free
util/image_decode_core, safe on any thread:

- the header is probed first; stills above 256 MP, any GIF or animated
  WebP canvas above 32 Mi px, and files above 256 MiB are rejected
  before anything is allocated (real photos, 200 MP phone shots and
  wide panoramas still load);
- a GIF decodes one composited frame at a time, downscaled as it goes,
  and stops at the frame cap: peak memory is stb's working buffers, two
  canvases and the small frames; a truncated GIF keeps the frames
  decoded so far;
- a header stb cannot read means "unsupported", never a blind decode;
- decodes above 16 MP run one at a time; a decode can be cancelled
  between frames.

util::StartImageDecode runs one decode on a detached worker that owns its
job and its lane's in-flight counter, so nothing waits at exit; the next
commits move avatars and the image picker onto it. The limits apply now
to avatars and the picker's hover previews; thumbnails and avatar imports
get them when they move to workers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 971f2abae5 perf(startup): prepare first-run node files in the background
On a first run or after an upgrade, Windows release builds wrote the
bundled Sapling params, asmap, node and miner (about 88 MB) in
App::init, before the first frame: 0.5-3 s of frozen blank window, 5-30 s
on a slow disk or under antivirus. Zip, AppImage and .app builds copied
the bundled files into the daemon dir and hashed the 51.5 MB of params on
the UI thread at the first node start, a 0.3-1.5 s freeze in the wizard
or the connect loop.

A stat-only check at startup decides whether anything is missing, stale
or unverified; a normal launch starts no thread and shows nothing.
Otherwise one background job extracts (Windows) or copies (Linux, macOS)
the files, then verifies the params once:

- The first frame paints at once. If the job takes longer than 0.4 s, a
  chip at the bottom shows "Preparing wallet files... N%", then
  "Verifying wallet files...".
- A node start on the UI thread while the job runs is deferred and
  replayed when it ends (never into a bootstrap download, a restart or a
  live connection); worker threads wait, and give up on quit. After an
  upgrade that ships a new miner, the node starts after the miner is
  written instead of before. Pool-miner starts and the miner version
  check wait too, so the old miner is never run or reported while it is
  being replaced.
- Each file goes to "<name>.partial", is flushed and renamed into place
  under one lock shared with the daemon start, the bundled-node
  reinstall and the miner extraction, and the job stops between 4 MB
  chunks at shutdown. An interrupted write used to leave a truncated
  binary that was trusted forever; a leftover .partial is now removed.

Other behaviour changes:

- Linux and macOS full-node builds copy the bundled node files (about
  77 MB) into the daemon dir at first launch, even if the wallet ends up
  on an external node; before, this happened at the first embedded start.
- Lite no longer writes the 53 MB of params and asmap it never reads.
- A disk-full or permission failure that leaves the node files unusable
  shows a warning; a miner blocked by antivirus alone does not.
- A corrupt or truncated param in a zip, AppImage or .app install is
  replaced from the bundle and verified again instead of trusted forever.
- Reinstalling the bundled node holds off the connect loop and a deferred
  start for its whole run (either could launch the old binary while the
  new one was written), and is refused while another restart is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 47180ff840 perf(startup): stat dragonxd instead of reading it for the update offer
On Windows every full-node launch read the whole installed dragonxd.exe
(about 16.7 MB) on the UI thread and byte-scanned it for a version
string, to decide whether to offer the bundled node, which only needs
whether the file exists and its size. getInstalledDaemonInfo() takes a
scanVersion flag, and the update offer now only stats the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 a700d2c7ee perf(wallets): walk added wallet folders off the UI thread
Every open of the Wallets dialog, Refresh, folder add or remove, and Open
on a stale row walked each user-added folder recursively on the UI thread
before the dialog drew: up to 40,000 entries per folder, roughly
50-150 ms warm on Linux and seconds on a cold disk, a network share or
Windows.

The datadir listing (a few top-level files) stays synchronous, so a
wallet just created shows at once. The recursive walk runs on a detached
worker with the same depth, hit and entry limits; its rows are merged at
the start of a later frame and only the new rows are probed.

- The dialog opens at once with the datadir rows. The external rows
  arrive when the walk ends, with "Scanning folders..." beside the
  scanned-folders caption, and the card grows once.
- Reopening within 60 s with the same folders reuses the last walk
  without touching the disk, so its rows and sizes can be up to a minute
  stale: a wallet copied into a folder in that minute needs Refresh.
  Refresh and Open on a stale row always walk again.
- While a walk or a wallet probe runs (up to 30 s) the dialog keeps
  frames coming, so rows and badges appear without moving the mouse.
- The 200-row cap now holds across all folders; each extra folder could
  add one row past it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 1658950386 fix(wallets): don't rebuild the wallet list inside its own row loop
Open on a wallet row whose file was moved or deleted after the list was
built rebuilt the list from inside the dialog's row loop. The loop still
held a reference to that row and walked a sort order sized for the old
list, so the click read freed memory and could index past the end. Open
now flags a rescan for the next frame; the vanished row drops out one
frame later, with no crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 bb84243114 fix(lite): keep a rejected import key out of the log and Settings
The backend's error for a key it doesn't recognize repeats the key
("'<key>' was not recognized as either a spending key or a viewing
key"). Import passed that text on as its error, so a mistyped private
key was written to the lite log (shown in the Console tab) and to the
Settings status line. A failed import now carries a fixed reason
(unrecognized format, wallet locked, key already exists, could not be
decoded, or rejected), which is all the log records. Settings shows
"Couldn't import the key — check it, unlock the wallet (if encrypted)
and try again." instead, translated in all eight languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 7c24400c83 fix(lite): ignore a click on the already-connected server
Clicking the Network-tab card of the server the wallet was already
pinned to and connected to tore the controller down and reopened the
wallet against the same server. That click is now a no-op. A wallet that
failed over to another server still reconnects, which retries the pinned
one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 55975901a2 perf(lite): move wallet ops and server switches off the UI thread
New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).

- The four ops run one at a time on a background lane and their results
  are applied on the main thread; Settings shows "Working…" meanwhile.
  Key and passphrase copies are wiped after the call, or when a queued
  op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
  first scan starts, so the save takes milliseconds and the scan runs on
  the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
  disabled while a scan runs ("Wait for blockchain to sync"); Redownload
  blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
  dropped, pending ones reported failed, its client shut down) and
  destroys it on a background thread. It no longer saves on the way out:
  the new client re-scans the blocks since the last save, as after a
  restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
  finish and save on the old client while the new one owns the wallet
  file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
  server reads once one fails, and then retries after 4, 8, then 15 s
  instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.

Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 b2a57a61a0 feat(util): add SerialJobLane
A FIFO of blocking jobs, run one at a time on a background thread that a
post starts when none is running and that exits once the queue is empty.
Each job returns a completion, and completions run only in pump(), on
the owner's thread. Unlike RPCWorker and AsyncTaskManager nothing ever
joins: abandon() (also run by the destructor) drops queued jobs and
undelivered completions and refuses later posts, and a job stuck in a
blocking call finishes on its own thread. It is meant for backend calls
that can wait out a long scan and may have to be given up. post()
reports a dropped job (abandoned lane, or no thread could be started),
and waitIdle() is bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 296c04386c perf(mining): sample daemon memory off the UI thread
The daemon's RSS was read on the UI thread: a scan of every /proc entry
on Linux (1-7 ms), a forked ps on macOS (up to ~30 ms). The mining poll
probed every 1-5 s on every tab, and the Mining tab's memory bar probed
again every 1.5 s.

util::BackgroundSampler now runs the probe on one lazily started thread
and caches the result. getDaemonMemoryUsageMB() returns the last sample
and asks for a new one at most every 3 s, and only while the Mining tab
memory bar or the Peers tab Memory cell is drawn: nothing on other tabs
or while minimized. EmbeddedDaemon::memoryProbe() hands the sampler a
pid snapshot (on Windows the value read from the owned process handle),
so nothing off-thread touches daemon state. The mining poll does no I/O
any more; it carries the last sample. Both shutdown paths stop the
sampler.

Fixed along the way: an unreadable /proc made the directory iterator
throw on the UI thread; the scan now just stops.

User-visible:
- the Peers tab Memory cell keeps updating during sync and shows an
  external daemon's memory (it showed the mining poll's value, which
  pauses during sync and covers only the embedded daemon);
- the Mining tab memory bar refreshes every ~3 s (was 1.5 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 2618744e59 fix(rescan): probe the node's block range off the UI thread
Finding the lowest block a node still has (Settings > Rescan, and the
automatic reconcile after a bootstrap) ran a binary search of about 42
synchronous getblockhash/getblock calls on the UI thread, each waiting
for the RPC connection behind any wallet scan (10-20 s on a big wallet)
and each with a 30 s timeout. The window froze for that long.

It now runs as one task on the wallet RPC worker, bounded to 90 s, and
the answer comes back on the UI thread; meanwhile the dialog shows
"Checking which blocks..." with Rescan disabled. An answer from an
earlier connection counts as "could not tell", and one for an earlier
opening of the dialog is dropped. The search probes block 1 first, so a
full-history node answers in one probe, and it answers "could not tell"
at once while a runtime rescan holds the lane.

Fixed along the way: any probe error (timeout, busy node) read as "block
missing", so a busy node could get a bogus "bootstrapped, rescan from
the tip"; and a non-string getblockhash reply never called back, leaving
the dialog on "Checking..." for good. Only the daemon's "can't read
block" error now counts as missing; anything else means "could not
tell".

User-visible:
- after a bootstrap, a probe that cannot tell is tried up to 6 times,
  30 s apart and only while the node is not busy, then a toast points
  to Settings > Rescan, instead of falling back to the -rescan
  restart, which fails on a bootstrapped node;
- the Settings Rescan dialog warns when the probe could not tell.

Two new strings, translated into all eight languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 ad0f60a747 perf(market): fetch price and market data on a dedicated HTTP lane
The CoinGecko price, tickers and market chart and the exchange candle
GETs ran on the wallet's RPC worker, the same FIFO lane as balance,
history and sends. A slow or blocked endpoint held that lane for up to
10-12 s per price or ticker fetch and 2 x 30 s per chart or candle job,
and a wallet scan in turn delayed the price. At quit a hung GET held
shutdown until the 15 s watchdog cut teardown short.

They now run on their own RPCWorker thread; results are applied on the
main thread and wake the idle loop. A new util::httpGet takes a cancel
flag (checked by libcurl's progress callback), sets CURLOPT_NOSIGNAL and
returns the HTTP status and error; httpGetString wraps it. Shutdown sets
the flag, so queued GETs return at once and one in flight aborts within
about a second. The price uses a plain in-flight flag instead of the
connection-scoped refresh job, so a disconnect no longer throws away a
good in-flight price and leaves it loading until the next tick.

User-visible:
- chart and candle GETs use a 5 s connect / 20 s total timeout (were
  15 s / 30 s);
- the price request goes through the shared GET: User-Agent
  ObsidianDragon/1.0 (was DragonX-Wallet/1.0) and a 16 MiB reply cap;
- market fetches on connect start alongside the wallet refresh instead
  of queuing behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 93b6871511 perf(history): write the history snapshot on change, off the UI thread
Every history refresh, address refresh, connect and lock-state check
rewrote the encrypted tx-history snapshot on the UI thread: a JSON
document of every row, its encryption, then INSERT OR REPLACE and a
prune, an estimated 40-120 ms on a 5k-transaction wallet, even when
nothing had changed.

Now a signature of what would be stored (about 1 ms on 5k rows)
decides whether to write. Confirmations count only by the thresholds
the UI reads (conflicted, pending, 1-9, 10-99, 100+); per-address scan
heights, exact confirmations and the tip drift every block and form a
separate part.

- Routine refreshes write a content change at most every 20 s (a
  deferred one is released from the update loop) and the per-block
  drift at most every 10 min. Nothing changed means no write.
- A mining-address relabel, a passphrase change, a rescan, a
  disconnect, a wallet lock and exit write whatever is not on disk yet,
  at once.
- The UI thread only copies the rows and the key into a job; a single
  background writer (newest job wins) builds, encrypts and stores it. A
  failed write is retried.
- Exit queues the final write so it overlaps the daemon stop, then
  waits up to 3 s for the writer; a disk that hangs past that keeps the
  previous snapshot.
- A loaded snapshot counts as stored, so the first identical refresh
  doesn't rewrite it. An address refresh re-keys the snapshot only when
  the address set changed.

After a hard kill the snapshot can now be up to ~20 s behind for rows
and 10 min for scan heights, exact confirmations and the tip (it was
one refresh); the first refresh replaces it either way. Lite builds,
which never read this cache, no longer write it on Refresh or a mining
relabel, and the screenshot capture mode's demo rows never reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 c0568eb930 refactor(history): make TransactionHistoryCache safe for a writer thread
The tx-history cache was only ever used from the UI thread and had no
locking. To let snapshots be written from a background thread:

- every SQL statement runs under one database mutex and the key fields
  under a second one, taken after it and never held across I/O or the
  key derivation;
- a write is a self-contained, move-only SnapshotJob holding the rows
  and a private copy of the key, which is wiped after the write or when
  a job never runs; the JSON build and encryption hold no lock;
- a job with an all-zero key is refused instead of overwriting a good
  snapshot with one nobody can read;
- the prune after a write also keeps the wallet unlocked now, so a late
  job for the previous wallet can't delete the salt of the one just
  switched to;
- keyId() gives a non-reversible tag of the installed key, so a
  passphrase change can be told from a re-unlock with the same one.

replace() stays a synchronous wrapper with the same result. A failed
INSERT is now logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 67260cc6f0 fix(mining): disable the thread stepper while a benchmark runs
The thread grid is inert during a benchmark, but the -/+ buttons and the
count field still took input: a click changed the shown and saved count
while the benchmark owned the miner's (before the previous commit it
also restarted xmrig at that count and spoiled the measurement). The
buttons and the field are now disabled until the benchmark ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 6c02e67c6d perf(mining): one save, at most one restart per thread-count gesture
Every thread-count change on the Mining tab saved settings.json and,
with a miner running, stopped and restarted it: xmrig re-inits its
RandomX dataset and reconnects to the pool, seconds of lost hashing
each time. A drag across the grid did this once per tile crossed (up to
~24), with a "Starting pool miner" toast each; every -/+ click or Enter
did it again.

The tab now hands each pick to the App, which puts the newest in
settings at once and applies it when the input settles: on drag
release, or 0.5 s after the last click, even if the Mining tab was left
in the meantime. The header, RAM estimate and tile fill still follow
the drag live.

- Pool: the restart is decided on the mining-control thread, after every
  earlier start or stop has run. It never starts a miner the user
  stopped, is a no-op when xmrig already runs at that count, and is no
  longer silently skipped when the pick lands in another restart's stop
  window (which left xmrig on the old count).
- Solo: one setgenerate per gesture. A pick made while one is in flight
  now waits for it instead of being dropped (another stale count).
- Clicking the tile that is already selected does nothing; it used to
  save settings and restart the miner.
- With idle thread scaling on, the grid no longer snaps back to the old
  count while a pick is on its way to the miner. A pool auto-balance
  restart keeps a count still queued.
- Idle thread scaling changes the pool count through the same retarget.
  Its correction after a start compares with the count last requested,
  not the xmrig API's lagging report, so it comes about 1 s in instead
  of after the first API report (3-5 s).
- A retarget whose pool config no longer validates shows the same error
  and stops the miner, as the old stop-then-start did.
- While a benchmark runs a pick is saved, but the miner change is
  dropped: the benchmark owns the count until it ends.

A crash within 0.5 s of a pick, or mid-drag, loses that pick; every
clean exit saves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 c9cb50f4dd fix(mining): make XmrigManager's thread count atomic
The requested thread count is written by generateConfig() on the
mining-control thread and read on the UI thread (the thread-grid sync
under idle thread scaling, the pool auto-balance restart) without any
synchronisation. The write also stored the unclamped count first and
clamped it afterwards, so a reader could see more threads than cores.
It is now an atomic, set by one store of the clamped value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 7649b1f8a3 perf(settings): write settings.json and wallets.json off the UI thread
Settings::save() and WalletIndex::save() ran the atomic write (temp
file, fsync, rename, directory fsync) on the UI thread: on every
preference change, and for wallets.json after every address-scan pass
whose wallet metadata changed, i.e. repeatedly during sync, when the
disk is busiest. Each took milliseconds to tens of milliseconds, and
seconds under heavy disk load.

The JSON is still built on the caller; the write is queued on an
App-owned CoalescingWriter, latest wins per file, one write at a time.

- After a fund- or security-critical setter (seed-migration stage,
  encryption pending, PIN enabled, active wallet file, wizard completed)
  the next save still waits until the file is on disk, so e.g. the
  sweep-submitted marker is durable before the sweep is broadcast. A
  failed durable save keeps the next one durable.
- Saves before the writer is attached (the upgrade re-save at startup),
  and in tests and tools, stay synchronous.
- main() flushes the queue (at most 3 s) before and after
  App::shutdown(), before the single-instance lock is released, so a
  relaunch never reads a stale settings.json.

A kill or power loss right after an ordinary save can now lose that
last preference change; the previous file stays intact. WalletIndex
saves report "queued" rather than "written" (no caller reads it). On a
wedged disk, exit can wait up to 2 x 3 s longer, inside the 15 s
watchdog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 72054fe17f perf(settings): save opacity, chat and contacts sliders on release
Six sliders (noise opacity, UI opacity, window opacity, chat font size,
chat poll rate, contacts list scale) rewrote settings.json, with its two
fsyncs, on every frame the value changed: 60-300 durable writes per
drag, at 1-4 ms each on NVMe, 3-20 ms on a Windows SSD, 20-60 ms on a
HDD and seconds while dragonxd is busy on the disk.

The value still applies live and is kept in Settings for the per-frame
readers; settings.json is written once, when the slider is released, as
the blur and font-scale sliders already did. A drag cut off before its
release (a dialog closed mid-drag) is persisted by the next save or at
exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 8f8dc4c922 perf(platform): launch URL and folder openers without waiting for them
On Linux and macOS openUrl/openFolder spawned xdg-open/open and waited
for it on the UI thread: 50-300 ms per click where the opener forks and
exits, and on generic xdg-open sessions (sway, i3, WSLg, ...), which run
the browser or file manager as xdg-open's foreground child, the window
froze until the user closed that app. The opener is now left running
and a detached thread reaps it; the click returns at once.

Its stdin, stdout and stderr go to /dev/null again. That redirect was
lost in the switch to posix_spawnp, so a text-mode browser fallback
could take over the wallet's terminal.

The result now means "launched" rather than "exited 0" (no caller reads
it), and a non-zero opener exit is reported on stderr instead of in the
debug log. Windows (ShellExecute, which never waited) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 0e2f853025 fix(platform): keep the single-instance lock fd out of child processes
On Linux and macOS the lock file was opened without O_CLOEXEC, so
xdg-open and the browser it starts, dragonxd and xmrig all inherited it.
A clean exit unlocks explicitly, but after a crash or kill the lock
stayed held for as long as any of those children ran (indefinitely with
"keep daemon running"), and the wallet refused to relaunch. The fd is
now opened O_CLOEXEC; nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 d5202f76ca fix(platform): write atomic files through a non-inheritable handle
On Windows writeFileAtomically wrote the temp file through std::ofstream,
whose CRT handle is inheritable. xmrig and the hidden helper commands
(where, nvidia-smi, xmrig --version) are started with handle inheritance
on, so one started during a save kept "<file>.tmp" open without delete
sharing, and every later replace of that file failed for as long as the
child ran: hours while mining, with settings saves quietly not landing.
The flush then reopened the file with share mode 0, so it was silently
skipped whenever AV or the search indexer had the file open, and a
replace refused by them just failed.

- Windows: one CreateFileA handle, not inheritable, writes and flushes
  the temp file; the replace is retried up to 4 times (20+40+80+160 ms)
  on access-denied, sharing or lock violations, where it used to fail.
- Linux/macOS: the temp file and directory fds are opened O_CLOEXEC, so
  a dragonxd or xmrig fork+exec doesn't inherit them.

A later commit moves settings writes to a background thread, which makes
every one of them concurrent with those spawns. The vault, the exports
and DRAGONX.conf use the same function and get the same fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 ed067f4a91 refactor(app): one idle-loop wake predicate for background results
hasPendingRPCResults() becomes hasPendingBackgroundResults(): the one
check the idle loop makes, every <= 200 ms, for work that finished on a
background thread and still has to be applied by update(). Lanes added
later put their term there. It still covers only the two RPC worker
lanes; no behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 3f1194a84b feat(util): add CoalescingWriter for background file/snapshot writes
One background thread for blocking write jobs, for the settings.json /
wallets.json saves and the tx-history snapshot that later commits move
off the UI thread. Jobs are keyed (a file path, "tx-history"):

- a submit for a key that is still queued replaces that job and keeps
  its place in line (latest wins);
- keys run first in, first out, one job at a time, so two writes of one
  file never overlap and an older snapshot never lands after a newer one;
- submitAndWait() blocks until that job (or a newer one for the key) has
  run and returns its result, for writes the caller needs on disk;
- flush() waits with a bound; stop() runs everything queued, then joins;
- the thread starts on first use and sleeps when idle; with no thread
  (none could be created, or after stop()) a job runs inline.

persistFileAtomically() routes Platform::writeFileAtomically through a
writer, or writes synchronously when there is none. Nothing uses it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 cee8b9d7f6 feat(merge): run per-address consolidation as a paced background job
"Keep in each address" posted one task that called z_mergetoaddress for
every batch of every address back to back; each call is a whole-wallet
scan under cs_main, and the daemon runs the ops one at a time anyway.
It is now an App-owned services::ConsolidationRun:

- one RPC at a time; the next merge only after the previous batch left the
  queue and a gap as long as the last call took (2-120 s);
- pauses while the node syncs, the wallet is locked, a send of ours is in
  progress, or another wallet scan is running;
- failures classified: nothing that may have queued is ever resent; a
  breaker stops after 3 dead addresses or 3 failed ops in a row;
- each merge is charged to the scan budget; one re-read and one forced
  balance at the end;
- it keeps running when the dialog closes: a status-bar chip, a run view
  with progress, the wait reason and a Stop button.

The "Into one" merge can no longer be confirmed twice while its call is
in flight. fake_dragonxd gains FAKE_SCAN_SECS, FAKE_MERGE_SERIAL and
FAKE_MERGE_FAIL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:06:34 -05:00
DanSandClaude Opus 5.5 b29a09eaa0 perf(chat): sends re-read by origin, chat scans under the scan budget
A z_sendmany submit set off a burst of wallet scans before anything had
changed. Ops now re-read the wallet when they finish, by who started them:
the user's sends and dialog ops get a re-read plus one forced balance once
none of their ops is pending; client auto-shield re-reads unforced; chat
messages and splits refresh only the chat note view. A pending send no
longer forces scans, and chat txids no longer enter the tracked-send set.

Chat ingest skips stored messages before decrypting and caches
undecryptable ones per identity. The inbox scan runs when getwalletinfo's
txcount changed (a cheap probe, at most 2% duty) or a cost-scaled safety
net is due; the note-buffer scan runs on need. Both are admitted by the
scan budget, on a steady clock. The chat payer comes from a per-address
view of verified notes, with debits re-applied across in-flight scans;
splits are sized to one payer and only when they can refill the buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:06:34 -05:00
DanSandClaude Opus 5.5 f86cc4d6b7 perf(net): hold wallet scans at connect, in the background and on tab entry
- Connect gate: after the node starts answering, wallet scans wait until the
  lock state is known and the node knows its tip (or the wallet has fewer
  than 500 transactions), at most 20 s. A node already behind latches the
  sync profile from the connect's getinfo. Meanwhile a list-only pass keeps
  the address list and the cached history up, and the session's first
  balance and per-address balances are read once even while catching up,
  so the wallet shows its last-synced values instead of 0.
- Background: minimized, hidden, or covered without focus (3 s debounce,
  any input cancels) polls only the chain state every 15 s. Wallet scans,
  the mining poll, price, nudges and auto-lock interaction tracking pause;
  auto-lock now fires while minimized. Leaving a held profile marks every
  timer due again.
- Tab entry marks the timers due instead of forcing scans; a stale list or
  a never-loaded history still loads on entry.
- The scan entry points own the hard gates, failed enumerations keep the
  previous lists behind a retry hold, and failed balance calls never
  overwrite known balances with 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:06:34 -05:00
DanSandClaude Opus 5.5 323143e111 perf(net): one scan budget for all wallet scans, gated on what changed
The balance, address scan, history and probe were throttled one by one at
10% each, which added up to 30-40% of dragonxd's cs_main with all of them
due. services::WalletScanGovernor runs one wallet scan at a time across
both RPC lanes and keeps one shared quiet clock at 15% of wall-clock (cut
when the node is behind). Waiters are served first in, first out, with a
head start for what the page shows and a starvation floor of max(30 s
visible / 900 s hidden, 15x the stream's cost). Explicit work (a dirty
set, the user's send, a first load) is never refused but is charged.

Routine scans run only when the tip, the mempool size (getmempoolinfo:
no cs_main) or a wallet-mutation epoch changed since their last good scan,
or a safety sweep is due. Pages that don't show the balance poll it at
most every max(10 min, 50x its cost) (2 min with client-side auto-shield);
coming back into view with an old value fetches it at once. The staleness
badge counts from the last poll that confirmed nothing changed, and hides
while the budget holds the balance within its guarantee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:06:34 -05:00
DanSandClaude Opus 5.5 879de37d18 perf(history): scan shielded history in bounded passes, not every block
Every block re-ran z_listreceivedbyaddress for every z-address (O(wallet)
each, under cs_main) and a whole-wallet z_listsentbyaddress. On a 40-address
wallet that was ~80% of wall-clock. The history now advances in passes:

- an address is rescanned when it is stale by a number of blocks scaled to
  its measured cost (8 to 500), never-scanned and hinted addresses first
  (a hint: z_listunspent shows a note the history doesn't);
- z_listsentbyaddress covers only the blocks since the last good sweep
  (+100 reorg margin); the whole-wallet sweep runs once per connect, with
  a 10 min / 1 h / 6 h backoff after real failures;
- listtransactions pages are sized from last time, and an unchanged tip
  reuses the transparent rows;
- found-but-unenriched txids carry over as a backlog; only -5 is a
  definitive "not ours" for z_viewtransaction;
- between blocks, a cheap recent poll plus a one-address probe.

Duty model for a 40-address wallet at 10 s per scan: ~9% instead of ~80%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:06:34 -05:00
DanSandClaude Opus 5.5 8b9a498eee perf(net): one balance scan per poll, filtered opstatus, a guarded merge scope
The Core poll ran z_gettotalbalance twice (minconf 0 and 1), each a whole-
wallet scan under dragonxd's cs_main. The spendable (minconf=1) call now
runs only after the display call succeeded, and only when something is
unconfirmed or pending or the exact total moved: about 95% of polls make
one scan instead of two. A forced post-send balance can no longer be lost
to a refused enqueue, and pending-send debits aren't re-applied each tick.

z_getoperationstatus is filtered to our opids with its real argument form
([[id,...]]; the old ["id"] was rejected), falling back to the unfiltered
call if a daemon refuses the filter. The Shield/Merge dialog's own polls
are filtered and session-guarded too, and finally reach Done; the dead
RPCClient wrappers are deleted.

The Merge/Consolidate scope scan becomes a tested state machine: one scan
at a time, failures latch behind a Retry button, only "node not ready"
retries by itself (10/30/60 s, at most 3), Lite never scans. Its 120 s
calls are maintenance calls: every main-thread disconnect aborts one in
flight first, so a lifecycle action no longer freezes the UI behind it.
An aborted merge that was already sent says it may still go through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:06:34 -05:00
DanSandClaude Opus 5.5 4eed4283d6 fix(chat): provision the chat identity after an encrypted wallet unlocks
Lite opens an encrypted wallet locked, but the model saying so arrives
a moment later. The first seed export therefore failed with "locked"
and disabled chat for good, so encrypted Lite wallets never got chat.

- While the wallet is locked, a failure recorded before the lock was
  seen is cleared, so provisioning runs again after the unlock (like
  lockLiteWallet()). Nothing is retried while locked: that branch
  returns before any RPC or backend call.
- Lite waits for the first model's lock state before exporting the
  seed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:10 -05:00
DanSandClaude Opus 5.5 7fa7c3d6da fix(lite): drop the 15 s startup overlay and a TLS connection per refresh
- The refresh-model mapper marks the encryption state known when the
  backend reports it. The wallet tabs wait for that, so every Lite
  launch sat behind the loading overlay until the 15 s "assume
  unencrypted" fallback.
- Each 2 s refresh skips the backend's `info` (a fresh TLS connection
  and GetLightdInfo, only a fallback for the chat tip, which the
  explicit height call already provides) and the gateway's `height`
  (replaced by that explicit call, which runs after the sync commands
  so it sees the newest block).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:10 -05:00
DanSandClaude Opus 5.5 2cf183ddd9 perf(mining): run the mine-when-idle check at 1 Hz, probe the GPU sparingly
checkIdleMining ran every frame: an X11 idle query and, with GPU-aware
idle detection, an nvidia-smi launch on the UI thread per frame.

- The check runs once a second, since its decisions work on a seconds
  scale (the idle delay is tens of seconds or more). A miner it started
  or scaled up still yields on the first frame that sees the user back:
  that path skips the gate, because frames can be seconds apart while
  the app idles.
- The GPU is probed only when input is idle and the result can change
  a decision, at most every 5 s, with hysteresis (busy at 10%+, clear
  below 5%) so a GPU hovering near 10% can't keep restarting the miner.
- Thread scaling returns early when no miner is running or a toggle is
  in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:10 -05:00
DanSandClaude Opus 5.5 7ceff1eaaa fix(market): back off failed market fetches instead of retrying every frame
The Market tab calls the exchange-list, price-history and candle
fetches every frame, so a failed fetch (offline, rate-limited, a
venue's API down) was retried on the very next frame, ~20 requests a
second.

- util::FetchBackoff: 30 s doubling to a 10 min cap, +/-20% jitter.
  The exchange list, the price history (a partial result counts as a
  failure) and the per-pair candles each back off on failure.
- Candle backoff is per pair. Switching to another pair still loads at
  once, a failed refresh keeps the on-screen pair's last candles, and
  switching back to a pair that is backing off shows the aggregate
  chart rather than the previous pair's candles.
- The Market tab's refresh button retries at once (at most every 5 s).
  A price fetch (60 s timer, no backoff) that succeeds after failing
  means the network is back, so it lifts all three backoffs.
- A partial price history no longer marks the chart loaded: with no
  fetch timestamp, the 30-minute reload gate measured system uptime
  and blocked retries for the first 30 minutes after boot.
- The exchange list goes through httpGetString (TLS verified, size
  capped) with short timeouts (5 s connect, 12 s total, as it runs on
  the shared RPC worker) instead of its own curl code. A failed fetch
  is retried; it used to be tried only once per session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:10 -05:00
DanSandClaude Opus 5.5 16e0ee9947 perf: stop per-frame SQLite writes and console spam, make perf.log opt-in
- Explorer: the tip check runs every frame the Explorer tab is open,
  and it read both tip rows and rewrote them each time (two autocommit
  WAL writes per frame). A steady tip is now kept in memory and skips
  SQLite. A new tip writes both rows in one savepoint, so a crash
  between them can't pair a new height with the old hash. A test
  checks that 100 steady checks write nothing.
- Console: while the daemon was in the Error state the Console tab
  added its error line on every poll, i.e. every frame, re-laying out
  the whole buffer each time. It prints once per error now: on entering
  Error, when the message changes, or after another crash
  (EmbeddedDaemon::getUnexpectedExitCount(), never reset). setState
  writes the message before the state, so a reader that sees Error sees
  that error's message. The stray " ===" suffix is gone.
- perf.log: the profiler appended a block every 300 frames on every
  run and never rotated the file (tens of MB a day for a wallet left
  open). It is now off unless the app starts with DRAGONX_PERFLOG=1;
  otherwise its zone hooks are near no-ops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:09 -05:00
DanSandClaude Opus 5.5 2d29a04981 perf(ui): stop forcing full wallet re-scans on restore and peers refresh
Restoring or un-maximizing the window called refreshNow(), which wipes
the history-scan progress and re-scans every z-address (a
z_listreceivedbyaddress per address, each holding cs_main). The Peers
tab's refresh button did the same, and holding F5 auto-repeated it.

- Window restore no longer forces a refresh; the regular timers catch
  up (skipping the daemon-output backlog stays).
- The Peers refresh button refreshes peers only.
- F5 ignores key repeat. An explicit F5 or History refresh still does
  the full re-scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:09 -05:00
DanSandClaude Opus 5.5 43fc0090d4 fix(wallet): retire tracked sends that only paid the wallet itself
A send stays tracked until z_viewtransaction shows an outgoing output.
Shields, merges/consolidations and chat note splits pay only the wallet
itself, so they never showed one. They stayed tracked for the whole
session and forced a full history refresh past the scan throttle on
every tab.

- A tracked send whose view has no outgoing output is retired once it
  is mined (1+ confirmations) or conflicted.
- Tracked sends are enriched first, and reaching the per-cycle view cap
  no longer ends the loop, so the cap can't starve them.
- Backstop: a tracked send still unsettled 20 blocks after it was added
  (expired, dropped from the mempool) stops being tracked.
- Tracked sends are cleared on disconnect: they belong to that
  connection's wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:09 -05:00
DanSandClaude Opus 5.5 1cb03332e7 fix(net): stop re-running the connect sequence while the wallet is locked
Connected-but-locked fell through to tryConnect() on every Core tick
while the lock screen sat idle: new sockets, getinfo and getwalletinfo,
a forced address scan, a CoinGecko fetch and a wallet-index rewrite.
Its wallet scans also held cs_main ahead of the user's
walletpassphrase.

- While locked, poll chain state only (refreshCoreData without the
  balance). That keeps the sync height current and the 3-strike
  lost-connection check armed, so a node that exits while locked is
  still restarted. Pool mining keeps the lock screen down, so while it
  runs the throttled balance, the peers and the lock state (for an
  unlock done in the Console tab) keep refreshing too.
- Warmup polls count refused/reset connections toward the
  lost-connection check, so a node that died during warmup is
  restarted instead of leaving the loading or lock screen over it.
- The wallet salvage/degraded detectors run at connect and again when
  warmup ends: for encrypted wallets they relied on the repeated
  tryConnect.
- restartDaemonAfterEncryption records the wallet as encrypted and
  locked itself (the wizard's deferred path never did), and marks the
  main client disconnected so the reconnect path picks up the restarted
  node, aborting any in-flight call first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:09 -05:00
DanSandClaude Opus 5.5 575abd4bcc perf(ui): render frames on demand instead of continuously
While theme effects were on (the default) the main loop drew every
frame it could. Where vsync is off (offscreen, some drivers, remote
sessions) an idle wallet spun at ~3,000 fps, 88% of a core; with vsync
it still drew every refresh. Frames are now paced by what needs them:

- full rate while there is input, a held key, a window appearing or
  settling, a smooth scroll or the UI sweep;
- ambient (30 fps focused, 10 fps unfocused) for theme effects, the
  acrylic backdrop, avatar animations and notifications;
- progress (10 fps focused, 4 fps unfocused) for UI that changes
  without input. The updater and bootstrap downloads, console
  commands, lite-wallet operations, seed migration, the loading
  overlay and the lock-screen countdowns ask for it with
  material::RequestProgressFrame();
- otherwise idle, as before: wait for events, with a housekeeping wake
  every 2 s (5 s unfocused).

A software frame cap (0.9x the display refresh) bounds the loop where
vsync is off, and hidden, occluded or minimized windows get no ambient
frames. Reduce Motion freezes the theme-effect clock
(ThemeEffects::setMotionFrozen), so it needs no ambient frames either.

The displayed-balance roll goes through one clamped StepDisplayLerp
(balance_lerp.h). An idle-length frame pushed dt*speed past 1, so the
roll overshot, and under Reduce Motion's speed of 999 it diverged to
inf/NaN. The classic layout had its own copy of the old code.

Idle CPU, Lite build, offscreen (no vsync): 88% -> 5% of a core with
effects on, and 91% -> 3% with effects saved off (with the previous
commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:09 -05:00
DanSandClaude Opus 5.5 fffab228d1 fix(ui): apply the saved appearance settings at startup
Low-spec mode, theme effects, blur, UI and noise opacity and the
console scanline reached the live effects state only once the Settings
page or the first-run wizard was opened. "Effects off" and low-spec
therefore reverted at every launch, and the animated defaults kept the
main loop busy (91% of a core on an idle Lite wallet whose effects were
saved off).

- App::applyVisualSettings(): one low-spec-aware path from the saved
  prefs to the effects state, used at startup (after the skin, since
  setActiveSkin reloads the effect config), by the low-spec hotkey, the
  wizard and the Settings page.
- Low-spec stays a runtime-only override. The Settings page shows the
  overrides but persists the real prefs from its snapshot, and opening
  it under low-spec no longer turns theme effects and blur back on.
  Unticking a low-spec in the wizard that was loaded rather than ticked
  there now restores the saved prefs.
- Theme effects on/off always pairs with reduced transparency (the
  rainbow, shimmer and glare layers), as at startup and in the hotkeys.
- kAcrylicMaxBlur moves to imgui_acrylic.h, so startup clamps blur
  values saved in the old 0-4 range the way the slider does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:52:09 -05:00
DanSandClaude Opus 5.5 402a3053dc fix(app): advance logic timers by wall-clock time
Logic timers advanced by io.DeltaTime, which update() clamps to 0.25 s
so animations don't jump after a pause. That matched real time only
while the main loop rendered continuously: with frames seconds apart, a
timer fed the clamped delta runs up to 8x slow. Affected: the refresh
scheduler, the seed-migration poll, the chat fast scan, the encryption
check, the loading and shutdown timers, the lock-screen countdowns, the
Settings debug gate and the mining benchmark.

- App::logicDeltaTime(): steady-clock seconds since the previous
  update(), capped at 6 s so a sleep/resume fires each timer once
  instead of replaying the gap. Visual interpolation keeps the clamped
  io.DeltaTime.
- The refresh timers tick before the RPC results are drained, so a
  hold that a callback arms (the connect-retry backoff) starts fresh
  instead of being charged the gap before it existed.
- The shutdown timer is measured from shutdown_start_time_:
  beginShutdown runs from SDL event handling, so an accumulated delta
  would count the idle gap before the close request.
- The lock-screen error and lockout countdowns subtract only the time
  since they were armed: a failed unlock arms them from an RPC result,
  which can land on the frame that ends an idle gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:51:02 -05:00
DanSandClaude Opus 5.5 ffce7cad24 Merge branch 'fix/faq-cjk-font-and-zz-send-history' into dev (already integrated)
This branch's only commit (424954f) is already on dev as an identical patch
(fae8b20, merged in 1151141; git cherry reports it as equivalent). A normal
merge conflicts only where dev has since moved past it: the NotoSansCJK
subset was rebuilt again as a superset that still contains the FAQ glyphs,
and app.cpp and node_status_banner.h changed later. This merge records the
branch as integrated and keeps dev's content unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:34:26 -05:00
DanSandClaude Opus 5.5 3961f84d16 Merge branch 'fix/audit-remediations' into dev
Every fix on this branch already reached dev through the port in bcee4bf
("apply audit remediations to dev"), adapted to dev's code and in several
places hardened further (0600-at-open xmrig config, strict 127.0.0.0/8
loopback check, metadata cap centralised in httpGetString). F14-1 and F8-2
were fixed independently on dev. All 10 conflicting files therefore resolve
to dev's side, which drops nothing.

The one thing dev lacked is the F14-1 regression test: a quoted empty
console argument ("") must be kept rather than shifting later positional
RPC params. This merge brings that test in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:34:19 -05:00
DanSandClaude Opus 5.5 71ed92b9f1 feat(wallet): consolidate notes per address, keeping each address's funds in place
Users who keep funds apart under different labelled addresses can now
shed notes (smaller wallet, faster scans/witness rebuilds) without the
funds being merged into a single address.

- Merge/Consolidate dialog: "Where should the funds go?" - Into one
  address (unchanged) or Keep in each address. The latter lists shielded
  addresses with 2+ spendable notes (label, note count, amount;
  too-small ones disabled) and merges each address's notes back into
  that address with z_mergetoaddress [addr] addr. Addresses with more
  notes than the batch size get several batches (the daemon locks a
  batch's notes before returning, so calls take disjoint notes); the
  dialog suggests a second run after those confirm. Per-address
  progress; ops keep running if the dialog is closed. The Consolidate
  (wallet-size) preset opens in this mode; Merge keeps "Into one".
  Shielded only: a transparent address can't take a merge's output.
- services/note_consolidation: groupSpendableNotes, worthConsolidating,
  estimateBatches, consolidateInPlace (pure; driven by a merge-call
  function), with unit tests against a stand-in following dragonxd's
  rules.
- fake_dragonxd: optional note wallet (FAKE_WALLET_NOTES,
  FAKE_MERGE_SECS) with z_mergetoaddress and its operations.
- i18n: 17 new keys in all 8 languages; CJK subset rebuilt.
- UI sweep: modal-consolidate target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:15:56 -05:00
DanSandClaude Opus 5.5 e367eb47ff chore(docs): move internal design docs, the overview mockup and old release notes out of the repo
They now live in the untracked docs/_archive/ (gitignored).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:32:34 -05:00
DanSandClaude Opus 5.5 31d8742ce7 feat(ui): explain mining addresses everywhere and stop offering them for payment
Follow-up to 5246898. A transparent address only ever receives mining
rewards on DragonX (private chain), so:

- One explainer ("Receives solo-mining rewards only - the network rejects
  payments to it. Rewards are shielded automatically once they mature.")
  on the Overview row hover, the Receive picker hover, the Receive card
  and the Validate Address result (now typed "Mining address
  (transparent)").
- Receive: a selected mining address shows the explainer and a "Use a
  shielded address" button (creates one if none exists) instead of the
  payment request, and a "Not for payments" panel instead of a QR code.
  The default selection prefers a shielded address; the "All" filter
  hides empty mining addresses unless solo mining or marked as a mining
  address; the filter reads All / Shielded / Mining.
- Request Payment: no transparent section, never prefilled with one.
- Overview: no "Mining - Mining" tag on a marked transparent row.
- FAQ: address answers rewritten; the send and memo answers no longer
  imply you can pay a transparent address. All 8 languages.

i18n: 4 new keys, 6 FAQ answers updated in place, 2 dead keys removed,
CJK subset rebuilt. UI sweep: receive-mining-address and
modal-validate-mining targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:32:34 -05:00
DanSandClaude Opus 5.5 5246898968 feat(ui): treat transparent addresses as mining-only addresses
DragonX is a private chain (-ac_private=1): consensus rejects any
non-coinbase output to a transparent address, so a t-address can only
ever receive mining rewards, and its coins can only move to a shielded
address. The wallet treated them like ordinary recipients.

Block paying one:
- Send: a transparent recipient shows "Mining address - can't be paid"
  and disables Review Send (tooltip explains; ask for a zs... address).
  Typing, paste, contacts and payment URIs all go through this field.
- Overview drag-to-transfer: a mining-address row is never a transfer
  target (it only reorders).
- Pool payout must be shielded (pools pay with ordinary transactions):
  field tooltip, disabled Start button and the start check say why.
- isValidRecipientAddress() is now shielded-only; new isDragonXAddress()
  and classifyRecipient() (Shielded / MiningOnly / Invalid).

Call them mining addresses:
- Overview: "Mining rewards" card (amber "Not yet shielded" when there
  is a balance; hover explains; click opens the Shield dialog), "New
  mining address" button with a tooltip, rows typed "Mining".
- QR / label dialogs, portfolio filter, contact badge ("Mining only"),
  Receive notifications.

i18n: 17 new keys in all 8 languages, 12 dead keys removed, CJK subset
rebuilt. Tests: recipient classification, translation completeness.
UI sweep: new "send-mining-recipient" target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:08:14 -05:00
DanSandClaude Opus 5.5 a3361020d1 feat(daemon): recognise our detached node, tail POSIX debug.log, more RPC threads
S12 - our node across sessions:
- The node the wallet launches is recorded in <datadir>/obsidiandragon-
  dragonxd.pid (pid + process start time + exe). A later session that
  finds it still running (after "Leave running" / keep_daemon_running)
  stops it gracefully on quit and waits for that exact process (no time
  limit, "Leave running" offered); never signals or kills it. A reused
  pid or a reboot never matches; stale records are deleted.
- stop_external_daemon is now honoured when this session only connected
  (no controller); it was ignored.

S13 - the node's own log on every platform:
- No -printtoconsole on Linux/macOS: the node writes its timestamped
  debug.log (which outlives the GUI) and we tail it next to the stderr
  pipe. Tail path from the datadir or the isolated override (Windows had
  %APPDATA% hard-coded, wrong for migrate-to-seed starts).
- After the node shrinks debug.log at startup, resume at this run's
  banner instead of re-reading the old run's tail.
- A node we didn't launch (left running, or found running) feeds the
  busy tracker from its debug.log, tied to the live process (our record,
  or POSIX hushd.pid) and only from lines it wrote. A GUI attaching to a
  node mid-rebuild now says "Node busy: rebuilding note witnesses" and
  asks before stopping it.
- A start that only finds a running node no longer resets the tracker;
  a found busy node shows the busy overlay at once.

S14: launch our node with -rpcthreads=16 unless DRAGONX.conf sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:06:52 -05:00
DanSandClaude Opus 5.5 e20b70d73f fix(daemon): never restart, delete or swap under a node that hasn't exited
Every operation that stops the node (restart, rescan, repair, delete
chain data, reinstall, bootstrap, wallet switch/adopt/restore/rebuild,
wizard restart, encrypt, decrypt) now:

- refuses up front while the node is rebuilding witnesses or has not
  answered for 30s, with a "try again when it's done" warning, and sends
  no stop;
- goes ahead only once the node's PROCESS has exited, not its RPC port.
  A stop that doesn't finish within 120s cancels the operation (warning;
  a cancelled rescan/repair disarms its flags); nothing is started over
  the old node, chain data isn't deleted, wallet.dat isn't swapped.

Specifics:
- stopDaemonForWalletSwitch() no longer reports "stopped" when the app
  is quitting (adopt could swap wallet.dat under a live node).
- Decrypt waited a fixed 3s after "stop" before renaming wallet.dat; it
  now waits for the process to exit, and scrubs the key export if not.
- Bootstrap waits for the process before cleanChainData and stops the
  node through the stop routine instead of the shared RPC client.
- StopScope::Connected lets switch/reinstall/decrypt stop an adopted or
  external node through the same never-kill routine.
- A bounded stop that can't reach the node (a foreign node refusing our
  credentials) gives up after 5s instead of 120s.
- macOS: isDaemonProcessRunning() checks processes (libproc), not the
  RPC port.
- rpcBusyWhileAlive() also reads the busy gate: since the gate, the
  first timeout usually lands on another lane and the 30s clock never
  started (also weakened the quit prompt for a silent rebuild).
- Notifications render during the first-run wizard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:22:39 -05:00
DanSandClaude Opus 5.5 1215ded1a0 feat(ui): say "Node busy" instead of "Launching dragonxd"; honest quit copy
A node rebuilding note witnesses for an hour was shown as "Starting
DragonX node... Launching dragonxd", which invited closing it.

- Overlay, a new NodeBusy banner and the status bar now say what the node
  is doing (rebuilding witnesses / catching up on blocks / up but not
  answering), with the elapsed time and Phase-1 block progress. A rebuild
  during startup warm-up replaces the stale "-28 Activating best chain"
  text, and the overlay re-applies its text once a second instead of only
  on a connect attempt (30s apart while the node is busy).
- Quit prompt: elapsed time, "Keep node running & quit" is the default,
  "Stop anyway & quit" becomes "Stop node & wait" (it no longer kills).
- A reinstall skipped because the node is still running now notifies.
- Status bar: don't repeat the warm-up status on the right, and don't
  print "Connected" there in non-English locales.
- i18n: 31 new keys in all 8 languages; 10 dead force-quit/old-modal keys
  removed; CJK subset rebuilt for the new glyphs.
- Tests: chooseDaemonStatus, NodeBusy banner priority, and every new key
  present in all 8 JSONs with matching printf conversions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 -05:00
DanSandClaude Opus 5.5 0f25c67600 feat(rpc): stop flooding a busy node's RPC queue; fix the reconnect storm
dragonxd serves RPC with 8 workers and a 16-deep queue. While a witness
rebuild holds cs_main, every request needing it waits, and a request the
client gave up on keeps its slot. The GUI kept polling and re-probing
until all 24 slots were its own abandoned requests (even stop was then
refused), treated the busy node as lost (tearing down the wallet state),
and retried connect at frame rate on some pages.

Busy gate (src/rpc/rpc_busy_gate.h), enforced in RPCClient::performCall
for the shared rpc_ / fast_rpc_:
- the first reply timeout or queue-full refusal marks the node busy;
  from then on nothing needing cs_main is sent (calls fail at once with
  the new RpcFailureKind::Deferred). stop, help and getrescaninfo are
  lock-free in dragonxd and always go through
- one canary getinfo with no client timeout waits on its own client and
  thread; its answer clears the gate and triggers an immediate refresh
  (queue-full: retried after 2..30 s; no answer: back to normal handling)
- the timer dispatch skips Core/Tx/Addresses/Peers/chat/mining/opid and
  connect attempts while busy
- the 3-strike teardown counts only failures that put the connection in
  doubt, never busy ones; no balance scans follow a busy chain-info call

Reconnect storm: retries were armed with setTimer(Core, kCoreDefault - 1),
i.e. "4 s elapsed", which against Overview's 2 s interval meant every
frame. RefreshScheduler::deferFor() is an absolute hold, and
ConnectRetryBackoff picks the delay by failure kind (not listening
1/2/5 s, queue full 2..30 s, busy 30 s, else 4 s).

Measured against the fake daemon: abandoned requests during a rebuild
went from ~24 (every slot) to at most 2, with no teardown and recovery
when the rebuild ends; a saturated queue sees about one canary per
back-off step instead of ~190 requests/s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:49:55 -05:00
DanSandClaude Opus 5.5 0c821333c8 fix(app): stop the seed-backup nudge and chat identity retrying every frame
The one-time "back up your seed phrase" nudge only stopped probing on a
success or the exact "not derived from a mnemonic" error. Any other
answer re-armed it for the next frame, so a daemon without the RPC
("Method not found", e.g. the older bundled binary) was sent
z_exportmnemonic at frame rate: ~2,300 requests/s against a test node.
Chat identity setup re-posted its fetch on the next frame after a
transient failure in the same way.

An inconclusive nudge probe now waits 5 minutes before the next; a
transient identity-fetch failure waits 10 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:49:55 -05:00
DanSandClaude Opus 5.5 98fa2154b4 test(tools): match dragonxd's lock-free getrescaninfo; count requests per method
- getrescaninfo takes no lock in dragonxd (it only reads wallet fields),
  so the stub no longer holds it behind cs_main. Holding it there made
  the GUI's rescan-progress poll look like an abandoned request every
  30 s during a simulated rebuild.
- a `methods` event lists the 10 most-requested JSON-RPC methods with
  counts (alongside `stats`, and at exit): what the GUI is polling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:49:43 -05:00
DanSandClaude Opus 5.5 6b14098bb2 fix(daemon): make the "keep the node running?" prompt actually appear
Quitting during a witness rebuild is supposed to ask before stopping the
node, because a stop throws the rebuild away and a killed node replays
and redoes it. The guard almost never fired:
- it returned false for any daemon >= v1.3.0, on the false premise that
  v1.3.0 checkpoints a rebuild (it does not; nothing survives a kill)
- it read a 16 KB debug.log tail and compared the last progress line with
  the newest line, so any unrelated line during the rebuild's silent
  second phase turned it off; and on POSIX that file is stale
- daemon_version, which the bypass needed, is zeroed when a busy node
  drops the connection

Now a DaemonBusyTracker (src/daemon/daemon_busy_detect.h, pure) follows
dragonxd's own markers in order: rebuilding (Phase-1 / legacy markers,
ended by the completion line, an abort line or the next UpdateTip) and
replaying (Activating best chain ... Done Loading), reset by a new banner
or "Shutdown: done". It is fed from the owned output on the UI thread,
including the backlog skipped after a restore. The quit guard prompts
when the quit would stop the node and it is rebuilding, or has been
alive but only timing out / refusing as busy for >= 30 s. A replay alone
never prompts: stopping it is safe. The stop loop uses the same signal
to never offer Kill during a rebuild.

Also:
- daemon output offsets are absolute, so a reader that falls behind a
  trim of the 1 MB buffer no longer skips new output (which could hide a
  start or end marker)
- a quit deferred while minimized restores and raises the window, and
  the wizard renders the prompt, so it is never waiting unseen

Verified live with the fake daemon: quitting mid-rebuild now shows the
prompt for a v1.3.0 node and leaves the node alone; a silent busy node
prompts after 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:02:27 -05:00
DanSandClaude Opus 5.5 821bebd13e test(tools): add FAKE_REBUILD_SILENT to fake_dragonxd
Holds cs_main like a witness rebuild but logs no rebuild markers: a node
that is busy while its output says nothing (an external node, or a
rebuild path that prints no progress), for exercising the GUI's
"busy but alive" handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:02:27 -05:00
DanSandClaude Opus 5.5 5993488176 feat(daemon): deliver stop reliably and never kill the node unasked
Closing the wallet sent "stop" at most twice, gated on a getinfo probe
that needs the daemon's cs_main, then killed the node after 20 s (Windows)
or 30 s (POSIX) whether or not stop had arrived. A node busy with an
hour-long witness rebuild therefore never got stop, was killed mid-
rebuild, and replayed everything since its last flush on every start.

Delivery (src/daemon/stop_delivery.h, RPCClient::open/trySendStop):
- stop goes over a dedicated client with no probe, so it never waits on
  rpc_'s lock or a cs_main-bound getinfo
- every attempt is classified, and StopDeliveryPolicy decides the next:
  Accepted / 503 = delivered; Queued (reply timeout) is resent at most
  every 30 s; QueueFull / not listening are retried every 1.5 s; a
  refusal after we were heard means the node is shutting down; a 401 on
  our own node tries the next credential source
- POSIX: a group SIGTERM goes out as soon as an attempt fails, which
  bypasses the HTTP queue entirely

Waiting (DaemonController::executeDaemonStop, pure, virtual-clock
tested):
- never kills on its own. Quitting waits as long as the node needs; the
  shutdown screen offers "Close wallet — leave node running" after 3 s,
  and "Kill node (unsafe)" (confirmed) only once stop was delivered and
  the node showed no CPU/I-O progress for ~10 min outside a rebuild
- other callers (restart, rescan, reinstall, switch, ...) wait up to
  120 s, let go once the app starts quitting, and keep watching a node
  that is still running; wallet switch refuses to go ahead then
- EmbeddedDaemon gains stop-session primitives (beginStop,
  pollOwnedProcess, signalGracefulStop, forceKillOwnedProcess,
  ownedResourceUsage over the Linux process group / Windows handle,
  endStop) and only one stop session runs at a time
- the Force Quit button and its stall heuristic are gone; "Leave
  running" aborts a pending stop attempt and closes at once

Verified against the fake daemon with the real GUI: closing mid-rebuild
now delivers stop (or SIGTERM 2 ms after a queue-full refusal) and the
node exits cleanly after its rebuild, where it was SIGKILLed before. The
shutdown screen, its "Leave running" button and the idle Kill offer
(after ~11 min) were checked by screenshot. New strings are English-
only for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:50:11 -05:00
DanSandClaude Opus 5.5 b1d5806f9e docs(tools): note headless fake-daemon runs with SDL_VIDEODRIVER=offscreen
Scripted runs nobody needs to watch can run the GUI without a window:
OpenGL still loads, and connecting and shutting down behave as usual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:49:58 -05:00
DanSandClaude Opus 5.5 a091bbc52f fix(daemon): never kill a dragonxd the wallet didn't launch
On Windows, stop() with no live process of its own looked up the first
"dragonxd.exe" by name and TerminateProcess'd it after the grace period:
an old node still shutting down (how an in-progress shutdown was killed),
a node on another datadir, or the user's main node. It was reached on a
lock-gated close, for an external node, after the tracked process had
exited, and from ~EmbeddedDaemon's stop(3000).

- Windows stop(): drop the by-name branch; with no tracked process it
  logs and returns. findProcessByName is detection only.
- ~EmbeddedDaemon no longer calls stop(); it stops watching and releases
  its handles. stop() returns whether the process exited on its own.
- SeedWalletCreator never sent its isolated node an RPC stop, so on
  Windows that node was always force-killed holding the wallet.dat it
  had just created, and the stack object's destructor could then kill
  the main node by name. It now stops the node over the node's own RPC
  on every path. A node that still had to be force-killed fails the
  creation (seed wiped; nothing has been swept into it yet), so an
  uncleanly closed wallet is never adopted.
- Reinstall waited only for the RPC port, which closes long before the
  process exits (locked exe on Windows, held datadir lock everywhere).
  It now waits for the process to exit and otherwise aborts without
  touching the binaries or starting a second node.
- Linux isDaemonProcessRunning also matches /proc/<pid>/exe, so a node
  whose main thread is already renamed "hush-shutoff" (still holding the
  datadir lock) counts as running.

Tests: a Windows real-process test (a fake dragonxd.exe on an isolated
datadir survives stop() and the destructor), a POSIX test that the
destructor leaves the process running, and the pure identity rule.
scripts/test-windows.sh passes the stub path to the Windows suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:44:35 -05:00
DanSandClaude Opus 5.5 d8175b7dc1 feat(rpc): classify RPC failures from structured data, not message text
Every caller guessed what went wrong from what(): "Timeout" meant busy,
"401" meant auth, and warmup was code -28 or one of six substrings. A
non-JSON HTTP 500 lost its body, so "Work queue depth exceeded" read as a
bare "HTTP 500", and libcurl reports connect- and reply-phase timeouts
with the same code.

New header-only src/rpc/rpc_failure.h:
- RpcFailureKind (NotListening, ConnectTimeout, ReplyTimeout, QueueFull,
  ShuttingDown, Unhandled, Auth, Warmup, AppError, Aborted, Other) and a
  pure classifyRpcFailure(curl code, request-sent, HTTP status, JSON-RPC
  code, plain-text body)
- formatHttpError keeps a short, printable plain-text body
- RpcError (moved here) stays "the daemon answered with a JSON-RPC
  error". The new RpcTransportError means "no JSON-RPC answer": curl
  failures and HTTP-layer refusals (401, 503, queue full, unhandled).
  Keeping them separate matters: callers catch RpcError to mean "the
  daemon said no" (chat identity's no-mnemonic fallback, seed export),
  so a timeout must not be one. They now treat a queue-full 500 as
  transient rather than as "no".

RPCClient:
- performCall tells the two timeouts apart with CURLINFO_REQUEST_SIZE
  (0 = never reached the daemon; reset on every transfer, checked on
  libcurl 7.81), and callRaw now shares performCall and the HTTP-failure
  path.
- connect() decides warmup by the -28 code only, so a plain-text body
  saying "Loading" can't pass for warmup, and records
  getLastConnectFailureKind().
- "Connection failed" names the kind and is logged once, then every
  100th repeat (one incident logged it 60k times).
- secret-bearing calls never echo a response body.

tryConnect's "401"/"Timeout" checks now use the kind, and
CoreRefreshResult carries blockchainErrKind/balanceErrKind for the busy
handling to come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:54:23 -05:00
DanSandClaude Opus 5.5 2c04b88e19 fix(daemon): let POSIX stop() see the daemon exit instead of always SIGKILLing
POSIX stop() set should_stop_, which made the monitor thread return
without reaping the child, and then polled the state-based isRunning(),
which stays true while the state is Stopping. So every Linux/macOS stop
waited out its whole grace period, sent SIGTERM, waited 10 s more and
SIGKILLed, even when the node had exited seconds earlier. Closing the GUI
with an idle node took ~30 s; a node still flushing at 30 s was killed.

- stop() joins the monitor first, then polls waitpid(pid, WNOHANG) on
  a cached pid, so it is the only reaper and sees the exit at once.
- markStopRequested() (set by App::stopEmbeddedDaemon before the RPC
  stop, and by stop() itself; cleared by start()) makes an exit that
  lands before stop() runs a clean stop instead of a crash, on POSIX
  and in the Windows monitor. crash_count_ drives auto-restart, and a
  crash recorded before the request keeps its count.
- The SIGTERM -> SIGKILL escalation stays for now, but only fires for
  a daemon that has really not exited. Replacing it with a wait policy
  and an explicit user choice is follow-up work.

Verified against the fake daemon: an idle node that exits 3.4 s after
stop used to be SIGTERMed at +20 s and SIGKILLed at +30 s (GUI closed
in 30.6 s); it is now reaped as "exited cleanly" ~50 ms after it exits
(GUI closes in 4.0 s). New POSIX tests cover the clean exit, the early
requested exit and a graceful SIGTERM, each time-bounded so the old
code fails fast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:02:45 -05:00
DanSandClaude Opus 5.5 bade958849 fix(tools): make run-gui-with-fake.sh refuse a Lite build dir
A Lite-configured tree builds ObsidianDragonLite and has no embedded
daemon, so any bin/ObsidianDragon left in it is a stale full-node binary
that the harness would otherwise run silently. Point --build-dir at a
full-node tree instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:02:37 -05:00
DanSandClaude Opus 5.5 9339bb0663 test(windows): build and run the test suite for Windows from WSL
scripts/test-windows.sh cross-compiles ObsidianDragonTests.exe (and the
fake_dragonxd stub) with mingw into build/windows-tests, reusing the
toolchain and dependency sources from build/windows, and runs it from
WSL through Windows interop (or wine). All tests pass on Windows.

CMake:
- Windows forced BUILD_TESTING OFF in the cache to keep curl's tests
  out, which also removed ObsidianDragonTests from every Windows build.
  Hide it from curl with a scoped variable instead, and restore our
  cache value afterwards (curl's cmake_dependent_option FORCE-writes
  it). Windows still defaults to OFF, so release builds are unchanged.
- ObsidianDragonTests now depends on copy_langs: i18n.cpp includes the
  generated embedded/lang_*.h, so a tree that builds only the tests
  failed (latent on Linux too).
- Link the Windows system libraries the tests need.

Tests:
- testTmpPath/fixtureDir/removeTree helpers: no hard-coded /tmp, a
  runtime fixture-dir override (the compiled-in path is a Linux one),
  and best-effort cleanup that can't abort the suite.
- Close the explorer cache before deleting its directory (Windows
  can't delete an open file).
- Compile out the address-book and wallet-index round-trips on Windows:
  getConfigDir() uses SHGetFolderPath there, which no environment
  variable redirects, so they would overwrite the real
  %APPDATA%\ObsidianDragon files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:24:51 -05:00
DanSandClaude Opus 5.5 7b6e8a8a8e test(daemon): add fake_dragonxd, a slow dragonxd stand-in for stop/kill testing
A scriptable fake dragonxd that reproduces, in minutes, the incident where
the GUI hard-killed a node busy with an hour-long witness rebuild:

- dragonxd's HTTP-RPC queue (8 workers + 16 queued, -rpcthreads /
  -rpcworkqueue honoured): a full queue answers an immediate plain-text
  500 "Work queue depth exceeded", and a request whose client gave up
  keeps its slot until a worker reaches it
- a global cs_main held by a "witness rebuild" that logs the real
  markers; every method except stop/help takes it
- warmup -28 with the daemon's allow-list, an optional startup replay
- dragonxd's shutdown order: stop (or SIGTERM on POSIX) unbinds RPC,
  503s open connections, waits for the rebuild, then logs
  "Shutdown: In progress..." / "Shutdown: done" and exits 0

Everything is appended to <datadir>/fake_dragonxd.events; a missing
"clean-exit" line means the process was killed. One portable C++17 file
(POSIX + Winsock), OFF by default (-DDRAGONX_BUILD_FAKE_DAEMON=ON), built
into <build>/fake_dragonxd/ so it can't shadow the real bin/dragonxd.
run-gui-with-fake.sh runs the GUI against it in a throwaway HOME.

Against master's GUI on Linux it reproduces the bug: closing during a
rebuild queues stop behind cs_main-blocked workers, then the GUI sends
SIGTERM and SIGKILL mid-rebuild, with no clean exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:24:42 -05:00
DanS 46189da0da Merge dev: format release-note checksums as a table 2026-09-04 05:07:40 -05:00
DanSandClaude Opus 4.8 bc6c1de7d6 docs: format release-note checksums as a File/SHA-256 table (match v2.0.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT
2026-09-04 05:07:40 -05:00
DanS ec88dac459 Merge dev: inline SHA-256 in v2.0.2 / lite v1.1.1 release notes 2026-09-04 05:05:01 -05:00
DanSandClaude Opus 4.8 d058e5fc2a docs: inline SHA-256 checksums in v2.0.2 / lite v1.1.1 release notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT
2026-09-04 05:04:51 -05:00
DanS 86b9d2e00d Merge dev: v2.0.2 / lite 1.1.1 release notes 2026-09-04 04:25:52 -05:00
DanSandClaude Opus 4.8 0056c46929 docs: add release notes for v2.0.2 and lite v1.1.1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT
2026-09-04 04:25:44 -05:00
DanS 1151141271 Merge dev: v2.0.2 / lite 1.1.1 — FAQ CJK glyphs, outgoing-tx history, lite encryption, migrate & sync fixes 2026-09-04 04:03:12 -05:00
DanSandClaude Opus 4.8 fae8b20fb7 fix: FAQ CJK glyphs, outgoing-tx history, lite encryption, migrate & sync safety
Bug-report fixes plus adversarial-audit follow-ups. Bumps full 2.0.1->2.0.2 and
lite 1.1.0->1.1.1.

i18n / fonts:
- Rebuild the NotoSansCJK subset with the glyphs the FAQ back-fill introduced
  (什/么/门/做); Chinese FAQ titles rendered as "??" on zh (and ja/ko).

Transaction history (enumeration gaps):
- Surface outgoing shielded z->z sends via z_listsentbyaddress (listtransactions
  and z_listreceivedbyaddress never report them).
- Surface z->t deshields by parsing z_listsentbyaddress transparentSends (which
  z_viewtransaction does not expose); dedupe t->t against listtransactions.
- Key send-row dedup on address so equal-value multi-output sends aren't dropped.

Lite wallet (key safety):
- The async create/restore/open path the UI uses now applies the passphrase:
  encrypt a new/restored wallet, unlock an existing one. It previously discarded
  the passphrase, storing the seed/keys in PLAINTEXT (create/restore) or leaving
  an encrypted wallet locked (open).

Migrate-to-seed (fund safety):
- Persist a sweep-submitted marker at broadcast so a resumed migration can't treat
  a ~0 balance (an unconfirmed in-flight sweep) as an empty wallet and adopt (swap
  wallet.dat) before the sweep confirms; best-effort locate routes to the confirm gate.

Sync / threading:
- Gate isSynced() on a peer-derived tip (tip_known) so a peerless node isn't reported
  synced (Send against a stale balance); add a "no peers" status banner.
- Guard daemon_status_ with a mutex (monitor-thread write vs UI-thread read).

Tests: extends tests/test_phase4.cpp across all of the above; suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT
2026-09-04 04:02:52 -05:00
DanSandClaude Opus 4.8 424954ff4f fix: FAQ CJK glyphs, outgoing-tx history, lite encryption, migrate & sync safety
Bug-report fixes plus adversarial-audit follow-ups. Bumps full 2.0.1->2.0.2 and
lite 1.1.0->1.1.1.

i18n / fonts:
- Rebuild the NotoSansCJK subset with the glyphs the FAQ back-fill introduced
  (什/么/门/做); Chinese FAQ titles rendered as "??" on zh (and ja/ko).

Transaction history (enumeration gaps):
- Surface outgoing shielded z->z sends via z_listsentbyaddress (listtransactions
  and z_listreceivedbyaddress never report them).
- Surface z->t deshields by parsing z_listsentbyaddress transparentSends (which
  z_viewtransaction does not expose); dedupe t->t against listtransactions.
- Key send-row dedup on address so equal-value multi-output sends aren't dropped.

Lite wallet (key safety):
- The async create/restore/open path the UI uses now applies the passphrase:
  encrypt a new/restored wallet, unlock an existing one. It previously discarded
  the passphrase, storing the seed/keys in PLAINTEXT (create/restore) or leaving
  an encrypted wallet locked (open).

Migrate-to-seed (fund safety):
- Persist a sweep-submitted marker at broadcast so a resumed migration can't treat
  a ~0 balance (an unconfirmed in-flight sweep) as an empty wallet and adopt (swap
  wallet.dat) before the sweep confirms; best-effort locate routes to the confirm gate.

Sync / threading:
- Gate isSynced() on a peer-derived tip (tip_known) so a peerless node isn't reported
  synced (Send against a stale balance); add a "no peers" status banner.
- Guard daemon_status_ with a mutex (monitor-thread write vs UI-thread read).

Tests: extends tests/test_phase4.cpp across all of the above; suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT
2026-09-04 04:02:27 -05:00
DanS cc2d383166 Merge dev: lite6 + lite7 default servers 2026-09-02 04:20:45 -05:00
DanSandClaude Opus 4.8 f30fdc5ed1 feat(lite): add lite6 + lite7 to the default lite-wallet server list
Ship https://lite6.dragonx.is and https://lite7.dragonx.is as default
lightwalletd servers in both the Settings defaults and the connection-service
fallback list. Existing installs (whose saved server list predates these)
pick them up via a load-time merge that appends any missing default server —
safe because servers are hidden, never deleted, so a removed server isn't
resurrected. Consolidates the Settings default list into a single
Settings::defaultLiteServers() source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 04:15:29 -05:00
DanS 290d3c31a2 Merge dev: i18n back-fill (313 strings × 8 languages) 2026-09-02 03:30:16 -05:00
DanSandClaude Opus 4.8 27fb83ba04 i18n: back-fill 313 UI strings across all 8 languages
Complete the de/es/fr/ja/ko/pt/ru/zh translations so every English source
key in i18n.cpp is now covered (2341 keys per language, 0 missing). Covers
the chat delete/block flow, the block-database reindex + wallet-recovery
prompts, node auto-shield status, the in-app FAQ (full-node + lite), the
daemon updater/version picker, the debug-options gate, and more.

Additive-only (no existing key changed). Printf format signatures preserved
and validated against i18n.cpp formatSignature; newlines, the "→" arrow, and
brand/technical identifiers kept verbatim. Terminology grounded on each
language's existing translations for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 03:30:03 -05:00
DanSandClaude Opus 4.8 0e78fe02e4 Merge dev: macOS Berkeley DB build fix (build.sh)
Derive the macOS BDB depends triple from the build arch + pass the BDB paths
to CMake, so the full-node mac release builds the wallet-rebuild helper on
Intel (x86_64) as well as Apple Silicon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 02:29:37 -05:00
DanSandClaude Opus 4.8 7914e9e9bb fix(build): derive the macOS Berkeley DB depends triple from the build arch
The macOS release path hardcoded aarch64-apple-darwin for the wallet-rebuild
helper's static libdb and — unlike the Linux/Windows paths — never passed
-DBDB_INCLUDE_DIR/-DBDB_LIBRARY to CMake. So on an Intel Mac the helper either
couldn't find libdb, or picked up macOS's SDK stub /usr/include/db.h (a DB 1.85
shim with no db_create) and failed to compile.

- mac_bdb_dir(): map the target arch to the depends triple (x86_64 ->
  x86_64-apple-darwin, arm64 -> aarch64-apple-darwin; universal / unknown falls
  back to the host arch).
- Pass -DBDB_INCLUDE_DIR / -DBDB_LIBRARY (when the vendored libdb is present) in
  BOTH the native and osxcross configures, so CMake uses our BDB 6.2 header
  instead of the SDK stub. Full-node only (lite has no BDB wallet.dat).
- Use the derived triple in require_wallet_rebuild_helper's fix hint.

Verified end-to-end by building both ObsidianDragon 2.0.1 and ObsidianDragonLite
1.1.0 .dmg/.app on an Intel Mac (x86_64; built single-arch via DRAGONX_MAC_ARCHS
since the vendored deps are x86_64-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 02:20:54 -05:00
DanSandClaude Opus 4.8 06d34dff5e Merge dev: ObsidianDragon 2.0.1 / ObsidianDragonLite 1.1.0
Brings the full 2.0.x line to master: security-audit remediations, diagnostics/
logging, mining overhaul, HiDPI/UI audit, wallet recovery + migrate-to-seed,
daemon-startup hardening, seed-phrase backup, in-app FAQ, i18n + 8-language
translations, chat delete/block, per-frame render perf, and the Lite variant
(1.1.0) with its variant-aware FAQ. Windows app exe now stripped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 01:14:27 -05:00
DanSandClaude Opus 4.8 c7e48c16f8 feat(lite): version 1.1.0 + variant-aware FAQ
Bump DRAGONX_LITE_VERSION 1.0.0 -> 1.1.0: since lite-1.0.0 the Lite variant
gained the whole 2.0.x shared UI/UX + diagnostics + i18n + perf work and this
session's chat delete/block — a minor bump (new features, no breaking change).

Make the FAQ variant-aware (walletFaq(fullNode)) so the Lite build reads
correctly: "What is ObsidianDragonLite?", encryption in the Wallet tab (not
Node & Security), migrate-to-seed hidden, node-specific answers reworded
neutrally ("the wallet syncs"), a new "Lite Wallet" subcategory (server model +
privacy tradeoff) standing in for the hidden Daemon group, and the redundant
single "Wallet" group tab dropped when there's no Daemon group to switch to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 01:12:08 -05:00
DanSandClaude Opus 4.8 4ee0f524d4 build(release): strip the Windows app exe
The Linux and macOS release paths already strip the main binary (and the
Windows path even stripped the dragonx-wallet-rebuild helper), but the
Windows app exe was shipping unstripped — ~5 MB of symbols on the full
node, ~19 MB on the params-heavy lite build. Strip it right after the
Windows build, best-effort with a warning fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 01:12:08 -05:00
DanSandClaude Opus 4.8 43c7be55c6 fix(ui): relocate + reword node auto-shield status; add chat-management FAQ entry
- Auto-shield status: the node's z_autoshieldstatus disabled_reason was echoed
  verbatim ("HD seed origin is not known-recoverable; back the seed up and pass
  -autoshield=1") and drawn INSIDE the Wallet OPTIONS checkbox grid, wedging a
  full-width line between the checkboxes. Move it to a full-width note BELOW the
  grid, and replace the raw daemon text with friendly, actionable wording keyed
  on the seed_recoverable flag (back up your seed to enable it); the raw daemon
  reason is kept on hover. Adds App::daemonAutoShieldSeedRecoverable().
- FAQ: add a Chat & Contacts entry ("How do I hide, delete, or block a
  conversation?") covering the hide / delete-revive / delete-&-block actions and
  the local-only caveat — data + i18n only, no UI code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 23:42:37 -05:00
DanSandClaude Opus 4.8 d9fa00bb38 perf: memoize per-frame render hot paths (console, transactions, recent lists)
Immediate-mode render functions re-run every frame; these rebuilt O(N)
state each time even when nothing changed. From a 6-lens perf audit, each
finding verified on a hot-path basis:

- Console: ConsoleModel gains revision(); the full-model filter scan and the
  glyph-by-glyph text-layout pass (BuildConsoleLayout) rebuild only when the
  model / filter / wrap-width / zoom change — previously it re-shaped up to
  10,000 lines every frame even when idle/scrolled. clear() force-invalidates
  the memo mid-render (no OOB on the just-emptied visible set).
- Transactions: the summary-card totals memoize behind the tab's existing
  FNV-1a fingerprint (also folds away a now-duplicate O(N) display-key pass).
- Send / Receive recent lists: early-exit the prefix scan (state.transactions
  is kept newest-first) instead of filtering the whole tx history every frame.
- network_refresh_service: O(new x total) txid find-and-replace -> hash map.
- Sidebar unconfirmed-tx badge cached on last_tx_update + tx count; the
  daemon-memory probe (/proc scan on Linux, popen on macOS) throttled to ~1.5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 22:55:04 -05:00
DanSandClaude Opus 4.8 0a042df8e0 feat(chat): per-conversation delete (revive + block); memoize chat/badge render
Adds a per-conversation "delete" with two modes, and removes the per-frame
rescans of the chat history that shared these files.

Delete conversation (header trash icon → confirm dialog):
- Delete (revive-on-new-message): clears local history and tombstones the
  messages (new chat_deleted table, keyed dedup hashes) so the every-few-
  seconds memo re-scan can't re-import them; a genuinely NEW message (new
  txid) revives the thread.
- Delete & block: removes history WITHOUT a tombstone and records the cid as
  blocked (settings); ChatService::ingest drops that conversation's messages
  — old and future — until unblocked from the "Blocked" manager, which then
  re-imports the conversation from chain.
- Local-only (messages remain on-chain; the peer keeps their copy).
  deleteConversation() deletes the DB rows FIRST and only then mutates the
  store, so a failed write can't leave the two diverged.
Unit-tested (revive / tombstone-survives-reload / block / unblock) and
adversarially reviewed (store/DB divergence, half-open DB, revive-unread).

Performance (chat + badge hot paths, from the perf audit):
- ChatStore gains revision(); the Chat unread badge is now a single O(N)
  no-alloc pass cached on it (was O(conversations x messages) copy+sort every
  frame). The conversation list and open thread are memoized on revision()
  (+ show-hidden and AddressBook::revision() for peer-name resolution).
- AddressBook gains revision() so an in-place contact rename invalidates the
  chat memo (an edit keeps entries().size() constant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 22:54:51 -05:00
DanSandClaude Opus 4.8 a60a2f8e39 fix(ui): stop sidebar badges from displacing button text
The Chat/History nav buttons centered their icon+label in a region that
shrank when a badge (unread count / mining dot) was present, and the
"has badge" test read the LIVE count — so the text jumped sideways the
moment a count toggled (e.g. a new chat message arrived). Reserve badge
clearance by whether the page CAN show a badge (constant per item), center
in the full button width regardless, and cap the label with symmetric
clearance so a long label still can't run under the corner badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 22:54:36 -05:00
DanSandClaude Fable 5 c252e60f7f docs: add v2.0.0 and lite-v1.0.0 release notes
Release notes for ObsidianDragon v2.0.0 (full-node) and ObsidianDragonLite
v1.0.0, with verified SHA-256s for the Linux/Windows artifacts and the
osxcross-built macOS artifacts. Both tags point at this commit's parent
(fffee9f); tables cover AppImage/zip/exe/dmg/app.zip per variant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:33:58 -05:00
DanSandClaude Fable 5 0e5533f6b9 fix(build): exit 0 on a successful single-platform release
build.sh runs under `set -e`, and its final line was
`[[ -d release/mac ]] && echo …`. On a build that did not produce a mac
artifact (e.g. --win-release), the `[[ -d ]]` test is false; set -e exempts the
left side of an && so the script does not abort, but that non-zero status
becomes the script's exit code — making a fully successful build report failure
(and breaking CI status). Add an explicit `exit 0` at the end; real failures
already exit 1 at their point of failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:44:01 -05:00
DanSandClaude Fable 5 080f3d9668 fix(build): enforce build-from-source for the lite backend (F15-1)
The lite-backend "signature verification" was attestation theater:
build-lite-backend-artifact.sh recorded a self-attested
signature_verification.verification_status="verified" with no cryptographic
check (the "verified" SHA was simply the artifact's own SHA), and CMake's
DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE gate trusted it — so a hand-edited
manifest could link an arbitrary prebuilt backend into ObsidianDragonLite.

Per the chosen remediation (build-from-source is the trust root):
- build-lite-backend-artifact.sh: reject --artifact/--no-build (prebuilt) and
  all --signature-* flags; the backend is always built from the vendored
  in-tree source (third_party/silentdragonxlite). Record built_from_source:true
  in the manifest provenance. Usage/header updated.
- CMakeLists.txt: remove the DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE option and
  its manifest signature-verification gate (kept the real required-symbol
  inventory check); update the config summary line.

Full-node CMake reconfigure + build pass; script bash -n clean and the removed
flags now hard-error. (The lite-backend link path needs cargo + backend and was
not runtime-tested in this environment.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:12:45 -05:00
DanSandClaude Fable 5 c6bd4eb733 fix(build): verify pinned build inputs (appimagetool, Sapling params)
Release-pipeline supply-chain hardening from the audit (F15-3/4, F15-6):

- appimagetool: stop fetching the moving "continuous" AppImageKit tag and
  running it unverified on the release builder. Pin appimagetool 1.9.0 and
  verify its SHA-256 (46fdd785…83d1) before exec, refusing to package on
  mismatch; a system-installed appimagetool (maintainer's own) is still used
  as-is. Applied in both build.sh and scripts/create-appimage.sh.
- setup.sh --sapling: the z.cash param download is plain HTTPS with no
  signature; verify both params against their fixed canonical SHA-256 (the
  same values already pinned in build-lite-backend-artifact.sh) and delete
  the file on mismatch.

All three scripts pass bash -n.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:02:17 -05:00
DanSandClaude Fable 5 13788b5dd0 fix(security): harden untrusted-input parsing (Tier 4 audit items)
- export: neutralize CSV/spreadsheet formula injection in transaction export —
  escapeCSV() now prefixes fields beginning with = + - @ / tab / CR with a
  single quote so a hostile memo/address cannot execute on open (F13-1).
- console: preserve explicitly-quoted empty arguments in ParseConsoleCommandArgs
  so a "" no longer collapses and shifts later positional RPC params; adds a
  regression assertion to testConsoleInputModel (F14-1).
- send: copy pending payment-URI address/memo with snprintf("%s") instead of
  strncpy(…, sizeof-1), which does not NUL-terminate on truncation (F8-2).

All build clean (full-node + tests) and the suite passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 01:56:13 -05:00
DanSandClaude Fable 5 5554016029 fix(security): remediate high/medium findings from branch audit
Static audit of the wallet surfaced supply-chain, TLS, and secret-lifecycle
issues. This fixes the six High-priority items plus four high-value Medium
items; all build clean (full-node + tests) and the suite passes.

Supply chain / integrity:
- bootstrap: reject zip-slip / path-traversal archive members before writing
  (isSafeArchivePath); the sibling updaters flatten via baseName(), bootstrap
  keeps sub-paths so it validates instead (F2-1).
- bootstrap: require a SHA-256 checksum — fail closed instead of extracting
  unverified chain data when none is published (F2-2).
- xmrig updater: fail closed when a signature is required but no key is pinned,
  matching the daemon updater (was: silent downgrade to checksum-only) (F1-1).
- xmrig/daemon updaters: cap metadata responses (MAXFILESIZE + 16 MiB hard cap
  in the write callback, which also catches chunked/no-Content-Length) (F1-2).

Network / TLS:
- rpc_client: set SSL_VERIFYPEER/VERIFYHOST explicitly instead of relying on
  libcurl defaults (F4-1).
- rpc_client: bound response accumulation at 256 MiB to prevent OOM from a
  compromised daemon (F4-3).
- lite_connection_service: reject remote http:// lite servers (loopback still
  allowed) to prevent a silent TLS downgrade of lightwalletd traffic (F8-1).

Secret lifecycle / fund safety:
- lite controller: propagate the save() result from encrypt/decryptWallet so a
  failed persist is not reported as success (F7-1).
- xmrig_manager: chmod(0600) the pool config before writing the API token /
  wallet address, closing the world-readable window (F5-2).
- app: clear a copied secret from the OS clipboard on shutdown so it does not
  outlive the process when the 45s auto-clear never fires (F3b-1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 01:16:24 -05:00
275 changed files with 55888 additions and 8651 deletions
+145 -24
View File
@@ -15,7 +15,7 @@ if(APPLE)
endif()
project(ObsidianDragon
VERSION 2.0.1
VERSION 2.0.2
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.0.0")
set(DRAGONX_LITE_VERSION "1.1.1")
set(DRAGONX_LITE_VERSION_SUFFIX "")
# C++17 standard
@@ -45,6 +45,19 @@ option(DRAGONX_ENABLE_EMBEDDED_DAEMON "Enable embedded dragonxd support" ON)
option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node features" OFF)
option(DRAGONX_ENABLE_LITE_BACKEND "Enable real lite wallet backend integration" OFF)
option(DRAGONX_ENABLE_CHAT "Enable the HushChat protocol/UI integration" ON)
option(DRAGONX_BUILD_FAKE_DAEMON "Build the fake slow dragonxd test stub (tools/fake_dragonxd); never shipped" OFF)
# The screenshot / full UI sweep injects demo data and touches the wallet's data and config dirs, so it is a
# dev tool only. build.sh passes it EXPLICITLY (ON for dev, OFF for every release configure) because build/linux
# and build/mac are shared between dev and release builds; dragonx_release refuses to package while it is ON.
option(DRAGONX_ENABLE_UI_SWEEP "Compile the UI sweep dev tool (never for shipped builds)" OFF)
# Embedded fallback copy of res/lang/*.json (xxd -i -> lang_*.h). Needed only by the Windows single-file exe (no res/ beside it);
# Linux zip/AppImage and the macOS .app always ship res/lang, which loadLanguage() reads first.
if(WIN32)
set(_DRAGONX_EMBED_LANG_DEFAULT ON)
else()
set(_DRAGONX_EMBED_LANG_DEFAULT OFF)
endif()
option(DRAGONX_EMBED_LANG "Embed res/lang/*.json in the binary as a fallback" ${_DRAGONX_EMBED_LANG_DEFAULT})
set(DRAGONX_LITE_BACKEND_LIBRARY "" CACHE FILEPATH "Path to a prebuilt SDXL-compatible lite backend library")
set(DRAGONX_LITE_BACKEND_INCLUDE_DIR "" CACHE PATH "Optional include directory for SDXL-compatible lite backend headers")
set(DRAGONX_LITE_BACKEND_EXTRA_LIBS "" CACHE STRING "Additional libraries needed by the SDXL-compatible lite backend")
@@ -154,6 +167,11 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
set(DRAGONX_LITE_BACKEND_READY ON)
endif()
# Windows (mingw cross) builds skip the large test target unless asked for with -DBUILD_TESTING=ON
# (scripts/test-windows.sh does that in its own build/windows-tests tree), so release builds are unchanged.
if(WIN32 AND NOT DEFINED BUILD_TESTING)
set(BUILD_TESTING OFF CACHE BOOL "Build the testing tree.")
endif()
include(CTest)
# Output directories
@@ -265,7 +283,11 @@ if(WIN32)
set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(CURL_STATICLIB ON CACHE BOOL "" FORCE)
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
# Keep curl's own tests out, without switching off ObsidianDragonTests for every Windows build:
# hide BUILD_TESTING behind a normal variable while curl configures, then restore our cache value
# (curl's cmake_dependent_option(BUILD_TESTING ...) FORCE-writes the OFF it saw into the cache).
set(_dragonx_build_testing "${BUILD_TESTING}")
set(BUILD_TESTING OFF)
set(CURL_DISABLE_LDAP ON CACHE BOOL "" FORCE)
set(CURL_DISABLE_LDAPS ON CACHE BOOL "" FORCE)
set(CURL_USE_SCHANNEL ON CACHE BOOL "" FORCE) # Use Windows native SSL
@@ -274,6 +296,8 @@ if(WIN32)
set(CURL_ZLIB OFF CACHE BOOL "" FORCE)
set(HTTP_ONLY ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(curl)
unset(BUILD_TESTING)
set(BUILD_TESTING "${_dragonx_build_testing}" CACHE BOOL "Build the testing tree." FORCE)
set(CURL_LIBRARIES libcurl_static)
set(CURL_INCLUDE_DIRS ${curl_SOURCE_DIR}/include ${curl_BINARY_DIR}/lib/curl)
else()
@@ -386,6 +410,12 @@ set(IMGUI_SOURCES
${IMGUI_DIR}/backends/imgui_impl_sdl3.cpp
)
# The ImGui demo window is unreachable in the wallet, so compile imgui_demo.cpp to its empty stubs
# (ShowDemoWindow/ShowStyleEditor/... stay defined; imgui.h declares them unconditionally, so this is ODR-safe).
# Do NOT use IMGUI_DISABLE_DEBUG_TOOLS: it changes imgui.h declarations and removes duplicate-ID highlighting.
# (Makefiles: a per-source define lands in the target's flags.make, so existing trees do one full app rebuild.)
set_source_files_properties(${IMGUI_DIR}/imgui_demo.cpp PROPERTIES COMPILE_DEFINITIONS IMGUI_DISABLE_DEMO_WINDOWS)
set(IMGUI_HEADERS
${IMGUI_DIR}/imgui.h
${IMGUI_DIR}/imconfig.h
@@ -454,8 +484,11 @@ set(APP_SOURCES
src/app_security.cpp
src/app_sweep.cpp
src/app_wizard.cpp
src/services/history_scan_policy.cpp
src/services/network_refresh_service.cpp
src/services/note_consolidation.cpp
src/services/refresh_scheduler.cpp
src/services/wallet_scan_governor.cpp
src/services/wallet_security_controller.cpp
src/services/wallet_security_workflow.cpp
src/services/wallet_security_workflow_executor.cpp
@@ -480,7 +513,9 @@ set(APP_SOURCES
src/wallet/lite_wallet_server_selection_adapter.cpp
src/wallet/lite_wallet_lifecycle_service.cpp
src/data/wallet_state.cpp
src/data/transaction_csv.cpp
src/data/transaction_history_cache.cpp
src/data/transaction_history_store_policy.cpp
src/ui/theme.cpp
src/ui/theme_loader.cpp
src/ui/explorer/explorer_block_cache.cpp
@@ -518,7 +553,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
@@ -544,6 +578,7 @@ set(APP_SOURCES
src/data/exchange_info.cpp
src/util/logger.cpp
src/util/async_task_manager.cpp
src/util/coalescing_writer.cpp
src/util/amount_format.cpp
src/util/address_validation.cpp
src/util/seed_phrase.cpp
@@ -554,6 +589,7 @@ set(APP_SOURCES
src/util/platform.cpp
src/util/payment_uri.cpp
src/util/texture_loader.cpp
src/util/image_decode_core.cpp
src/util/svg_texture.cpp
src/util/noise_texture.cpp
src/daemon/embedded_daemon.cpp
@@ -600,8 +636,15 @@ endif()
set(APP_HEADERS
src/app.h
src/services/history_scan_policy.h
src/services/network_refresh_service.h
src/services/refresh_scheduler.h
src/services/wallet_scan_governor.h
src/services/connect_scan_gate.h
src/services/scan_policy.h
src/services/chat_scan_policy.h
src/services/mining_thread_commit.h
src/services/lowest_block_search.h
src/services/wallet_security_controller.h
src/services/wallet_security_workflow.h
src/services/wallet_security_workflow_executor.h
@@ -629,6 +672,7 @@ set(APP_HEADERS
src/config/version.h
src/data/wallet_state.h
src/data/transaction_history_cache.h
src/data/transaction_history_store_policy.h
src/ui/theme.h
src/ui/theme_loader.h
src/ui/explorer/explorer_block_cache.h
@@ -637,6 +681,8 @@ set(APP_HEADERS
src/ui/windows/balance_tab.h
src/ui/windows/balance_address_list.h
src/ui/windows/balance_recent_tx.h
src/ui/windows/balance_lerp.h
src/ui/windows/chat_thread_layout.h
src/ui/windows/balance_tab_helpers.h
src/ui/windows/send_tab.h
src/ui/windows/receive_tab.h
@@ -657,7 +703,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
@@ -677,12 +722,19 @@ set(APP_HEADERS
src/rpc/rpc_client.h
src/rpc/rpc_worker.h
src/rpc/connection.h
src/rpc/long_call_lane.h
src/rpc/types.h
src/config/settings.h
src/data/address_book.h
src/data/exchange_info.h
src/util/logger.h
src/util/async_task_manager.h
src/util/serial_job_lane.h
src/util/fetch_backoff.h
src/util/coalescing_writer.h
src/util/local_time.h
src/util/background_sampler.h
src/util/text_fit.h
src/util/amount_format.h
src/util/base64.h
src/util/single_instance.h
@@ -766,9 +818,21 @@ ${CMAKE_SOURCE_DIR}/res/fonts/NotoEmoji-Subset.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/TwemojiMozilla-Color.ttf"
)
# Embed ui.toml + default_banlist via INCBIN (same pattern as fonts) - zero configure cost, no
# generated header, so editing them re-assembles one tiny TU instead of recompiling every includer.
configure_file(
${CMAKE_SOURCE_DIR}/src/embedded/embedded_text.cpp.in
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
@ONLY)
set_source_files_properties(
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
PROPERTIES OBJECT_DEPENDS
"${CMAKE_SOURCE_DIR}/res/themes/ui.toml;${CMAKE_SOURCE_DIR}/res/default_banlist.txt")
add_executable(ObsidianDragon
${APP_SOURCES}
${CMAKE_BINARY_DIR}/generated/embedded_fonts.cpp
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
${IMGUI_SOURCES}
${QRCODE_SOURCES}
${GLAD_SOURCES}
@@ -889,6 +953,8 @@ target_compile_definitions(ObsidianDragon PRIVATE
DRAGONX_ENABLE_EMBEDDED_DAEMON=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
DRAGONX_ENABLE_CHAT=$<BOOL:${DRAGONX_ENABLE_CHAT}>
DRAGONX_EMBED_LANG=$<BOOL:${DRAGONX_EMBED_LANG}>
DRAGONX_ENABLE_UI_SWEEP=$<BOOL:${DRAGONX_ENABLE_UI_SWEEP}>
)
if(WIN32)
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_USE_DX11)
@@ -905,7 +971,9 @@ if(DRAGONX_FREETYPE)
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_HAVE_FREETYPE)
endif()
add_executable(HushChatFixtureCheck
# Dev-only HushChat fixture CLI (nothing runs it; not linked into the app or tests). Not part of `all`:
# cmake --build build/linux --target HushChatFixtureCheck (output: build/linux/bin/HushChatFixtureCheck)
add_executable(HushChatFixtureCheck EXCLUDE_FROM_ALL
tools/hushchat_fixture_check.cpp
src/chat/chat_protocol.cpp
src/chat/chat_fixture_tooling.cpp
@@ -948,9 +1016,11 @@ endif()
# so edits to res/lang/*.json are picked up by 'make' without re-running cmake.
file(GLOB LANG_FILES ${CMAKE_SOURCE_DIR}/res/lang/*.json)
if(LANG_FILES)
find_program(XXD_EXECUTABLE NAMES xxd)
if(NOT XXD_EXECUTABLE)
message(WARNING "xxd not found; runtime language JSON files will be copied, but embedded build/generated/embedded/lang_*.h files will not be regenerated")
if(DRAGONX_EMBED_LANG)
find_program(XXD_EXECUTABLE NAMES xxd)
endif()
if(DRAGONX_EMBED_LANG AND NOT XXD_EXECUTABLE)
message(FATAL_ERROR "DRAGONX_EMBED_LANG is ON but xxd was not found; it generates build/generated/embedded/lang_*.h. Install xxd or configure with -DDRAGONX_EMBED_LANG=OFF")
endif()
file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/generated/embedded)
@@ -967,7 +1037,7 @@ if(LANG_FILES)
list(APPEND LANG_OUTPUTS ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res/lang/${LANG_FILENAME})
# Also regenerate the embedded header so the binary always has fresh translations
if(XXD_EXECUTABLE)
if(DRAGONX_EMBED_LANG AND XXD_EXECUTABLE)
get_filename_component(LANG_CODE ${LANG_FILENAME} NAME_WE)
set(LANG_HEADER ${CMAKE_BINARY_DIR}/generated/embedded/lang_${LANG_CODE}.h)
add_custom_command(
@@ -985,20 +1055,6 @@ if(LANG_FILES)
message(STATUS " Language files: ${LANG_FILES}")
endif()
# Embed ui.toml into the binary so it's always available at runtime
include(${CMAKE_SOURCE_DIR}/cmake/EmbedResources.cmake)
file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/generated)
embed_resource(
${CMAKE_SOURCE_DIR}/res/themes/ui.toml
${CMAKE_BINARY_DIR}/generated/ui_toml_embedded.h
ui_toml
)
embed_resource(
${CMAKE_SOURCE_DIR}/res/default_banlist.txt
${CMAKE_BINARY_DIR}/generated/default_banlist_embedded.h
default_banlist
)
# Note: xmrig is embedded via build.sh (embedded_data.h) for Windows builds,
# following the same pattern as daemon embedding.
@@ -1105,6 +1161,47 @@ else()
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
endif()
# Release packaging (build.sh --linux-release / --win-release / --mac-release) builds ONLY what ships:
# cmake --build . --target dragonx_release
# The test binary, the lite smoke tools and the dev CLIs are skipped WITHOUT writing BUILD_TESTING=OFF into the
# build/<platform> cache that dev builds share. A stale OFF would leave ctest running an old test binary.
# If a release path starts copying a new CMake-built file out of bin/, add its target here.
add_custom_target(dragonx_release)
add_dependencies(dragonx_release ObsidianDragon)
foreach(_dragonx_release_dep dragonx-wallet-rebuild copy_langs copy_themes)
if(TARGET ${_dragonx_release_dep})
add_dependencies(dragonx_release ${_dragonx_release_dep})
endif()
endforeach()
# Backstop for the UI sweep dev tool: a tree whose cache says ON (e.g. hand-configured) cannot package a release.
if(DRAGONX_ENABLE_UI_SWEEP)
add_custom_target(dragonx_release_sweep_guard
COMMAND ${CMAKE_COMMAND} -E echo "DRAGONX_ENABLE_UI_SWEEP=ON: refusing to package a release"
COMMAND ${CMAKE_COMMAND} -E false)
add_dependencies(dragonx_release dragonx_release_sweep_guard)
endif()
# -----------------------------------------------------------------------------
# fake_dragonxd — a deliberately slow, scriptable dragonxd stand-in that reproduces the daemon-stop
# incident (usage and the incident it models: tools/fake_dragonxd/README.md).
# Test tooling only: OFF by default, never packaged, and written to <build>/fake_dragonxd/ so its
# "dragonxd" name can't shadow the real bin/dragonxd.
# -----------------------------------------------------------------------------
if(DRAGONX_BUILD_FAKE_DAEMON)
add_executable(fake_dragonxd tools/fake_dragonxd/fake_dragonxd.cpp)
target_link_libraries(fake_dragonxd PRIVATE nlohmann_json::nlohmann_json)
if(WIN32)
target_link_libraries(fake_dragonxd PRIVATE ws2_32)
else()
find_package(Threads REQUIRED)
target_link_libraries(fake_dragonxd PRIVATE Threads::Threads)
endif()
set_target_properties(fake_dragonxd PROPERTIES
OUTPUT_NAME dragonxd
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/fake_dragonxd)
message(STATUS "fake_dragonxd test stub: ON (${CMAKE_BINARY_DIR}/fake_dragonxd/)")
endif()
# -----------------------------------------------------------------------------
# Tests
# -----------------------------------------------------------------------------
@@ -1112,8 +1209,11 @@ endif()
if(BUILD_TESTING)
add_executable(ObsidianDragonTests
tests/test_phase4.cpp
src/services/history_scan_policy.cpp
src/services/network_refresh_service.cpp
src/services/note_consolidation.cpp
src/services/refresh_scheduler.cpp
src/services/wallet_scan_governor.cpp
src/services/wallet_security_controller.cpp
src/services/wallet_security_workflow.cpp
src/services/wallet_security_workflow_executor.cpp
@@ -1156,17 +1256,24 @@ if(BUILD_TESTING)
src/util/seed_phrase.cpp
src/util/i18n.cpp
src/util/text_format.cpp
src/util/image_decode_core.cpp
src/data/wallet_state.cpp
src/data/transaction_csv.cpp
src/data/transaction_history_cache.cpp
src/data/transaction_history_store_policy.cpp
src/data/address_book.cpp
src/data/wallet_index.cpp
src/daemon/lifecycle_adapters.cpp
src/daemon/embedded_daemon.cpp
src/rpc/connection.cpp
src/rpc/rpc_client.cpp
src/util/base64.cpp
src/config/settings.cpp
src/resources/embedded_resources.cpp
src/util/secure_vault.cpp
src/util/platform.cpp
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
src/util/coalescing_writer.cpp
src/util/logger.cpp
src/util/lite_server_probe.cpp
src/util/pool_registry_core.cpp
@@ -1175,6 +1282,8 @@ if(BUILD_TESTING)
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp
src/util/daemon_updater_core.cpp
src/util/bootstrap.cpp
src/util/single_instance.cpp
${MINIZ_SOURCES}
)
@@ -1187,6 +1296,7 @@ if(BUILD_TESTING)
${SODIUM_INCLUDE_DIR}
${CURL_INCLUDE_DIRS}
${MINIZ_DIR}
${libwebp_SOURCE_DIR}/src # <webp/decode.h>, <webp/demux.h> for image_decode_core.cpp
)
target_link_libraries(ObsidianDragonTests PRIVATE
@@ -1194,6 +1304,8 @@ if(BUILD_TESTING)
sqlite3_amalgamation
${SODIUM_LIBRARY}
${CURL_LIBRARIES}
webp
webpdemux
)
target_compile_definitions(ObsidianDragonTests PRIVATE
@@ -1201,6 +1313,8 @@ if(BUILD_TESTING)
DRAGONX_LITE_BUILD=$<BOOL:${DRAGONX_BUILD_LITE}>
DRAGONX_ENABLE_EMBEDDED_DAEMON=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
DRAGONX_EMBED_LANG=$<BOOL:${DRAGONX_EMBED_LANG}>
DRAGONX_ENABLE_UI_SWEEP=$<BOOL:${DRAGONX_ENABLE_UI_SWEEP}>
DRAGONX_TEST_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/tests/fixtures"
)
@@ -1211,6 +1325,13 @@ if(BUILD_TESTING)
if(UNIX)
target_link_libraries(ObsidianDragonTests PRIVATE ${CMAKE_DL_LIBS})
endif()
if(WIN32)
target_link_libraries(ObsidianDragonTests PRIVATE ws2_32 crypt32 wldap32 psapi iphlpapi shell32 ole32 advapi32 bcrypt)
endif()
# i18n.cpp includes the generated embedded/lang_*.h headers; a tree that builds only this target needs them too.
if(TARGET copy_langs)
add_dependencies(ObsidianDragonTests copy_langs)
endif()
add_test(NAME ObsidianDragonPhase4Tests COMMAND ObsidianDragonTests)
endif()
+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?
+85 -25
View File
@@ -213,6 +213,21 @@ require_wallet_rebuild_helper() {
info " wallet-rebuild helper present: $helper"
}
# Vendored Berkeley DB depends directory for the macOS wallet-rebuild helper, by TARGET arch. The daemon
# links a static libdb-6.2; the helper must match the build arch, so derive the triple from it rather than
# hardcoding one. Single-arch: x86_64 -> x86_64-apple-darwin, arm64 -> aarch64-apple-darwin. "universal"
# (or anything unexpected) falls back to the host arch — a universal .app needs a universal libdb, so if
# only a single-arch libdb is vendored, build single-arch via DRAGONX_MAC_ARCHS.
mac_bdb_dir() {
local arch="$1" triple
case "$arch" in
x86_64) triple="x86_64-apple-darwin" ;;
arm64) triple="aarch64-apple-darwin" ;;
*) [[ "$(uname -m)" == "arm64" ]] && triple="aarch64-apple-darwin" || triple="x86_64-apple-darwin" ;;
esac
printf '%s/external/dragonx/depends/%s\n' "$SCRIPT_DIR" "$triple"
}
# ── Helper: find resource files ──────────────────────────────────────────────
find_sapling_params() {
local dirs=(
@@ -334,6 +349,7 @@ build_dev() {
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=ON \
-DDRAGONX_ENABLE_UI_SWEEP=ON \
"${CMAKE_LITE_ARGS[@]}"
info "Building with $JOBS jobs ..."
@@ -348,8 +364,8 @@ build_dev() {
# ═══════════════════════════════════════════════════════════════════════════════
build_release_linux() {
header "Release: Linux x86_64"
local bd="$SCRIPT_DIR/build/linux"
local out="$SCRIPT_DIR/release/linux"
local bd="${DRAGONX_BUILD_ROOT:-$SCRIPT_DIR/build}/linux"
local out="${DRAGONX_RELEASE_ROOT:-$SCRIPT_DIR/release}/linux"
if $CLEAN; then
info "Cleaning $bd ..."; rm -rf "$bd"
@@ -370,15 +386,17 @@ build_release_linux() {
exit 1
fi
info "Configuring ..."
# UI sweep OFF explicitly: build/linux is shared with dev builds, which cache it ON.
cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=ON \
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
"${BDB_ARGS[@]}" \
"${CMAKE_LITE_ARGS[@]}"
info "Building with $JOBS jobs ..."
cmake --build . -j "$JOBS"
cmake --build . -j "$JOBS" --target dragonx_release
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
@@ -562,8 +580,8 @@ APPRUN
# ═══════════════════════════════════════════════════════════════════════════════
build_release_win() {
header "Release: Windows x86_64 (cross-compile)"
local bd="$SCRIPT_DIR/build/windows"
local out="$SCRIPT_DIR/release/windows"
local bd="${DRAGONX_BUILD_ROOT:-$SCRIPT_DIR/build}/windows"
local out="${DRAGONX_RELEASE_ROOT:-$SCRIPT_DIR/release}/windows"
if $CLEAN; then
info "Cleaning $bd ..."; rm -rf "$bd"
@@ -631,13 +649,16 @@ TOOLCHAIN
mkdir -p "$RES"
SAPLING_SPEND="" SAPLING_OUTPUT="" ASMAP_DAT=""
find_sapling_params || true
find_asmap || true
if [[ -n "$SAPLING_SPEND" && -n "$SAPLING_OUTPUT" ]]; then
cp -f "$SAPLING_SPEND" "$RES/sapling-spend.params"
cp -f "$SAPLING_OUTPUT" "$RES/sapling-output.params"
local EMBED_NODE_DATA=false
should_bundle_full_node_assets && EMBED_NODE_DATA=true
if $EMBED_NODE_DATA; then
find_sapling_params || true
find_asmap || true
fi
# Lite needs no Sapling params/asmap (its backend embeds its own), so it always gets a header for
# images/themes/xmrig; the full node still requires the params to be found.
if ! $EMBED_NODE_DATA || [[ -n "$SAPLING_SPEND" && -n "$SAPLING_OUTPUT" ]]; then
cat > "$GEN/embedded_data.h" <<HDR
// Auto-generated — DO NOT EDIT (build.sh --win-release)
#pragma once
@@ -645,16 +666,27 @@ TOOLCHAIN
#include <cstddef>
#include "incbin.h"
HDR
if $EMBED_NODE_DATA; then
cp -f "$SAPLING_SPEND" "$RES/sapling-spend.params"
cp -f "$SAPLING_OUTPUT" "$RES/sapling-output.params"
cat >> "$GEN/embedded_data.h" <<HDR
INCBIN(sapling_spend_params, "$RES/sapling-spend.params");
INCBIN(sapling_output_params, "$RES/sapling-output.params");
HDR
if [[ -n "$ASMAP_DAT" ]]; then
cp -f "$ASMAP_DAT" "$RES/asmap.dat"
echo "INCBIN(asmap_dat, \"$RES/asmap.dat\");" >> "$GEN/embedded_data.h"
if [[ -n "$ASMAP_DAT" ]]; then
cp -f "$ASMAP_DAT" "$RES/asmap.dat"
echo "INCBIN(asmap_dat, \"$RES/asmap.dat\");" >> "$GEN/embedded_data.h"
else
echo 'extern "C" { static const uint8_t* g_asmap_dat_data = nullptr; }' >> "$GEN/embedded_data.h"
echo 'static const unsigned int g_asmap_dat_size = 0;' >> "$GEN/embedded_data.h"
fi
else
echo 'extern "C" { static const uint8_t* g_asmap_dat_data = nullptr; }' >> "$GEN/embedded_data.h"
echo 'static const unsigned int g_asmap_dat_size = 0;' >> "$GEN/embedded_data.h"
info "Lite mode: not embedding Sapling params/asmap (the SDXL backend embeds its own)"
for sym in sapling_spend_params sapling_output_params asmap_dat; do
echo "extern \"C\" { static const uint8_t* g_${sym}_data = nullptr; }" >> "$GEN/embedded_data.h"
echo "static const unsigned int g_${sym}_size = 0;" >> "$GEN/embedded_data.h"
done
fi
# ── Daemon binaries ──────────────────────────────────────────────
@@ -794,6 +826,7 @@ HDR
info "Embedded resources header generated"
else
warn "Building WITHOUT embedded resources (Sapling params not found)"
rm -f "$GEN/embedded_data.h" # never compile a stale header left by the other variant in this shared build dir
fi
# ── Fetch libsodium for Windows if needed ──────────────────────────────
@@ -834,14 +867,20 @@ HDR
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
"${BDB_ARGS[@]}" \
"${FT_CMAKE_ARG[@]}" \
"${CMAKE_LITE_ARGS[@]}"
info "Building with $JOBS jobs ..."
cmake --build . -j "$JOBS"
cmake --build . -j "$JOBS" --target dragonx_release
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
# Strip the app exe — the Linux and macOS release paths already strip theirs, and even the Windows
# helper (dragonx-wallet-rebuild.exe) is stripped, but the main app exe was shipping unstripped
# (~5MB of symbols on the full node, ~19MB on the params-heavy lite build). Best-effort.
x86_64-w64-mingw32-strip --strip-all "bin/${APP_BASENAME}.exe" 2>/dev/null \
|| warn " strip unavailable — shipping unstripped ${APP_BASENAME}.exe"
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
@@ -876,9 +915,14 @@ HDR
info "Lite mode: skipping daemon and Sapling/asmap assets in Windows zip"
fi
# Bundle xmrig for mining support
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
# The exe embeds xmrig (HAS_EMBEDDED_XMRIG) and getXmrigPath() always prefers / re-extracts the copy in
# %APPDATA%\<app>\dragonx, so a loose exe-dir xmrig.exe is never used. Ship it loose only when not embedded.
if grep -q '^#define HAS_EMBEDDED_XMRIG 1' "$GEN/embedded_data.h" 2>/dev/null; then
info "xmrig.exe is embedded in the exe — not copied loose into the zip"
else
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
fi
cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -1011,8 +1055,8 @@ build_release_mac() {
fi
header "Release: macOS ($MAC_ARCH$(${IS_CROSS} && echo ' — cross-compile'))"
local bd="$SCRIPT_DIR/build/mac"
local out="$SCRIPT_DIR/release/mac"
local bd="${DRAGONX_BUILD_ROOT:-$SCRIPT_DIR/build}/mac"
local out="${DRAGONX_RELEASE_ROOT:-$SCRIPT_DIR/release}/mac"
if $CLEAN; then
info "Cleaning $bd ..."; rm -rf "$bd"
@@ -1078,13 +1122,21 @@ TOOLCHAIN
fi
info "Configuring (cross-compile via osxcross) ..."
# Vendored static libdb for the recovery helper (full-node only), by target arch; see mac_bdb_dir.
local mac_bdb; mac_bdb="$(mac_bdb_dir "$MAC_ARCH")"
local BDB_ARGS=()
if should_bundle_full_node_assets && [[ -f "$mac_bdb/lib/libdb-6.2.a" && -f "$mac_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$mac_bdb/include" -DBDB_LIBRARY="$mac_bdb/lib/libdb-6.2.a" )
fi
cmake "$SCRIPT_DIR" \
-DCMAKE_TOOLCHAIN_FILE="$bd/osxcross-toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
${COMPILER_RT:+-DOSXCROSS_COMPILER_RT="$COMPILER_RT"} \
"${BDB_ARGS[@]}" \
"${CMAKE_LITE_ARGS[@]}"
else
# Build libsodium as universal if needed
@@ -1106,24 +1158,32 @@ TOOLCHAIN
fi
info "Configuring (native macOS, arch: $MAC_ARCHS) ..."
# Point CMake at the vendored static libdb for the recovery helper (full-node only); see mac_bdb_dir.
local mac_bdb; mac_bdb="$(mac_bdb_dir "$MAC_ARCH")"
local BDB_ARGS=()
if should_bundle_full_node_assets && [[ -f "$mac_bdb/lib/libdb-6.2.a" && -f "$mac_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$mac_bdb/include" -DBDB_LIBRARY="$mac_bdb/lib/libdb-6.2.a" )
fi
cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
"${BDB_ARGS[@]}" \
"${CMAKE_LITE_ARGS[@]}"
fi
info "Building with $JOBS jobs ..."
cmake --build . -j "$JOBS"
cmake --build . -j "$JOBS" --target dragonx_release
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
# A full-node release MUST include the recovery helper. macOS needs a static libdb-6.2 (Homebrew
# berkeley-db for a native build, or a vendored external/dragonx/depends/<triple>) — otherwise CMake
# skips the target and this fails loudly rather than shipping a mac release with no recovery option.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/external/dragonx/depends/aarch64-apple-darwin"
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$(mac_bdb_dir "$MAC_ARCH")"
# Strip — use osxcross strip for cross-builds
if $IS_CROSS; then
-42
View File
@@ -1,42 +0,0 @@
# CMake script to embed binary files as C arrays
function(embed_resource RESOURCE_FILE OUTPUT_FILE VAR_NAME)
file(READ "${RESOURCE_FILE}" HEX_CONTENT HEX)
# Convert hex to C array format
string(LENGTH "${HEX_CONTENT}" HEX_LENGTH)
math(EXPR BYTE_COUNT "${HEX_LENGTH} / 2")
set(ARRAY_CONTENT "")
set(INDEX 0)
while(INDEX LESS HEX_LENGTH)
string(SUBSTRING "${HEX_CONTENT}" ${INDEX} 2 BYTE)
string(APPEND ARRAY_CONTENT "0x${BYTE},")
math(EXPR INDEX "${INDEX} + 2")
math(EXPR BYTE_NUM "${INDEX} / 2")
math(EXPR MOD "${BYTE_NUM} % 16")
if(MOD EQUAL 0)
string(APPEND ARRAY_CONTENT "\n ")
endif()
endwhile()
# Generate header file
set(HEADER_CONTENT "// Auto-generated embedded resource - DO NOT EDIT
#pragma once
#include <cstddef>
#include <cstdint>
namespace embedded {
inline constexpr unsigned char ${VAR_NAME}_data[] = {
${ARRAY_CONTENT}
};
inline constexpr size_t ${VAR_NAME}_size = ${BYTE_COUNT};
} // namespace embedded
")
file(WRITE "${OUTPUT_FILE}" "${HEADER_CONTENT}")
message(STATUS "Embedded ${RESOURCE_FILE} -> ${OUTPUT_FILE} (${BYTE_COUNT} bytes)")
endfunction()
-549
View File
@@ -1,549 +0,0 @@
# Daemon Startup Hardening — Implementation Plan
Eight verified edge-case defects in how ObsidianDragon brings up (and watches) the
`dragonxd` daemon at launch. Each entry is a buildable fix: the defect (with exact
line references), the chosen approach, the call sites, a representative change, and how
to verify it.
- **Scope:** full-node startup path (`--lite` excludes the embedded daemon entirely).
- **Source:** line references are exact against branch `dev` @ `45b652f`.
- **Provenance:** findings verified by direct source read; each fix designed by an
independent agent grounded in the cited files, with a sequencing pass for ordering,
shared helpers, and merge conflicts.
**Severity:** 2 High, 6 Medium · **Effort:** ≈ 25–35 engineering-hours · **7 landing steps.**
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
**Status: all 8 landed & verified** (build-clean, `ctest` green after each) across four commits on
`dev` — lifecycle cluster (F1/F2/F4), filesystem+params cluster (F7/F6/F5), F3, and F8. Six new
pure-helper unit tests added.
**Wrap-up done:** release notes added (`CHANGELOG.md`, F8 breaking change front and center); i18n
back-fill applied additively to `res/lang/*.json` (42 keys — all 6 for es/de/fr/pt/ru; 6 zh/ja/ko
entries whose glyphs aren't in the current `NotoSansCJK-Subset.ttf` were left on English fallback
rather than render as tofu).
**Still owed before release:** a **CJK subset-font rebuild** (`scripts/build_cjk_subset.py`, needs
the Noto CJK source font) to cover the 6 deferred zh/ja/ko strings. *(F1 and F2 now have headless
integration-test coverage — see the progress log — so their GUI repros are optional, not blocking.)*
---
## Recommended rollout sequence
A real dependency order, not a checklist. The daemon-lifecycle cluster lands first
because it makes the `State::Error` / `crash_count_` contract trustworthy — which the
connect-stall panel and the lock gate both build on. The filesystem cluster lands
around a single shared helper. The connectivity-breaking security flip lands last.
| Step | Finding(s) | Site | Why here | Status |
|------|-----------|------|----------|--------|
| 1 | **F1** | `embedded_daemon.cpp` · `isRunning()` | Smallest/highest-severity; establishes the reliable Error/crash-count transition steps 3 & 6 depend on. | ☑ |
| 2 | **F2** | `embedded_daemon.cpp` · `startProcess()` | Same file family, different function; test the F1+F2 pair together with `kill -SEGV` / bad-binary repros. | ☑ |
| 3 | **F4** | `embedded_daemon.cpp` · `start()` | After F1/F2 so crash-count semantics are settled; its bail deliberately stays out of the crash path. | ☑ |
| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | ☑ |
| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | ☑ |
| 6 | **F3** | `app.cpp` · `renderLoadingOverlay()` | After F1 — panel is guarded off during `State::Error` (owned by the crash-count hint). | ☑ |
| 7 | **F8** | `connection.cpp` · `tryConnect()` | Largest; only connectivity-breaking default flip — land last, with release notes. | ☑ |
---
## F1 — Double-`waitpid` race can swallow a daemon crash
**Severity:** High · **Effort:** S (~1–2h) · **Status:** ☑ landed & verified
### The defect
`EmbeddedDaemon::isRunning()` (`embedded_daemon.cpp:1136`, POSIX branch) calls
`waitpid(WNOHANG)` — from the **UI thread, nearly every frame** — racing
`monitorProcess()`'s own reap at `:1244`. `waitpid` is one-shot: if the UI thread wins,
the monitor never decodes the exit, so `crash_count_` never increments, `State::Error`
never fires, and the 3-strike auto-restart cap (`app_network.cpp:479`) is defeated. The
sibling `XmrigManager::isRunning()` (`xmrig_manager.cpp:512`) already fixed exactly this
with an atomic read.
### The fix
Make `isRunning()` read the existing `std::atomic<State> state_` (member at
`embedded_daemon.h:253`) instead of calling `waitpid`, leaving `monitorProcess()` as the
sole reaper. Predicate is `Running || Stopping` — `Stopping` must stay "alive" because
`stop()`'s graceful/SIGTERM wait loops poll `isRunning()` before the process has exited.
### Files touched
- `src/daemon/embedded_daemon.cpp` — `isRunning()`, POSIX branch (~1136)
### Core change
```cpp
bool EmbeddedDaemon::isRunning() const // POSIX branch
{
// Read the atomic state_ instead of waitpid() — monitorProcess() is the
// sole reaper. Previously both threads reaped; if the UI thread won, the
// monitor never saw the exit (crash_count_ / exit code / Error all lost).
if (process_pid_ <= 0) return false;
State s = state_.load(std::memory_order_relaxed);
// Stopping stays "alive": stop()'s wait loops poll isRunning() while
// state_ == Stopping, before the process has actually terminated.
return (s == State::Running || s == State::Stopping);
}
```
### Verification
- Manual: `kill -SEGV` the daemon 10–20×; the monitor must report the exit and increment `crash_count_` every time (previously intermittent).
- Regression: a normal Settings-driven stop still escalates SIGTERM→SIGKILL (the `Stopping` predicate).
- Not unit-testable (real fork/exec/waitpid) — consistent with the no-process-spawn harness.
### Dependencies
Mirrors `XmrigManager::isRunning()`. Flags a separate latent hazard (out of scope):
`stop()`'s final blocking `waitpid` (`:1220`) can still race a mid-sleep monitor
iteration — file as its own ticket.
---
## F2 — exec-after-fork silent failure: "Running" for a daemon that never started
**Severity:** High · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
### The defect
In `startProcess()` (`embedded_daemon.cpp:957–1061`, POSIX) the parent runs
`process_pid_ = pid; return true;` **unconditionally** after `fork()` — with no
exec-status handshake. On a non-executable / wrong-arch / corrupt binary the child's
`execv` fails and it `_exit(127)`s, but `start()` has already set `State::Running`
(`:565`). The real cause never reaches `last_error_`; it surfaces later, generically,
as "exited unexpectedly (exit code 127)".
### The fix
Add a **close-on-exec self-pipe** handshake — `pipe() + fcntl(FD_CLOEXEC)`, deliberately
**not** `pipe2()` (macOS lacks it; the POSIX branch is shared). The child writes `errno`
only on `execv` failure; a successful exec closes the write end for free. Parent reads:
EOF ⇒ success; 4 bytes ⇒ reap the zombie, set a precise `last_error_` ("not executable
or wrong architecture"), and return `false` so `start()` never reports Running. EINTR-safe
on both ends. Also comments the unchecked parent-side `setpgid` at `:1053`.
### Files touched
- `src/daemon/embedded_daemon.cpp` — `startProcess()` parent read path
- `src/daemon/embedded_daemon.cpp` — child `execv`-failure write (~1043)
- `src/daemon/embedded_daemon.cpp` — `setpgid` best-effort comment (~1053)
### Core change
```cpp
// Self-pipe exec handshake (pipe()+FD_CLOEXEC; NOT pipe2 — macOS lacks it).
int execpipe[2]; pipe(execpipe);
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
pid_t pid = fork();
if (pid == 0) { // child
close(execpipe[0]);
/* setpgid / chdir / dup2 / argv … */
execv(binary_path.c_str(), argv.data());
int e = errno; // execv failed
while (write(execpipe[1], &e, sizeof e) < 0 && errno == EINTR) {}
_exit(127);
}
close(execpipe[1]); // parent: must close or read() never EOFs
int child_errno = 0, total = 0;
for (;;) { // EOF ⇒ exec ok; 4 bytes ⇒ exec failed
ssize_t n = read(execpipe[0], (char*)&child_errno + total, sizeof(int) - total);
if (n == 0) break;
if (n < 0) { if (errno == EINTR) continue; break; }
if ((total += n) >= (int)sizeof(int)) break;
}
close(execpipe[0]);
if (total >= (int)sizeof(int)) { // exec never happened
waitpid(pid, nullptr, 0); // reap the zombie
last_error_ = "dragonxd could not be executed: " +
std::string(strerror(child_errno)) +
" — not executable or wrong architecture";
return false; // start() no longer reports Running
}
```
### Verification
- Point at a `chmod -x` / wrong-arch file → `start()` returns false immediately, precise message, no leftover zombie.
- Success path: real binary still starts with no perceptible added latency.
- Optional pure `formatExecFailureError(errno)` helper for a `test_phase4.cpp` unit test.
### Dependencies
F1 (same function family; sequence F1→F2). **Highest-risk mistake:** forgetting
`FD_CLOEXEC` makes every successful start hang the parent read forever.
---
## F4 — Stale datadir-lock start → restart storm that wedges the UI
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
### The defect
`start()` (`embedded_daemon.cpp:466`) gates only on the RPC port (`:482`), never on
`isDaemonProcessRunning()` (`:1292`). A graceful shutdown frees the port but keeps the
datadir `.lock` for up to ~90s. A rapid stop→start spawns a daemon that dies "Cannot
obtain a lock on data directory" — routed to the generic crash path. With a ~4s retry
cadence, **three lock races in ~12s exhaust the 3-strike budget** and wedge the UI long
before the lock actually clears.
### The fix
Fail-fast with a **short bounded local wait (~300ms), not a 90s block**. After the port
bail, consult `isDaemonProcessRunning()` — gated by `!skip_port_check_` and exempt when
`override_datadir_` is set, so the isolated migrate-to-seed daemon still works. A pure
`evaluateDatadirLockGate()` returns a **distinct non-crash Error** that never increments
`crash_count_`. The connect loop's own retry then absorbs the transient.
### Files touched
- `src/daemon/embedded_daemon.h` — decision struct, helper decl, poll constants
- `src/daemon/embedded_daemon.cpp` — `start()` gate + `evaluateDatadirLockGate()`
### Core change
```cpp
static StartLockGateDecision evaluateDatadirLockGate(
bool skipPortCheck, bool isolatedOverride, bool stillRunningAfterWait) {
if (skipPortCheck || isolatedOverride) return {true, ""}; // migrate-to-seed exempt
if (!stillRunningAfterWait) return {true, ""};
return {false, "A previous dragonxd is still shutting down and holding the "
"data directory lock. Retrying shortly…"};
}
// start() — after the isPortInUse() bail, before setState(Starting):
if (!skip_port_check_ && override_datadir_.empty()) {
bool stillLocked = false; // ~300ms bounded wait, NOT ~90s
for (int i = 0; i < kDatadirLockWaitMaxPolls; ++i) {
if (!isDaemonProcessRunning()) { stillLocked = false; break; }
stillLocked = true;
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
}
auto gate = evaluateDatadirLockGate(false, false, stillLocked);
if (!gate.proceed) { setState(State::Error, gate.errorMessage); return false; }
}
```
### Verification
- Unit: `evaluateDatadirLockGate()` across the skip / isolated / still-running matrix.
- Manual: rapid restart into a lingering lock → distinct message, no crash-cap wedge.
- Migrate-to-seed second daemon still starts (isolated exemption).
### Dependencies
F1/F2 (must not touch `crash_count_`; wording must not collide with the monitor's
"exited unexpectedly"). Same TU, different function.
---
## F5 — Extraction / copy write-failures never surfaced up front
**Severity:** Medium · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
### The defect
`startEmbeddedDaemon()` discards `extractEmbeddedResources()`'s `bool` return
(`app.cpp:4152`) and the second copy-fallback loop drops `copy_file`'s `error_code`
entirely (`:4236`). Only Sapling params **existence** is re-checked — never the daemon
binary/CLI/tx/asmap. A disk-full or truncated `dragonxd` write falls straight through to
spawn and fails opaquely. The innermost write already returns `false`
(`embedded_resources.cpp:307`) — the signal is simply thrown away.
### The fix
Minimal, surgical wiring — no new abstraction. Capture the extraction return and, on
failure, set `daemon_status_ = TR("sb_daemon_extract_failed")` and `return false` before
spawning. In the second copy loop, check `ec` after each `copy_file`, track `copyFailed`,
and abort with a dir-parameterized `sb_daemon_files_failed`. An **absent source** stays
fine (optional files); only an actual `error_code` counts. Written so F6/F7 slot in later
without re-touching this control flow.
### Files touched
- `src/app.cpp` — `startEmbeddedDaemon()` extraction check (~4152)
- `src/app.cpp` — second copy-fallback loop (~4210–4242)
- `src/util/i18n.cpp` + `res/lang/*.json` — 2 additive keys
### Core change
```cpp
// stop discarding the extraction result (~4152)
if (!resources::extractEmbeddedResources()) {
daemon_status_ = TR("sb_daemon_extract_failed"); // disk full / permission denied
return false; // abort before spawning
}
// second copy-fallback loop — was dropping ec entirely (~4236)
bool copyFailed = false;
for (const char* name : { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }) {
fs::path dst = fs::path(daemon_dir) / name;
if (fs::exists(dst)) continue; // already present — skip
for (const auto& dir : searchDirs) {
fs::path src = fs::path(dir) / name;
if (!fs::exists(src)) continue; // absent source is OK, not a failure
fs::copy_file(src, dst, ec);
if (ec) { copyFailed = true; ec.clear(); }
break;
}
}
if (copyFailed) {
char buf[512];
snprintf(buf, sizeof buf, TR("sb_daemon_files_failed"), daemon_dir.c_str());
daemon_status_ = buf;
return false; // don't fall through to spawn
}
```
### Verification
- Unit: `extractEmbeddedResources()` returns false without embedded resources.
- Extract the copy loop into a testable helper; force one dst write to fail (dst is an existing directory).
- Manual: near-full tmpfs / read-only dir → clear status, daemon controller never constructed.
### Dependencies
Shares the `daemon_status_` surfacing convention with F6; its early-return pattern is the
template F7 matches. Open item: remove truncated dst files so a retry re-copies.
---
## F6 — Sapling params validated by existence/size only, never hashed
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
> **As-built note.** `verifySaplingParams()` now delegates to a public, injectable
> `verifySaplingParamsIn(dir, digests)` so the integrity + marker-cache logic is unit-testable
> with synthetic small files (the real 48 MB params aren't in the repo). i18n keys for F5 were
> added to `i18n.cpp` (English source of truth); the `res/lang/*.json` back-fill via
> `scripts/add_missing_translations.py` is deferred to a single run at the end of the batch,
> per the cross-cutting note. Non-English locales fall back to English until then.
### The defect
`verifySaplingParams()` (`connection.cpp:123`) only calls `fs::exists()`;
`resourceNeedsUpdate()` (`embedded_resources.cpp:250`) is size-only. On Linux (no
embedded resources) a **truncated-but-present** param passes and is handed to the daemon,
which then fails to build shielded proofs mid-operation — far from the real cause.
### The fix
Add a pinned `{ filename → size, sha256 }` table (one source of truth, cross-referenced
to `scripts/build-lite-backend-artifact.sh`) and hash-check each param after the
existence check, reusing the existing `util::sha256Hex` (no second implementation). Since
these are ~48 MB, **cache the result** via a `.sapling_verified` marker keyed on
`size:mtime` — re-hash only when the stat line changes, so startup isn't slowed.
### Files touched
- `src/rpc/connection.h` — `verifySaplingParams` decl
- `src/rpc/connection.cpp` — digest table, marker helpers, rewrite
### Core change
```cpp
// connection.cpp — pinned known-good digests
// (source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params)
constexpr SaplingParamDigest kSaplingParamDigests[] = {
{ "sapling-spend.params", 47958396, "8e48ffd2…efc13" },
{ "sapling-output.params", 3592860, "2f0ebbcb…fb0e4" },
};
bool Connection::verifySaplingParams() {
// existence check (unchanged) …
// cache: skip re-hashing a ~48 MB file unless size:mtime changed
if (readMarkerMatches(marker, statLines)) return true;
for (auto& d : kSaplingParamDigests)
if (util::sha256Hex(bytes) != d.sha256) return false; // reuse existing helper
writeMarker(marker, statLines);
return true;
}
```
### Verification
- Unit: good params pass; truncated / wrong-bytes rejected; marker cache short-circuits re-hash unless size/mtime changed. Real temp-file fixtures (matches existing `sha256Hex` tests).
### Dependencies
F7 (reuse fs-error idiom; shares the `startEmbeddedDaemon`/`verifySaplingParams` block).
Third caller of the existing `util::sha256Hex`.
---
## F7 — Directory-create errors universally ignored on the daemon-env path
**Severity:** Medium · **Effort:** S (~3–4h) · **Status:** ☑ landed & verified
> **As-built notes.** Two deviations from the original design, both confirmed against the code:
> (1) `embedded_resources.cpp:270` already checks its `error_code` and returns `false` on failure — it was **not** a bug, so it is left untouched.
> (2) Of the four `autoDetectConfig` callers, only the primary connect path (`app_network.cpp:243`) was wired to check `dir_error`; the other three degrade gracefully on their own — `app.cpp:4306` and `app_wizard.cpp:912` are stop paths that already gate on empty creds, and `settings_page.cpp:434` is read-only display. `dir_error` is set by `autoDetectConfig`, so they can be wired later if desired.
### The defect
Five startup directory-create sites either drop the `error_code` or use the throwing
overload with no `catch`: `main.cpp:730`, `connection.cpp:216` (can throw **uncaught**
through its callers), `embedded_resources.cpp:270`, `app.cpp:4172`/`4218`. A read-only
home or permission-denied yields a confusing "conf missing" / "binary not found"
downstream — or an uncaught `filesystem_error` — instead of a clear cause.
### The fix
One shared, non-throwing `Platform::ensureDirectory(dir, outError)` in
`util/platform.{h,cpp}` that produces a single consistent message. Replace all five
sites; `autoDetectConfig()` moves off the throwing overload and sets a new
`ConnectionConfig::dir_error` that its four callers check and bail on. This is the
**structural owner** of the fs-error idiom that F5 and F6 reuse.
### Files touched
- `src/util/platform.h` / `.cpp` — `ensureDirectory()`
- `src/rpc/connection.h` / `.cpp` — `dir_error` + `autoDetectConfig`
- `main.cpp`, `app.cpp`, `app_network.cpp`, `app_wizard.cpp`, `settings_page.cpp`, `embedded_resources.cpp` — 5 sites + 4 callers
- `tests/test_phase4.cpp` — `TestPlatformEnsureDirectory`
### Core change
```cpp
// util/platform.cpp — one shared, non-throwing helper
bool Platform::ensureDirectory(const std::string& dir, std::string* outError) {
std::error_code ec;
if (std::filesystem::is_directory(dir, ec)) return true;
ec.clear();
std::filesystem::create_directories(dir, ec);
if (ec) {
if (outError)
*outError = "Cannot create " + dir + ": " + ec.message() +
". Check permissions / free space.";
return false;
}
return true;
}
// Replaces 5 ad-hoc sites; autoDetectConfig() now sets ConnectionConfig::dir_error,
// and its 4 callers bail on it.
```
### Verification
- Unit `TestPlatformEnsureDirectory`: existing dir → true; fresh nested → created; POSIX unwritable → false + message.
- All four `autoDetectConfig` callers tolerate `dir_error`. Pre-App-init site (main.cpp) reports via stderr / MessageBox.
### Dependencies
**Owns** `Platform::ensureDirectory` (used by F5, F6) and the `ConnectionConfig`
extension (coordinated with F8). Land before F5/F6/F8.
---
## F8 — Plaintext-remote RPC credential transmission is warn-only
**Severity:** Medium · **Effort:** M (~6–9h) · **Status:** ☑ landed & verified
> **⚠️ RELEASE NOTES REQUIRED — breaking default flip.** A wallet configured to talk to a
> **remote** `rpchost` over **plain HTTP** (no `rpctls=1`) will now be **refused** at connect
> time instead of warned. Affected users must add **`rpcallowplaintext=1`** to `DRAGONX.conf`
> (or switch to `rpctls=1`) to reconnect. Local/embedded daemons (`127.0.0.0/8`, `localhost`,
> `::1`) are unaffected. Call this out prominently in the release notes.
>
> **As-built note.** Shipped the security-complete core: `isLocalHost` tightened to exact
> loopback (`isExactIPv4Loopback` — `127.evil.com` no longer passes), refuse-by-default in
> `tryConnect`, and the `rpcallowplaintext` conf-key opt-in. The **Settings toggle UI was
> deferred** — the RPC section of `settings_page.cpp` is read-only display and a security
> toggle there is riskier surface; the conf-key opt-in fully covers recovery, and the refusal
> status/notification tells the user exactly what to add. The toggle can be added later
> (persist a `Settings` flag and OR it into `allowsPlaintextRemote`).
### The defect
A remote `rpchost` without `rpctls=1` sends Basic-auth `rpcuser:rpcpassword` over
cleartext HTTP. `tryConnect()` (`app_network.cpp:314`) only shows a **dismissible
warning** then proceeds — a local-network MITM sees the credentials. Compounding it,
`isLocalHost()`'s naive `rfind("127.",0)==0` misclassifies `127.evil.com` as local,
suppressing even the warning.
### The fix
Change the policy to **refuse-by-default with an explicit, persisted opt-in** — a
`rpcallowplaintext=1` conf key (for hand-editors) and a Settings toggle. Block the
connect and show a **blocking modal** explaining the risk and how to enable TLS or opt
in; localhost is unaffected. Tighten `isLocalHost()` to exact `127.x.y.z` / `::1` /
`localhost` via `isExactIPv4Loopback()`. **Back-compat:** default off ⇒ existing remote
users hit a hard stop until they opt in — **ship with prominent release notes.**
### Files touched
- `src/rpc/connection.h` / `.cpp` — `isLocalHost`, `allow_plaintext_remote`, `parseConfFile`
- `src/config/settings.h` / `.cpp` — persisted opt-in
- `src/app_network.cpp`, `src/app.h` — refuse + modal dispatch
- `src/ui/windows/plaintext_remote_rpc_dialog.h` — new blocking modal
- `src/ui/pages/settings_page.cpp` — toggle UI
### Core change
```cpp
// Tightened loopback test — "127.evil.com" is NOT local
bool Connection::isLocalHost(const std::string& host) {
std::string h = stripBrackets(lowercase(host));
return h == "localhost" || h == "::1" || isExactIPv4Loopback(h); // exact 127.x.y.z
}
// Refuse-by-default with an explicit, persisted opt-in
const bool plaintextRemote = rpc::Connection::usesPlaintextRemote(config);
const bool plaintextAllowed = config.allow_plaintext_remote // rpcallowplaintext=1
|| settings_.getAllowPlaintextRemoteRpc(); // Settings toggle
if (plaintextRemote && !plaintextAllowed) {
connection_status_ = TR("sb_plaintext_remote_blocked");
showPlaintextRemoteRpcDialog(config.host + ":" + config.port); // blocking modal
return; // no creds sent
}
```
### Verification
- Unit: `isLocalHost` — `127.evil.com` false, `127.0.0.1`/`::1`/`localhost` true; `allowsPlaintextRemote` honors conf key + settings flag.
- Manual: remote plaintext blocked; modal fires; opt-in persists across restart.
### Dependencies
F7 (second extender of `ConnectionConfig`/`parseConfFile`; land after so the struct grows
once). Wire `renderPlaintextRemoteRpcDialog` into the app modal-dispatch list.
---
## Shared helpers & coordination points
| Helper | Purpose | Used by |
|--------|---------|---------|
| `Platform::ensureDirectory()` | Single non-throwing directory-create with one consistent message; replaces five ad-hoc sites. Owned by F7. | F7, F5, F6 |
| `ConnectionConfig` extension | Coordination point, not a function: F7 adds `dir_error`, F8 adds `allow_plaintext_remote`. Land F7→F8 so it grows once per step. | F7, F8 |
| `util::sha256Hex` *(existing)* | Already-compiled, curl-free SHA-256. F6 becomes its third caller — no second hash routine. | F6 |
| `connectHasStalled()` *(new, pure)* | Stall predicate split out of the ImGui/App code for unit testing, per the `*_updater_core.cpp` precedent. | F3 |
| `evaluateDatadirLockGate()` *(new, pure)* | Lock-gate decision as `{proceed, message}` from three booleans — unit-testable without real process/fs I/O. | F4 |
## F3 — Unbounded connect spinner (deferred to step 6)
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
> **As-built note.** `renderLoadingOverlay()` is a pure draw-list overlay with **no interactive
> widgets** (the existing crash case at ~5289 already communicates via guidance *text*, relying on
> the sidebar staying reachable). So rather than inject `ActionButton`s — which would fight the
> non-interactive overlay — the stall notice follows that same idiom: a "Taking longer than
> expected" title + a reassuring body (with elapsed seconds) + a full-node-gated hint ("Open
> Settings → Restart Daemon, or check the Console"). This let me drop the planned
> `WalletState::connect_stalled` flag too: the stalled state is computed locally in the overlay
> from `connect_stall_since_`, so the only new member is `App::connect_stall_since_`.
The connect loop retries forever while `!state_.connected` (`app.cpp:1239`);
`loading_timer_` only animates the spinner. Stamp `connect_stall_since_` when
"reachable but not ready" is first seen; a pure `connectHasStalled()` helper (new
`util/connect_stall.h`, default 45s from `ui.toml`) flips `state_.connect_stalled` at
threshold, and `renderLoadingOverlay()` shows a "Taking longer than expected" panel with
Retry / Restart daemon / Open console (full-node gated). The background retry keeps
firing — recovery clears the panel automatically. Guarded off while the daemon is in
`State::Error` (owned by F1's crash-count hint). Full detail lives in the sequencing/
design record; see the shared-helper table above.
---
## Cross-cutting notes
- **One TU, three functions.** `embedded_daemon.cpp` is edited by F1 (`isRunning`),
F2 (`startProcess`) and F4 (`start`) — no literal hunk overlap, but land in order to
keep "monitorProcess is the sole reaper" coherent.
- **Connection struct grows twice.** `connection.h/.cpp` is touched by F6, F7 and F8;
F7 and F8 both extend `ConnectionConfig` and `parseConfFile` — highest collision risk.
Sequence F7→F6→F8.
- **Testability split.** The three new pure predicates all get `tests/test_phase4.cpp`
coverage. F1/F2's fork/exec/waitpid changes are **not** unit-testable — they rely on
manual `kill` / non-executable-binary repros, consistent with the no-process-spawn harness.
- **i18n is additive-only.** Add each finding's English keys to `strings_`, then run
`scripts/add_missing_translations.py` **once at the very end**
(`json.dump indent=4, sort_keys=True, ensure_ascii=False`) — never bulk-regenerate a
`res/lang/*.json`.
- **F8 is a breaking default flip.** Refuse-plaintext-by-default stops existing
remote-RPC users cold until they opt in. Lands last, gated behind a persisted opt-in,
with release notes calling out the new `rpcallowplaintext` key and the Settings toggle.
- **Latent hazard, out of scope.** F1 surfaces (but doesn't fix) a second
double-`waitpid` window between `stop()`'s final blocking reap (`:1220`) and a
mid-sleep monitor iteration — file it as its own ticket.
---
## Progress log
- **F1/F2 integration tests** — ☑ added `testExecFailureReported` (F2) and `testDaemonCrashDetected` (F1) to `test_phase4.cpp`, driving the **real** `EmbeddedDaemon` fork/exec/waitpid code headlessly (POSIX; required linking `embedded_daemon.cpp` into the test target — its deps were already there). The F1 test hammers `isRunning()` from the test thread while the child exits, so it's a genuine regression test for the reap race. **The F2 test caught a real bug:** `start()`'s failure branch overwrote `startProcess()`'s precise `last_error_` ("…not executable or wrong architecture") with a generic "Failed to start dragonxd process" (because `setState(Error, …)` stores its message into `last_error_`), so the precise reason never reached `getLastError()`/the UI — **fixed** to preserve the detail (now also surfaced via the state callback / crash panel). Build-clean; `ctest` 1/1.
- **F1** — ☑ landed: `isRunning()` (POSIX) now reads the atomic `state_` (predicate `Running || Stopping`) instead of calling `waitpid`, leaving `monitorProcess()` the sole reaper. Clean build (all targets link); `ctest` 1/1 passing. Not unit-testable — needs the manual `kill -SEGV` repro before release.
- **F2** — ☑ landed: `startProcess()` (POSIX) now creates a `FD_CLOEXEC` self-pipe before `fork()`; the child writes `errno` to it on `execv` failure, the parent reads EOF-vs-errno and, on failure, reaps the zombie + sets a precise `last_error_` ("not executable or wrong architecture") + returns `false` (so `start()` no longer reports `Running` for a daemon that never started). Parent-side `setpgid` is now best-effort with a `DEBUG_LOGF` on failure. Clean build; `ctest` 1/1 passing. Not unit-testable — needs the manual non-executable / wrong-arch-binary repro before release.
- **F8** — ☑ landed: `isLocalHost()` tightened to exact loopback via `isExactIPv4Loopback` (a `127.`-prefixed *hostname* like `127.evil.com` is no longer misclassified as local). `tryConnect()` now **refuses** a plaintext connection to a remote host instead of warn-and-proceeding — a local-network MITM can no longer capture `rpcuser:rpcpassword` — unless the user opts in with `rpcallowplaintext=1` in `DRAGONX.conf` (new `ConnectionConfig::allow_plaintext_remote` + `allowsPlaintextRemote()` policy). The refusal surfaces via status line + a one-time notification. New `testIsLocalHost` (12 assertions) + `testAllowsPlaintextRemote` (5). Clean build; `ctest` 1/1 passing. **Breaking — needs release notes; Settings-toggle UI deferred (see as-built note).**
- **F3** — ☑ landed: the connect loop now stamps `connect_stall_since_ = ImGui::GetTime()` the moment the daemon first goes "reachable but not ready" (warmup branch + `applyDaemonInitStatus`), and clears it in `onConnected` / `onDisconnected` / warmup-complete — all in `app_network.cpp`. The pure `util::connectHasStalled(stallSince, now, threshold)` helper (new `util/connect_stall.h`, default 45 s from `ui.toml`) drives a draw-list "Taking longer than expected" notice in `renderLoadingOverlay()` (title + elapsed-seconds body + full-node hint), guarded off while the daemon is in `State::Error`. Background retry continues, so the notice self-clears on connect. New `testConnectHasStalled` unit test (7 assertions). Clean build; `ctest` 1/1 passing. (Draw-list text, not buttons — see as-built note above.)
- **F6** — ☑ landed: `verifySaplingParams()` now hash-verifies each Sapling param against its pinned canonical SHA-256 (from `build-lite-backend-artifact.sh`), replacing the existence-only check, so a truncated/corrupt-but-present param is rejected instead of failing later on a shielded op. A `<params_dir>/.sapling_verified` marker keyed on `size:mtime` skips re-hashing ~48 MB on every startup. Logic extracted to the injectable `verifySaplingParamsIn(dir, digests)`; new `testVerifySaplingParams` unit test (valid / marker fast-path / wrong-hash / truncated / missing). Clean build; `ctest` 1/1 passing.
- **F5** — ☑ landed: `startEmbeddedDaemon()` now checks `extractEmbeddedResources()`'s return (abort with `sb_daemon_extract_failed` on failure) and the previously-dropped `copy_file` `error_code` in the daemon-binary fallback loop (abort with `sb_daemon_files_failed` incl. the dir), so a disk-full / truncated `dragonxd` write is surfaced up front instead of failing opaquely at spawn. An absent source file stays non-fatal. Two i18n keys added to `i18n.cpp`. Clean build; `ctest` 1/1 passing.
- **F7** — ☑ landed: new non-throwing `Platform::ensureDirectory(dir, outError)` in `util/platform.{h,cpp}` with one consistent message. Replaces the unchecked/throwing directory-create sites at `main.cpp:730` (pre-init: now logs + `MessageBoxA` on Windows + `return 1`), `connection.cpp:216` (autoDetectConfig now uses the ec overload — **no more uncaught `filesystem_error`** — and sets the new `ConnectionConfig::dir_error`), and both `app.cpp` daemon-dir sites (surface via `daemon_status_` + `return false`). Primary connect path (`app_network.cpp:243`) checks `dir_error` and bails to the status line instead of mislabelling it "waiting for config". `embedded_resources.cpp:270` left as-is (already correct). New `testPlatformEnsureDirectory` unit test (existing-dir / fresh-nested / empty / parent-is-file). Clean build; `ctest` 1/1 passing.
- **F4** — ☑ landed: `start()` now gates on a lingering datadir lock after the port bail. When `!skip_port_check_ && override_datadir_.empty()`, it polls `isDaemonProcessRunning()` with a bounded ~300 ms wait (3 × 100 ms, breaks early), then a pure header-inline `evaluateDatadirLockGate()` decides: if a sibling `dragonxd` is still alive it bails with a distinct **non-crash** `State::Error` ("…holding the data directory lock. Retrying shortly…") that never touches `crash_count_`, so the 3-strike cap can't trip; the connect loop's retry resumes once the lock clears. Isolated migrate-to-seed starts are exempt. New `testDatadirLockGate` unit test (5 assertions, proceed/bail/2× exempt) added to `test_phase4.cpp`. Clean build; `ctest` 1/1 passing.
+43
View File
@@ -0,0 +1,43 @@
**DragonX (DRGX) Lite Desktop Wallet — v1.1.1**
ObsidianDragonLite is the lightweight, node-free companion to ObsidianDragon — it connects to a DragonX lite-wallet server for near-instant startup, with the same native ImGui interface and shielded-first workflow. **v1.1.1 is a maintenance release** centered on an important wallet-encryption fix.
---
## ⚠️ Security fix — please read
**Creating or restoring a Lite wallet with a passphrase now actually encrypts it.**
In prior versions, the create / restore / open flow used by the Settings UI **silently discarded the passphrase you entered**:
- A **newly created or restored** wallet was saved to disk **unencrypted** — the seed and spending keys were stored in the clear even though you set a passphrase.
- **Opening** an already-encrypted wallet did not unlock it with the supplied passphrase (it opened locked and re-prompted).
v1.1.1 fixes this: create/restore now **encrypt** the wallet with your passphrase (the backend encrypts and locks it), and open now **unlocks** it.
**What you should do:** if you created or restored a Lite wallet **with a passphrase** on an earlier version, treat that wallet's on-disk data as unencrypted. Open it in v1.1.1 and re-apply encryption from **Settings → encrypt** (and, if you're concerned the plaintext wallet file may have been exposed, move the funds to a freshly created encrypted wallet). Wallets you never set a passphrase on are unaffected.
---
## Also fixed
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
---
## Downloads
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
| File | SHA-256 |
|------|---------|
| ObsidianDragonLite-1.1.1.AppImage | `323ff5f8658a5550aa12678ce4be37e32a2bfd3f3b0543074cc86a4267895dcf` |
| ObsidianDragonLite-1.1.1-Linux-x64.zip | `e8604e479aed7e020946df45bf0d9373401354907e10565011694efa9978e631` |
| ObsidianDragonLite-1.1.1.exe | `4cdcf291fe43c4fed04258aa55bb4e9b591f52780f9db5fb8f8bef522c43a351` |
| ObsidianDragonLite-1.1.1-Windows-x64.zip | `c387bfcda508f32195c41fabec9f23d8b094d693a5dba41fbf8ec4abab6f2f6e` |
| ObsidianDragonLite-1.1.1-macOS-x86_64.dmg | `bb0c491cb8f90ae55d162ec7c04c8f30988fa2f79c7911471220cddab44f99a5` |
| ObsidianDragonLite-1.1.1-macOS-x86_64.app.zip | `a2a75fe40bf1f4db3abed8206dcc40d9fc74d8db2df7704afc9c834106b1c247` |
---
For the full feature set (node-free operation, wallet lifecycle, shielded + transparent send/shield/receive, HushChat, server browser with failover, 8-language UI), see the **lite-v1.0.0** release notes — v1.1.1 changes only the items listed above.
+41
View File
@@ -0,0 +1,41 @@
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.2**
ObsidianDragon is a native ImGui-based full-node wallet for the DragonX network, with an embedded `dragonxd` daemon and the integrated DRG-XMRig CPU miner. **v2.0.2 is a maintenance release** — a focused set of transaction-history, sync-safety, and correctness fixes on top of v2.0.x. No configuration changes are required; just replace the binary.
---
## What's Fixed in v2.0.2
### Transaction history
- **Outgoing shielded sends now appear in History.** Sends *from* a shielded (z-)address — both **z→z** (shielded-to-shielded) and **z→t** (deshield to a transparent address) — were missing from the History tab, because the daemon's `listtransactions` doesn't report them and `z_viewtransaction` doesn't expose the transparent leg. The wallet now enumerates them via `z_listsentbyaddress`, so **your History matches your balance.** (Your funds were never affected — only the display.)
- **More accurate send rows** — a single transaction with two equal-value outputs to different recipients is no longer collapsed into one row, and a transparent self-send / change output is no longer double-counted as both sent and received.
### Sync & fund safety
- **A node that loses all its peers is no longer reported as "synced."** Previously, a wallet whose node dropped to **0 peers** could keep showing a stale balance as if it were current — and allow a Send against it. The wallet now treats a node with no peer-derived chain tip as *not synced*, and a new **"Connected, but no network peers — the node can't sync"** banner explains the stall instead of silently looking healthy.
- **Migrate-to-seed hardening.** If a migrate-to-seed was interrupted (app or daemon restart) *after* the sweep transaction had broadcast but *before* it confirmed, the wallet could mistake the temporarily-zero balance for an empty wallet and adopt the new seed wallet — swapping `wallet.dat` — before the sweep was mined. It now records that a sweep is in flight and **will not swap your wallet until the sweep confirms**, so funds can't be stranded in the timestamped backup.
- **Fixed a rare crash** caused by a data race on the daemon-status text (the daemon-monitor thread writing it while the UI thread read it).
### Display / internationalization
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
---
## Downloads
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
| File | SHA-256 |
|------|---------|
| ObsidianDragon-2.0.2.AppImage | `9bf7328965c6348113ba7a30b0284f15787c635af17efae3dfa8a87e56e10c9b` |
| ObsidianDragon-2.0.2-Linux-x64.zip | `1371880c4187a94d4cb56d535471142c5f1a8053ef73fffd99d59e7b78c75c42` |
| ObsidianDragon-2.0.2.exe | `673347d2696fb6f5bd09dc25e415d5ae8e532abde75b8d48c3cd26545461de50` |
| ObsidianDragon-2.0.2-Windows-x64.zip | `f5ea7c45e66c6c1b90a8127f96dee04235691acb913ddc86327f2060798cd28c` |
| ObsidianDragon-2.0.2-macOS-x86_64.dmg | `7038580807a8e93e5443bdc04f2aede3ae1c30e5bc9f8776a207edb2e87771d1` |
| ObsidianDragon-2.0.2-macOS-x86_64.app.zip | `78159512ca684b95528231fd46f2d07b5bcc6f2da4d815fc1ecb2ff52127f41c` |
---
For the full feature set (HushChat messaging, seed-phrase wallets & migrate-to-seed, in-app node/miner updaters, built-in mining, 8-language UI), see the **v2.0.0** release notes — v2.0.2 changes only the fixes listed above.
-167
View File
@@ -1,167 +0,0 @@
# Wallet Loading & Management — Hardening Plan
Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
diagnosability QoL). Companion to the findings artifact. Line references are against `dev`.
- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the
code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted
(W1-5), 1 raised (W5-3 Low→Med).
- **Severity:** 8 High · 12 Medium · 13 Low.
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
---
## Roadmap (ordered by risk; shared fixes grouped)
| Phase | Findings | Theme | Status |
|-------|----------|-------|--------|
| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
| **P1-A** | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
---
## P0-A — Secret hardening
Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
key/passphrase paths, plus console redaction and deleting the plaintext export.
- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an
allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`,
`encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`,
`signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and
keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for
unit testing. **← implementing first (self-contained + testable).**
- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport<ts>` plaintext
key dump after `z_importwallet` succeeds (overwrite-then-unlink).
- **W4-3 (High)** `app_network.cpp:4481` — `sodium_memzero` the concatenated all-keys string in
`exportAllKeys`; write the backup 0600. (Also unify with `ExportAllKeysDialog` — QoL.)
- **W4-1 (High)** `app_network.cpp:3801` — zero the key copies in `importPrivateKey`/`sweepPrivateKey`
(local + worker-lambda copies).
- **W2-3 (Med)** `app_security.cpp:1481` — zero the passphrase threaded through the decrypt lambda chain.
- **W4-5 (Med)** `app.cpp:3577` — the seed-backup `.txt` is a permanent predictable cleartext seed;
at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase.
- **W5-3 (Med)** `lite_wallet_lifecycle_service.cpp:322` — remove the dead `passphrase` field from the
lite create/open/restore requests (unused; a secret copied for nothing).
## P0-B — Encryption integrity
- **W2-2 / W4-2 (High)** `wallet_security_controller.h:89` — the wizard's deferred encryption is
in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a
wallet the user believes is encrypted stays plaintext. Fix: persist a lightweight
`encryption_requested_but_incomplete` settings flag (NEVER the passphrase) when
`beginDeferredEncryption` is called; surface a persistent warning banner while it's set; clear it
only on confirmed `encryptwallet` success; on next connect, if set, re-prompt for the passphrase to
complete it.
- **W2-4 (Med)** `app_security.cpp:480` — `lockWallet` only sets `locked` on RPC success; log the
failure and notify (currently a silent no-op that can leave the wallet unlocked).
## P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully)
- **W3-1 (High)** `app_network.cpp:4327` — adopt hardcodes `datadir + "/wallet.dat"`; use
`settings_->getActiveWalletFile()` so migrating a non-default active wallet swaps the right file.
- **W3-2 (High)** `seed_wallet_creator.cpp:57` — `remove_all(<config>/seed-migrate)` unconditionally
at Phase-1 start; refuse to wipe if a temp `DRAGONX/wallet.dat` already exists (a prior un-adopted
swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry.
- **W3-4 (Med)** `app_network.cpp:1124` — block wallet switching while a migration is *pending*
(`getSeedMigrationPending()`), not only while the dialog is open.
- **W3-3 (Med)** `app_network.cpp:4231` — persist the sweep opid so an app-close mid-Sweeping can
resume/re-poll it instead of silently dropping the txid.
## P1-B — Missing/wrong wallet-file safety
- **W1-1 (High)** `app_network.cpp:1109` — `fs::exists()`-check the target wallet file in
`switchToWallet()` and before the first daemon launch at startup; if missing, block with an explicit
"Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the
daemon fabricate an empty wallet.
- **W1-3 (Med)** `app_network.cpp:1095` — defer the `syncedHere=true` stamp to the first successful
address/balance readback (idHash non-empty), not bare `onConnected()`.
- **W1-2 (Med)** `app_network.cpp:198` — split `DB_CORRUPT`-specific strings from the generic "Error
loading wallet" fallback; give `DB_TOO_NEW` its own message/action (not a salvage offer).
- **W1-4 (Low)** `wallets_dialog.h:393` — re-`fs::exists()` the in-datadir row before switching (match
the out-of-datadir path).
## P2 — State & lite persistence
- **W6-2 (Med)** `network_refresh_service.cpp:1183` — record a per-field last-success timestamp / a
"refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current.
- **W5-1 / W5-2 (Med)** `lite_wallet_controller.cpp:78,603` — `liteLog()` the failed save and bubble a
one-shot UI warning (both call sites currently discard the bool).
- **W6-1 (Med)** `wallet_state.h:313` — reset `mining`/`pool_mining` in `clear()` (or comment why not).
- **W6-3 (Low)** `address_book.cpp:46` — per-entry try/catch: skip + count malformed entries instead
of discarding the whole list.
## F — Diagnostics foundation + QoL
Land W7-2 first — it unblocks the rest.
- **W7-2 (Med)** `logger.cpp:31` — call `Logger::instance().init(<config>/dragonx-debug.log)` early in
`main()` on all platforms; add an "Open log folder" action.
- **W7-3 (Med)** `main.cpp:144` — add a `sigaction`-based crash handler writing `dragonx-crash.log` on
POSIX (mirror the Windows SEH path).
- **W7-4 (Low)** `logger.cpp:39` — size-cap/rotate the log on `init()`.
- **QoL** — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner;
refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured
switch/migration audit logging; restore-from-seed entry point (W4-4, effort L).
---
## Progress log
- **Adversarial review of the 3 diagnostics UI features** — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found **no** Push/Pop imbalance. Fixes landed:
- **(Med) Alert popup grew off the right edge** — pivot `(0,1)` pinned the panel's *left* edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicit `SetNextWindowPos` pivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-*right* corner at the bell (pivot `(1,1)`, at `bellMax.x`) so it grows left over the canvas.
- **(Low) Staleness badge could flash red on reconnect** — `WalletState::clear()` reset everything *except* the four `last_*_update` stamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps in `clear()` (all readers treat 0 as "never"; verified `app_network.cpp:1473` guards on `!= 0`).
- **(Low) Banner min-height floor wasn't DPI-scaled** — `std::max(minH, baseH*vScale())` compared a raw-px floor against a scaled value; now `minH * dpiScale()`.
- **(Low) New i18n keys weren't in `res/lang/`** — back-filled all 16 diagnostics/QoL keys (this session's node_banner_*/data_stale_*/alerts_*/settings_*/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font.
- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 1–4s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.**
- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
- **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(<config>/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed).
- **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter.
- **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded.
Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed:
- **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss).
- **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads).
- **W6-1 (Med):** `WalletState::clear()` didn't reset `mining`/`pool_mining`, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset in `clear()` (the daemon restarts on switch, so mining genuinely stops).
- **W6-3 (Low):** `AddressBook::load()` did `entries_.clear()` then threw on the first non-object element — discarding **every** contact. Now it guards `is_object()` + per-entry try/catch, skipping and counting malformed entries.
Build-clean; `ctest` 1/1. **Remaining P2:** W6-2 (surface refresh staleness — the timestamps exist in `WalletState`; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle).
- **P1-B / W1-3 + startup wallet-existence guard** — ☑ landed:
- **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open.
- **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened.
Build-clean; `ctest` 1/1.
- **P1-A / W3-3 (sweep opid persistence)** — ☑ **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang.
- **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error.
- **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale".
- **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
- **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
- **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
- **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".
Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch).
- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully):
- **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race).
- **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(<config>/seed-migrate)` unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) when `DRAGONX/wallet.dat` already exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one.
- **W3-4 (Med):** `switchToWallet` only blocked switching while the migration *dialog* was open; closing it via "Later" mid-migration dropped the guard. Now also blocks while `getSeedMigrationPending()`.
Build-clean; `ctest` 1/1. **Remaining P1-A:** W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid).
- **P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail)** — ☑ landed:
- **W2-2:** the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with **no record it was ever requested** — the user believing it was encrypted. Now a persisted `encryption_pending` settings flag is set the moment encryption is requested (**never the passphrase** — only the fact). `refreshWalletEncryptionState()` reconciles it on every connect: wallet observed **encrypted** → clear the flag; wallet **not** encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session **"your wallet is NOT encrypted — open Settings to finish"** warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice.
- **W2-4:** `lockWallet()`'s continuation only handled success — a failed `walletlock` silently left the wallet **unlocked** (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed.
Touches `settings.{h,cpp}`, `app_wizard.cpp`, `app_security.cpp`, `app.h`. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean, `ctest` 1/1.
- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore** → `encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open** → `unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)*
- **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to <path>". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: <path>", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision.
- **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code:
- **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy.
- **W4-3** `exportAllKeys`/`backupWallet`: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written via `Platform::writeFileAtomically(..., restrictPermissions=true)` (atomic + 0600) instead of a umask-default `ofstream`.
- **W2-3** decrypt-wallet passphrase: `std::move`-captured into the worker lambda (so no plaintext copy is left in the calling frame) and `sodium_memzero`'d right after `unlockWallet` (its only use).
Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean; `ctest` 1/1 (no regression). **Remaining in P0-A:** W5-3 (remove the dead lite `passphrase` field), W4-5 (predictable plaintext seed-backup file).
- **P0-A / W2-1** — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (`obsidiandecryptexport…`) as soon as the `z_importwallet` attempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (`wallet.dat.encrypted.bak`). `app_security.cpp` (after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean, `ctest` 1/1 (no regression).
- **P0-A / W7-1** — ☑ landed: `RedactConsoleCommand`/`ConsoleCommandCarriesSecret` in `console_tab_helpers` redact secret-bearing commands (an allowlist of 13 first-tokens: `walletpassphrase`, `encryptwallet`, `z_importkey`, …) to `> walletpassphrase ****` before they hit the console echo AND the recall history; the real command still executes unredacted. Wired into `submitConsoleCommand` (`console_tab.cpp`). New `testConsoleSecretRedaction` (11 assertions). Clean build; `ctest` 1/1. (Output-secret commands like `z_exportkey` — result redaction — remain a follow-up.)
File diff suppressed because one or more lines are too long
Binary file not shown.
+453 -25
View File
@@ -19,11 +19,13 @@
"about_license_text": "Diese Software wird unter der GNU General Public License v3 (GPLv3) veröffentlicht. Sie dürfen diese Software gemäß den Lizenzbedingungen frei verwenden, modifizieren und verbreiten.",
"about_peers_count": "%zu Peers",
"about_release": "Release",
"about_source": "Quelle",
"about_title": "Über ObsidianDragon",
"about_version": "Version:",
"about_website": "Webseite",
"acrylic": "Acryl",
"add": "Hinzufügen",
"addr_type_mining": "Mining",
"address": "Adresse",
"address_book_add": "Adresse hinzufügen",
"address_book_add_new": "Neue hinzufügen",
@@ -67,6 +69,7 @@
"appx_blockchain_maintenance_in_progress": "Eine Blockchain-Wartung läuft bereits.",
"appx_blockchain_rescan_complete": "Blockchain-Neuscan abgeschlossen",
"appx_bootstrap_complete_reconciling": "Bootstrap abgeschlossen — Ihr Wallet wird mit den neuen Chain-Daten abgeglichen.",
"appx_bootstrap_reconcile_probe_failed": "Ihr Wallet konnte nicht mit den Bootstrap-Chain-Daten abgeglichen werden: Der Node war zu ausgelastet, um seine Blöcke zu prüfen. Starten Sie Neu scannen in den Einstellungen, sobald er aufgeholt hat.",
"appx_cancel": "Abbrechen",
"appx_cleaning_up": "Aufräumen...",
"appx_confirm_your_backup": "Backup bestätigen",
@@ -77,11 +80,15 @@
"appx_creating_your_wallet": "Ihr Wallet wird erstellt…",
"appx_daemon_error": "Daemon-Fehler",
"appx_daemon_reinstall_in_progress": "Die Daemon-Neuinstallation läuft bereits.",
"appx_daemon_reinstall_node_busy": "Der Node läuft noch, daher wurde der Daemon nicht neu installiert. Versuchen Sie es erneut, sobald er beendet ist.",
"appx_disconnecting": "Verbindung wird getrennt...",
"appx_done": "Fertig",
"appx_dragonxd_output": "dragonxd-Ausgabe",
"appx_encrypting_wallet": "Wallet wird verschlüsselt...",
"appx_fullnode_lifecycle_unavailable_lite": "Full-Node-Lebenszyklusaktionen sind im Lite-Build nicht verfügbar",
"appx_import_cancelled": "Abgebrochen: Der Node scannt möglicherweise noch. Wenn das Guthaben des Schlüssels nicht erscheint, führen Sie Einstellungen → Neu scannen aus.",
"appx_import_interrupted": "Der Import wurde unterbrochen, daher ist der Schlüssel möglicherweise schon in der Wallet. Wenn sein Guthaben nicht erscheint, führen Sie Einstellungen → Neu scannen aus.",
"appx_import_refused": "Der Node hat die Anfrage abgelehnt, daher wurde der Schlüssel nicht importiert.",
"appx_installing_bundled_daemon": "Mitgelieferter Daemon wird installiert — der Node stoppt, aktualisiert und startet neu...",
"appx_invalid_payment_uri_prefix": "Ungültige Zahlungs-URI: ",
"appx_ive_written_it_down": "Ich habe sie notiert",
@@ -98,7 +105,8 @@
"appx_no_bundled_daemon_to_install": "Dieser Build enthält keinen mitgelieferten Daemon zur Installation",
"appx_no_embedded_daemon_to_install": "Dieser Build enthält keinen eingebetteten Daemon zur Installation",
"appx_node_busy_restarting": "Der Node ist mit dem Neustart beschäftigt — versuchen Sie es gleich erneut.",
"appx_node_rebuilding_witness_cache": "Node baut seinen Witness-Cache neu auf",
"appx_node_busy_try_later": "Der Node ist mit einer langen Aufgabe beschäftigt (etwa dem Neuaufbau der Note-Witnesses). Versuchen Sie es erneut, wenn sie abgeschlossen ist; ein Stoppen jetzt könnte diese Arbeit von vorn beginnen lassen.",
"appx_node_not_stopped": "Der Node wurde nicht rechtzeitig beendet, daher wurde nichts geändert. Versuchen Sie es in ein paar Minuten erneut.",
"appx_not_next_word": " — das ist nicht das nächste Wort",
"appx_payment_request_loaded": "Zahlungsanforderung geladen",
"appx_pool_miner_connected_and_hashing": "Pool-Miner verbunden und aktiv.",
@@ -119,11 +127,7 @@
"appx_simple_background_enabled": "Einfacher Hintergrund aktiviert",
"appx_skip": "Überspringen",
"appx_skip_anyway": "Trotzdem überspringen",
"appx_still_status_prefix": "Weiterhin \"",
"appx_still_status_suffix": "\" — ein erzwungenes Beenden jetzt kann die Chain-Daten beschädigen.",
"appx_stop_anyway_and_quit": "Trotzdem stoppen & beenden",
"appx_stopping_daemon_deleting_blockchain": "Daemon wird gestoppt und Blockchain-Daten werden gelöscht...",
"appx_stopping_node_discards_rebuild": "Wenn Sie den Node jetzt stoppen, wird der laufende Neuaufbau verworfen und beim nächsten Öffnen des Wallets neu gestartet (mehrere Minuten). Sie können den Node stattdessen weiterlaufen lassen.",
"appx_stopping_pool_miner": "Pool-Miner wird gestoppt...",
"appx_syncing_pct_block_n_of_n": "Synchronisierung %.1f%% — Block %d / %d",
"appx_tap_words_in_order": "Tippen Sie die Wörter in der richtigen Reihenfolge an, um zu bestätigen, dass Sie sie gespeichert haben.",
@@ -133,8 +137,14 @@
"appx_use_settings_restart_daemon_hint": "Verwenden Sie Einstellungen > Daemon neu starten, um es erneut zu versuchen",
"appx_waiting_for_daemon_to_encrypt_wallet": "Warten, bis der Daemon das Wallet verschlüsselt...",
"appx_wallet_created_and_backed_up": "Wallet erstellt und gesichert.",
"appx_wallet_op_node_busy": "Der Node ist beschäftigt und antwortet nicht. Versuchen Sie den Import oder Neuscan erneut, wenn er fertig ist.",
"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_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.",
"autoshield_off_generic": "Ihr Node schützt geschürfte Coinbase-Coins derzeit nicht automatisch.",
"av_intro": "Mining-Software wird oft als potenziell unerwünscht eingestuft. Führen Sie diese Schritte aus, um das Pool-Mining zu aktivieren:",
"av_open_security": "Windows-Sicherheit öffnen",
"av_step1": "Schritt 1: Ausnahme hinzufügen",
@@ -170,21 +180,20 @@
"balance_history_collecting": "Guthabenverlauf — Daten werden gesammelt...",
"balance_layout": "Guthaben-Layout",
"balance_layout_switched": "Layout: %s",
"balance_mining_fmt": "Mining-Belohnungen: %.8f",
"balance_mining_rate": "Schürfe %s",
"balance_shielded_fmt": "Abgeschirmt: %.8f",
"balance_syncing_pct": "Synchronisiere %.1f%%",
"balance_transparent_fmt": "Transparent: %.8f",
"baltab_market": "Markt",
"baltab_market_price_4dp": "Markt: $%.4f",
"baltab_market_price_8dp": "Markt: $%.8f",
"baltab_mining_addresses_count": "%d Mining-Adressen",
"baltab_mining_amount": "Mining-Belohnungen %.8f",
"baltab_pct_change_24h": "%s%.1f%% 24h",
"baltab_pct_of_total_zaddr": "%.0f%% des Gesamtbetrags · %d Z-addr",
"baltab_shielded": "Geschützt",
"baltab_shielded_amount": "Geschützt %.8f",
"baltab_t_addresses_count": "%d T-Adressen",
"baltab_total_balance": "Gesamtguthaben",
"baltab_transparent": "Transparent",
"baltab_transparent_amount": "Transparent %.8f",
"ban": "Sperren",
"banned_peers": "Gesperrte Peers",
"block": "Block",
@@ -192,6 +201,12 @@
"block_click_copy": "Klicken zum Kopieren",
"block_click_next": "Klicken für nächsten Block",
"block_click_prev": "Klicken für vorherigen Block",
"block_db_reindex_body": "Dies passiert meist nach einem Daemon-Update, das das Format auf der Festplatte ändert, oder wenn der Block-Index beschädigt ist. Ihre Wallet und Ihre Coins sind sicher — der Node kann die Chain nur nicht laden, daher wird das Guthaben mit null angezeigt.\n\nDas Neuaufbauen liest Ihre vorhandenen Block-Dateien erneut ein und kann eine Weile dauern (dabei wird auch Ihre Wallet erneut gescannt). Es wird nichts heruntergeladen.",
"block_db_reindex_confirm": "Block-Datenbank neu aufbauen",
"block_db_reindex_notify": "Der Node kann seine Block-Datenbank nicht lesen (oft nach einem Daemon-Update). Bauen Sie sie neu auf, um Ihr Guthaben wiederherzustellen — siehe die Aufforderung oder Einstellungen › Node.",
"block_db_reindex_started": "Die Block-Datenbank wird aus Ihren Blöcken neu aufgebaut — dies kann eine Weile dauern.",
"block_db_reindex_title": "Block-Datenbank neu aufbauen?",
"block_db_reindex_warn": "Der Node kann seine Block-Datenbank nicht lesen.",
"block_explorer": "Block-Explorer",
"block_get_info": "Block-Info abrufen",
"block_hash": "Block-Hash:",
@@ -228,6 +243,7 @@
"change_pass_confirm": "Neue bestätigen:",
"change_pass_current": "Aktuelle Passphrase:",
"change_pass_new": "Neue Passphrase:",
"change_pass_pin_note": "Diese Wallet hat eine Schnell-Entsperr-PIN. Nach der Änderung ist sie veraltet: Entfernen Sie die PIN in den Einstellungen und legen Sie sie neu fest.",
"change_pass_title": "Passphrase ändern",
"characters": "Zeichen",
"chat": "Chat",
@@ -239,6 +255,10 @@
"chat_accent_theme": "Design",
"chat_add_contact": "Kontakt hinzufügen",
"chat_awaiting_key": "Warten auf Antwort",
"chat_blocked_desc": "Blockierte Unterhaltungen werden entfernt und ihre Nachrichten verworfen — alte wie neue — bis Sie die Blockierung aufheben. Beim Aufheben wird die Unterhaltung erneut aus der Chain importiert.",
"chat_blocked_manage": "Blockiert",
"chat_blocked_title": "Blockierte Unterhaltungen",
"chat_blocked_toast": "Unterhaltung gelöscht & blockiert",
"chat_bubble_minimal": "Minimal",
"chat_bubble_rounded": "Abgerundet",
"chat_bubble_square": "Eckig",
@@ -251,6 +271,16 @@
"chat_contact_added": "Kontakt hinzugefügt – benenne ihn in Kontakte um",
"chat_contact_request": "kontaktanfrage",
"chat_copy_address_tip": "Zum Kopieren der Adresse klicken",
"chat_delete": "Unterhaltung löschen",
"chat_delete_block": "Löschen & blockieren",
"chat_delete_body_prefix": "Ihre lokale Kopie der Unterhaltung mit ",
"chat_delete_body_suffix": " löschen?",
"chat_delete_confirm": "Löschen",
"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_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",
"chat_density_comfortable": "Komfortabel",
"chat_density_compact": "Kompakt",
"chat_emoji_color": "Farbig",
@@ -274,6 +304,7 @@
"chat_new_button": "Neuer Chat",
"chat_new_message": "Nachricht",
"chat_new_message_toast": "Neue verschlüsselte Chat-Nachricht",
"chat_new_needs_zaddr": "Chat benötigt eine geschützte (z-)Adresse — transparente (t-)Adressen können keine verschlüsselten Nachrichten empfangen.",
"chat_new_send": "Anfrage senden",
"chat_new_title": "Neuer Chat",
"chat_new_zaddr": "z-Adresse des Empfängers",
@@ -319,6 +350,8 @@
"chat_ts_24h": "24-Stunden",
"chat_ts_global": "Global folgen",
"chat_ts_global_short": "Global",
"chat_unblock": "Blockierung aufheben",
"chat_unblocked_toast": "Blockierung der Unterhaltung aufgehoben",
"chat_unhide": "Einblenden",
"chat_unmute": "Stummschaltung aufheben",
"chat_verify_key": "Identitätsschlüssel – zum Verifizieren vergleichen",
@@ -371,6 +404,7 @@
"console": "Konsole",
"console_accents": "Farbakzente",
"console_app": "App",
"console_auto_focus": "Eingabe beim Öffnen fokussieren",
"console_auto_scroll": "Automatisch scrollen",
"console_available_commands": "Verfügbare Befehle:",
"console_backend_reference": "Backend-Befehlsreferenz",
@@ -465,11 +499,15 @@
"console_tab_completion": "Tab zur Vervollständigung",
"console_text_colors": "Textfarben",
"console_toggle_accents": "Farbakzente der Zeilen umschalten",
"console_toggle_auto_focus": "Den Cursor beim Öffnen des Konsolen-Tabs im Befehlsfeld platzieren",
"console_toggle_text_color": "Textfarben der Zeilen umschalten",
"console_type_help": "Geben Sie 'help' ein für verfügbare Befehle",
"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_funds_btn": "Konsolidieren",
"consolidate_title": "Guthaben konsolidieren",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "Dieses Bild konnte nicht geladen werden.",
"contact_avatar_badge": "Abzeichen",
@@ -481,7 +519,7 @@
"contact_avatar_image_hint": "Das Bild wird in die App kopiert, damit es verfügbar bleibt, wenn das Original verschoben wird.",
"contact_avatar_remove": "Entfernen",
"contact_avatar_shielded": "Abgeschirmt",
"contact_avatar_transparent": "Transparent",
"contact_chip_mining": "Nur Mining",
"contact_global": "In jeder Wallet anzeigen (globaler Kontakt)",
"contact_global_badge_tt": "Globaler Kontakt — in jeder Wallet sichtbar",
"contact_global_tt": "Ein: Dieser Kontakt bleibt sichtbar, egal welche Wallet Sie laden. Aus: Er gehört nur zur aktuellen Wallet.",
@@ -515,9 +553,13 @@
"daemon_maintenance_label": "WARTUNG",
"daemon_none_bundled": "keiner in diesem Build",
"daemon_not_installed": "nicht installiert",
"daemon_status_diff": "Version weicht ab",
"daemon_status_differ": "Installierte Binärdatei unterscheidet sich von der gebündelten Version.",
"daemon_status_match": "Installierte Binärdatei entspricht der gebündelten Version.",
"daemon_status_missing": "Kein Daemon installiert — installieren Sie die mitgelieferte Version.",
"daemon_status_none": "Nicht installiert",
"daemon_status_ok": "Aktuell",
"daemon_update_active_tt": "Der derzeit installierte (aktive) Node",
"daemon_update_available": "Eine neue Node-Version ist verfügbar",
"daemon_update_body": "Dieser Wallet-Build enthält einen neueren DragonX-Node als den derzeit installierten. Beim Aktualisieren wird der installierte dragonxd (sowie dragonx-cli/dragonx-tx) ersetzt und der Node anschließend gestoppt und neu gestartet, damit die neue Version wirksam wird. Empfohlen — ein neuerer Node kann Funktionen (z. B. Unterstützung für Wiederherstellungsphrasen) bieten, die der alte nicht hat.",
"daemon_update_browse": "Alle Releases durchsuchen…",
@@ -527,19 +569,28 @@
"daemon_update_download_install": "Herunterladen & installieren",
"daemon_update_downloading": "Wird heruntergeladen…",
"daemon_update_failed": "Update fehlgeschlagen",
"daemon_update_install_this": "Diese Version installieren",
"daemon_update_installed": "Installiert:",
"daemon_update_installed_ok": "Node installiert",
"daemon_update_installing": "Wird installiert…",
"daemon_update_keep": "Aktuellen behalten",
"daemon_update_later": "Später",
"daemon_update_latest": "Neueste:",
"daemon_update_latest_badge": "neueste",
"daemon_update_latest_tt": "Neueste verfügbare Version",
"daemon_update_loading": "Releases werden geladen…",
"daemon_update_no_notes": "Keine Versionshinweise für diese Version.",
"daemon_update_now": "Jetzt aktualisieren",
"daemon_update_prerelease_tt": "Vorabversion / Testbuild",
"daemon_update_prompt_title": "Node-Daemon aktualisieren?",
"daemon_update_reinstall": "Neu installieren",
"daemon_update_released": "Veröffentlicht:",
"daemon_update_restart_note": "Starten Sie den Daemon neu, um die neue Version auszuführen.",
"daemon_update_restart_now": "Daemon jetzt neu starten",
"daemon_update_safe": "Ihre Wallet, Ihre Schlüssel und Ihre Blockchain-Daten bleiben unberührt — nur die Programmdateien des Daemons werden ersetzt. Wenn Sie bewusst einen eigenen Node betreiben, wählen Sie „Aktuellen behalten“.",
"daemon_update_status_current": "Diese Version ist installiert",
"daemon_update_status_latest": "Neueste Version",
"daemon_update_status_older": "Älter als Ihre installierte Version",
"daemon_update_title": "Node-Daemon aktualisieren?",
"daemon_update_unavailable_body": "Für diese Plattform ist kein Node-Build verfügbar.",
"daemon_update_unavailable_title": "Node-Updates nicht verfügbar",
@@ -555,6 +606,13 @@
"data_stale_tooltip": "Der Kontostand ist möglicherweise veraltet – die Wallet hat kürzlich keine Aktualisierung erhalten. Überprüfe deine Node-Verbindung.",
"date": "Datum",
"date_label": "Datum:",
"debug_gate_confirm": "Debug-Optionen anzeigen",
"debug_gate_incorrect": "Falsche PIN oder Passphrase.",
"debug_gate_pass_prompt": "Geben Sie Ihre Wallet-Passphrase ein, um fortzufahren:",
"debug_gate_pin_prompt": "Geben Sie Ihre Entsperr-PIN ein, um fortzufahren:",
"debug_gate_title": "Debug-Optionen anzeigen?",
"debug_gate_verifying": "Wird überprüft…",
"debug_gate_warning": "Dies sind erweiterte Diagnose- und Node-Feinabstimmungsoptionen. Sie können vertrauliche Informationen offenlegen und die Funktionsweise Ihres Nodes verändern. Fahren Sie nur fort, wenn Sie wissen, was Sie tun.",
"debug_logging": "FEHLERPROTOKOLLIERUNG",
"decrypt_desc": "Die Wallet wird exportiert, der Daemon mit einer frischen, unverschlüsselten Wallet neu gestartet und alle Schlüssel werden erneut importiert. Je nach Wallet-Größe kann dies einige Minuten dauern.",
"decrypt_error_title": "Entschlüsselung fehlgeschlagen",
@@ -577,9 +635,15 @@
"disconnected": "Getrennt",
"dismiss": "Verwerfen",
"display": "Anzeige",
"done": "Fertig",
"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",
@@ -630,6 +694,7 @@
"explorer_tx_outputs": "Ausgaben",
"explorer_tx_size": "Größe",
"explorer_txid": "TxID:",
"explorer_urls_hdr": "URLS",
"export": "Exportieren",
"export_csv": "CSV exportieren",
"export_keys_btn": "Schlüssel exportieren",
@@ -655,9 +720,155 @@
"export_tx_success": "Transaktionen erfolgreich exportiert",
"export_tx_title": "Transaktionen als CSV exportieren",
"export_viewing_key": "Betrachtungsschlüssel exportieren",
"failed_create_mining_address": "Mining-Adresse konnte nicht erstellt werden",
"failed_create_shielded": "Abgeschirmte Adresse konnte nicht erstellt werden",
"failed_create_transparent": "Transparente Adresse konnte nicht erstellt werden",
"faq": "FAQ",
"faq_d_mgmt_1_a": "Der eingebettete Node startet automatisch mit der Wallet und stoppt bei Bedarf, sodass Sie ihn normalerweise nicht manuell verwalten müssen. Erweiterte Steuerelemente (einschließlich des Neustarts des Daemons) finden Sie unter Einstellungen > Node & Sicherheit.",
"faq_d_mgmt_1_q": "Wie starte oder stoppe ich den Node?",
"faq_d_mgmt_2_a": "Wenn Sie die Wallet mit einem selbst gestarteten Node verbinden, entscheidet diese Option, ob das Schließen der Wallet auch diesen Node beendet. Lassen Sie sie deaktiviert, wenn Ihr Node nach dem Schließen der Wallet weiterlaufen soll.",
"faq_d_mgmt_2_q": "Was bewirkt „externen Daemon beim Beenden schließen“?",
"faq_d_mgmt_3_a": "Node- und Wallet-Daten befinden sich im DragonX-Datenverzeichnis unter Ihrem Benutzerprofil (zum Beispiel in AppData unter Windows oder Ihrem Home-Ordner unter Linux/macOS). Die Blockchain ist der große Teil davon; halten Sie genug freien Speicherplatz für ihr Wachstum bereit.",
"faq_d_mgmt_3_q": "Wo werden die Blockchain- und Wallet-Daten gespeichert?",
"faq_d_mgmt_title": "Node-Verwaltung",
"faq_d_mine_1_a": "Im Tab „Mining“ können Sie mit Ihrer CPU minen. DragonX verwendet den RandomX-Algorithmus, der für gewöhnliche Prozessoren ausgelegt ist. Stellen Sie die Anzahl der Threads ein und starten Sie — Belohnungen fließen in Ihre Wallet.",
"faq_d_mine_1_q": "Wie mine ich DragonX?",
"faq_d_mine_2_a": "Solo-Mining sendet Belohnungen direkt an Sie, zahlt aber nur, wenn Sie einen Block finden — das ist selten, sofern Sie nicht über viel Hashrate verfügen. Pool-Mining teilt die Arbeit mit anderen für kleinere, aber gleichmäßigere Auszahlungen. Der Mining-Tab unterstützt beides.",
"faq_d_mine_2_q": "Solo- oder Pool-Mining — was soll ich wählen?",
"faq_d_mine_3_a": "Im Pool-Bereich des Mining-Tabs gibt es die Schaltfläche „Miner aktualisieren…“, die den neuesten optimierten DragonX-Miner herunterlädt, verifiziert und installiert. Wie beim Node-Updater wird jeder Download vor der Installation per Prüfsumme und Signatur verifiziert.",
"faq_d_mine_3_q": "Wie aktualisiere ich den Miner?",
"faq_d_mine_4_a": "Der aktuelle Node kann einen integrierten Stratum-Server betreiben, sodass andere Miner sich mit Ihrem Rechner verbinden können. Dafür gibt es einen Schalter unter Einstellungen > Node & Sicherheit. Standardmäßig lauscht er nur lokal; ihn für andere Rechner zu öffnen, erfordert bewusstes Freischalten.",
"faq_d_mine_4_q": "Kann ich einen Mining-Pool hosten?",
"faq_d_mine_title": "Mining & Pools",
"faq_d_net_1_a": "Direkt nach dem Start braucht der Node einen Moment, um Peers zu finden — ein kurzzeitiges „0 Peers“ ist also normal. Hält es an, prüfen Sie Ihre Internetverbindung und ob eine Firewall den Node blockiert. Der Node findet Peers automatisch über eingebaute Seeds.",
"faq_d_net_1_q": "Die Wallet zeigt keine Peers oder Verbindungen an — was ist los?",
"faq_d_net_2_a": "Ja — DragonX-Nodes kommunizieren über TLS miteinander, Peer-Verbindungen sind also verschlüsselt. Auch die Wallet spricht mit ihrem eigenen Node über einen sicheren lokalen Kanal.",
"faq_d_net_2_q": "Ist meine Verbindung zum Netzwerk verschlüsselt?",
"faq_d_net_title": "Peers & Netzwerk",
"faq_d_node_1_a": "Der Daemon, dragonxd, ist der DragonX Full Node. Er lädt und verifiziert die gesamte Blockchain, leitet Transaktionen an das Netzwerk weiter und beantwortet die Anfragen der Wallet. Einen eigenen Node zu betreiben bedeutet, dass Sie keinem fremden Server vertrauen müssen.",
"faq_d_node_1_q": "Was ist der Daemon (dragonxd)?",
"faq_d_node_2_a": "Standardmäßig startet und verwaltet die Wallet ihren eigenen mitgelieferten Node (eingebettet) — Sie müssen nichts tun. Wenn Sie dragonxd bereits selbst betreiben, kann die Wallet sich stattdessen mit diesem externen Node verbinden. Die Wallet erkennt automatisch, welche Situation vorliegt, und verhält sich entsprechend.",
"faq_d_node_2_q": "Eingebetteter vs. externer Node — was ist der Unterschied?",
"faq_d_node_3_a": "Ein Full Node prüft jede Regel der Blockchain eigenständig, sodass Ihre Wallet der Mathematik vertraut statt einem Dritten. Das stärkt außerdem das Netzwerk. Der Preis dafür sind Speicherplatz und eine anfängliche Synchronisierung.",
"faq_d_node_3_q": "Warum überhaupt einen Full Node betreiben?",
"faq_d_node_title": "Der Full Node",
"faq_d_perf_1_a": "Die Blockchain ist der größte Posten und wächst mit der Zeit, halten Sie also mehrere Gigabyte frei. Der Node nutzt für mehr Geschwindigkeit einen Datenbank-Cache; die mitgelieferten Standardwerte sind auf typische Rechner abgestimmt. Fortgeschrittene Nutzer können den Datenbank-Cache des Nodes anpassen, indem sie dbcache in der Konfigurationsdatei (DRAGONX.conf) bearbeiten.",
"faq_d_perf_1_q": "Wie viel Speicherplatz nutzt der Node, und kann ich den Speicherverbrauch anpassen?",
"faq_d_perf_2_a": "Aktivieren Sie den Bootstrap-Download (im Einrichtungsassistenten angeboten), um eine aktuelle, verifizierte Kopie der Chain zu laden, statt jeden Block von Grund auf zu prüfen. Auch eine schnelle Festplatte (SSD) hilft erheblich.",
"faq_d_perf_2_q": "Wie beschleunige ich die anfängliche Synchronisierung?",
"faq_d_perf_title": "Speicher & Leistung",
"faq_d_sync_1_a": "Beim Start lädt und prüft der Node Blöcke, bevor er die Wallet bedienen kann. Der Bildschirm „Blöcke werden verarbeitet / Blöcke werden angewendet, um den aktuellen Chain-Status aufzubauen“ ist dieses Aufwärmen. Das ist normal; die Wallet wird nutzbar, sobald der Node fertig ist und seine Höhe meldet.",
"faq_d_sync_1_q": "Was bedeutet „Blöcke werden verarbeitet“ beim Start?",
"faq_d_sync_2_a": "Für Ihre geschützten Guthaben führt der Node kryptografische „Witnesses“, die private Ausgaben ermöglichen. Nach bestimmten Neustarts oder Updates baut er diesen Cache neu auf, indem er aktuelle Blöcke erneut durchsucht. Bei einer Wallet mit vielen Transaktionen kann das mehrere Minuten dauern und zusätzlichen Speicher benötigen.\n\nSie hängt dabei nicht fest — lassen Sie sie fertig werden. Ein erzwungenes Beenden mittendrin führt nur dazu, dass der Neuaufbau beim nächsten Mal von vorn beginnt.",
"faq_d_sync_2_q": "Was bedeutet „Witness-Cache wird neu aufgebaut“?",
"faq_d_sync_3_a": "Nahe der Chain-Spitze konkurriert die Arbeit, die Ihre Wallet durchsucht (Guthaben und Notes prüfen), mit dem Node um dieselbe interne Sperre, was das Verbinden der letzten Blöcke bei einer großen Wallet verlangsamen kann. Neuere Versionen drosseln dieses Durchsuchen, damit der Node aufholen kann. Auf einem leichteren Tab zu bleiben (oder einfach zu warten) lässt es schneller fertig werden.",
"faq_d_sync_3_q": "Warum ist die Synchronisierung langsam, obwohl ich fast aufgeholt habe?",
"faq_d_sync_4_a": "Die erste Synchronisierung lädt und prüft die gesamte Chain und kann je nach Verbindung und Festplatte eine Weile dauern. Sie können sie deutlich beschleunigen, indem Sie im Einrichtungsassistenten den Bootstrap-Download aktivieren, der eine aktuelle, verifizierte Kopie der Chain-Daten lädt.",
"faq_d_sync_4_q": "Wie lange dauert die anfängliche Synchronisierung?",
"faq_d_sync_title": "Synchronisierung & Blockchain",
"faq_d_trbl_1_a": "Normalerweise nicht. Bei einer großen Wallet kann der Node mehrere Minuten damit verbringen, geschützte Witnesses neu aufzubauen oder Blöcke anzuwenden — währenddessen wirkt er pausiert und reagiert möglicherweise langsam. Geben Sie ihm Zeit — das Debug-Log (Einstellungen) zeigt stetigen Fortschritt, wenn er arbeitet.\n\nVermeiden Sie ein erzwungenes Beenden während dieser Phase; es startet die Arbeit beim nächsten Start von vorn.",
"faq_d_trbl_1_q": "Der Node scheint bei „Beste Chain wird aktiviert“ hängen zu bleiben — ist er eingefroren?",
"faq_d_trbl_2_a": "Wenn eine Wallet-Datei wiederhergestellt wurde, aber ein Teil ihrer Schlüsseldaten fehlt, öffnet der Node sie möglicherweise in einem eingeschränkten „degraded“-Modus, in dem er keine neuen Adressen ableiten oder Guthaben schützen kann. Die sicherste Lösung ist, aus Ihrer Seed-Wiederherstellungsphrase eine frische Wallet wiederherzustellen.",
"faq_d_trbl_2_q": "Was ist der „degraded“-Modus?",
"faq_d_trbl_3_a": "Ein Re-Scan lässt die Wallet die Chain erneut lesen, um Ihre Transaktionen wiederzufinden (nützlich nach dem Import von Schlüsseln). Ein Re-Index baut die Blockchain-Datenbank des Nodes neu auf und ist nur nötig, wenn diese Datenbank nach einem Versionswechsel beschädigt oder inkompatibel ist. Beides kann eine Weile dauern; starten Sie es unter Einstellungen > Node & Sicherheit.",
"faq_d_trbl_3_q": "Wann sollte ich re-indizieren oder re-scannen?",
"faq_d_trbl_4_a": "Der Speicherverbrauch steigt bei intensiver Arbeit wie einem Witness-Neuaufbau oder einer anfänglichen Synchronisierung stark an und fällt danach wieder ab. Anhaltend hoher Speicherverbrauch im Leerlauf ist ungewöhnlich — ein Neustart der Wallet behebt das. Stellen Sie auf einem Rechner mit wenig RAM sicher, dass keine anderen speicherhungrigen Apps konkurrieren.",
"faq_d_trbl_4_q": "Der Node nutzt viel Speicher — ist das normal?",
"faq_d_trbl_title": "Problembehandlung",
"faq_d_upd_1_a": "Öffnen Sie Einstellungen > Node & Sicherheit > Daemon-Binärdatei und nutzen Sie „Nach Updates suchen…“. Die Wallet lädt den neuesten verifizierten Node-Build herunter und installiert ihn; die neue Version wird beim nächsten Start des Daemons wirksam.",
"faq_d_upd_1_q": "Wie aktualisiere ich den Node?",
"faq_d_upd_2_a": "Ja — der Update-Dialog listet jede Version auf, sodass Sie einen bestimmten oder älteren Build wählen können. Seien Sie beim Downgrade vorsichtig: Ein älterer Node akzeptiert möglicherweise keine von einem neueren geschriebenen Blockchain-Daten und benötigt eventuell einen Re-Index.",
"faq_d_upd_2_q": "Kann ich eine bestimmte Node-Version installieren?",
"faq_d_upd_3_a": "Jedes heruntergeladene Node-Archiv wird vor der Installation gegen eine Prüfsumme und eine kryptografische Signatur verifiziert. Schlägt die Verifizierung fehl, verweigert die Wallet das Update.",
"faq_d_upd_3_q": "Ist das Update sicher?",
"faq_d_upd_title": "Node aktualisieren",
"faq_group_daemon": "Daemon",
"faq_group_wallet": "Wallet",
"faq_intro": "Antworten zu Ihrer Wallet und dem DragonX-Node. Suchen Sie oder blättern Sie unten nach Thema.",
"faq_intro_lite": "Antworten zu Ihrer Lite-Wallet. Suchen Sie oder blättern Sie unten nach Thema.",
"faq_l_gs_1_a": "ObsidianDragonLite ist die schlanke DragonX-Wallet. Statt einen Full Node zu betreiben, verbindet sie sich mit einem DragonX-Lite-Server, startet also schnell und lädt nicht die gesamte Blockchain herunter.\n\nSie verwaltet Ihre Schlüssel, Guthaben und Sendevorgänge genau wie die vollständige Wallet — siehe den Abschnitt „Lite-Wallet“ unten für die Funktionsweise der Serververbindung und den Kompromiss bei der Privatsphäre.",
"faq_l_gs_1_q": "Was ist ObsidianDragonLite?",
"faq_l_lite_1_a": "Die Lite-Wallet spricht mit einem DragonX-Lite-Server, statt einen eigenen Full Node zu betreiben. Das macht sie schnell startbereit und schont Speicherplatz und Bandbreite, da sie die gesamte Blockchain nicht selbst herunterlädt oder prüft. Ihre privaten Schlüssel verbleiben stets auf Ihrem Gerät — sie werden nie an den Server gesendet.",
"faq_l_lite_1_q": "Wie unterscheidet sich die Lite-Wallet von der vollständigen Wallet?",
"faq_l_lite_2_a": "Öffnen Sie Einstellungen und wählen Sie einen Server. Sie können jederzeit den Server wechseln, die Wallet synchronisiert sich dann neu mit dem neuen Server. Ist ein Server langsam oder nicht erreichbar, versuchen Sie einen anderen.",
"faq_l_lite_2_q": "Wie wähle oder ändere ich den Server?",
"faq_l_lite_3_a": "Ihre Schlüssel verlassen niemals Ihr Gerät, und geschützte Beträge bleiben verschlüsselt. Der Lite-Server sieht jedoch, welche Adressen Ihre Wallet abfragt, sowie Ihre IP-Adresse, wodurch er Ihre Adressen miteinander verknüpfen kann — ein Kompromiss zwischen Komfort und Privatsphäre. Für maximale Privatsphäre nutzen Sie die Full-Node-Wallet, die die Kette selbst verifiziert und Ihre Adressen keinem Server preisgibt.",
"faq_l_lite_3_q": "Ist die Lite-Wallet genauso privat wie der Full Node?",
"faq_l_lite_title": "Lite-Wallet",
"faq_l_sec_1_a": "Öffnen Sie Einstellungen > Wallet und legen Sie im Sicherheitsbereich eine Passphrase fest. Die Verschlüsselung schützt Ihre privaten Schlüssel auf der Festplatte, sodass jemand mit Zugriff auf die Dateien Ihre Coins trotzdem nicht ausgeben kann.\n\nWählen Sie eine starke Passphrase und verlieren Sie sie nicht — ohne sie gibt es keine Möglichkeit, eine verschlüsselte Wallet wiederherzustellen.",
"faq_no_results": "Keine Ergebnisse. Versuchen Sie einen anderen Suchbegriff.",
"faq_open_tooltip": "Hilfe & FAQ",
"faq_search_hint": "FAQ durchsuchen…",
"faq_title": "Hilfe & FAQ",
"faq_w_addr_1_a": "Abgeschirmte Adressen (z-Adressen, beginnend mit zs) sind die Adressen, die Sie bei DragonX verwenden. Sie nutzen Zero-Knowledge-Kryptografie: Betrag, Absender und Empfänger werden on-chain verschlüsselt, und Ihr Guthaben bleibt privat.\n\nTransparente Adressen (beginnend mit R) sind Mining-Adressen. DragonX ist eine vollständig private Chain, daher können sie nur Mining-Belohnungen empfangen – das Netzwerk lehnt jede an sie gesendete Zahlung ab. Dort eingehende Belohnungen sind öffentlich, bis sie abgeschirmt werden, was die Wallet automatisch erledigt.",
"faq_w_addr_1_q": "Was ist der Unterschied zwischen transparenten und geschützten Adressen?",
"faq_w_addr_2_a": "Verwenden Sie für alles, was Sie senden und empfangen, eine abgeschirmte (zs-)Adresse – bei DragonX ist sie die einzige Art, an die gezahlt werden kann.\n\nTransparente (Mining-)Adressen sind nur für Solo-Mining-Belohnungen da. Geben Sie nie eine für eine Zahlung heraus: Das Netzwerk würde sie ablehnen.",
"faq_w_addr_2_q": "Welchen Adresstyp sollte ich verwenden?",
"faq_w_addr_3_a": "Eine abgeschirmte Adresse wiederzuverwenden ist unbedenklich und verrät nichts über Ihren Verlauf. Sie können trotzdem im Tab „Empfangen“ neue anlegen, zum Beispiel eine pro Kontakt, um zu sehen, wer Sie bezahlt hat. Mining-Adressen sind nur für Mining-Belohnungen da; eine neue brauchen Sie selten.",
"faq_w_addr_3_q": "Sollte ich eine Adresse wiederverwenden oder eine neue erstellen?",
"faq_w_addr_4_a": "Beim Abschirmen werden Coins von einer transparenten (Mining-)Adresse auf eine abgeschirmte verschoben und damit privat. Neu geminte Coins kommen auf einer Mining-Adresse an und werden automatisch abgeschirmt, sobald sie gereift sind.\n\nSie können auch manuell in der Wallet abschirmen; eine Abschirmung ist nur eine besondere Transaktion und braucht daher die übliche Bestätigungszeit.",
"faq_w_addr_4_q": "Was bedeutet „Schützen“?",
"faq_w_addr_title": "Adressen & Privatsphäre",
"faq_w_bal_1_a": "Solange Ihre Wallet noch synchronisiert, ist Ihr Guthaben unvollständig und kann 0 anzeigen — es wurden noch nicht alle Transaktionen gescannt. Es füllt sich, sobald die Synchronisierung abgeschlossen ist.\n\nBeobachten Sie die Statusleiste unten: Sie zeigt den Verbindungsstatus und die Blockhöhe an. Sobald die Synchronisierung abgeschlossen ist, ist Ihr Guthaben korrekt.",
"faq_w_bal_1_q": "Warum ist mein Guthaben 0 oder aktualisiert sich nicht?",
"faq_w_bal_2_a": "Jeder neue Block, der auf den Block mit Ihrer Transaktion folgt, fügt eine Bestätigung hinzu. Mehr Bestätigungen bedeuten, dass die Zahlung fester verankert ist. Empfangene Gelder werden nach der ersten Bestätigung ausgabefähig.",
"faq_w_bal_2_q": "Was sind Bestätigungen?",
"faq_w_bal_3_a": "Das Gesamtguthaben umfasst Gelder, die noch bestätigt werden oder vorübergehend gesperrt sind (zum Beispiel Coins, die gerade geschützt werden). Verfügbar ist das, was Sie jetzt sofort senden können. Beide Werte gleichen sich an, sobald Transaktionen bestätigt werden.",
"faq_w_bal_3_q": "Was ist der Unterschied zwischen Gesamt- und verfügbarem Guthaben?",
"faq_w_bal_title": "Guthaben & Synchronisierung",
"faq_w_chat_1_a": "Chat ist ein verschlüsselter, on-chain-basierter Messenger, der in die Wallet integriert ist. Nachrichten werden als private, geschützte Memos gesendet, sodass nur Sie und Ihr Kontakt sie lesen können.",
"faq_w_chat_1_q": "Was ist die Chat-Funktion?",
"faq_w_chat_2_a": "Ihre Chat-Identität wird aus der Wiederherstellungsphrase Ihrer Wallet abgeleitet, sie ist also an Ihre Wallet gebunden — stellen Sie die Wallet wieder her, und Ihre Identität kehrt zurück. Sie erstellen kein separates Konto oder Passwort.",
"faq_w_chat_2_q": "Wie funktioniert meine Chat-Identität?",
"faq_w_chat_3_a": "Fügen Sie einen Kontakt über dessen Adresse im Tab Kontakte hinzu, optional mit Namen und Avatar. Kontakte können privat für die aktuelle Wallet bleiben oder über alle Wallets hinweg geteilt werden, die Sie auf diesem Computer öffnen.",
"faq_w_chat_3_q": "Wie funktionieren Kontakte?",
"faq_w_chat_4_a": "Öffnen Sie eine Unterhaltung und verwenden Sie die Symbole in deren Kopfzeile. Ausblenden (das Auge) entfernt sie aus der Liste, behält aber alle Nachrichten — eine neue Nachricht blendet sie wieder ein. Löschen (der Papierkorb) öffnet einen Dialog: „Löschen“ entfernt den Verlauf auf diesem Gerät (schreibt Ihnen die Person erneut, kommt die Unterhaltung zurück), während „Löschen & blockieren“ zusätzlich zukünftige Nachrichten stoppt, bis Sie die Person aus der Liste „Blockiert“ oberhalb der Unterhaltungsliste entblocken. All dies gilt nur lokal für dieses Gerät — die Nachrichten bleiben auf der Blockchain, und die andere Person behält ihre eigene Kopie; es ist also kein „Zurückrufen“.",
"faq_w_chat_4_q": "Wie blende ich eine Unterhaltung aus, lösche oder blockiere sie?",
"faq_w_chat_title": "Chat & Kontakte",
"faq_w_gs_1_a": "ObsidianDragon ist eine Full-Node-Wallet für DragonX (DRGX). Sie verwaltet Ihre Coins und betreibt im Desktop-Build im Hintergrund einen DragonX-Full-Node (den Daemon), sodass Ihre Wallet die Blockchain selbst verifiziert, statt einem Server eines Drittanbieters zu vertrauen.\n\nStellen Sie sich das als zwei zusammenarbeitende Teile vor: die Wallet (Schlüssel, Guthaben, Senden) und den Node (Blockchain und Netzwerk). Die Fragen hier sind entsprechend aufgeteilt — siehe die Tabs Wallet und Daemon oben.",
"faq_w_gs_1_q": "Was ist ObsidianDragon?",
"faq_w_gs_2_a": "Beim ersten Start erstellt der Einrichtungsassistent automatisch eine Wallet für Sie. Neue Wallets basieren auf einer geheimen Wiederherstellungsphrase (einer Liste von Wörtern). Notieren Sie diese Phrase und bewahren Sie sie offline auf — es ist die einzige Möglichkeit, Ihre Gelder wiederherzustellen, falls dieser Computer verloren geht.\n\nSie können die Phrase jederzeit unter Einstellungen > Sicherung & Daten > Seed phrase einsehen oder sichern.",
"faq_w_gs_2_q": "Wie erstelle ich eine neue Wallet?",
"faq_w_gs_3_a": "Verwenden Sie den Wiederherstellungs-Ablauf per Seed-Phrase, wenn Sie eine Wiederherstellungsphrase besitzen. (Im Full-Node-Build können Sie vor dem Start auch eine vorhandene wallet.dat-Datei in das Datenverzeichnis der Wallet kopieren.)\n\nNach der Wiederherstellung scannt die Wallet erneut, um Ihre vergangenen Transaktionen zu finden — Guthaben und Verlauf können daher beim ersten Mal etwas dauern, bis sie erscheinen.",
"faq_w_gs_3_q": "Wie stelle ich eine bereits vorhandene Wallet wieder her?",
"faq_w_gs_4_a": "Der Assistent lässt Sie ein Erscheinungsbild/Theme wählen, Verschlüsselung und eine PIN einrichten und — im Full-Node-Build — optional einen Bootstrap herunterladen, um die erste Synchronisierung zu beschleunigen. Sie können jeden Schritt überspringen und all dies später in den Einstellungen ändern.",
"faq_w_gs_4_q": "Was macht die Ersteinrichtung?",
"faq_w_gs_title": "Erste Schritte",
"faq_w_sec_1_a": "Öffnen Sie Einstellungen > Node & Sicherheit und legen Sie eine Passphrase fest (Bereich SICHERHEIT). Die Verschlüsselung schützt Ihre privaten Schlüssel auf der Festplatte, sodass jemand mit Zugriff auf die Dateien Ihre Coins trotzdem nicht ausgeben kann.\n\nWählen Sie eine starke Passphrase und verlieren Sie sie nicht — ohne sie gibt es keine Möglichkeit, eine verschlüsselte Wallet wiederherzustellen.",
"faq_w_sec_1_q": "Wie verschlüssele ich meine Wallet?",
"faq_w_sec_2_a": "Die PIN sperrt den Bildschirm der App, sodass Guthaben und Aktionen verborgen sind, wenn Sie kurz weg sind. Sie ist eine Komfortsperre zusätzlich zur Verschlüsselung; Sie richten sie im Assistenten oder in den Einstellungen ein, nachdem die Wallet verschlüsselt wurde.",
"faq_w_sec_2_q": "Was ist die PIN / der Sperrbildschirm?",
"faq_w_sec_3_a": "Nein. Die Wallet-Verschlüsselung kann nicht umgangen werden. Wenn Sie noch Ihre Wiederherstellungsphrase besitzen, können Sie die Wallet daraus in eine neue Wallet wiederherstellen und eine neue Passphrase festlegen. Ohne Passphrase oder Seed-Phrase können die Gelder nicht wiederhergestellt werden.",
"faq_w_sec_3_q": "Ich habe meine Passphrase vergessen — kann sie wiederhergestellt werden?",
"faq_w_sec_title": "Sicherheit & Verschlüsselung",
"faq_w_seed_1_a": "Gehen Sie zu Einstellungen > Sicherung & Daten > Seed phrase. Notieren Sie die Wörter der Reihe nach auf Papier und bewahren Sie sie an einem sicheren, offline zugänglichen Ort auf. Jeder, der die Phrase kennt, kann Ihre Coins ausgeben — speichern Sie sie daher niemals als Foto, in einer E-Mail oder einer Cloud-Notiz.",
"faq_w_seed_1_q": "Wie sichere ich meine Wiederherstellungsphrase?",
"faq_w_seed_2_a": "Ältere (Legacy-)Wallets basierten nicht auf einer Seed-Phrase. Die Wallet kann eine Legacy-Wallet in eine moderne, seed-basierte Wallet migrieren: Dabei wird eine neue Seed-Wallet erstellt und Ihre Gelder werden dorthin überführt. Suchen Sie nach „Zu Seed-Wallet migrieren…“ unter Einstellungen > Sicherung & Daten. Diese Funktion benötigt die aktuelle Daemon-Version.",
"faq_w_seed_2_q": "Meine Wallet hat keine Wiederherstellungsphrase — kann ich eine hinzufügen?",
"faq_w_seed_3_a": "Verwenden Sie beim Einrichten einer Wallet den Seed-Wiederherstellungs-Ablauf und geben Sie Ihre Wörter der Reihe nach ein. Ihre Wallet scannt anschließend erneut, um Guthaben und Verlauf wiederherzustellen, was beim ersten Mal etwas dauern kann.",
"faq_w_seed_3_q": "Wie stelle ich aus meiner Wiederherstellungsphrase wieder her?",
"faq_w_seed_4_a": "Ihre Seed-Phrase ist die primäre Sicherung und reicht aus, um alles wiederherzustellen. Eine Kopie Ihrer Wallet-Datei ist eine praktische zusätzliche Sicherung, die außerdem Bezeichnungen und Einstellungen erhält. Bewahren Sie jede Sicherung offline und privat auf.",
"faq_w_seed_4_q": "Sollte ich auch wallet.dat sichern?",
"faq_w_seed_title": "Seed-Phrase & Sicherung",
"faq_w_send_1_a": "Öffnen Sie den Tab Senden, fügen Sie die Adresse des Empfängers ein, geben Sie einen Betrag ein und bestätigen Sie. Wenn die Wallet verschlüsselt ist, werden Sie gebeten, sie für die Transaktion zu entsperren.\n\nZahlungen gehen an abgeschirmte (zs-)Adressen und bleiben privat. Die Wallet sendet nicht an eine transparente (Mining-)Adresse: Das Netzwerk lehnt Zahlungen an sie ab.",
"faq_w_send_1_q": "Wie sende ich Gelder?",
"faq_w_send_2_a": "DragonX-Gebühren sind sehr niedrig und werden automatisch festgelegt. Die Gebühr wird angezeigt, bevor Sie bestätigen. Normalerweise müssen Sie sie nicht ändern.",
"faq_w_send_2_q": "Welche Gebühr zahle ich?",
"faq_w_send_3_a": "Ja — beim Senden an eine geschützte (z-)Adresse können Sie ein verschlüsseltes Memo anhängen. Nur der Empfänger kann es lesen.",
"faq_w_send_3_q": "Kann ich einer Zahlung eine Nachricht beifügen?",
"faq_w_send_4_a": "Eine Transaktion ist „ausstehend“, bis sie in einen Block aufgenommen wird und Bestätigungen erhält. Das dauert normalerweise ein bis zwei Minuten. Geschützte Transaktionen erfordern außerdem, dass Ihre Wallet synchronisiert ist, um den Beweis zu erstellen.\n\nBleibt eine Sendung ungewöhnlich lange ausstehend, prüfen Sie, ob Ihre Wallet verbunden und synchronisiert ist (siehe die Statusleiste unten im Fenster).",
"faq_w_send_4_q": "Ich habe eine Zahlung gesendet, aber sie zeigt „ausstehend“ — warum?",
"faq_w_send_title": "Senden & Empfangen",
"faq_w_set_1_a": "Öffnen Sie Einstellungen > Erscheinungsbild, um ein Design und eine Akzentfarbe zu wählen. Mehrere helle und dunkle Skins sind enthalten, und Effekte wie Weichzeichnen lassen sich für leistungsschwächere Rechner abschalten.",
"faq_w_set_1_q": "Wie ändere ich das Design?",
"faq_w_set_2_a": "Ja — Einstellungen > Erscheinungsbild enthält eine Sprachauswahl. Die Wallet wird mit mehreren Übersetzungen ausgeliefert; alles, was noch nicht übersetzt ist, greift auf Englisch zurück.",
"faq_w_set_2_q": "Kann ich die Sprache ändern?",
"faq_w_set_3_a": "Verwenden Sie die Schriftskalierung in Einstellungen > Erscheinungsbild. Sie skaliert die gesamte Oberfläche, was auf hochauflösenden (HiDPI-)Bildschirmen hilfreich ist, auf denen die App sonst klein dargestellt werden könnte.",
"faq_w_set_3_q": "Der Text ist zu klein (oder zu groß) — kann ich ihn skalieren?",
"faq_w_set_4_a": "Aktivieren Sie den Preisabruf in den Einstellungen. Die Wallet zeigt dann einen ungefähren Fiat-Wert neben den Guthaben an und kann ein kleines Marktdiagramm anzeigen. Preise dienen nur zur Information.",
"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:",
@@ -665,14 +876,9 @@
"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",
"force_quit": "Sofort beenden",
"force_quit_confirm_msg": "Dies wird den Daemon sofort beenden ohne sauberes Herunterfahren.\nDies kann den Blockchain-Index beschädigen und eine Neusynchronisierung erfordern.",
"force_quit_confirm_title": "Sofort beenden?",
"force_quit_warning": "Dies wird den Daemon sofort beenden ohne sauberes Herunterfahren. Kann eine Neusynchronisierung der Blockchain erfordern.",
"force_quit_yes": "Sofort beenden",
"from": "Von",
"from_upper": "VON",
"full_details": "Alle Details",
@@ -741,6 +947,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",
@@ -815,11 +1022,13 @@
"lite_birthday_backup": "Geburtstag: %llu (auch diesen sichern)",
"lite_birthday_hint": "Blockhöhe, ab der gescannt werden soll. Bei 0 belassen, falls unbekannt (langsamerer vollständiger Scan).",
"lite_birthday_label": "Geburtsblock",
"lite_confirm_label": "Bestätigen",
"lite_console_backend_commands": "Backend-Befehle:",
"lite_console_help_passthrough": "Jede andere Eingabe wird als Lite-Wallet-Konsolenbefehl ausgeführt.",
"lite_copy": "Kopieren",
"lite_could_not_start": "Der Vorgang konnte nicht gestartet werden",
"lite_could_not_write": "Konnte nicht schreiben ",
"lite_encrypt_offline_warning": "Wer die Wallet-Datei hat, kann Passphrasen offline durchprobieren. Sichern Sie zuerst Ihre Wiederherstellungsphrase und verwenden Sie eine lange Passphrase.",
"lite_encrypt_wallet": "Wallet verschlüsseln",
"lite_encryption_removed": "Verschlüsselung entfernt",
"lite_enter_all_seed_words": "Alle 24 Seed-Wörter zur Wiederherstellung eingeben (%d erhalten)",
@@ -827,6 +1036,7 @@
"lite_hide_wipe": "Ausblenden & löschen",
"lite_import": "Importieren",
"lite_import_key_label": "Schlüssel importieren",
"lite_key_import_failed": "Schlüssel konnte nicht importiert werden — prüfen Sie ihn, entsperren Sie die Wallet (falls verschlüsselt) und versuchen Sie es erneut.",
"lite_key_imported": "Schlüssel importiert — führen Sie eine Synchronisierung durch, um den Verlauf zu scannen",
"lite_lock_failed": "Sperren fehlgeschlagen",
"lite_lock_now": "Jetzt sperren",
@@ -960,11 +1170,67 @@
"memo_optional": "MEMO (OPTIONAL)",
"memo_upper": "MEMO",
"memo_z_only": "Hinweis: Memos sind nur beim Senden an abgeschirmte (z) Adressen verfügbar",
"merge_addr_created": "Geschützte Adresse erstellt.",
"merge_advanced": "Erweitert",
"merge_back": "Zurück",
"merge_batch_fmt": "Führt bis zu %d Inputs pro Durchgang zusammen — wiederholen Sie den Vorgang, um den Rest abzuschließen.",
"merge_confirm_btn": "Bestätigen",
"merge_confirm_fmt": "~%s DRGX aus %d Input(s) in %s konsolidieren?",
"merge_create_zaddr": "Geschützte Adresse erstellen",
"merge_creating": "Adresse wird erstellt…",
"merge_description": "Mehrere UTXOs zu einer einzelnen abgeschirmten Adresse zusammenführen. Dies kann die Wallet-Größe reduzieren und die Privatsphäre verbessern.",
"merge_fee_hint": "Netzwerkgebühr für diese Transaktion.",
"merge_funds": "Gelder zusammenführen",
"merge_into_one": "In eine Adresse",
"merge_keep_each": "In jeder Adresse belassen",
"merge_keep_each_batches_fmt": "Adressen mit mehr als %d Notes werden in mehreren Durchgängen zusammengeführt; führen Sie es nach deren Bestätigung erneut aus, um den Rest zusammenzuführen.",
"merge_keep_each_confirm_fmt": "%d Notes in %d Adressen konsolidieren? Jede Adresse behält ihre eigenen Mittel. Gebühren: etwa %s DRGX (eine pro Durchgang).",
"merge_keep_each_desc": "Die Notes jeder Adresse werden in genau diese Adresse zurückgeführt, sodass Mittel, die Sie unter verschiedenen Bezeichnungen getrennt halten, getrennt bleiben. Nur abgeschirmte Adressen – transparente Mittel können nur abgeschirmt werden (In eine Adresse).",
"merge_keep_each_none": "Jede abgeschirmte Adresse hat bereits nur eine Note – nichts zu konsolidieren.",
"merge_keep_each_nothing_submitted": "Nichts wurde konsolidiert – der Node hat jede Zusammenführung abgelehnt.",
"merge_keep_each_row_fmt": "%s · %d Notes · %s DRGX",
"merge_keep_each_run_again": "Adressen, die in mehreren Durchgängen zusammengeführt wurden, haben jetzt eine Note pro Durchgang. Führen Sie dies nach deren Bestätigung erneut aus, um sie zu einer zusammenzuführen.",
"merge_keep_each_select": "Wählen Sie mindestens eine Adresse zum Konsolidieren aus.",
"merge_keep_each_some_failed": "Einige Zusammenführungen sind fehlgeschlagen – siehe Liste oben.",
"merge_keep_each_status_done": "%s – fertig: %d Notes in %d Durchgang/Durchgängen zusammengeführt",
"merge_keep_each_status_failed": "%s – fehlgeschlagen: %s",
"merge_keep_each_status_not_started": "%s – nicht gestartet: %s",
"merge_keep_each_status_running": "%s – %d Notes werden zusammengeführt (%d von %d Durchgängen fertig)…",
"merge_keep_each_status_unconfirmed": "%s – Status konnte nicht bestätigt werden",
"merge_keep_each_too_small": "Zu klein zum Konsolidieren: Die Gebühr würde diese Mittel aufbrauchen.",
"merge_max_inputs": "Max. Inputs pro Batch",
"merge_no_spendable": "Noch keine verfügbaren Inputs zum Konsolidieren.",
"merge_progress": "Konsolidierung läuft… dies kann einige Minuten dauern. Sie können dieses Fenster schließen.",
"merge_run_aborted_toast": "Die Verbindung zum Node wurde zurückgesetzt, daher wurde die Konsolidierung gestoppt. Führen Sie sie erneut aus, um fortzufahren.",
"merge_run_busy": "Eine Konsolidierung läuft. Warten Sie, bis sie fertig ist, oder stoppen Sie sie zuerst.",
"merge_run_chip_fmt": "Konsolidierung %d/%d",
"merge_run_err_timeout": "Der Node hat nicht rechtzeitig geantwortet; der Durchgang ist möglicherweise trotzdem eingereiht.",
"merge_run_halted_fmt": "Konsolidierung nach wiederholten Fehlern gestoppt: %s",
"merge_run_migration_pending": "Schließen Sie die Seed-Migration ab oder brechen Sie sie ab, bevor Sie konsolidieren.",
"merge_run_progress_fmt": "Konsolidierung läuft… Durchgang %d von etwa %d, bisher %d Notes zusammengeführt. Sie können dieses Fenster schließen; der Vorgang läuft im Hintergrund weiter.",
"merge_run_start_failed": "Die Konsolidierung konnte nicht gestartet werden – der Node ist nicht verbunden.",
"merge_run_status_not_run": "%s – nicht konsolidiert (%d Notes)",
"merge_run_status_waiting": "%s – wartet (%d Notes)",
"merge_run_stop": "Stoppen",
"merge_run_stopped_toast": "Konsolidierung gestoppt. Führen Sie sie erneut aus, um den Rest zusammenzuführen.",
"merge_run_stopping": "Wird nach den bereits gesendeten Durchgängen gestoppt…",
"merge_run_wait_batch": "Die Durchgänge werden verteilt, damit der Node reaktionsfähig bleibt…",
"merge_run_wait_send": "Pausiert, bis Ihre ausstehende Transaktion durch ist.",
"merge_run_wait_sync": "Pausiert: Der Node synchronisiert noch.",
"merge_scope_failed_fmt": "Ihre Inputs konnten nicht gelesen werden: %s",
"merge_scope_fmt": "%d transparente + %d geschützte Inputs · ~%s DRGX verfügbar",
"merge_scope_loading": "Ihre Inputs werden geprüft…",
"merge_send_failed": "Zusammenführen fehlgeschlagen: ",
"merge_source": "Konsolidieren",
"merge_src_both": "Beide",
"merge_src_shielded": "Geschützt",
"merge_src_transparent": "Transparent",
"merge_started": "Zusammenführung gestartet",
"merge_submit_uncertain": "Der Node hat das Zusammenführen nicht bestätigt, bevor die Verbindung unterbrochen wurde. Es kann trotzdem ausgeführt werden – prüfen Sie nach dem erneuten Verbinden den Verlauf, bevor Sie erneut zusammenführen.",
"merge_title": "An Adresse zusammenführen",
"merge_wait_locked": "Die Wallet ist gesperrt. Entsperren Sie sie, um fortzufahren.",
"merge_waiting_node": "Warten auf den Node…",
"merge_where": "Wohin sollen die Mittel gehen?",
"mig_adopt": "Dies zu meiner Wallet machen",
"mig_adopt_now": "Seed-Wallet übernehmen",
"mig_adopting": "Ihre neue Wallet wird installiert und neu gescannt…",
@@ -1016,6 +1282,8 @@
"miner_fee": "Miner-Gebühr",
"mining": "Mining",
"mining_active": "Aktiv",
"mining_addr_explainer": "Empfängt nur Solo-Mining-Belohnungen – das Netzwerk lehnt Zahlungen an diese Adresse ab. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
"mining_address": "Mining-Adresse",
"mining_address_copied": "Mining-Adresse kopiert",
"mining_all_time": "Gesamt",
"mining_already_saved": "Pool-URL bereits gespeichert",
@@ -1029,6 +1297,8 @@
"mining_benchmark_testing": "Testen",
"mining_benchmark_tooltip": "Optimale Thread-Anzahl für diese CPU finden",
"mining_block_copied": "Block-Hash kopiert",
"mining_card_click_to_shield": "Klicken, um gereifte Belohnungen jetzt abzuschirmen.",
"mining_card_tooltip": "Solo-Mining-Belohnungen gehen an Ihre Mining-Adressen (transparent, R…). Diese Adressen können keine Zahlungen empfangen. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
"mining_chart_1m_ago": "vor 1m",
"mining_chart_5m_ago": "vor 5m",
"mining_chart_now": "Jetzt",
@@ -1044,6 +1314,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",
@@ -1073,6 +1345,7 @@
"mining_payout_address": "Auszahlungsadresse",
"mining_payout_foreign": "⚠ Diese Auszahlungsadresse befindet sich nicht in Ihrer aktuellen Wallet — geschürfte Belohnungen würden an eine andere Wallet gehen. Aktualisieren Sie sie, wenn Sie die Wallet gewechselt haben.",
"mining_payout_invalid": "Keine gültige DragonX-Adresse — vor dem Start korrigieren, sonst gehen die Mining-Belohnungen verloren.",
"mining_payout_needs_shielded": "Pools zahlen mit gewöhnlichen Transaktionen aus, die bei DragonX keine Mining-Adresse (transparent, R…) erreichen können. Verwenden Sie eine abgeschirmte Adresse (sie beginnt mit zs) als Auszahlungsadresse.",
"mining_payout_tooltip": "Adresse für Mining-Belohnungen",
"mining_pool": "Pool",
"mining_pool_fee": "Gebühr",
@@ -1085,6 +1358,8 @@
"mining_refresh": "Aktualisieren",
"mining_remove": "Entfernen",
"mining_reset_defaults": "Standardwerte zurücksetzen",
"mining_rewards": "Mining-Belohnungen",
"mining_rewards_unshielded": "Noch nicht abgeschirmt",
"mining_save_payout_address": "Auszahlungsadresse speichern",
"mining_save_pool_url": "Pool-URL speichern",
"mining_saved_addresses": "Gespeicherte Adressen:",
@@ -1118,10 +1393,10 @@
"network_fee": "NETZWERKGEBÜHR",
"network_hashrate": "Netzwerk-Hashrate",
"new": "+ Neu",
"new_mining_address": "Neue Mining-Adresse",
"new_mining_address_created": "Neue Mining-Adresse erstellt",
"new_shielded_created": "Neue abgeschirmte Adresse erstellt",
"new_t_address": "Neue T-Adresse",
"new_t_transparent": "Neue t-Adresse (Transparent)",
"new_transparent_created": "Neue transparente Adresse erstellt",
"new_z_address": "Neue Z-Adresse",
"new_z_shielded": "Neue z-Adresse (Abgeschirmt)",
"no_addresses": "Keine Adressen gefunden. Erstellen Sie eine mit den Schaltflächen oben.",
@@ -1141,7 +1416,19 @@
"node_banner_offline_title": "Nicht mit dem DragonX-Node verbunden",
"node_banner_reconnect": "Erneut verbinden",
"node_banner_restart": "Node neu starten",
"node_busy_rebuild_desc": "Läuft seit %s. Bei großen Wallets kann das etwa eine Stunde dauern, und der Node kann der Wallet erst danach antworten.",
"node_busy_rebuild_extending": "Witnesses werden erweitert (der Node meldet für diesen Schritt keinen Fortschritt).",
"node_busy_rebuild_reading": "Blöcke werden gelesen: %ld / %ld",
"node_busy_rebuild_title": "Node beschäftigt: Note-Witnesses werden neu aufgebaut",
"node_busy_replay_desc": "Läuft seit %s. Der Node wendet Blöcke erneut an, die er vor dem letzten Beenden empfangen hat.",
"node_busy_replay_title": "Node beschäftigt: Blöcke werden nachgeholt",
"node_busy_rpc_desc": "Der Node läuft, ist aber seit %s zu beschäftigt, um der Wallet zu antworten – meist wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses.",
"node_busy_rpc_title": "Node beschäftigt: antwortet noch nicht",
"node_busy_running": "Läuft seit %s",
"node_security": "KNOTEN & SICHERHEIT",
"node_starting_busy_desc": "Der Node ist erreichbar, aber beschäftigt und antwortet noch nicht. Das ist nach einem Update oder beim ersten Start normal und kann einige Minuten dauern.",
"node_starting_launch_desc": "dragonxd wird gestartet, warte auf Verbindung…",
"node_starting_title": "DragonX-Node wird gestartet…",
"noise": "Rauschen",
"not_connected": "Nicht mit Daemon verbunden...",
"not_connected_to_daemon": "Nicht mit Daemon verbunden",
@@ -1202,6 +1489,13 @@
"peers_upper": "PEERS",
"peers_version": "Version",
"pending": "Ausstehend",
"picker_dat_count": "%d Wallet-Datei(en) in diesem Ordner",
"picker_dat_none": "Keine Wallet-Dateien direkt in diesem Ordner",
"picker_empty": "Dieser Ordner enthält keine Unterordner oder Wallet-Dateien.",
"picker_home": "Home-Ordner",
"picker_select": "Diesen Ordner durchsuchen",
"picker_title": "Ordner zum Durchsuchen auswählen",
"picker_up": "Eine Ebene nach oben",
"pin_change_desc": "Ändern Sie Ihre Entsperr-PIN. Sie benötigen Ihre aktuelle PIN und eine neue PIN.",
"pin_confirm_new_label": "Neue PIN bestätigen:",
"pin_current_label": "Aktuelle PIN:",
@@ -1287,6 +1581,8 @@
"ram_wallet_gb": "Wallet: %.1f GB",
"ram_wallet_mb": "Wallet: %.0f MB",
"receive": "Empfangen",
"receive_not_for_payments": "Nicht für Zahlungen",
"receive_use_shielded": "Abgeschirmte Adresse verwenden",
"received": "empfangen",
"received_filter": "Empfangen",
"received_label": "Empfangen",
@@ -1301,6 +1597,7 @@
"reduce_motion": "Bewegung reduzieren",
"refresh": "Aktualisieren",
"refresh_now": "Jetzt aktualisieren",
"refresh_waits_for_sync": "Der Knoten synchronisiert noch. Die Wallet-Daten werden aktualisiert, sobald er fertig ist.",
"remove_favorite": "Favorit entfernen",
"repair_wallet": "Wallet reparieren",
"report_bug": "Fehler melden",
@@ -1315,12 +1612,12 @@
"request_select_address": "Adresse auswählen...",
"request_shielded_addrs": "-- Abgeschirmte Adressen --",
"request_title": "Zahlung anfordern",
"request_transparent_addrs": "-- Transparente Adressen --",
"request_uri_copied": "Zahlungs-URI in Zwischenablage kopiert",
"rescan": "Neu scannen",
"rescan_bootstrapped_msg": "Ihr Node wurde per Bootstrap eingerichtet, daher liegen Blöcke unterhalb des Snapshots nicht auf der Festplatte und ein Neuscan ab dem Genesis-Block würde fehlschlagen. Führen Sie den Neuscan ab einer Höhe durch, die Ihr Snapshot umfasst, um das ausgegebene Guthaben Ihrer Wallet abzugleichen. Ihre wallet.dat und Ihre Blockchain-Daten werden nicht gelöscht.",
"rescan_detecting": "Prüfe, welche Blöcke Ihr Node auf der Festplatte hat…",
"rescan_from_height": "Neu scannen ab Blockhöhe:",
"rescan_probe_failed": "Es konnte nicht geprüft werden, welche Blöcke Ihr Node auf der Festplatte hat (er ist ausgelastet oder eine Anfrage ist abgelaufen). Wenn dieser Node per Bootstrap eingerichtet wurde, brechen Sie ab und versuchen Sie es in einer Minute erneut: Der vollständige -rescan-Neustart unten schlägt auf einem per Bootstrap eingerichteten Node fehl.",
"reset_to_defaults": "Standardwerte zurücksetzen",
"restarting_after_encryption": "Daemon wird nach Verschlüsselung neu gestartet...",
"restore_address": "Adresse wiederherstellen",
@@ -1338,6 +1635,7 @@
"save_z_transactions": "Z-Tx in Tx-Liste speichern",
"sb_auth_failed": "Authentifizierung fehlgeschlagen — rpcuser/rpcpassword prüfen",
"sb_block": "Block: %d",
"sb_block_db_unreadable": "Blockdatenbank unlesbar — Neuaufbau erforderlich",
"sb_building_witnesses": "Zeugen werden erstellt",
"sb_building_witnesses_pct": "Witnesses werden neu aufgebaut %.0f%%",
"sb_connecting_daemon": "Verbindung zu dragonxd...",
@@ -1353,6 +1651,7 @@
"sb_dragonxd_stopped": "dragonxd gestoppt",
"sb_dragonxd_stopping": "dragonxd wird gestoppt...",
"sb_extracting_sapling": "Sapling-Parameter werden extrahiert...",
"sb_finishing_repair": "Wallet-Reparatur wird abgeschlossen — erneutes Scannen…",
"sb_importing_keys": "Schlüssel importieren",
"sb_loading_config": "Konfiguration laden...",
"sb_mining_hs": "%.1f H/s",
@@ -1361,6 +1660,7 @@
"sb_net_khs": "Netz: %.2f KH/s",
"sb_net_mhs": "Netz: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf nicht gefunden",
"sb_node_busy": "Node beschäftigt",
"sb_peers": "Peers: %zu",
"sb_plaintext_remote_blocked": "RPC-Anmeldedaten werden nicht im Klartext an einen entfernten Host gesendet. Füge rpcallowplaintext=1 zu DRAGONX.conf hinzu, um dies zu erlauben, oder aktiviere TLS mit rpctls=1.",
"sb_rescanning": "Neuscan",
@@ -1374,6 +1674,7 @@
"sb_waiting_config": "Warten auf Daemon-Konfiguration...",
"sb_waiting_daemon": "Warten auf dragonxd...",
"sb_waiting_daemon_err": "Warten auf dragonxd — %s",
"sb_wallet_needs_recovery": "Wallet-Reparatur verfügbar",
"sb_warming_up": "Aufwärmen...",
"sb_witness_cache": "Zeugen werden neu aufgebaut",
"scale_effects": "SKALIERUNG & EFFEKTE",
@@ -1381,6 +1682,7 @@
"screenshot_sweep": "Screenshot-Durchlauf ausführen",
"screenshot_sweep_desc": "Durchläuft jedes Design über jeden Tab und speichert von jedem einen Screenshot in tab-spezifischen Unterordnern im Screenshots-Ordner des Konfigurationsverzeichnisses (überschreibt den vorherigen Durchlauf). Läuft einige Sekunden.",
"screenshot_sweep_full": "Vollständiger UI-Durchlauf",
"screenshot_sweep_refused": "Vollständiger UI-Durchlauf abgelehnt; siehe Debug-Log",
"search_icons": "Symbole suchen...",
"search_placeholder": "Suchen...",
"sec_changing_passphrase": "Passphrase wird geändert...",
@@ -1402,15 +1704,19 @@
"sec_mode_passphrase": " Passphrase",
"sec_not_connected_to_daemon": "Nicht mit Daemon verbunden",
"sec_not_connected_to_daemon_pin": "Nicht mit Daemon verbunden",
"sec_passphrase_changed_pin_stale": "Passphrase geändert; Ihre PIN ist jetzt veraltet",
"sec_passphrase_changed_successfully": "Passphrase erfolgreich geändert",
"sec_pin_changed_successfully": "PIN erfolgreich geändert",
"sec_pin_removed": "PIN entfernt",
"sec_pin_set_successfully": "PIN erfolgreich festgelegt",
"sec_pin_stale_msg": "PIN veraltet. Verwenden Sie Ihre Passphrase.",
"sec_pin_unreadable": "PIN-Datei unlesbar. Verwenden Sie Ihre Passphrase.",
"sec_restart_daemon_for_encryption": "Bitte starten Sie Ihren Daemon neu, damit die Verschlüsselung wirksam wird.",
"sec_too_many_attempts_wait": "Zu viele Versuche. Warten Sie %.0f Sekunden...",
"sec_total_elapsed_fmt": "Gesamtdauer: %dm %02ds",
"sec_unlock_button": "Entsperren",
"sec_unlock_failed_prefix": "Entsperren fehlgeschlagen: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Entsperren%s",
"sec_use_passphrase_instead": "Stattdessen Passphrase verwenden",
"sec_use_pin_instead": "Stattdessen PIN verwenden",
@@ -1445,6 +1751,7 @@
"send_amount": "Betrag",
"send_amount_details": "BETRAGSDETAILS",
"send_amount_upper": "BETRAG",
"send_available_note": "verfügbar",
"send_cannot_send_now": "Senden derzeit nicht möglich — prüfen Sie Verbindung, Synchronisierung und verfügbares Guthaben.",
"send_clear_fields": "Alle Formularfelder leeren?",
"send_contacts_button": "Aus Kontakten auswählen",
@@ -1462,6 +1769,8 @@
"send_from_this_address": "Von dieser Adresse senden",
"send_go_to_receive": "Zum Empfangen",
"send_keep": "Behalten",
"send_mining_addr_blocked": "Dies ist eine Mining-Adresse (transparent). Bei DragonX können solche Adressen nur Mining-Belohnungen empfangen – das Netzwerk lehnt Zahlungen an sie ab. Bitten Sie den Empfänger um eine abgeschirmte Adresse (sie beginnt mit zs).",
"send_mining_addr_short": "Mining-Adresse – kann keine Zahlungen empfangen",
"send_network_fee": "NETZWERKGEBÜHR",
"send_no_balance": "Kein Guthaben",
"send_no_recent": "Keine kürzlichen Sendungen",
@@ -1489,7 +1798,6 @@
"send_txid_copied": "TxID in Zwischenablage kopiert",
"send_txid_label": "TxID: %s",
"send_valid_shielded": "Gültige abgeschirmte Adresse",
"send_valid_transparent": "Gültige transparente Adresse",
"send_wallet_empty": "Ihre Wallet ist leer",
"send_yes_clear": "Ja, leeren",
"sender_balance": "Absender: %.8f → %.8f DRGX",
@@ -1552,6 +1860,7 @@
"settings_open_log_folder": "Log-Ordner öffnen",
"settings_other": "Sonstiges",
"settings_pin_active": "PIN",
"settings_pin_out_of_date": "PIN veraltet",
"settings_privacy": "Datenschutz",
"settings_quick_unlock_pin": "Schnell-Entsperr-PIN",
"settings_reduce_transparency": "Transparenz reduzieren",
@@ -1617,11 +1926,24 @@
"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",
"showing_transactions": "Zeige %d–%d von %d Transaktionen (gesamt: %zu)",
"showing_x_of_y": "%d von %d Adressen angezeigt",
"shutdown_kill_confirm_msg": "Der Node zeigt seit Langem keine CPU- oder Festplattenaktivität und hängt möglicherweise.\nWenn Sie ihn erzwungen beenden, geht alles Ungespeicherte verloren; sein nächster Start kann deutlich länger dauern.",
"shutdown_kill_confirm_title": "Node erzwungen beenden?",
"shutdown_kill_confirm_yes": "Erzwungen beenden",
"shutdown_kill_node": "Node erzwungen beenden (unsicher)",
"shutdown_kill_node_tip": "Beendet den Node sofort. Alles, was er noch nicht gespeichert hat, geht verloren, und beim nächsten Start spielt er die Blöcke seit dem letzten Speichern erneut ein.",
"shutdown_leave_running": "Wallet schließen – Node weiterlaufen lassen",
"shutdown_leave_running_tip": "Schließt die Wallet sofort. Der Node läuft allein weiter und beendet, was er gerade tut; nichts geht verloren.",
"shutdown_stop_asking": "Node wird zum Beenden aufgefordert…",
"shutdown_stop_cpu_suffix": " · CPU %d%%",
"shutdown_stop_delivered": "Der Node fährt herunter und speichert seinen Zustand",
"shutdown_stop_idle": "Seit %d Min. keine CPU- oder Festplattenaktivität – der Node hängt möglicherweise",
"shutdown_stop_queued": "Der Node hat die Stopp-Anforderung erhalten, ist aber beschäftigt – er stoppt, sobald er kann",
"simple_background": "Einfacher Hintergrund",
"slider_off": "Aus",
"sort_amount_high": "Größter Betrag",
@@ -1629,14 +1951,29 @@
"sort_date_newest": "Neueste zuerst",
"sort_date_oldest": "Älteste zuerst",
"start_mining": "Mining starten",
"startup_files_preparing": "Wallet-Dateien werden vorbereitet... %d%%",
"startup_files_verifying": "Wallet-Dateien werden überprüft...",
"status": "Status",
"stop_external": "Externen Daemon stoppen",
"stop_mining": "Mining stoppen",
"stopconfirm_busy_body": "Er ist seit %s zu beschäftigt, um der Wallet zu antworten – vermutlich wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses. Wenn Sie ihn jetzt stoppen, muss der nächste Start diese Arbeit eventuell wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
"stopconfirm_busy_title": "Der Node ist beschäftigt",
"stopconfirm_rebuild_body": "Er läuft seit %s, und bei großen Wallets kann das etwa eine Stunde dauern. Wenn Sie den Node jetzt stoppen, wird entweder bis zum Ende des Neuaufbaus gewartet, oder der nächste Start muss den Neuaufbau samt Nachholen wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
"stopconfirm_rebuild_title": "Der Node baut die Note-Witnesses neu auf",
"stopconfirm_stop_wait": "Node stoppen & warten",
"stopconfirm_walletop_body": "Der Node durchsucht die Blockchain für einen Schlüsselimport oder Neuscan. Wenn Sie ihn jetzt stoppen, endet dieser Scan vorzeitig, und Guthaben, das er noch nicht erreicht hat, erscheint erst nach Einstellungen → Neu scannen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet den Scan selbstständig.",
"stopconfirm_walletop_title": "Ein Schlüsselimport oder Neuscan läuft",
"stratum_allowip_hint": "Miner von IP oder CIDR zulassen (leer = nur dieser Computer)",
"stratum_expose_warn": "Öffnet einen Mining-Port für das von Ihnen zugelassene Netzwerk — nur in einem vertrauenswürdigen LAN verwenden.",
"stratum_host": "Mining-Pool hosten (Stratum)",
"stratum_host_hint": "Miner verbinden sich mit diesem Computer über Port 22769 (RPC-Port + 1000) mit einem RandomX-Stratum-Miner. Starten Sie den Daemon neu, um die Änderung zu übernehmen.",
"stratum_host_section": "MINING-POOL-HOSTING",
"submitting_transaction": "Transaktion wird übermittelt...",
"success": "Erfolg",
"summary": "Zusammenfassung",
"sweep_button": "Fegen",
"sweep_caveat": "Importiert den Schlüssel, um eine Transaktion zu signieren, die alle Mittel an Ihre Adresse verschiebt. Der Schlüssel bleibt mit leerem Guthaben in Ihrer Wallet.",
"sweep_current_theme_only": "Nur aktuelles Design",
"sweep_dest_label": "Gefegte Mittel senden an",
"sweep_dest_new": "Neue abgeschirmte Adresse (empfohlen)",
"sweep_done": "Fertig — Mittel an Ihre Adresse gefegt.",
@@ -1672,7 +2009,6 @@
"switch_stopnode_title": "Laufenden Knoten stoppen?",
"switch_stopnode_warn": "Es läuft bereits ein Knoten, den diese Wallet nicht gestartet hat.",
"syncing": "Synchronisiere...",
"t_address": "T-Adresse",
"t_addresses": "T-Adressen",
"test_connection": "Testen",
"theme": "Design",
@@ -1694,6 +2030,7 @@
"tools": "WERKZEUGE",
"tools_actions": "Werkzeuge & Aktionen...",
"tools_actions_hdr": "WERKZEUGE & AKTIONEN",
"tor_never_applied_notice": "Die Option „Tor verwenden“ hat nie funktioniert: Ihre Verbindungen wurden NICHT über Tor geleitet. Sie wurde ausgeschaltet. Alternativen finden Sie im Tooltip der Option in den Einstellungen.",
"total": "Gesamt",
"total_balance_label": "Gesamtguthaben",
"transaction_id": "TRANSAKTIONS-ID",
@@ -1707,8 +2044,6 @@
"transfer_sent": "Überweisung gesendet",
"transfer_sent_desc": "Ihre Überweisung wurde an das Netzwerk gesendet.",
"transfer_to": "Überweisen an:",
"transparent": "Transparent",
"transparent_address": "Transparente Adresse",
"try_again": "Erneut versuchen",
"tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer",
"tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten",
@@ -1740,10 +2075,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",
@@ -1752,6 +2093,7 @@
"tt_keep_daemon": "Der Daemon wird beim Ausführen des Einrichtungsassistenten gestoppt",
"tt_language": "Schnittstellensprache der Wallet-UI",
"tt_layout_hotkey": "Hotkey: Links-/Rechts-Pfeiltasten zum Wechseln der Balance-Layouts",
"tt_lite_confirm_pass": "Geben Sie die neue Passphrase erneut ein; sie muss übereinstimmen und mindestens 8 Zeichen lang sein",
"tt_lite_copy": "Das angezeigte Geheimnis in die Zwischenablage kopieren",
"tt_lite_decrypt_pass": "Gib deine Passphrase ein, um die Verschlüsselung von der Wallet zu entfernen",
"tt_lite_encrypt": "Die Wallet mit der obigen Passphrase verschlüsseln; sie wird sofort gesperrt und benötigt die Passphrase zum Entsperren",
@@ -1780,11 +2122,16 @@
"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",
"tt_open_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen",
"tt_open_dir": "Klicken, um im Dateimanager zu öffnen",
"tt_open_log_folder": "Öffnet den Ordner mit den Debug- und Absturzprotokollen",
"tt_pin_out_of_date": "Die Wallet-Passphrase wurde geändert, nachdem diese PIN festgelegt wurde, daher kann die PIN die Wallet nicht mehr entsperren. Entfernen Sie die PIN und legen Sie sie neu fest.",
"tt_reduce_motion": "Animierte Übergänge und Saldo-Lerp für Barrierefreiheit deaktivieren",
"tt_remove_encrypt": "Verschlüsselung entfernen und Wallet ungeschützt speichern",
"tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern",
@@ -1814,14 +2161,20 @@
"tt_simple_bg": "Einen einfachen Verlauf für den Hintergrund verwenden\\nHotkey: Ctrl+Up",
"tt_simple_bg_alt": "Eine Verlaufsversion des Theme-Hintergrundbilds verwenden\\nHotkey: Ctrl+Up",
"tt_stop_external": "Gilt bei Verbindung zu einem Daemon,\\nder außerhalb dieser Wallet gestartet wurde",
"tt_stratum_host": "Betreibt einen RandomX-Stratum-Pool-Server auf diesem Node, sodass andere RandomX-Miner diesen Computer ansprechen können. Erfordert einen Node ab v1.3.0 und einen Daemon-Neustart, um wirksam zu werden.",
"tt_sweep_current_theme_only": "Nur das aktive Design durchsuchen, statt jedes Design zu durchlaufen",
"tt_test_conn": "Die RPC-Verbindung zum Daemon überprüfen",
"tt_theme_effects": "Schimmer, Glühen, Farbton-Zyklen pro Theme",
"tt_theme_hotkey": "Hotkey: Ctrl+Links/Rechts zum Wechseln der Themes",
"tt_tor": "Daemon-Verbindungen für Anonymität über das Tor-Netzwerk leiten",
"tt_tor_unavailable": "Noch nicht verfügbar: Diese Wallet leitet keinen Datenverkehr über Tor. Nur für den Node können Sie proxy=127.0.0.1:9050 zu DRAGONX.conf hinzufügen und den Node neu starten. Tor muss laufen, sonst findet der Node keine Peers. Preis-, Update-, Bootstrap- und Pool-Anfragen verbinden sich weiterhin direkt; nutzen Sie dafür ein systemweites VPN/Tor.",
"tt_tor_unavailable_lite": "Noch nicht verfügbar: Diese Wallet leitet keinen Datenverkehr über Tor. Nutzen Sie ein systemweites VPN/Tor.",
"tt_tx_url": "Basis-URL zum Anzeigen von Transaktionen in einem Block-Explorer",
"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_view_transactions": "Transaktionen mit dieser Adresse im Verlauf-Tab anzeigen",
"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_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)",
@@ -1838,6 +2191,7 @@
"tx_loading_queued": "Transaktionsaktualisierung in der Warteschlange",
"tx_loading_refreshing_cached": "Wallet-Verlauf wird aktualisiert (%d zwischengespeichert)",
"tx_loading_scanning_shielded": "Abgeschirmter Verlauf wird gescannt (%d Adressen)",
"tx_loading_waits_for_sync": "Der Verlauf wird aktualisiert, sobald der Knoten fertig synchronisiert ist",
"tx_mined": "GEMINED",
"tx_progress_balances": "Guthaben wird aktualisiert",
"tx_progress_finalizing": "Transaktion wird abgeschlossen",
@@ -1869,6 +2223,7 @@
"validate_description": "Geben Sie eine DragonX-Adresse ein, um zu prüfen, ob sie gültig ist und ob sie zu dieser Wallet gehört.",
"validate_invalid": "UNGÜLTIG",
"validate_is_mine": "Diese Wallet besitzt diese Adresse",
"validate_mining_type": "Mining-Adresse (transparent)",
"validate_not_mine": "Nicht im Besitz dieser Wallet",
"validate_ownership": "Eigentum:",
"validate_results": "Ergebnisse:",
@@ -1876,7 +2231,6 @@
"validate_shielded_type": "Abgeschirmt (z-Adresse)",
"validate_status": "Status:",
"validate_title": "Adresse validieren",
"validate_transparent_type": "Transparent (t-Adresse)",
"validate_type": "Typ:",
"validate_valid": "GÜLTIG",
"validating": "Validiere...",
@@ -1886,10 +2240,71 @@
"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.",
"wallet_diagnostics_hdr": "DIAGNOSE",
"wallet_empty": "Ihre Wallet ist leer",
"wallet_empty_hint": "Wechseln Sie zu Empfangen, um Ihre Adresse zu erhalten und Gelder zu empfangen.",
"wallet_options_hdr": "OPTIONEN",
"wallet_rebuild_failed": "Der Neuaufbau hat keine gültige Wallet erzeugt, daher wurde nichts geändert. Ihre Wallet ist unangetastet.",
"wallet_rebuild_install_failed": "Die neu aufgebaute Wallet konnte nicht installiert werden; Ihre aktuelle Wallet wurde beibehalten.",
"wallet_rebuild_no_helper": "Der Wallet-Neuaufbau-Helfer ist in diesem Build nicht verfügbar. Verwenden Sie Wiederherstellen, oder bauen Sie manuell neu auf.",
"wallet_rebuild_no_source": "Es konnte keine lesbare Wallet zum Neuaufbau gefunden werden. Nichts wurde geändert.",
"wallet_rebuild_ok": "Wallet-Datenbank neu aufgebaut — der Node lädt sie und scannt erneut nach Ihrem Guthaben.",
"wallet_rebuild_started": "Ihre Wallet-Datei wird repariert — bitte schließen Sie dieses Fenster nicht.",
"wallet_recovered_body": "Es wurde nichts gelöscht. Ihre ursprüngliche Wallet ist weiterhin als datierte Sicherungsdatei auf Ihrem Computer gespeichert, und jede Option unten kopiert oder benennt Dateien nur um — gelöscht wird nie etwas. Ihre Schlüssel werden nie neu erzeugt, nur erneut eingelesen.\n\nDie jetzt geladene reparierte Kopie fehlen möglicherweise ein paar aktuelle Transaktionen, daher kann Ihr Guthaben etwas niedrig erscheinen, bis Sie unten abschließen und es neu gescannt wird.",
"wallet_recovered_dismiss": "Nicht jetzt — reparierte Kopie behalten",
"wallet_recovered_dismiss_sub": "Es werden keine Dateien geändert. Sie können dies jederzeit über die Statusleiste erneut öffnen; Ihr Original bleibt so oder so sicher gesichert.",
"wallet_recovered_notify": "Ihre Wallet-Datei musste repariert werden — Ihr Original wurde sicher gesichert. Öffnen Sie die App, um Ihre Optionen zu prüfen.",
"wallet_recovered_open_folder": "Dateien anzeigen",
"wallet_recovered_open_folder_sub": "Öffnet den Wallet-Datenordner, damit Sie die Sicherungsdateien selbst prüfen können — es wird nichts geändert.",
"wallet_recovered_rebuild": "Automatisch reparieren (empfohlen)",
"wallet_recovered_rebuild_sub": "Liest jeden wiederherstellbaren Datensatz aus Ihrer vollständigsten Wallet-Datei erneut ein und schreibt eine saubere neue Datei, dann wird neu gestartet. Die gründlichste Option — Ihre aktuelle Datei wird so oder so als datierte Sicherung behalten.",
"wallet_recovered_restore": "Stattdessen die Originaldatei wiederherstellen",
"wallet_recovered_restore_sub": "Setzt Ihre größte unveränderte Sicherung unverändert zurück, dann wird neu gescannt — etwas schneller, aber nur so vollständig, wie diese eine Datei war. Ihre aktuelle Datei wird so oder so als datierte Sicherung behalten.",
"wallet_recovered_safety": "Ihre Coins sind sicher.",
"wallet_recovered_title": "Ihre Wallet-Datei benötigt eine kurze Reparatur",
"wallet_recovered_warn": "Beim Start der App wurde festgestellt, dass Ihre Wallet-Datei die Konsistenzprüfung nicht bestanden hat — das passiert meist nach einem App-Update oder einem unsauberen Herunterfahren. Die App hat Ihre Daten bereits geschützt: Sie hat die alte Datei beiseitegelegt und eine reparierte Kopie geladen, damit Sie nicht feststecken.",
"wallet_recovery_decide_later": "Später entscheiden",
"wallet_recovery_decide_later_tip": "Schließt dies und behält die jetzt geladene Kopie. Es wird nichts geändert, und Sie können jederzeit reparieren — die Statusleiste behält einen Link „Wallet-Reparatur verfügbar“.",
"wallet_recovery_details_label": "Technische Details anzeigen",
"wallet_recovery_done": "Fertig",
"wallet_recovery_failure_body": "Die Reparatur wurde ausgeführt, aber ihr Ergebnis hat die Überprüfung nicht bestanden (es konnte nicht gelesen werden oder hatte keine Adressen), daher wurde es automatisch verworfen, bevor es jemals etwas ersetzt hat. Ihre Wallet ist genau so, wie sie vor dem Klick war — auf der Festplatte hat sich nichts geändert.",
"wallet_recovery_failure_title": "Die Reparatur wurde nicht abgeschlossen",
"wallet_recovery_files_detail": "Ihre ursprüngliche Wallet ist noch vorhanden — die App hat sie in Ihrem Datenordner in eine datierte Sicherung (wallet.<Zahlen>.bak) umbenannt und nichts gelöscht. „Reparieren“ liest jeden Datensatz aus Ihrer vollständigsten Wallet in eine brandneue, saubere Datei ein. „Wiederherstellen“ kopiert Ihre größte Sicherung exakt so zurück, wie sie ist. Die gerade geladene Kopie wird ebenfalls als Sicherung behalten.",
"wallet_recovery_files_label": "Was passiert mit meinen Dateien?",
"wallet_recovery_notnow_short": "Nicht jetzt",
"wallet_recovery_other_options": "Weitere Optionen",
"wallet_recovery_rebuild_card": "Automatisch reparieren",
"wallet_recovery_rebuild_card_desc": "Liest jeden wiederherstellbaren Datensatz in eine saubere Datei ein, dann wird neu gestartet. Die gründlichste Option.",
"wallet_recovery_recommended": "EMPFOHLEN",
"wallet_recovery_repair_go": "Reparieren",
"wallet_recovery_rescan_body": "Die Blockchain wird neu gescannt, um Ihr Guthaben und Ihren Transaktionsverlauf wiederherzustellen. Dies kann mehrere Minuten dauern — Sie müssen nichts tun, und starten Sie den Node bitte nicht neu, während dies läuft.",
"wallet_recovery_rescan_elapsed": "Arbeitet seit %s",
"wallet_recovery_rescan_size": "Die reparierte Wallet ist jetzt %s groß und füllt sich weiter",
"wallet_recovery_rescan_slow": "Dies dauert länger als gewöhnlich, was bei einer großen Wallet normal ist. Es ist sicher, es weiterlaufen zu lassen — Sie können den Fortschritt unter Erweitert ▸ Konsole beobachten.",
"wallet_recovery_rescan_title": "Ihre Wallet-Reparatur wird abgeschlossen",
"wallet_recovery_restore_card": "Original wiederherstellen",
"wallet_recovery_restore_card_desc": "Setzt Ihre größte unveränderte Sicherung unverändert zurück — nur so vollständig, wie diese Datei war.",
"wallet_recovery_restore_go": "Wiederherstellen",
"wallet_recovery_success_body": "Die App hat Ihre reparierte Wallet geladen und scannt neu, um Ihr Guthaben zu summieren — dies kann einige Minuten dauern. Sie liest jeden Datensatz, den sie kann, aber bei seltenen beschädigten Dateien kann sie etwas weniger — oder gelegentlich mehr — Adressen wiederherstellen als zuvor. Prüfen Sie nach Abschluss des Neuscans, ob Guthaben und Verlauf korrekt aussehen.",
"wallet_recovery_success_restore": "Ihre größte Sicherungsdatei ist wieder an ihrem Platz und wird jetzt geladen, und die App scannt neu. Sie wurde unverändert wiederhergestellt, ist also genau so vollständig, wie diese Datei war — prüfen Sie Ihr Guthaben, sobald der Neuscan abgeschlossen ist.",
"wallet_recovery_try_other": "Die andere Option versuchen",
"wallet_recovery_whats_happened": "Was ist passiert?",
"wallet_recovery_working_label": "Wird ausgeführt",
"wallet_restore_bad_backup": "Die gesicherte Wallet-Datei scheint unlesbar zu sein, daher wurde sie NICHT wiederhergestellt — Ihre aktuelle Wallet ist unverändert. Stellen Sie stattdessen von Ihrer eigenen Sicherung wieder her.",
"wallet_restore_busy": "Der Node startet gerade neu — versuchen Sie es gleich noch einmal.",
"wallet_restore_copy_failed": "Die gesicherte Wallet konnte nicht installiert werden; Ihre aktuelle Wallet wurde beibehalten.",
"wallet_restore_move_failed": "Die aktuelle Wallet konnte nicht beiseitegelegt werden — es wurde nichts geändert.",
"wallet_restore_no_backup": "Es konnte keine wallet.<Zeitstempel>.bak zum Wiederherstellen gefunden werden. Es wurde nichts geändert.",
"wallet_restore_no_restart": "Ihre ursprüngliche Wallet wurde wiederhergestellt, aber der Node wurde nicht neu gestartet — starten Sie ihn über die Einstellungen.",
"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_consolidate": "Notizen konsolidieren…",
"wallet_size_warn": "Diese Wallet-Datei ist groß. Das Konsolidieren Ihrer Notizen kann weiteres Wachstum eindämmen.",
"wallets_active": "Aktiv",
"wallets_add": "Ordner hinzufügen",
"wallets_add_folder": "Auch einen weiteren Ordner nach Wallet-Dateien durchsuchen:",
@@ -1932,7 +2347,9 @@
"wallets_open_inplace_tt": "Öffnet diese Wallet an ihrem Ort — im Datenverzeichnis verlinkt (keine Kopie)",
"wallets_remove_folder": "Diesen Ordner nicht mehr durchsuchen",
"wallets_reveal": "Ordner anzeigen",
"wallets_scan_folder": "Einen weiteren Ordner nach Wallets durchsuchen…",
"wallets_scanned_folders": "Durchsuchte Ordner:",
"wallets_scanning": "Ordner werden durchsucht…",
"wallets_sort_addresses": "Adressen",
"wallets_sort_asc": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)",
"wallets_sort_by": "Sortieren:",
@@ -2001,19 +2418,30 @@
"wiz_welcome_sub": "Ein paar schnelle Einstellungen und Ihr Full Node ist bereit.",
"wiz_welcome_title": "Willkommen bei ObsidianDragon!",
"wizard_daemon_start_failed": "Daemon-Start fehlgeschlagen — wird automatisch wiederholt",
"xmrig_active_tt": "Der aktuell installierte (aktive) Miner",
"xmrig_browse_releases": "Alle Releases durchsuchen…",
"xmrig_checking": "Nach dem neuesten Miner wird gesucht…",
"xmrig_current": "Aktuell:",
"xmrig_downgrade_note": "Das Installieren einer anderen Miner-Version ersetzt die aktuelle; stoppen Sie zuerst das Mining, dann wird sie beim nächsten Start wirksam.",
"xmrig_download_install": "Herunterladen & installieren",
"xmrig_downloading": "Wird heruntergeladen…",
"xmrig_install_this": "Diese Version installieren",
"xmrig_installed": "Installiert:",
"xmrig_installed_ok": "Miner installiert",
"xmrig_installing": "Wird installiert…",
"xmrig_latest": "Neueste:",
"xmrig_latest_badge": "neueste",
"xmrig_latest_tt": "Neueste verfügbare Version",
"xmrig_loading_releases": "Releases werden geladen…",
"xmrig_no_notes": "Keine Versionshinweise für diese Version.",
"xmrig_none": "keiner",
"xmrig_prerelease_tt": "Vorabversion / Testbuild",
"xmrig_reinstall": "Neu installieren",
"xmrig_released": "Veröffentlicht:",
"xmrig_releases": "xmrig-Releases",
"xmrig_status_current": "Diese Version ist installiert",
"xmrig_status_latest": "Neueste Version",
"xmrig_status_older": "Älter als Ihre installierte Version",
"xmrig_stop_mining_first": "Stoppen Sie das Mining, bevor Sie den Miner aktualisieren.",
"xmrig_unavailable_body": "Für diese Plattform ist kein Miner-Build verfügbar.",
"xmrig_unavailable_title": "Miner-Updates nicht verfügbar",
+453 -25
View File
@@ -19,11 +19,13 @@
"about_license_text": "Este software se distribuye bajo la Licencia Pública General de GNU v3 (GPLv3). Usted es libre de usar, modificar y distribuir este software bajo los términos de la licencia.",
"about_peers_count": "%zu nodos",
"about_release": "Producción",
"about_source": "Código fuente",
"about_title": "Acerca de ObsidianDragon",
"about_version": "Versión:",
"about_website": "Sitio Web",
"acrylic": "Acrílico",
"add": "Agregar",
"addr_type_mining": "Minería",
"address": "Dirección",
"address_book_add": "Agregar Dirección",
"address_book_add_new": "Agregar Nueva",
@@ -67,6 +69,7 @@
"appx_blockchain_maintenance_in_progress": "Ya hay una operación de mantenimiento de la cadena de bloques en curso.",
"appx_blockchain_rescan_complete": "Reescaneo de la cadena de bloques completado",
"appx_bootstrap_complete_reconciling": "Arranque inicial completado; reconciliando tu cartera con los nuevos datos de la cadena.",
"appx_bootstrap_reconcile_probe_failed": "No se pudo reconciliar tu cartera con los datos de la cadena del bootstrap: el nodo estuvo demasiado ocupado para comprobar qué bloques tiene. Ejecuta Re-escanear desde Ajustes cuando se haya puesto al día.",
"appx_cancel": "Cancelar",
"appx_cleaning_up": "Limpiando...",
"appx_confirm_your_backup": "Confirma tu copia de seguridad",
@@ -77,11 +80,15 @@
"appx_creating_your_wallet": "Creando tu cartera…",
"appx_daemon_error": "Error del daemon",
"appx_daemon_reinstall_in_progress": "La reinstalación del daemon ya está en curso.",
"appx_daemon_reinstall_node_busy": "El nodo sigue en marcha, así que no se reinstaló el daemon. Vuelve a intentarlo cuando se haya detenido.",
"appx_disconnecting": "Desconectando...",
"appx_done": "Listo",
"appx_dragonxd_output": "Salida de dragonxd",
"appx_encrypting_wallet": "Cifrando la cartera...",
"appx_fullnode_lifecycle_unavailable_lite": "Las acciones de ciclo de vida del nodo completo no están disponibles en la versión lite",
"appx_import_cancelled": "Cancelado: puede que el nodo siga escaneando. Si los fondos de la clave no aparecen, ejecuta Ajustes → Re-escanear.",
"appx_import_interrupted": "La importación se interrumpió, así que la clave puede que ya esté en la cartera. Si sus fondos no aparecen, ejecuta Ajustes → Re-escanear.",
"appx_import_refused": "El nodo rechazó la solicitud, así que la clave no se importó.",
"appx_installing_bundled_daemon": "Instalando el daemon incluido; el nodo se detendrá, se actualizará y se reiniciará...",
"appx_invalid_payment_uri_prefix": "URI de pago no válida: ",
"appx_ive_written_it_down": "Ya la anoté",
@@ -98,7 +105,8 @@
"appx_no_bundled_daemon_to_install": "Esta versión no incluye un daemon para instalar",
"appx_no_embedded_daemon_to_install": "Esta versión no incluye un daemon integrado para instalar",
"appx_node_busy_restarting": "El nodo está ocupado reiniciándose; inténtalo de nuevo en un momento.",
"appx_node_rebuilding_witness_cache": "El nodo está reconstruyendo su caché de testigos",
"appx_node_busy_try_later": "El nodo está ocupado terminando una tarea larga (como reconstruir los testigos de notas). Vuelve a intentarlo cuando termine; detenerlo ahora podría hacer que empiece ese trabajo de nuevo.",
"appx_node_not_stopped": "El nodo no se detuvo a tiempo, así que no se cambió nada. Vuelve a intentarlo en unos minutos.",
"appx_not_next_word": " — esa no es la siguiente palabra",
"appx_payment_request_loaded": "Solicitud de pago cargada",
"appx_pool_miner_connected_and_hashing": "Minero de pool conectado y calculando hashes.",
@@ -119,11 +127,7 @@
"appx_simple_background_enabled": "Fondo simple activado",
"appx_skip": "Omitir",
"appx_skip_anyway": "Omitir de todos modos",
"appx_still_status_prefix": "Aún \"",
"appx_still_status_suffix": "\" — forzar el cierre ahora podría dañar los datos de la cadena.",
"appx_stop_anyway_and_quit": "Detener de todos modos y salir",
"appx_stopping_daemon_deleting_blockchain": "Deteniendo el daemon y eliminando los datos de la cadena de bloques...",
"appx_stopping_node_discards_rebuild": "Detener el nodo ahora descarta la reconstrucción en curso y la reinicia (varios minutos) la próxima vez que abras la cartera. También puedes dejar el nodo en ejecución.",
"appx_stopping_pool_miner": "Deteniendo el minero de pool...",
"appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloque %d / %d",
"appx_tap_words_in_order": "Toca las palabras en el orden correcto para confirmar que las guardaste.",
@@ -133,8 +137,14 @@
"appx_use_settings_restart_daemon_hint": "Usa Ajustes > Reiniciar daemon para intentarlo de nuevo",
"appx_waiting_for_daemon_to_encrypt_wallet": "Esperando a que el daemon cifre la cartera...",
"appx_wallet_created_and_backed_up": "Cartera creada y respaldada.",
"appx_wallet_op_node_busy": "El nodo está ocupado y no responde. Vuelve a intentar la importación o el reescaneo cuando termine.",
"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_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.",
"autoshield_off_generic": "Tu nodo no está blindando automáticamente el coinbase minado en este momento.",
"av_intro": "El software de minería suele marcarse como potencialmente no deseado. Sigue estos pasos para habilitar la minería en pool:",
"av_open_security": "Abrir Seguridad de Windows",
"av_step1": "Paso 1: Agregar exclusión",
@@ -170,21 +180,20 @@
"balance_history_collecting": "Historial de saldo — recopilando datos...",
"balance_layout": "Diseño de Saldo",
"balance_layout_switched": "Diseño: %s",
"balance_mining_fmt": "Recompensas de minería: %.8f",
"balance_mining_rate": "Minando %s",
"balance_shielded_fmt": "Protegido: %.8f",
"balance_syncing_pct": "Sincronizando %.1f%%",
"balance_transparent_fmt": "Transparente: %.8f",
"baltab_market": "Mercado",
"baltab_market_price_4dp": "Mercado: $%.4f",
"baltab_market_price_8dp": "Mercado: $%.8f",
"baltab_mining_addresses_count": "%d direcciones de minería",
"baltab_mining_amount": "Recompensas de minería %.8f",
"baltab_pct_change_24h": "%s%.1f%% 24h",
"baltab_pct_of_total_zaddr": "%.0f%% del total · %d Z-addr",
"baltab_shielded": "Blindado",
"baltab_shielded_amount": "Blindado %.8f",
"baltab_t_addresses_count": "%d T-addresses",
"baltab_total_balance": "Saldo total",
"baltab_transparent": "Transparente",
"baltab_transparent_amount": "Transparente %.8f",
"ban": "Bloquear",
"banned_peers": "Nodos Bloqueados",
"block": "Bloque",
@@ -192,6 +201,12 @@
"block_click_copy": "Clic para copiar",
"block_click_next": "Clic para ver bloque siguiente",
"block_click_prev": "Clic para ver bloque anterior",
"block_db_reindex_body": "Esto suele ocurrir después de que una actualización del daemon cambia el formato en disco, o si el índice de bloques está dañado. Tu cartera y tus monedas están a salvo — el nodo simplemente no puede cargar la cadena, así que los saldos aparecen en cero.\n\nReconstruir vuelve a leer tus archivos de bloques existentes y puede tardar un rato (también reescanea tu cartera). No se descarga nada.",
"block_db_reindex_confirm": "Reconstruir base de datos de bloques",
"block_db_reindex_notify": "El nodo no puede leer su base de datos de bloques (suele pasar tras una actualización del daemon). Reconstrúyela para restaurar tu saldo — consulta el aviso, o Ajustes › Nodo.",
"block_db_reindex_started": "Reconstruyendo la base de datos de bloques a partir de tus bloques — esto puede tardar un rato.",
"block_db_reindex_title": "¿Reconstruir base de datos de bloques?",
"block_db_reindex_warn": "El nodo no puede leer su base de datos de bloques.",
"block_explorer": "Explorador de Bloques",
"block_get_info": "Obtener Info del Bloque",
"block_hash": "Hash del Bloque:",
@@ -228,6 +243,7 @@
"change_pass_confirm": "Confirmar nueva:",
"change_pass_current": "Frase de contraseña actual:",
"change_pass_new": "Nueva frase de contraseña:",
"change_pass_pin_note": "Esta cartera tiene un PIN de desbloqueo rápido. Tras el cambio quedará desactualizado: quita el PIN en Ajustes y vuelve a establecerlo.",
"change_pass_title": "Cambiar frase de contraseña",
"characters": "caracteres",
"chat": "Chat",
@@ -239,6 +255,10 @@
"chat_accent_theme": "Tema",
"chat_add_contact": "Añadir contacto",
"chat_awaiting_key": "Esperando respuesta",
"chat_blocked_desc": "Las conversaciones bloqueadas se eliminan y sus mensajes se descartan — antiguos y nuevos — hasta que las desbloquees. Desbloquear reimporta la conversación desde la cadena.",
"chat_blocked_manage": "Bloqueadas",
"chat_blocked_title": "Conversaciones bloqueadas",
"chat_blocked_toast": "Conversación eliminada y bloqueada",
"chat_bubble_minimal": "Mínima",
"chat_bubble_rounded": "Redondeada",
"chat_bubble_square": "Cuadrada",
@@ -251,6 +271,16 @@
"chat_contact_added": "Contacto añadido: renómbralo en Contactos",
"chat_contact_request": "solicitud de contacto",
"chat_copy_address_tip": "Clic para copiar la dirección",
"chat_delete": "Eliminar conversación",
"chat_delete_block": "Eliminar y bloquear",
"chat_delete_body_prefix": "¿Eliminar tu copia local de la conversación con ",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "Eliminar",
"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_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",
"chat_density_comfortable": "Cómoda",
"chat_density_compact": "Compacta",
"chat_emoji_color": "Color",
@@ -274,6 +304,7 @@
"chat_new_button": "Nuevo chat",
"chat_new_message": "Mensaje",
"chat_new_message_toast": "Nuevo mensaje de chat cifrado",
"chat_new_needs_zaddr": "El chat necesita una dirección blindada (z) — las direcciones transparentes (t) no pueden recibir mensajes cifrados.",
"chat_new_send": "Enviar solicitud",
"chat_new_title": "Nuevo chat",
"chat_new_zaddr": "Dirección z del destinatario",
@@ -319,6 +350,8 @@
"chat_ts_24h": "24 horas",
"chat_ts_global": "Seguir global",
"chat_ts_global_short": "Global",
"chat_unblock": "Desbloquear",
"chat_unblocked_toast": "Conversación desbloqueada",
"chat_unhide": "Mostrar",
"chat_unmute": "Reactivar",
"chat_verify_key": "Clave de identidad: compárala para verificar",
@@ -371,6 +404,7 @@
"console": "Consola",
"console_accents": "Acentos de color",
"console_app": "App",
"console_auto_focus": "Enfocar la entrada al abrir",
"console_auto_scroll": "Auto-desplazamiento",
"console_available_commands": "Comandos disponibles:",
"console_backend_reference": "Referencia de Comandos del Backend",
@@ -465,11 +499,15 @@
"console_tab_completion": "Tab para completar",
"console_text_colors": "Colores de texto",
"console_toggle_accents": "Alternar acentos de color de línea",
"console_toggle_auto_focus": "Coloca el cursor en el cuadro de comandos al abrir la pestaña Consola",
"console_toggle_text_color": "Alternar colores de texto de línea",
"console_type_help": "Escribe 'help' para ver los comandos disponibles",
"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_funds_btn": "Consolidar",
"consolidate_title": "Consolidar fondos",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "No se pudo cargar esa imagen.",
"contact_avatar_badge": "Insignia",
@@ -481,7 +519,7 @@
"contact_avatar_image_hint": "La imagen se copia en la app para que siga disponible si el original se mueve.",
"contact_avatar_remove": "Quitar",
"contact_avatar_shielded": "Blindada",
"contact_avatar_transparent": "Transparente",
"contact_chip_mining": "Solo minería",
"contact_global": "Mostrar en todas las carteras (contacto global)",
"contact_global_badge_tt": "Contacto global — visible en todas las carteras",
"contact_global_tt": "Activado: este contacto permanece visible sin importar qué cartera cargues. Desactivado: pertenece solo a la cartera actual.",
@@ -515,9 +553,13 @@
"daemon_maintenance_label": "MANTENIMIENTO",
"daemon_none_bundled": "ninguno en esta compilación",
"daemon_not_installed": "no instalado",
"daemon_status_diff": "La versión difiere",
"daemon_status_differ": "El binario instalado difiere de la versión incluida.",
"daemon_status_match": "El binario instalado coincide con la versión integrada.",
"daemon_status_missing": "No hay ningún daemon instalado — instala la versión incluida.",
"daemon_status_none": "No instalado",
"daemon_status_ok": "Actualizado",
"daemon_update_active_tt": "El nodo actualmente instalado (activo)",
"daemon_update_available": "Hay una nueva versión del nodo disponible",
"daemon_update_body": "Esta versión de la cartera incluye un nodo DragonX más reciente que el que está instalado actualmente. Al actualizar se reemplaza el dragonxd instalado (y dragonx-cli/dragonx-tx), y luego se detiene y reinicia el nodo para que la nueva versión surta efecto. Recomendado: un nodo más reciente puede añadir funciones (p. ej. compatibilidad con frase de recuperación) que el antiguo no tiene.",
"daemon_update_browse": "Explorar todas las versiones…",
@@ -527,19 +569,28 @@
"daemon_update_download_install": "Descargar e instalar",
"daemon_update_downloading": "Descargando…",
"daemon_update_failed": "Error en la actualización",
"daemon_update_install_this": "Instalar esta versión",
"daemon_update_installed": "Instalado:",
"daemon_update_installed_ok": "Nodo instalado",
"daemon_update_installing": "Instalando…",
"daemon_update_keep": "Mantener actual",
"daemon_update_later": "Más tarde",
"daemon_update_latest": "Más reciente:",
"daemon_update_latest_badge": "más reciente",
"daemon_update_latest_tt": "La versión más reciente disponible",
"daemon_update_loading": "Cargando versiones…",
"daemon_update_no_notes": "No hay notas de la versión para esta versión.",
"daemon_update_now": "Actualizar ahora",
"daemon_update_prerelease_tt": "Versión preliminar / de prueba",
"daemon_update_prompt_title": "¿Actualizar el daemon del nodo?",
"daemon_update_reinstall": "Reinstalar",
"daemon_update_released": "Publicada:",
"daemon_update_restart_note": "Reinicia el daemon para empezar a ejecutar la nueva versión.",
"daemon_update_restart_now": "Reiniciar el daemon ahora",
"daemon_update_safe": "Tu cartera, tus claves y los datos de la blockchain no se ven afectados: solo se reemplazan los archivos del programa daemon. Si ejecutas deliberadamente un nodo personalizado, elige Mantener actual.",
"daemon_update_status_current": "Esta versión está instalada",
"daemon_update_status_latest": "Última versión",
"daemon_update_status_older": "Anterior a tu versión instalada",
"daemon_update_title": "¿Actualizar el daemon del nodo?",
"daemon_update_unavailable_body": "No hay una compilación del nodo disponible para esta plataforma.",
"daemon_update_unavailable_title": "Actualizaciones del nodo no disponibles",
@@ -555,6 +606,13 @@
"data_stale_tooltip": "El saldo puede estar desactualizado: la cartera no ha recibido una actualización reciente. Comprueba la conexión con tu nodo.",
"date": "Fecha",
"date_label": "Fecha:",
"debug_gate_confirm": "Mostrar opciones de depuración",
"debug_gate_incorrect": "PIN o contraseña incorrectos.",
"debug_gate_pass_prompt": "Ingresa la contraseña de tu cartera para continuar:",
"debug_gate_pin_prompt": "Ingresa tu PIN de desbloqueo para continuar:",
"debug_gate_title": "¿Mostrar opciones de depuración?",
"debug_gate_verifying": "Verificando…",
"debug_gate_warning": "Estas son opciones avanzadas de diagnóstico y ajuste del nodo. Pueden exponer información sensible y cambiar el funcionamiento de tu nodo. Continúa solo si sabes lo que haces.",
"debug_logging": "REGISTRO DE DEPURACIÓN",
"decrypt_desc": "Se exportará el monedero, se reiniciará el daemon con un monedero nuevo sin cifrar y se reimportarán todas las claves. Esto puede tardar varios minutos según el tamaño del monedero.",
"decrypt_error_title": "Error al descifrar",
@@ -577,9 +635,15 @@
"disconnected": "Desconectado",
"dismiss": "Descartar",
"display": "Pantalla",
"done": "Listo",
"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",
@@ -630,6 +694,7 @@
"explorer_tx_outputs": "Salidas",
"explorer_tx_size": "Tamaño",
"explorer_txid": "TxID:",
"explorer_urls_hdr": "URLS",
"export": "Exportar",
"export_csv": "Exportar CSV",
"export_keys_btn": "Exportar Claves",
@@ -655,9 +720,155 @@
"export_tx_success": "Transacciones exportadas exitosamente",
"export_tx_title": "Exportar Transacciones a CSV",
"export_viewing_key": "Exportar Clave de Vista",
"failed_create_mining_address": "No se pudo crear la dirección de minería",
"failed_create_shielded": "Error al crear dirección protegida",
"failed_create_transparent": "Error al crear dirección transparente",
"faq": "Preguntas frecuentes",
"faq_d_mgmt_1_a": "El nodo integrado se inicia automáticamente con la cartera y se detiene cuando corresponde, así que normalmente no necesitas administrarlo a mano. Los controles avanzados (incluido reiniciar el daemon) están en Ajustes > Nodo y seguridad.",
"faq_d_mgmt_1_q": "¿Cómo inicio o detengo el nodo?",
"faq_d_mgmt_2_a": "Si conectas la cartera a un nodo que iniciaste tú mismo, esta opción decide si cerrar la cartera también apaga ese nodo. Déjala desactivada si quieres que tu nodo siga funcionando después de cerrar la cartera.",
"faq_d_mgmt_2_q": "¿Qué hace «cerrar el daemon externo al salir»?",
"faq_d_mgmt_3_a": "Los datos del nodo y la cartera están en el directorio de datos de DragonX, dentro de tu perfil de usuario (por ejemplo, en AppData en Windows o en tu carpeta personal en Linux/macOS). La cadena de bloques es la parte más grande; conserva suficiente espacio libre en disco para que pueda crecer.",
"faq_d_mgmt_3_q": "¿Dónde se almacenan la cadena de bloques y los datos de la cartera?",
"faq_d_mgmt_title": "Gestión del nodo",
"faq_d_mine_1_a": "La pestaña Minería te permite minar con tu CPU. DragonX usa el algoritmo RandomX, diseñado para procesadores normales. Define el número de hilos e inicia — las recompensas se acreditan en tu cartera.",
"faq_d_mine_1_q": "¿Cómo mino DragonX?",
"faq_d_mine_2_a": "La minería solo envía las recompensas directamente a ti, pero solo paga cuando encuentras un bloque, lo cual es poco frecuente a menos que tengas mucho hashrate. La minería en pool comparte el trabajo con otros para pagos más pequeños y constantes. La pestaña Minería admite ambas.",
"faq_d_mine_2_q": "Minería solo vs. en pool — ¿cuál elijo?",
"faq_d_mine_3_a": "La sección de pool en la pestaña Minería tiene un botón «Actualizar minero…» que descarga, verifica e instala la última versión optimizada del minero de DragonX. Igual que el actualizador del nodo, cada descarga se verifica con checksum y firma antes de instalarse.",
"faq_d_mine_3_q": "¿Cómo actualizo el minero?",
"faq_d_mine_4_a": "El nodo actual puede ejecutar un servidor stratum integrado para que otros mineros apunten a tu equipo. Hay un interruptor para esto en Ajustes > Nodo y seguridad. Por defecto solo escucha localmente; exponerlo a otros equipos requiere abrirlo deliberadamente.",
"faq_d_mine_4_q": "¿Puedo alojar un pool de minería?",
"faq_d_mine_title": "Minería y pools",
"faq_d_net_1_a": "Justo después de iniciar, el nodo necesita un momento para encontrar pares, así que un «0 pares» breve es normal. Si persiste, comprueba tu conexión a internet y que un firewall no esté bloqueando el nodo. El nodo encuentra pares automáticamente mediante semillas integradas.",
"faq_d_net_1_q": "La cartera no muestra pares ni conexiones — ¿qué ocurre?",
"faq_d_net_2_a": "Sí — los nodos de DragonX se comunican entre sí mediante TLS, por lo que las conexiones entre pares están cifradas. La cartera también se comunica con su propio nodo por un canal local seguro.",
"faq_d_net_2_q": "¿Está cifrada mi conexión a la red?",
"faq_d_net_title": "Pares y red",
"faq_d_node_1_a": "El demonio, dragonxd, es el nodo completo de DragonX. Descarga y verifica toda la cadena de bloques, retransmite transacciones a la red y responde a las consultas de la cartera. Ejecutar tu propio nodo significa que no tienes que confiar en el servidor de nadie más.",
"faq_d_node_1_q": "¿Qué es el demonio (dragonxd)?",
"faq_d_node_2_a": "Por defecto, la cartera inicia y gestiona su propio nodo integrado (embebido) — no tienes que hacer nada. Si ya ejecutas dragonxd por tu cuenta, la cartera puede conectarse a ese nodo externo en su lugar. La cartera detecta en qué situación está y actúa en consecuencia.",
"faq_d_node_2_q": "Nodo integrado vs. externo — ¿cuál es la diferencia?",
"faq_d_node_3_a": "Un nodo completo valida cada regla de la cadena de bloques de forma independiente, así que tu cartera confía en las matemáticas en lugar de en un tercero. También refuerza la red. La contrapartida es el espacio en disco y una sincronización inicial.",
"faq_d_node_3_q": "¿Por qué ejecutar un nodo completo?",
"faq_d_node_title": "El nodo completo",
"faq_d_perf_1_a": "La cadena de bloques es el elemento más grande y crece con el tiempo, así que conserva varios gigabytes libres. El nodo usa una caché de base de datos para mayor velocidad; los valores predeterminados incluidos están ajustados para equipos típicos. Los usuarios avanzados pueden ajustar la caché de base de datos del nodo editando dbcache en su archivo de configuración (DRAGONX.conf).",
"faq_d_perf_1_q": "¿Cuánto disco usa el nodo y puedo ajustar la memoria?",
"faq_d_perf_2_a": "Activa la descarga de arranque (bootstrap) (ofrecida en el asistente de configuración) para obtener una copia reciente y verificada de la cadena en lugar de validar cada bloque desde cero. Un disco rápido (SSD) también ayuda mucho.",
"faq_d_perf_2_q": "¿Cómo hago más rápida la sincronización inicial?",
"faq_d_perf_title": "Almacenamiento y rendimiento",
"faq_d_sync_1_a": "Al iniciar, el nodo carga y valida bloques antes de poder atender a la cartera. La pantalla «Procesando bloques / Aplicando bloques para construir el estado actual de la cadena» es ese calentamiento. Es normal; la cartera se vuelve utilizable cuando el nodo termina e informa su altura.",
"faq_d_sync_1_q": "¿Qué significa «Procesando bloques» al iniciar?",
"faq_d_sync_2_a": "Para tus fondos blindados, el nodo mantiene «testigos» criptográficos que te permiten gastar de forma privada. Tras ciertos reinicios o actualizaciones, reconstruye esta caché reescaneando bloques recientes. En una cartera con muchas transacciones, esto puede tardar varios minutos y usar memoria adicional.\n\nNo está atascado — deja que termine. Forzar el cierre a la mitad solo hace que la reconstrucción se reinicie la próxima vez.",
"faq_d_sync_2_q": "¿Qué es «Reconstruyendo caché de testigos»?",
"faq_d_sync_3_a": "Cerca de la punta de la cadena, el trabajo que escanea tu cartera (comprobando saldos y notas) compite con el nodo por el mismo bloqueo interno, lo que puede ralentizar la conexión de los últimos bloques en una cartera grande. Las versiones recientes limitan ese escaneo para que el nodo pueda ponerse al día. Quedarte en una pestaña más ligera (o simplemente esperar) permite que termine más rápido.",
"faq_d_sync_3_q": "¿Por qué la sincronización es lenta cuando ya casi estoy al día?",
"faq_d_sync_4_a": "La primera sincronización descarga y verifica toda la cadena y puede tardar según tu conexión y disco. Puedes acelerarla drásticamente activando la descarga de arranque (bootstrap) en el asistente de configuración, que obtiene una copia reciente y verificada de los datos de la cadena.",
"faq_d_sync_4_q": "¿Cuánto dura la sincronización inicial?",
"faq_d_sync_title": "Sincronización y cadena de bloques",
"faq_d_trbl_1_a": "Normalmente no. En una cartera grande, el nodo puede pasar varios minutos reconstruyendo testigos blindados o aplicando bloques, durante lo cual parece pausado y puede tardar en responder. Dale tiempo — el registro de depuración (Ajustes) muestra progreso constante si está funcionando.\n\nEvita forzar el cierre durante esta fase; reinicia el trabajo desde cero en el próximo arranque.",
"faq_d_trbl_1_q": "El nodo parece atascado en «Activando la mejor cadena» — ¿está congelado?",
"faq_d_trbl_2_a": "Si un archivo de cartera se recupera pero falta parte de sus datos de claves, el nodo puede abrirlo en un modo «degradado» limitado, donde no puede derivar nuevas direcciones ni blindar fondos. La solución más segura es restaurar desde tu frase de recuperación en una cartera nueva.",
"faq_d_trbl_2_q": "¿Qué es el «modo degradado»?",
"faq_d_trbl_3_a": "Un reescaneo hace que la cartera vuelva a leer la cadena para redescubrir tus transacciones (útil tras importar claves). Una reindexación reconstruye la base de datos de la cadena de bloques del nodo y solo es necesaria si esa base de datos está dañada o es incompatible tras un cambio de versión. Ambas pueden tardar un rato; inícialas desde Ajustes > Nodo y seguridad.",
"faq_d_trbl_3_q": "¿Cuándo debo reindexar o reescanear?",
"faq_d_trbl_4_a": "El uso de memoria aumenta durante trabajo intenso, como una reconstrucción de testigos o una sincronización inicial, y vuelve a bajar al terminar. Un uso de memoria alto y sostenido en reposo es inusual — reiniciar la cartera lo libera. En un equipo con poca RAM, asegúrate de que otras aplicaciones que consumen mucha memoria no compitan por ella.",
"faq_d_trbl_4_q": "El nodo usa mucha memoria — ¿es normal?",
"faq_d_trbl_title": "Solución de problemas",
"faq_d_upd_1_a": "Abre Ajustes > Nodo y seguridad > Binario del demonio y usa «Buscar actualizaciones…». La cartera descarga la última versión verificada del nodo y la instala; la nueva versión se aplica la próxima vez que se inicie el demonio.",
"faq_d_upd_1_q": "¿Cómo actualizo el nodo?",
"faq_d_upd_2_a": "Sí — el diálogo de actualización enumera todas las versiones para que puedas elegir una compilación específica o anterior. Ten cuidado al degradar: un nodo más antiguo puede no aceptar datos de la cadena de bloques escritos por uno más reciente y podría necesitar una reindexación.",
"faq_d_upd_2_q": "¿Puedo instalar una versión específica del nodo?",
"faq_d_upd_3_a": "Cada archivo de nodo descargado se verifica con un checksum y una firma criptográfica antes de instalarse. Si la verificación falla, la cartera rechaza la actualización.",
"faq_d_upd_3_q": "¿Es segura la actualización?",
"faq_d_upd_title": "Actualizar el nodo",
"faq_group_daemon": "Demonio",
"faq_group_wallet": "Cartera",
"faq_intro": "Respuestas sobre tu cartera y el nodo de DragonX. Busca, o explora por tema abajo.",
"faq_intro_lite": "Respuestas sobre tu cartera Lite. Busca, o explora por tema abajo.",
"faq_l_gs_1_a": "ObsidianDragonLite es la cartera ligera de DragonX. En lugar de ejecutar un nodo completo, se conecta a un servidor lite de DragonX, así que inicia rápido y no descarga toda la cadena de bloques.\n\nGestiona tus claves, saldos y envíos igual que la cartera completa — consulta la sección «Cartera Lite» más abajo para ver cómo funciona la conexión al servidor y la contrapartida de privacidad.",
"faq_l_gs_1_q": "¿Qué es ObsidianDragonLite?",
"faq_l_lite_1_a": "La cartera Lite se comunica con un servidor lite de DragonX en lugar de ejecutar su propio nodo completo. Eso la hace rápida de iniciar y ligera en disco y ancho de banda, porque no descarga ni verifica toda la cadena de bloques por sí misma. Tus claves privadas siempre permanecen en tu dispositivo — nunca se envían al servidor.",
"faq_l_lite_1_q": "¿En qué se diferencia la cartera Lite de la cartera completa?",
"faq_l_lite_2_a": "Abre Ajustes y elige un servidor. Puedes cambiar de servidor en cualquier momento y la cartera se resincroniza desde el nuevo. Si un servidor va lento o no responde, prueba con otro.",
"faq_l_lite_2_q": "¿Cómo elijo o cambio el servidor?",
"faq_l_lite_3_a": "Tus claves nunca salen de tu dispositivo, y los importes protegidos permanecen cifrados. Pero el servidor lite sí ve qué direcciones consulta tu cartera y tu dirección IP, por lo que puede vincular tus direcciones entre sí — un compromiso entre comodidad y privacidad. Para la máxima privacidad, usa la cartera de full node, que verifica la cadena por sí misma y no revela tus direcciones a un servidor.",
"faq_l_lite_3_q": "¿Es la cartera Lite tan privada como el full node?",
"faq_l_lite_title": "Cartera Lite",
"faq_l_sec_1_a": "Ve a Configuración > Cartera y define una contraseña en la sección de seguridad. El cifrado protege tus claves privadas en el disco, de modo que alguien con acceso a los archivos aún no podría gastar tus monedas.\n\nElige una contraseña segura y no la pierdas — no hay forma de recuperar una cartera cifrada sin ella.",
"faq_no_results": "Sin resultados. Prueba con otro término de búsqueda.",
"faq_open_tooltip": "Ayuda y preguntas frecuentes",
"faq_search_hint": "Buscar en las preguntas frecuentes…",
"faq_title": "Ayuda y preguntas frecuentes",
"faq_w_addr_1_a": "Las direcciones blindadas (direcciones z, que empiezan por zs) son las que usas en DragonX. Utilizan criptografía de conocimiento cero, así que el importe, el remitente y el destinatario se cifran en la cadena y tu saldo sigue siendo privado.\n\nLas direcciones transparentes (que empiezan por R) son direcciones de minería. DragonX es una cadena totalmente privada, por lo que solo pueden recibir recompensas de minería; la red rechaza cualquier pago enviado a ellas. Las recompensas que llegan allí son públicas hasta que se blindan, algo que la cartera hace automáticamente.",
"faq_w_addr_1_q": "¿Cuál es la diferencia entre direcciones transparentes y blindadas?",
"faq_w_addr_2_a": "Usa una dirección blindada (zs) para todo lo que envíes y recibas; en DragonX es el único tipo al que se puede pagar.\n\nLas direcciones transparentes (de minería) son solo para recompensas de minería en solitario. Nunca des una para recibir un pago: la red lo rechazará.",
"faq_w_addr_2_q": "¿Qué tipo de dirección debería usar?",
"faq_w_addr_3_a": "Reutilizar una dirección blindada no es un problema y no revela tu historial. Aun así puedes crear otras nuevas en la pestaña Recibir, por ejemplo una por contacto para saber quién te ha pagado. Las direcciones de minería son solo para recompensas de minería; rara vez necesitarás una nueva.",
"faq_w_addr_3_q": "¿Debería reutilizar una dirección o crear una nueva?",
"faq_w_addr_4_a": "Blindar mueve monedas de una dirección transparente (de minería) a una blindada, haciéndolas privadas. Las monedas recién minadas llegan a una dirección de minería y se blindan automáticamente cuando maduran.\n\nTambién puedes blindar manualmente desde la cartera; un blindaje es solo una transacción especial, así que tarda el tiempo normal de confirmación.",
"faq_w_addr_4_q": "¿Qué es \"blindar\"?",
"faq_w_addr_title": "Direcciones y privacidad",
"faq_w_bal_1_a": "Mientras tu cartera aún se está sincronizando, tu saldo está incompleto y puede mostrar 0 — todavía no ha escaneado todas tus transacciones. Se completa cuando termina la sincronización.\n\nObserva la barra de estado en la parte inferior: muestra el estado de la conexión y la altura de bloque. Una vez que la sincronización termina, tu saldo es exacto.",
"faq_w_bal_1_q": "¿Por qué mi saldo está en 0 o no se actualiza?",
"faq_w_bal_2_a": "Cada bloque nuevo minado sobre el bloque que contiene tu transacción añade una confirmación. Más confirmaciones significan que el pago está más firmemente asentado. Los fondos recibidos se pueden gastar después de la primera confirmación.",
"faq_w_bal_2_q": "¿Qué son las confirmaciones?",
"faq_w_bal_3_a": "El total incluye fondos que aún se están confirmando o están temporalmente bloqueados (por ejemplo, monedas en proceso de blindarse). El saldo disponible es lo que puedes enviar ahora mismo. Ambos convergen a medida que las transacciones se confirman.",
"faq_w_bal_3_q": "¿Cuál es la diferencia entre el saldo total y el disponible?",
"faq_w_bal_title": "Saldo y sincronización",
"faq_w_chat_1_a": "Chat es un mensajero cifrado en la cadena, integrado en la cartera. Los mensajes se envían como memos blindados privados, así que solo tú y tu contacto pueden leerlos.",
"faq_w_chat_1_q": "¿Qué es la función de Chat?",
"faq_w_chat_2_a": "Tu identidad de chat se deriva de la frase de recuperación de tu cartera, así que viaja con tu cartera — restaura la cartera y tu identidad vuelve. No creas una cuenta ni una contraseña aparte.",
"faq_w_chat_2_q": "¿Cómo funciona mi identidad de chat?",
"faq_w_chat_3_a": "Añade un contacto por su dirección en la pestaña Contactos, opcionalmente con un nombre y un avatar. Los contactos se pueden mantener privados solo para la cartera actual o compartirse entre todas las carteras que abras en este equipo.",
"faq_w_chat_3_q": "¿Cómo funcionan los contactos?",
"faq_w_chat_4_a": "Abre una conversación y usa los íconos de su encabezado. Ocultar (el ojo) la quita de la lista pero conserva todos los mensajes — un mensaje nuevo la vuelve a mostrar. Eliminar (la papelera) abre un diálogo: \"Eliminar\" borra el historial en este dispositivo (si esa persona te escribe de nuevo, la conversación vuelve), mientras que \"Eliminar y bloquear\" también impide sus mensajes futuros hasta que la desbloquees desde la lista \"Bloqueados\" encima de la lista de conversaciones. Todo esto es local a este dispositivo — los mensajes permanecen en la cadena de bloques y la otra persona conserva su propia copia, así que no es un \"deshacer envío\".",
"faq_w_chat_4_q": "¿Cómo oculto, elimino o bloqueo una conversación?",
"faq_w_chat_title": "Chat y contactos",
"faq_w_gs_1_a": "ObsidianDragon es una cartera de nodo completo para DragonX (DRGX). Gestiona tus monedas y, en la versión de escritorio, ejecuta un nodo completo de DragonX (el daemon) en segundo plano para que tu cartera verifique la cadena de bloques por sí misma en lugar de confiar en un servidor de terceros.\n\nPiénsalo como dos partes que trabajan juntas: la cartera (claves, saldos, envíos) y el nodo (la cadena de bloques y la red). Las preguntas aquí están divididas del mismo modo — mira las pestañas Cartera y Daemon arriba.",
"faq_w_gs_1_q": "¿Qué es ObsidianDragon?",
"faq_w_gs_2_a": "En el primer inicio, el asistente de configuración crea una cartera por ti automáticamente. Las carteras nuevas están respaldadas por una frase de recuperación secreta (una lista de palabras). Anota esa frase y guárdala sin conexión — es la única forma de restaurar tus fondos si pierdes este equipo.\n\nPuedes revisar o respaldar la frase en cualquier momento desde Configuración > Copia de seguridad y datos > Frase de recuperación.",
"faq_w_gs_2_q": "¿Cómo creo una nueva cartera?",
"faq_w_gs_3_a": "Usa el flujo de restauración con frase de recuperación si tienes una frase de recuperación. (En la versión de nodo completo también puedes copiar un archivo wallet.dat existente en el directorio de datos de la cartera antes de iniciarla.)\n\nDespués de restaurar, la cartera vuelve a escanear para encontrar tus transacciones pasadas, así que tu saldo e historial pueden tardar un poco en aparecer la primera vez.",
"faq_w_gs_3_q": "¿Cómo restauro una cartera que ya tengo?",
"faq_w_gs_4_a": "El asistente te permite elegir una apariencia/tema, configurar el cifrado y un PIN y, en la versión de nodo completo, descargar opcionalmente un bootstrap para acelerar la primera sincronización. Puedes omitir cualquier paso y cambiar todo esto más tarde en Configuración.",
"faq_w_gs_4_q": "¿Qué hace la configuración inicial?",
"faq_w_gs_title": "Primeros pasos",
"faq_w_sec_1_a": "Ve a Configuración > Nodo y seguridad y define una contraseña (la sección SEGURIDAD). El cifrado protege tus claves privadas en el disco, de modo que alguien con acceso a los archivos aún no podría gastar tus monedas.\n\nElige una contraseña segura y no la pierdas — no hay forma de recuperar una cartera cifrada sin ella.",
"faq_w_sec_1_q": "¿Cómo cifro mi cartera?",
"faq_w_sec_2_a": "El PIN bloquea la pantalla de la app para que los saldos y las acciones queden ocultos cuando te alejas. Es un bloqueo de conveniencia adicional al cifrado; lo configuras durante el asistente o en Configuración, después de cifrar la cartera.",
"faq_w_sec_2_q": "¿Qué es el PIN o la pantalla de bloqueo?",
"faq_w_sec_3_a": "No. El cifrado de la cartera no se puede eludir. Si aún conservas tu frase de recuperación, puedes restaurar la cartera a partir de ella en una cartera nueva y establecer una contraseña nueva. Sin la contraseña ni la frase de recuperación, los fondos no se pueden recuperar.",
"faq_w_sec_3_q": "Olvidé mi contraseña — ¿se puede recuperar?",
"faq_w_sec_title": "Seguridad y cifrado",
"faq_w_seed_1_a": "Ve a Configuración > Copia de seguridad y datos > Frase de recuperación. Anota las palabras en papel en orden y guárdalas en un lugar seguro y sin conexión. Cualquiera que tenga la frase puede gastar tus monedas, así que nunca la guardes en una foto, un correo o una nota en la nube.",
"faq_w_seed_1_q": "¿Cómo respaldo mi frase de recuperación?",
"faq_w_seed_2_a": "Las carteras antiguas (heredadas) no se basaban en una frase de recuperación. La cartera puede migrar una cartera heredada a una moderna con frase de recuperación: crea una nueva cartera con frase y transfiere tus fondos a ella. Busca \"Migrar a una cartera con frase…\" en Configuración > Copia de seguridad y datos. Esta función necesita la versión actual del daemon.",
"faq_w_seed_2_q": "Mi cartera no tiene frase de recuperación — ¿puedo añadir una?",
"faq_w_seed_3_a": "Usa el flujo de restauración con frase de recuperación al configurar una cartera e ingresa tus palabras en orden. Tu cartera luego vuelve a escanear para reconstruir tu saldo e historial, lo cual puede tardar un poco la primera vez.",
"faq_w_seed_3_q": "¿Cómo restauro a partir de mi frase de recuperación?",
"faq_w_seed_4_a": "Tu frase de recuperación es el respaldo principal y basta para restaurarlo todo. Una copia de tu archivo de cartera es un respaldo secundario práctico que además conserva las etiquetas y la configuración. Mantén cualquier respaldo sin conexión y privado.",
"faq_w_seed_4_q": "¿Debería respaldar también wallet.dat?",
"faq_w_seed_title": "Frase de recuperación y copia de seguridad",
"faq_w_send_1_a": "Abre la pestaña Enviar, pega la dirección del destinatario, ingresa un monto y confirma. Si la cartera está cifrada, se te pedirá desbloquearla para la transacción.\n\nLos pagos van a direcciones blindadas (zs) y son privados. La cartera no envía a una dirección transparente (de minería): la red rechaza los pagos a ellas.",
"faq_w_send_1_q": "¿Cómo envío fondos?",
"faq_w_send_2_a": "Las comisiones de DragonX son muy bajas y se establecen automáticamente. La comisión se muestra antes de confirmar. Normalmente no necesitas cambiarla.",
"faq_w_send_2_q": "¿Qué comisión pago?",
"faq_w_send_3_a": "Sí — al enviar a una dirección blindada (z) puedes incluir un memo cifrado. Solo el destinatario puede leerlo.",
"faq_w_send_3_q": "¿Puedo adjuntar un mensaje a un pago?",
"faq_w_send_4_a": "Una transacción está \"pendiente\" hasta que se mina en un bloque y obtiene confirmaciones. Esto suele tardar uno o dos minutos. Las transacciones blindadas también necesitan que tu cartera esté sincronizada para construir la prueba.\n\nSi un envío permanece pendiente durante un tiempo inusualmente largo, verifica que tu cartera esté conectada y sincronizada (mira la barra de estado en la parte inferior de la ventana).",
"faq_w_send_4_q": "Envié un pago pero dice pendiente — ¿por qué?",
"faq_w_send_title": "Envío y recepción",
"faq_w_set_1_a": "Abre Ajustes > Apariencia para elegir un tema y un color de acento. Se incluyen varios estilos claros y oscuros, y los efectos como el desenfoque se pueden desactivar en equipos menos potentes.",
"faq_w_set_1_q": "¿Cómo cambio el tema?",
"faq_w_set_2_a": "Sí — Ajustes > Apariencia tiene un selector de idioma. La cartera incluye varias traducciones; lo que aún no esté traducido se muestra en inglés.",
"faq_w_set_2_q": "¿Puedo cambiar el idioma?",
"faq_w_set_3_a": "Usa la opción de escala de fuente en Ajustes > Apariencia. Escala toda la interfaz, lo cual es útil en pantallas de alta resolución (HiDPI) donde la app podría verse pequeña.",
"faq_w_set_3_q": "El texto es demasiado pequeño (o grande) — ¿puedo escalarlo?",
"faq_w_set_4_a": "Activa la obtención de precios en Ajustes. La cartera mostrará entonces un valor fiat aproximado junto a los saldos y puede mostrar un pequeño gráfico de mercado. Los precios son solo informativos.",
"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:",
@@ -665,14 +876,9 @@
"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",
"force_quit": "Forzar Salida",
"force_quit_confirm_msg": "Esto matará inmediatamente el daemon sin un apagado limpio.\nEsto puede corromper el índice de la blockchain y requerir una resincronización.",
"force_quit_confirm_title": "¿Forzar Salida?",
"force_quit_warning": "Esto matará inmediatamente el daemon sin un apagado limpio. Puede requerir una resincronización de la blockchain.",
"force_quit_yes": "Forzar Salida",
"from": "Desde",
"from_upper": "DESDE",
"full_details": "Detalles Completos",
@@ -741,6 +947,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",
@@ -815,11 +1022,13 @@
"lite_birthday_backup": "Cumpleaños: %llu (respalda esto también)",
"lite_birthday_hint": "Altura de bloque desde la que empezar a escanear. Deja 0 si se desconoce (escaneo completo más lento).",
"lite_birthday_label": "Fecha de creación",
"lite_confirm_label": "Confirmar",
"lite_console_backend_commands": "Comandos del backend:",
"lite_console_help_passthrough": "Cualquier otra entrada se ejecuta como un comando de consola de la cartera lite.",
"lite_copy": "Copiar",
"lite_could_not_start": "No se pudo iniciar la operación",
"lite_could_not_write": "No se pudo escribir ",
"lite_encrypt_offline_warning": "Cualquiera que tenga el archivo de la cartera puede probar frases de contraseña sin conexión. Haz primero una copia de seguridad de tu frase de recuperación y usa una frase de contraseña larga.",
"lite_encrypt_wallet": "Cifrar cartera",
"lite_encryption_removed": "Cifrado eliminado",
"lite_enter_all_seed_words": "Ingrese las 24 palabras semilla para restaurar (se obtuvieron %d)",
@@ -827,6 +1036,7 @@
"lite_hide_wipe": "Ocultar y borrar",
"lite_import": "Importar",
"lite_import_key_label": "Importar clave",
"lite_key_import_failed": "No se pudo importar la clave: compruébala, desbloquea el monedero (si está cifrado) e inténtalo de nuevo.",
"lite_key_imported": "Clave importada: ejecuta una sincronización para escanear su historial",
"lite_lock_failed": "Error al bloquear",
"lite_lock_now": "Bloquear ahora",
@@ -960,11 +1170,67 @@
"memo_optional": "MEMO (OPCIONAL)",
"memo_upper": "MEMO",
"memo_z_only": "Nota: Los memos solo están disponibles al enviar a direcciones blindadas (z)",
"merge_addr_created": "Dirección blindada creada.",
"merge_advanced": "Avanzado",
"merge_back": "Atrás",
"merge_batch_fmt": "Fusiona hasta %d entradas por ejecución — repite para completar el resto.",
"merge_confirm_btn": "Confirmar",
"merge_confirm_fmt": "¿Consolidar ~%s DRGX de %d entrada(s) en %s?",
"merge_create_zaddr": "Crear dirección blindada",
"merge_creating": "Creando dirección…",
"merge_description": "Fusiona múltiples UTXOs en una sola dirección protegida. Esto puede ayudar a reducir el tamaño de la cartera y mejorar la privacidad.",
"merge_fee_hint": "Comisión de red para esta transacción.",
"merge_funds": "Fusionar Fondos",
"merge_into_one": "A una sola dirección",
"merge_keep_each": "Mantener en cada dirección",
"merge_keep_each_batches_fmt": "Las direcciones con más de %d notas se fusionan en varios lotes; vuelve a ejecutarlo cuando se confirmen para fusionar el resto.",
"merge_keep_each_confirm_fmt": "¿Consolidar %d notas en %d direcciones? Cada dirección conserva sus propios fondos. Comisiones: unos %s DRGX (una por lote).",
"merge_keep_each_desc": "Las notas de cada dirección se fusionan de nuevo en esa misma dirección, así que los fondos que mantienes separados con distintas etiquetas siguen separados. Solo direcciones blindadas: los fondos transparentes solo pueden blindarse (A una sola dirección).",
"merge_keep_each_none": "Cada dirección blindada ya tiene una sola nota: no hay nada que consolidar.",
"merge_keep_each_nothing_submitted": "No se consolidó nada: el nodo rechazó todas las fusiones.",
"merge_keep_each_row_fmt": "%s · %d notas · %s DRGX",
"merge_keep_each_run_again": "Las direcciones fusionadas en varios lotes tienen ahora una nota por lote. Vuelve a ejecutarlo cuando se confirmen para fusionarlas en una.",
"merge_keep_each_select": "Marca al menos una dirección para consolidar.",
"merge_keep_each_some_failed": "Algunas fusiones fallaron; consulta la lista de arriba.",
"merge_keep_each_status_done": "%s: listo: %d notas fusionadas en %d lote(s)",
"merge_keep_each_status_failed": "%s: falló: %s",
"merge_keep_each_status_not_started": "%s: no iniciado: %s",
"merge_keep_each_status_running": "%s: fusionando %d notas (%d de %d lotes listos)…",
"merge_keep_each_status_unconfirmed": "%s: no se pudo confirmar el estado",
"merge_keep_each_too_small": "Demasiado poco para consolidar: la comisión se llevaría estos fondos.",
"merge_max_inputs": "Máximo de entradas por lote",
"merge_no_spendable": "Aún no hay entradas disponibles para consolidar.",
"merge_progress": "Consolidando… esto puede tardar varios minutos. Puedes cerrar esta ventana.",
"merge_run_aborted_toast": "La conexión con el nodo se reinició, así que la consolidación se detuvo. Ejecútala de nuevo para continuar.",
"merge_run_busy": "Hay una consolidación en curso. Espera a que termine o detenla primero.",
"merge_run_chip_fmt": "Consolidando %d/%d",
"merge_run_err_timeout": "El nodo no respondió a tiempo; puede que el lote siga en cola.",
"merge_run_halted_fmt": "Consolidación detenida tras fallos repetidos: %s",
"merge_run_migration_pending": "Termina o cancela la migración de la semilla antes de consolidar.",
"merge_run_progress_fmt": "Consolidando… lote %d de unos %d, %d notas fusionadas hasta ahora. Puedes cerrar esta ventana; seguirá en segundo plano.",
"merge_run_start_failed": "No se pudo iniciar la consolidación: el nodo no está conectado.",
"merge_run_status_not_run": "%s: sin consolidar (%d notas)",
"merge_run_status_waiting": "%s: en espera (%d notas)",
"merge_run_stop": "Detener",
"merge_run_stopped_toast": "Consolidación detenida. Ejecútala de nuevo para fusionar el resto.",
"merge_run_stopping": "Deteniendo tras los lotes ya enviados…",
"merge_run_wait_batch": "Espaciando los lotes para que el nodo siga respondiendo…",
"merge_run_wait_send": "En pausa hasta que se complete tu transacción pendiente.",
"merge_run_wait_sync": "En pausa: el nodo aún se está sincronizando.",
"merge_scope_failed_fmt": "No se pudieron leer tus entradas: %s",
"merge_scope_fmt": "%d entradas transparentes + %d blindadas · ~%s DRGX disponibles",
"merge_scope_loading": "Comprobando tus entradas…",
"merge_send_failed": "Error al fusionar: ",
"merge_source": "Consolidar",
"merge_src_both": "Ambas",
"merge_src_shielded": "Blindadas",
"merge_src_transparent": "Transparentes",
"merge_started": "Operación de fusión iniciada",
"merge_submit_uncertain": "El nodo no confirmó la fusión antes de que se interrumpiera la conexión. Puede que aún se complete: revisa el Historial tras reconectar antes de fusionar de nuevo.",
"merge_title": "Fusionar a Dirección",
"merge_wait_locked": "La cartera está bloqueada. Desbloquéala para continuar.",
"merge_waiting_node": "Esperando al nodo…",
"merge_where": "¿Adónde deben ir los fondos?",
"mig_adopt": "Hacer que esta sea mi cartera",
"mig_adopt_now": "Adoptar cartera con frase",
"mig_adopting": "Instalando tu nueva cartera y reescaneando…",
@@ -1016,6 +1282,8 @@
"miner_fee": "Tarifa de minero",
"mining": "Minería",
"mining_active": "Activo",
"mining_addr_explainer": "Solo recibe recompensas de minería en solitario; la red rechaza los pagos a esta dirección. Las recompensas se blindan automáticamente cuando maduran.",
"mining_address": "Dirección de minería",
"mining_address_copied": "Dirección de minería copiada",
"mining_all_time": "Todo el Tiempo",
"mining_already_saved": "URL del pool ya guardada",
@@ -1029,6 +1297,8 @@
"mining_benchmark_testing": "Probando",
"mining_benchmark_tooltip": "Encontrar el número óptimo de hilos para esta CPU",
"mining_block_copied": "Hash de bloque copiado",
"mining_card_click_to_shield": "Haz clic para blindar ahora las recompensas maduras.",
"mining_card_tooltip": "Las recompensas de minería en solitario llegan a tus direcciones de minería (transparentes, R…). Esas direcciones no pueden recibir pagos. Las recompensas se blindan automáticamente cuando maduran.",
"mining_chart_1m_ago": "hace 1m",
"mining_chart_5m_ago": "hace 5m",
"mining_chart_now": "Ahora",
@@ -1044,6 +1314,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",
@@ -1073,6 +1345,7 @@
"mining_payout_address": "Dirección de Pago",
"mining_payout_foreign": "⚠ Esta dirección de pago no está en tu cartera actual — las recompensas minadas irían a otra cartera. Actualízala si cambiaste de cartera.",
"mining_payout_invalid": "No es una dirección DragonX válida — corrígela antes de empezar, o se pierden las recompensas de minería.",
"mining_payout_needs_shielded": "Los pools pagan con transacciones normales, que en DragonX no pueden llegar a una dirección de minería (transparente, R…). Usa una dirección blindada (empieza por zs) como dirección de pago.",
"mining_payout_tooltip": "Dirección para recibir recompensas de minería",
"mining_pool": "Pool",
"mining_pool_fee": "Comisión",
@@ -1085,6 +1358,8 @@
"mining_refresh": "Actualizar",
"mining_remove": "Eliminar",
"mining_reset_defaults": "Restablecer Valores",
"mining_rewards": "Recompensas de minería",
"mining_rewards_unshielded": "Aún sin blindar",
"mining_save_payout_address": "Guardar dirección de pago",
"mining_save_pool_url": "Guardar URL del pool",
"mining_saved_addresses": "Direcciones Guardadas:",
@@ -1118,10 +1393,10 @@
"network_fee": "COMISIÓN DE RED",
"network_hashrate": "Tasa de Hash de la Red",
"new": "+ Nuevo",
"new_mining_address": "Nueva dirección de minería",
"new_mining_address_created": "Nueva dirección de minería creada",
"new_shielded_created": "Nueva dirección protegida creada",
"new_t_address": "Nueva Dirección T",
"new_t_transparent": "Nueva dirección t (Transparente)",
"new_transparent_created": "Nueva dirección transparente creada",
"new_z_address": "Nueva Dirección Z",
"new_z_shielded": "Nueva dirección z (Blindada)",
"no_addresses": "No se encontraron direcciones. Cree una usando los botones de arriba.",
@@ -1141,7 +1416,19 @@
"node_banner_offline_title": "No conectado al nodo DragonX",
"node_banner_reconnect": "Reconectar",
"node_banner_restart": "Reiniciar nodo",
"node_busy_rebuild_desc": "En curso desde hace %s. En carteras grandes puede tardar alrededor de una hora, y el nodo no puede responder a la cartera hasta que termine.",
"node_busy_rebuild_extending": "Ampliando testigos (el nodo no informa del progreso de este paso).",
"node_busy_rebuild_reading": "Leyendo bloques: %ld / %ld",
"node_busy_rebuild_title": "Nodo ocupado: reconstruyendo testigos de notas",
"node_busy_replay_desc": "En curso desde hace %s. El nodo está volviendo a aplicar bloques que recibió antes de detenerse por última vez.",
"node_busy_replay_title": "Nodo ocupado: poniéndose al día con los bloques",
"node_busy_rpc_desc": "El nodo está en marcha, pero lleva %s demasiado ocupado para responder a la cartera, normalmente por una operación larga como la reconstrucción de testigos.",
"node_busy_rpc_title": "Nodo ocupado: aún no responde",
"node_busy_running": "En curso desde hace %s",
"node_security": "NODO Y SEGURIDAD",
"node_starting_busy_desc": "El nodo está accesible pero ocupado y aún no responde. Es normal tras una actualización o en el primer inicio; puede tardar unos minutos.",
"node_starting_launch_desc": "Iniciando dragonxd y esperando a que esté en línea…",
"node_starting_title": "Iniciando el nodo DragonX…",
"noise": "Ruido",
"not_connected": "No conectado al daemon...",
"not_connected_to_daemon": "No conectado al daemon",
@@ -1202,6 +1489,13 @@
"peers_upper": "NODOS",
"peers_version": "Versión",
"pending": "Pendiente",
"picker_dat_count": "%d archivo(s) de cartera en esta carpeta",
"picker_dat_none": "No hay archivos de cartera directamente en esta carpeta",
"picker_empty": "Esta carpeta no tiene subcarpetas ni archivos de cartera.",
"picker_home": "Carpeta de inicio",
"picker_select": "Escanear esta carpeta",
"picker_title": "Selecciona una carpeta para escanear",
"picker_up": "Subir un nivel",
"pin_change_desc": "Cambia tu PIN de desbloqueo. Necesitas tu PIN actual y un nuevo PIN.",
"pin_confirm_new_label": "Confirmar nuevo PIN:",
"pin_current_label": "PIN actual:",
@@ -1287,6 +1581,8 @@
"ram_wallet_gb": "Billetera: %.1f GB",
"ram_wallet_mb": "Billetera: %.0f MB",
"receive": "Recibir",
"receive_not_for_payments": "No para pagos",
"receive_use_shielded": "Usar una dirección blindada",
"received": "recibido",
"received_filter": "Recibido",
"received_label": "Recibido",
@@ -1301,6 +1597,7 @@
"reduce_motion": "Reducir Movimiento",
"refresh": "Actualizar",
"refresh_now": "Actualizar Ahora",
"refresh_waits_for_sync": "El nodo aún se está sincronizando. Los datos de la cartera se actualizarán cuando termine.",
"remove_favorite": "Quitar favorito",
"repair_wallet": "Reparar cartera",
"report_bug": "Reportar Error",
@@ -1315,12 +1612,12 @@
"request_select_address": "Seleccionar dirección...",
"request_shielded_addrs": "-- Direcciones Protegidas --",
"request_title": "Solicitar Pago",
"request_transparent_addrs": "-- Direcciones Transparentes --",
"request_uri_copied": "URI de pago copiada al portapapeles",
"rescan": "Re-escanear",
"rescan_bootstrapped_msg": "Tu nodo se inicializó con un bootstrap, por lo que los bloques anteriores a la instantánea no están en disco y un reescaneo desde el génesis fallaría. Reescanea desde una altura que incluya tu instantánea para reconciliar el saldo gastado de tu cartera. Tu wallet.dat y los datos de la cadena no se eliminan.",
"rescan_detecting": "Comprobando qué bloques tiene tu nodo en disco…",
"rescan_from_height": "Reescanear desde la altura de bloque:",
"rescan_probe_failed": "No se pudo comprobar qué bloques tiene tu nodo en disco (está ocupado o una solicitud agotó el tiempo de espera). Si este nodo se inicializó con un bootstrap, cancela y vuelve a intentarlo en un minuto: el reinicio completo con -rescan de abajo falla en un nodo inicializado con bootstrap.",
"reset_to_defaults": "Restablecer Valores",
"restarting_after_encryption": "Reiniciando daemon después del cifrado...",
"restore_address": "Restaurar dirección",
@@ -1338,6 +1635,7 @@
"save_z_transactions": "Guardar Z-tx en lista",
"sb_auth_failed": "Autenticación fallida — verifique rpcuser/rpcpassword",
"sb_block": "Bloque: %d",
"sb_block_db_unreadable": "Base de datos de bloques ilegible — es necesario reconstruirla",
"sb_building_witnesses": "Estableciendo testigos",
"sb_building_witnesses_pct": "Reconstruyendo testigos %.0f%%",
"sb_connecting_daemon": "Conectando a dragonxd...",
@@ -1353,6 +1651,7 @@
"sb_dragonxd_stopped": "dragonxd detenido",
"sb_dragonxd_stopping": "Deteniendo dragonxd...",
"sb_extracting_sapling": "Extrayendo parámetros Sapling...",
"sb_finishing_repair": "Terminando la reparación de la cartera — reescaneando…",
"sb_importing_keys": "Importando claves",
"sb_loading_config": "Cargando configuración...",
"sb_mining_hs": "%.1f H/s",
@@ -1361,6 +1660,7 @@
"sb_net_khs": "Red: %.2f KH/s",
"sb_net_mhs": "Red: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf no encontrado",
"sb_node_busy": "Nodo ocupado",
"sb_peers": "Pares: %zu",
"sb_plaintext_remote_blocked": "Se rechaza enviar credenciales RPC en texto plano a un host remoto. Añade rpcallowplaintext=1 a DRAGONX.conf para permitirlo, o habilita TLS con rpctls=1.",
"sb_rescanning": "Reescaneando",
@@ -1374,6 +1674,7 @@
"sb_waiting_config": "Esperando configuración del daemon...",
"sb_waiting_daemon": "Esperando a dragonxd...",
"sb_waiting_daemon_err": "Esperando a dragonxd — %s",
"sb_wallet_needs_recovery": "Reparación de cartera disponible",
"sb_warming_up": "Calentando...",
"sb_witness_cache": "Reconstruyendo testigos",
"scale_effects": "ESCALA Y EFECTOS",
@@ -1381,6 +1682,7 @@
"screenshot_sweep": "Ejecutar barrido de capturas",
"screenshot_sweep_desc": "Recorre cada tema en cada pestaña y guarda una captura de pantalla de cada una en subcarpetas por pestaña dentro de la carpeta de capturas del directorio de configuración (sobrescribiendo el barrido anterior). Se ejecuta durante unos segundos.",
"screenshot_sweep_full": "Barrido completo de la interfaz",
"screenshot_sweep_refused": "Barrido completo de la interfaz rechazado; ver registro de depuración",
"search_icons": "Buscar iconos...",
"search_placeholder": "Buscar...",
"sec_changing_passphrase": "Cambiando la frase de contraseña...",
@@ -1402,15 +1704,19 @@
"sec_mode_passphrase": " Frase de contraseña",
"sec_not_connected_to_daemon": "Sin conexión con el daemon",
"sec_not_connected_to_daemon_pin": "Sin conexión con el daemon",
"sec_passphrase_changed_pin_stale": "Frase de contraseña cambiada; tu PIN está desactualizado",
"sec_passphrase_changed_successfully": "Frase de contraseña cambiada correctamente",
"sec_pin_changed_successfully": "PIN cambiado correctamente",
"sec_pin_removed": "PIN eliminado",
"sec_pin_set_successfully": "PIN configurado correctamente",
"sec_pin_stale_msg": "PIN desactualizado. Usa tu frase de contraseña.",
"sec_pin_unreadable": "Archivo del PIN ilegible. Usa tu frase de contraseña.",
"sec_restart_daemon_for_encryption": "Reinicia el daemon para que el cifrado surta efecto.",
"sec_too_many_attempts_wait": "Demasiados intentos. Espera %.0f segundos...",
"sec_total_elapsed_fmt": "Tiempo total: %dm %02ds",
"sec_unlock_button": "Desbloquear",
"sec_unlock_failed_prefix": "Error al desbloquear: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Desbloqueando%s",
"sec_use_passphrase_instead": "Usar frase de contraseña en su lugar",
"sec_use_pin_instead": "Usar PIN en su lugar",
@@ -1445,6 +1751,7 @@
"send_amount": "Cantidad",
"send_amount_details": "DETALLES DE CANTIDAD",
"send_amount_upper": "CANTIDAD",
"send_available_note": "disponible",
"send_cannot_send_now": "No se puede enviar ahora — revisa la conexión, la sincronización y el saldo disponible.",
"send_clear_fields": "¿Limpiar todos los campos del formulario?",
"send_contacts_button": "Elegir de contactos",
@@ -1462,6 +1769,8 @@
"send_from_this_address": "Enviar desde esta dirección",
"send_go_to_receive": "Ir a Recibir",
"send_keep": "Mantener",
"send_mining_addr_blocked": "Esta es una dirección de minería (transparente). En DragonX solo pueden recibir recompensas de minería; la red rechaza los pagos a ellas. Pide al destinatario una dirección blindada (empieza por zs).",
"send_mining_addr_short": "Dirección de minería: no puede recibir pagos",
"send_network_fee": "COMISIÓN DE RED",
"send_no_balance": "Sin saldo",
"send_no_recent": "No hay envíos recientes",
@@ -1489,7 +1798,6 @@
"send_txid_copied": "TxID copiado al portapapeles",
"send_txid_label": "TxID: %s",
"send_valid_shielded": "Dirección protegida válida",
"send_valid_transparent": "Dirección transparente válida",
"send_wallet_empty": "Tu cartera está vacía",
"send_yes_clear": "Sí, Limpiar",
"sender_balance": "Remitente: %.8f → %.8f DRGX",
@@ -1552,6 +1860,7 @@
"settings_open_log_folder": "Abrir carpeta de registros",
"settings_other": "Otros",
"settings_pin_active": "PIN",
"settings_pin_out_of_date": "PIN desactualizado",
"settings_privacy": "Privacidad",
"settings_quick_unlock_pin": "PIN de desbloqueo rápido",
"settings_reduce_transparency": "Reducir transparencia",
@@ -1617,11 +1926,24 @@
"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",
"showing_transactions": "Mostrando %d–%d de %d transacciones (total: %zu)",
"showing_x_of_y": "Mostrando %d de %d direcciones",
"shutdown_kill_confirm_msg": "El nodo lleva mucho tiempo sin actividad de CPU ni de disco y puede estar bloqueado.\nSi fuerzas su cierre se perderá todo lo que no haya guardado; su próximo inicio puede tardar mucho más.",
"shutdown_kill_confirm_title": "¿Forzar el cierre del nodo?",
"shutdown_kill_confirm_yes": "Forzar cierre",
"shutdown_kill_node": "Forzar cierre del nodo (inseguro)",
"shutdown_kill_node_tip": "Detiene el nodo de inmediato. Se pierde todo lo que no haya guardado, y en el próximo inicio volverá a procesar los bloques desde el último guardado.",
"shutdown_leave_running": "Cerrar la cartera y dejar el nodo en marcha",
"shutdown_leave_running_tip": "Cierra la cartera ahora. El nodo sigue funcionando por su cuenta y termina lo que está haciendo; no se pierde nada.",
"shutdown_stop_asking": "Pidiendo al nodo que se detenga…",
"shutdown_stop_cpu_suffix": " · CPU %d%%",
"shutdown_stop_delivered": "El nodo se está cerrando y guardando su estado",
"shutdown_stop_idle": "Sin actividad de CPU ni de disco desde hace %d min; puede que el nodo esté bloqueado",
"shutdown_stop_queued": "El nodo recibió la orden de detenerse, pero está ocupado; se detendrá en cuanto pueda",
"simple_background": "Fondo simple",
"slider_off": "Apagado",
"sort_amount_high": "Mayor importe",
@@ -1629,14 +1951,29 @@
"sort_date_newest": "Más recientes primero",
"sort_date_oldest": "Más antiguas primero",
"start_mining": "Iniciar Minería",
"startup_files_preparing": "Preparando los archivos de la cartera... %d%%",
"startup_files_verifying": "Verificando los archivos de la cartera...",
"status": "Estado",
"stop_external": "Detener daemon externo",
"stop_mining": "Detener Minería",
"stopconfirm_busy_body": "Lleva %s demasiado ocupado para responder a la cartera, probablemente por una operación larga como la reconstrucción de testigos. Detenerlo ahora puede hacer que el próximo inicio repita ese trabajo.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
"stopconfirm_busy_title": "El nodo está ocupado",
"stopconfirm_rebuild_body": "Lleva %s en curso, y en carteras grandes puede tardar alrededor de una hora. Detener el nodo ahora o bien espera a que termine la reconstrucción, o bien hace que el próximo inicio la repita junto con cualquier puesta al día.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
"stopconfirm_rebuild_title": "El nodo está reconstruyendo los testigos de notas",
"stopconfirm_stop_wait": "Detener el nodo y esperar",
"stopconfirm_walletop_body": "El nodo está escaneando la cadena para una importación de clave o un reescaneo. Si lo detienes ahora, ese escaneo termina antes de tiempo, y los fondos a los que aún no ha llegado solo aparecerán tras Ajustes → Re-escanear.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará el escaneo por su cuenta.",
"stopconfirm_walletop_title": "Hay una importación de clave o un reescaneo en curso",
"stratum_allowip_hint": "Permitir mineros desde IP o CIDR (en blanco = solo este equipo)",
"stratum_expose_warn": "Abre un puerto de minería a la red que permitas — úsalo solo en una LAN de confianza.",
"stratum_host": "Alojar un pool de minería (stratum)",
"stratum_host_hint": "Los mineros se conectan a este equipo en el puerto 22769 (puerto RPC + 1000) con un minero stratum RandomX. Reinicia el demonio para aplicar el cambio.",
"stratum_host_section": "ALOJAMIENTO DE POOL DE MINERÍA",
"submitting_transaction": "Enviando transacción...",
"success": "Éxito",
"summary": "Resumen",
"sweep_button": "Barrer",
"sweep_caveat": "Importa la clave para firmar una transacción que mueve todos sus fondos a tu dirección. La clave permanece en tu monedero con saldo cero.",
"sweep_current_theme_only": "Solo el tema actual",
"sweep_dest_label": "Enviar los fondos barridos a",
"sweep_dest_new": "Nueva dirección blindada (recomendado)",
"sweep_done": "Listo — fondos barridos a tu dirección.",
@@ -1672,7 +2009,6 @@
"switch_stopnode_title": "¿Detener el nodo en ejecución?",
"switch_stopnode_warn": "Ya hay un nodo en ejecución que esta cartera no inició.",
"syncing": "Sincronizando...",
"t_address": "Dirección T",
"t_addresses": "Direcciones T",
"test_connection": "Probar",
"theme": "Tema",
@@ -1694,6 +2030,7 @@
"tools": "HERRAMIENTAS",
"tools_actions": "Herramientas y Acciones...",
"tools_actions_hdr": "HERRAMIENTAS Y ACCIONES",
"tor_never_applied_notice": "La opción \"Usar Tor\" nunca funcionó: tus conexiones NO se enrutaron a través de Tor. Se ha desactivado. Consulta su descripción emergente en Ajustes para ver alternativas.",
"total": "Total",
"total_balance_label": "Saldo Total",
"transaction_id": "ID DE TRANSACCIÓN",
@@ -1707,8 +2044,6 @@
"transfer_sent": "Transferencia Enviada",
"transfer_sent_desc": "Su transferencia ha sido enviada a la red.",
"transfer_to": "Transferir a:",
"transparent": "Transparente",
"transparent_address": "Dirección Transparente",
"try_again": "Reintentar",
"tt_addr_url": "URL base para ver direcciones en un explorador de bloques",
"tt_address_book": "Administrar direcciones guardadas para envío rápido",
@@ -1740,10 +2075,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",
@@ -1752,6 +2093,7 @@
"tt_keep_daemon": "El daemon se detendrá cuando ejecute el asistente de configuración",
"tt_language": "Idioma de la interfaz de la billetera",
"tt_layout_hotkey": "Atajo: teclas de flecha izquierda/derecha para cambiar diseños de Balance",
"tt_lite_confirm_pass": "Escribe de nuevo la nueva frase de contraseña; debe coincidir y tener al menos 8 caracteres",
"tt_lite_copy": "Copia el secreto revelado al portapapeles",
"tt_lite_decrypt_pass": "Introduce tu frase de contraseña para quitar el cifrado de la cartera",
"tt_lite_encrypt": "Cifra la cartera con la frase de contraseña de arriba; se bloquea de inmediato y requiere la frase para desbloquearse",
@@ -1780,11 +2122,16 @@
"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",
"tt_open_data_dir": "Abre en el gestor de archivos la carpeta con los datos de tu cartera y de la blockchain",
"tt_open_dir": "Clic para abrir en explorador de archivos",
"tt_open_log_folder": "Abre la carpeta que contiene los registros de depuración y de fallos",
"tt_pin_out_of_date": "La frase de contraseña de la cartera cambió después de establecer este PIN, así que el PIN ya no puede desbloquearla. Quita el PIN y vuelve a establecerlo.",
"tt_reduce_motion": "Desactivar transiciones animadas y lerp de saldo para accesibilidad",
"tt_remove_encrypt": "Quitar cifrado y almacenar la billetera sin protección",
"tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear",
@@ -1814,14 +2161,20 @@
"tt_simple_bg": "Usar un gradiente simple para el fondo\\nAtajo: Ctrl+Up",
"tt_simple_bg_alt": "Usar una versión degradada de la imagen de fondo del tema\\nAtajo: Ctrl+Up",
"tt_stop_external": "Se aplica al conectarse a un daemon\\niniciado fuera de esta billetera",
"tt_stratum_host": "Ejecuta un servidor de pool stratum RandomX en este nodo para que otros mineros RandomX puedan apuntar a este equipo. Requiere un nodo v1.3.0+ y reiniciar el demonio para aplicar el cambio.",
"tt_sweep_current_theme_only": "Barrer solo el tema activo en lugar de recorrer todos los temas",
"tt_test_conn": "Verificar la conexión RPC al daemon",
"tt_theme_effects": "Brillo, resplandor, ciclo de tono por tema",
"tt_theme_hotkey": "Atajo: Ctrl+Izquierda/Derecha para cambiar temas",
"tt_tor": "Enrutar conexiones del daemon a través de la red Tor para anonimato",
"tt_tor_unavailable": "Aún no disponible: esta cartera no enruta ningún tráfico a través de Tor. Solo para el nodo, puedes añadir proxy=127.0.0.1:9050 a DRAGONX.conf y reiniciar el nodo. Tor debe estar en ejecución o el nodo no encontrará otros nodos. Las solicitudes de precios, actualizaciones, bootstrap y pool siguen conectándose directamente; para ellas usa una VPN/Tor de todo el sistema.",
"tt_tor_unavailable_lite": "Aún no disponible: esta cartera no enruta ningún tráfico a través de Tor. Usa una VPN/Tor de todo el sistema.",
"tt_tx_url": "URL base para ver transacciones en un explorador de bloques",
"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_view_transactions": "Mostrar las transacciones relacionadas con esta dirección en la pestaña Historial",
"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_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)",
@@ -1838,6 +2191,7 @@
"tx_loading_queued": "Actualización de transacciones en cola",
"tx_loading_refreshing_cached": "Actualizando el historial de la cartera (%d en caché)",
"tx_loading_scanning_shielded": "Escaneando historial protegido (%d direcciones)",
"tx_loading_waits_for_sync": "El historial se actualizará cuando el nodo termine de sincronizarse",
"tx_mined": "MINADO",
"tx_progress_balances": "Actualizando saldos",
"tx_progress_finalizing": "Finalizando transacción",
@@ -1869,6 +2223,7 @@
"validate_description": "Ingresa una dirección DragonX para verificar si es válida y si pertenece a esta cartera.",
"validate_invalid": "INVÁLIDA",
"validate_is_mine": "Esta cartera es dueña de esta dirección",
"validate_mining_type": "Dirección de minería (transparente)",
"validate_not_mine": "No es propiedad de esta cartera",
"validate_ownership": "Propiedad:",
"validate_results": "Resultados:",
@@ -1876,7 +2231,6 @@
"validate_shielded_type": "Protegida (dirección z)",
"validate_status": "Estado:",
"validate_title": "Validar Dirección",
"validate_transparent_type": "Transparente (dirección t)",
"validate_type": "Tipo:",
"validate_valid": "VÁLIDA",
"validating": "Validando...",
@@ -1886,10 +2240,71 @@
"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.",
"wallet_diagnostics_hdr": "DIAGNÓSTICOS",
"wallet_empty": "Tu cartera está vacía",
"wallet_empty_hint": "Cambia a Recibir para obtener tu dirección y empezar a recibir fondos.",
"wallet_options_hdr": "OPCIONES",
"wallet_rebuild_failed": "La reconstrucción no produjo una cartera válida, así que no se cambió nada. Tu cartera no fue modificada.",
"wallet_rebuild_install_failed": "No se pudo instalar la cartera reconstruida; tu cartera actual se dejó tal cual.",
"wallet_rebuild_no_helper": "El asistente de reconstrucción de cartera no está disponible en esta compilación. Usa Restaurar, o reconstruye manualmente.",
"wallet_rebuild_no_source": "No se pudo encontrar una cartera legible para reconstruir. No se cambió nada.",
"wallet_rebuild_ok": "Base de datos de la cartera reconstruida — el nodo la está cargando y reescaneando para calcular tu saldo.",
"wallet_rebuild_started": "Reparando tu archivo de cartera — por favor no cierres esta ventana.",
"wallet_recovered_body": "No se ha eliminado nada. Tu cartera original sigue guardada en tu computadora como un archivo de respaldo con fecha, y cada opción a continuación solo copia o renombra archivos — nunca borra ninguno. Tus claves nunca se regeneran, solo se vuelven a leer.\n\nEs posible que a la copia reparada que está cargada ahora le falten algunas transacciones recientes, así que tu saldo puede verse un poco bajo hasta que termines abajo y se vuelva a escanear.",
"wallet_recovered_dismiss": "Ahora no — mantener la copia reparada",
"wallet_recovered_dismiss_sub": "No se cambia ningún archivo. Puedes volver a abrir esto cuando quieras desde la barra de estado; tu original queda respaldado de forma segura de todos modos.",
"wallet_recovered_notify": "Tu archivo de cartera necesitaba una reparación — tu original se respaldó de forma segura. Abre la app para revisar tus opciones.",
"wallet_recovered_open_folder": "Mostrarme los archivos",
"wallet_recovered_open_folder_sub": "Abre la carpeta de datos de la cartera para que puedas inspeccionar los archivos de respaldo tú mismo — nada cambia.",
"wallet_recovered_rebuild": "Reparar automáticamente (recomendado)",
"wallet_recovered_rebuild_sub": "Vuelve a leer cada registro recuperable de tu archivo de cartera más completo y escribe uno nuevo limpio, luego reinicia. La opción más exhaustiva — tu archivo actual se conserva como respaldo con fecha de todos modos.",
"wallet_recovered_restore": "Restaurar el archivo original en su lugar",
"wallet_recovered_restore_sub": "Vuelve a poner en su lugar tu respaldo más grande sin modificar, tal cual, y luego vuelve a escanear — algo más rápido, pero solo tan completo como lo era ese archivo. Tu archivo actual se conserva como respaldo con fecha de todos modos.",
"wallet_recovered_safety": "Tus monedas están a salvo.",
"wallet_recovered_title": "Tu archivo de cartera necesita una reparación rápida",
"wallet_recovered_warn": "Cuando la app se inició, encontró que tu archivo de cartera no pasó su verificación de consistencia — esto suele ocurrir después de una actualización de la app o un cierre inesperado. La app ya protegió tus datos: apartó el archivo antiguo y cargó una copia reparada para que no te quedes bloqueado.",
"wallet_recovery_decide_later": "Decidir más tarde",
"wallet_recovery_decide_later_tip": "Cierra esto y mantiene la copia que está cargada ahora. Nada cambia, y puedes reparar cuando quieras — la barra de estado conserva un enlace de “Reparación de cartera disponible”.",
"wallet_recovery_details_label": "Mostrar detalles técnicos",
"wallet_recovery_done": "Listo",
"wallet_recovery_failure_body": "La reparación se ejecutó, pero su resultado no pasó la verificación (no se pudo leer, o no tenía direcciones), así que se descartó automáticamente antes de reemplazar nada. Tu cartera está exactamente como estaba antes de que hicieras clic — nada cambió en el disco.",
"wallet_recovery_failure_title": "La reparación no se completó",
"wallet_recovery_files_detail": "Tu cartera original sigue aquí — la app la renombró a un respaldo con fecha (wallet.<números>.bak) en tu carpeta de datos y no ha eliminado nada. “Reparar” lee cada registro de tu cartera más completa en un archivo nuevo y limpio. “Restaurar” copia tu respaldo más grande de vuelta exactamente como está. La copia cargada en este momento también se conserva como respaldo.",
"wallet_recovery_files_label": "¿Qué pasa con mis archivos?",
"wallet_recovery_notnow_short": "Ahora no",
"wallet_recovery_other_options": "Otras opciones",
"wallet_recovery_rebuild_card": "Reparar automáticamente",
"wallet_recovery_rebuild_card_desc": "Lee cada registro recuperable en un archivo limpio, luego reinicia. La opción más exhaustiva.",
"wallet_recovery_recommended": "RECOMENDADO",
"wallet_recovery_repair_go": "Reparar",
"wallet_recovery_rescan_body": "Volviendo a escanear la cadena de bloques para reconstruir tu saldo e historial de transacciones. Esto puede tardar varios minutos — no necesitas hacer nada, y por favor no reinicies el nodo mientras se ejecuta.",
"wallet_recovery_rescan_elapsed": "Trabajando desde hace %s",
"wallet_recovery_rescan_size": "La cartera reconstruida ahora pesa %s y sigue completándose",
"wallet_recovery_rescan_slow": "Esto está tardando más de lo habitual, lo cual es normal en una cartera grande. Es seguro dejarlo en ejecución — puedes ver el progreso en Avanzado ▸ Consola.",
"wallet_recovery_rescan_title": "Terminando la reparación de tu cartera",
"wallet_recovery_restore_card": "Restaurar original",
"wallet_recovery_restore_card_desc": "Vuelve a poner en su lugar tu respaldo más grande sin modificar, tal cual — solo tan completo como era ese archivo.",
"wallet_recovery_restore_go": "Restaurar",
"wallet_recovery_success_body": "La app cargó tu cartera reparada y está volviendo a escanear para totalizar tu saldo — esto puede tardar unos minutos. Lee cada registro que puede, pero en archivos dañados poco frecuentes puede recuperar algunas direcciones menos — u ocasionalmente más — que antes. Cuando termine el reescaneo, comprueba que tu saldo e historial se vean correctos.",
"wallet_recovery_success_restore": "Tu archivo de respaldo más grande ya está en su lugar y cargando ahora, y la app está volviendo a escanear. Se restauró tal cual, así que es exactamente tan completo como era ese archivo — comprueba tu saldo cuando termine el reescaneo.",
"wallet_recovery_try_other": "Probar la otra opción",
"wallet_recovery_whats_happened": "¿Qué pasó?",
"wallet_recovery_working_label": "Trabajando",
"wallet_restore_bad_backup": "El archivo de cartera de respaldo parece ilegible, así que NO se restauró — tu cartera actual no cambió. Restaura desde tu propio respaldo en su lugar.",
"wallet_restore_busy": "El nodo está ocupado reiniciándose — inténtalo de nuevo en un momento.",
"wallet_restore_copy_failed": "No se pudo instalar la cartera de respaldo; tu cartera actual se dejó en su lugar.",
"wallet_restore_move_failed": "No se pudo apartar la cartera actual — no se cambió nada.",
"wallet_restore_no_backup": "No se encontró un wallet.<timestamp>.bak para restaurar. No se cambió nada.",
"wallet_restore_no_restart": "Tu cartera original se restauró, pero el nodo no se reinició — inícialo desde Ajustes.",
"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_consolidate": "Consolidar notas…",
"wallet_size_warn": "Este archivo de cartera es grande. Consolidar tus notas puede frenar un mayor crecimiento.",
"wallets_active": "Activa",
"wallets_add": "Agregar carpeta",
"wallets_add_folder": "Escanear también otra carpeta en busca de archivos de cartera:",
@@ -1932,7 +2347,9 @@
"wallets_open_inplace_tt": "Abre esta cartera donde está, enlazándola al directorio de datos (sin copiar)",
"wallets_remove_folder": "Dejar de escanear esta carpeta",
"wallets_reveal": "Mostrar carpeta",
"wallets_scan_folder": "Escanear otra carpeta en busca de carteras…",
"wallets_scanned_folders": "Carpetas escaneadas:",
"wallets_scanning": "Buscando en las carpetas…",
"wallets_sort_addresses": "Direcciones",
"wallets_sort_asc": "Ascendente (más antiguo / menos / más pequeño primero)",
"wallets_sort_by": "Ordenar:",
@@ -2001,19 +2418,30 @@
"wiz_welcome_sub": "Unas cuantas opciones rápidas y tu nodo completo estará listo.",
"wiz_welcome_title": "¡Bienvenido a ObsidianDragon!",
"wizard_daemon_start_failed": "Error al iniciar el daemon — se reintentará automáticamente",
"xmrig_active_tt": "El minero actualmente instalado (activo)",
"xmrig_browse_releases": "Ver todos los lanzamientos…",
"xmrig_checking": "Buscando el minero más reciente…",
"xmrig_current": "Actual:",
"xmrig_downgrade_note": "Instalar una versión de minero diferente reemplaza la actual; detén la minería primero, y se aplicará en el siguiente inicio.",
"xmrig_download_install": "Descargar e instalar",
"xmrig_downloading": "Descargando…",
"xmrig_install_this": "Instalar esta versión",
"xmrig_installed": "Instalado:",
"xmrig_installed_ok": "Minero instalado",
"xmrig_installing": "Instalando…",
"xmrig_latest": "Más reciente:",
"xmrig_latest_badge": "la más reciente",
"xmrig_latest_tt": "Versión más reciente disponible",
"xmrig_loading_releases": "Cargando versiones…",
"xmrig_no_notes": "No hay notas de la versión para esta versión.",
"xmrig_none": "ninguno",
"xmrig_prerelease_tt": "Compilación preliminar / de pruebas",
"xmrig_reinstall": "Reinstalar",
"xmrig_released": "Publicado:",
"xmrig_releases": "versiones de xmrig",
"xmrig_status_current": "Esta versión está instalada",
"xmrig_status_latest": "Última versión",
"xmrig_status_older": "Anterior a tu versión instalada",
"xmrig_stop_mining_first": "Detén la minería antes de actualizar el minero.",
"xmrig_unavailable_body": "No hay ninguna versión del minero disponible para esta plataforma.",
"xmrig_unavailable_title": "Actualizaciones del minero no disponibles",
+453 -25
View File
@@ -19,11 +19,13 @@
"about_license_text": "Ce logiciel est publié sous la licence publique générale GNU v3 (GPLv3). Vous êtes libre d'utiliser, de modifier et de distribuer ce logiciel selon les termes de la licence.",
"about_peers_count": "%zu pairs",
"about_release": "Version",
"about_source": "Source",
"about_title": "À propos d'ObsidianDragon",
"about_version": "Version :",
"about_website": "Site web",
"acrylic": "Acrylique",
"add": "Ajouter",
"addr_type_mining": "Minage",
"address": "Adresse",
"address_book_add": "Ajouter une adresse",
"address_book_add_new": "Ajouter",
@@ -67,6 +69,7 @@
"appx_blockchain_maintenance_in_progress": "Une opération de maintenance de la blockchain est déjà en cours.",
"appx_blockchain_rescan_complete": "Nouvelle analyse de la blockchain terminée",
"appx_bootstrap_complete_reconciling": "Amorçage terminé — réconciliation de votre portefeuille avec les nouvelles données de la chaîne.",
"appx_bootstrap_reconcile_probe_failed": "Impossible de réconcilier votre portefeuille avec les données de chaîne de l'amorçage : le nœud est resté trop occupé pour vérifier ses blocs. Lancez Re-scanner depuis les Paramètres une fois qu'il aura rattrapé son retard.",
"appx_cancel": "Annuler",
"appx_cleaning_up": "Nettoyage...",
"appx_confirm_your_backup": "Confirmez votre sauvegarde",
@@ -77,11 +80,15 @@
"appx_creating_your_wallet": "Création de votre portefeuille…",
"appx_daemon_error": "Erreur du démon",
"appx_daemon_reinstall_in_progress": "La réinstallation du démon est déjà en cours.",
"appx_daemon_reinstall_node_busy": "Le nœud est toujours actif, le démon n'a donc pas été réinstallé. Réessayez une fois qu'il sera arrêté.",
"appx_disconnecting": "Déconnexion...",
"appx_done": "Terminé",
"appx_dragonxd_output": "Sortie de dragonxd",
"appx_encrypting_wallet": "Chiffrement du portefeuille...",
"appx_fullnode_lifecycle_unavailable_lite": "Les actions de cycle de vie du nœud complet ne sont pas disponibles dans la version allégée",
"appx_import_cancelled": "Annulé : le nœud est peut-être encore en train d'analyser. Si les fonds de la clé n'apparaissent pas, lancez Paramètres → Re-scanner.",
"appx_import_interrupted": "L'importation a été interrompue, la clé est donc peut-être déjà dans le portefeuille. Si ses fonds n'apparaissent pas, lancez Paramètres → Re-scanner.",
"appx_import_refused": "Le nœud a refusé la requête, la clé n'a donc pas été importée.",
"appx_installing_bundled_daemon": "Installation du démon intégré — le nœud va s'arrêter, se mettre à jour, puis redémarrer...",
"appx_invalid_payment_uri_prefix": "URI de paiement invalide : ",
"appx_ive_written_it_down": "Je les ai notés",
@@ -98,7 +105,8 @@
"appx_no_bundled_daemon_to_install": "Cette version ne contient aucun démon intégré à installer",
"appx_no_embedded_daemon_to_install": "Cette version ne contient aucun démon embarqué à installer",
"appx_node_busy_restarting": "Le nœud est occupé à redémarrer — réessayez dans un instant.",
"appx_node_rebuilding_witness_cache": "Le nœud reconstruit son cache de témoins",
"appx_node_busy_try_later": "Le nœud termine une longue tâche (comme la reconstruction des témoins de notes). Réessayez une fois qu'elle est finie ; l'arrêter maintenant pourrait lui faire recommencer ce travail.",
"appx_node_not_stopped": "Le nœud ne s'est pas arrêté à temps, rien n'a donc été modifié. Réessayez dans quelques minutes.",
"appx_not_next_word": " — ce n'est pas le mot suivant",
"appx_payment_request_loaded": "Demande de paiement chargée",
"appx_pool_miner_connected_and_hashing": "Mineur de pool connecté et en cours de hachage.",
@@ -119,11 +127,7 @@
"appx_simple_background_enabled": "Arrière-plan simple activé",
"appx_skip": "Ignorer",
"appx_skip_anyway": "Ignorer quand même",
"appx_still_status_prefix": "Toujours « ",
"appx_still_status_suffix": " » — forcer la fermeture maintenant peut corrompre les données de la chaîne.",
"appx_stop_anyway_and_quit": "Arrêter quand même et quitter",
"appx_stopping_daemon_deleting_blockchain": "Arrêt du démon et suppression des données de la blockchain...",
"appx_stopping_node_discards_rebuild": "Arrêter le nœud maintenant abandonne la reconstruction en cours et la relance (plusieurs minutes) à la prochaine ouverture du portefeuille. Vous pouvez plutôt laisser le nœud en marche.",
"appx_stopping_pool_miner": "Arrêt du mineur de pool...",
"appx_syncing_pct_block_n_of_n": "Synchronisation %.1f%% — Bloc %d / %d",
"appx_tap_words_in_order": "Touchez les mots dans le bon ordre pour confirmer que vous les avez enregistrés.",
@@ -133,8 +137,14 @@
"appx_use_settings_restart_daemon_hint": "Utilisez Paramètres > Redémarrer le démon pour réessayer",
"appx_waiting_for_daemon_to_encrypt_wallet": "En attente du chiffrement du portefeuille par le démon...",
"appx_wallet_created_and_backed_up": "Portefeuille créé et sauvegardé.",
"appx_wallet_op_node_busy": "Le nœud est occupé et ne répond pas. Relancez l'importation ou la réanalyse une fois qu'il a fini.",
"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_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é.",
"autoshield_off_generic": "Votre nœud ne blinde pas automatiquement les coinbase minées en ce moment.",
"av_intro": "Les logiciels de minage sont souvent signalés comme potentiellement indésirables. Suivez ces étapes pour activer le minage en pool :",
"av_open_security": "Ouvrir Sécurité Windows",
"av_step1": "Étape 1 : Ajouter une exclusion",
@@ -170,21 +180,20 @@
"balance_history_collecting": "Historique du solde — collecte des données...",
"balance_layout": "Disposition du solde",
"balance_layout_switched": "Disposition : %s",
"balance_mining_fmt": "Récompenses de minage : %.8f",
"balance_mining_rate": "Minage %s",
"balance_shielded_fmt": "Blindé : %.8f",
"balance_syncing_pct": "Synchronisation %.1f%%",
"balance_transparent_fmt": "Transparent : %.8f",
"baltab_market": "Marché",
"baltab_market_price_4dp": "Marché : %.4f $",
"baltab_market_price_8dp": "Marché : %.8f $",
"baltab_mining_addresses_count": "%d adresses de minage",
"baltab_mining_amount": "Récompenses de minage %.8f",
"baltab_pct_change_24h": "%s%.1f%% 24h",
"baltab_pct_of_total_zaddr": "%.0f%% du total · %d Z-addr",
"baltab_shielded": "Protégé",
"baltab_shielded_amount": "Protégé %.8f",
"baltab_t_addresses_count": "%d T-addresses",
"baltab_total_balance": "Solde total",
"baltab_transparent": "Transparent",
"baltab_transparent_amount": "Transparent %.8f",
"ban": "Bannir",
"banned_peers": "Pairs bannis",
"block": "Bloc",
@@ -192,6 +201,12 @@
"block_click_copy": "Cliquez pour copier",
"block_click_next": "Cliquez pour voir le bloc suivant",
"block_click_prev": "Cliquez pour voir le bloc précédent",
"block_db_reindex_body": "Cela arrive généralement après qu'une mise à jour du démon change le format sur disque, ou si l'index des blocs est endommagé. Votre portefeuille et vos pièces sont en sécurité — le nœud ne peut simplement pas charger la chaîne, donc les soldes affichent zéro.\n\nLa reconstruction relit vos fichiers de blocs existants et peut prendre du temps (elle rescanne aussi votre portefeuille). Rien n'est téléchargé.",
"block_db_reindex_confirm": "Reconstruire la base de données de blocs",
"block_db_reindex_notify": "Le nœud ne peut pas lire sa base de données de blocs (souvent après une mise à jour du démon). Reconstruisez-la pour restaurer votre solde — voir l'invite, ou Paramètres › Nœud.",
"block_db_reindex_started": "Reconstruction de la base de données de blocs à partir de vos blocs — cela peut prendre du temps.",
"block_db_reindex_title": "Reconstruire la base de données de blocs ?",
"block_db_reindex_warn": "Le nœud ne peut pas lire sa base de données de blocs.",
"block_explorer": "Explorateur de blocs",
"block_get_info": "Obtenir les infos du bloc",
"block_hash": "Hash du bloc :",
@@ -228,6 +243,7 @@
"change_pass_confirm": "Confirmer la nouvelle :",
"change_pass_current": "Phrase secrète actuelle :",
"change_pass_new": "Nouvelle phrase secrète :",
"change_pass_pin_note": "Ce portefeuille a un PIN de déverrouillage rapide. Après le changement, il sera obsolète : supprimez le PIN dans les Paramètres puis définissez-le à nouveau.",
"change_pass_title": "Changer la phrase secrète",
"characters": "caractères",
"chat": "Discussion",
@@ -239,6 +255,10 @@
"chat_accent_theme": "Thème",
"chat_add_contact": "Ajouter un contact",
"chat_awaiting_key": "En attente de réponse",
"chat_blocked_desc": "Les conversations bloquées sont supprimées et leurs messages sont rejetés — anciens et nouveaux — jusqu'à ce que vous débloquiez. Débloquer réimporte la conversation depuis la chaîne.",
"chat_blocked_manage": "Bloqué",
"chat_blocked_title": "Conversations bloquées",
"chat_blocked_toast": "Conversation supprimée et bloquée",
"chat_bubble_minimal": "Minimale",
"chat_bubble_rounded": "Arrondie",
"chat_bubble_square": "Carrée",
@@ -251,6 +271,16 @@
"chat_contact_added": "Contact ajouté — renommez-le dans Contacts",
"chat_contact_request": "demande de contact",
"chat_copy_address_tip": "Cliquer pour copier l'adresse",
"chat_delete": "Supprimer la conversation",
"chat_delete_block": "Supprimer et bloquer",
"chat_delete_body_prefix": "Supprimer votre copie locale de la conversation avec ",
"chat_delete_body_suffix": " ?",
"chat_delete_confirm": "Supprimer",
"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_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",
"chat_density_comfortable": "Confortable",
"chat_density_compact": "Compacte",
"chat_emoji_color": "Couleur",
@@ -274,6 +304,7 @@
"chat_new_button": "Nouvelle discussion",
"chat_new_message": "Message",
"chat_new_message_toast": "Nouveau message chiffré",
"chat_new_needs_zaddr": "Le chat nécessite une adresse blindée (z) — les adresses transparentes (t) ne peuvent pas recevoir de messages chiffrés.",
"chat_new_send": "Envoyer la demande",
"chat_new_title": "Nouvelle discussion",
"chat_new_zaddr": "Adresse Z du destinataire",
@@ -319,6 +350,8 @@
"chat_ts_24h": "24 heures",
"chat_ts_global": "Suivre global",
"chat_ts_global_short": "Global",
"chat_unblock": "Débloquer",
"chat_unblocked_toast": "Conversation débloquée",
"chat_unhide": "Afficher",
"chat_unmute": "Réactiver",
"chat_verify_key": "Clé d'identité — comparez pour vérifier",
@@ -371,6 +404,7 @@
"console": "Console",
"console_accents": "Accents de couleur",
"console_app": "App",
"console_auto_focus": "Placer le focus sur le champ de saisie à l'ouverture",
"console_auto_scroll": "Défilement auto",
"console_available_commands": "Commandes disponibles :",
"console_backend_reference": "Référence des commandes du backend",
@@ -465,11 +499,15 @@
"console_tab_completion": "Tab pour compléter",
"console_text_colors": "Couleurs du texte",
"console_toggle_accents": "Basculer les accents de couleur des lignes",
"console_toggle_auto_focus": "Placer le curseur dans la zone de commande à l'ouverture de l'onglet Console",
"console_toggle_text_color": "Basculer les couleurs du texte des lignes",
"console_type_help": "Tapez 'help' pour les commandes disponibles",
"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_funds_btn": "Consolider",
"consolidate_title": "Consolider les fonds",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "Cette image n'a pas pu être chargée.",
"contact_avatar_badge": "Badge",
@@ -481,7 +519,7 @@
"contact_avatar_image_hint": "L'image est copiée dans l'application pour rester disponible si l'original est déplacé.",
"contact_avatar_remove": "Retirer",
"contact_avatar_shielded": "Blindée",
"contact_avatar_transparent": "Transparente",
"contact_chip_mining": "Minage seul",
"contact_global": "Afficher dans tous les portefeuilles (contact global)",
"contact_global_badge_tt": "Contact global — visible dans tous les portefeuilles",
"contact_global_tt": "Activé : ce contact reste visible quel que soit le portefeuille chargé. Désactivé : il appartient au portefeuille actuel uniquement.",
@@ -515,9 +553,13 @@
"daemon_maintenance_label": "MAINTENANCE",
"daemon_none_bundled": "aucun dans cette version",
"daemon_not_installed": "non installé",
"daemon_status_diff": "Version différente",
"daemon_status_differ": "Le binaire installé diffère de la version intégrée.",
"daemon_status_match": "Le binaire installé correspond à la version intégrée.",
"daemon_status_missing": "Aucun daemon installé — installez la version intégrée.",
"daemon_status_none": "Non installé",
"daemon_status_ok": "À jour",
"daemon_update_active_tt": "Le nœud actuellement installé (actif)",
"daemon_update_available": "Une nouvelle version du nœud est disponible",
"daemon_update_body": "Cette version du portefeuille inclut un nœud DragonX plus récent que celui actuellement installé. La mise à jour remplace le dragonxd installé (ainsi que dragonx-cli/dragonx-tx), puis arrête et redémarre le nœud pour que la nouvelle version prenne effet. Recommandé — un nœud plus récent peut ajouter des fonctionnalités (par exemple la prise en charge des phrases de récupération) que l'ancien n'a pas.",
"daemon_update_browse": "Parcourir toutes les versions…",
@@ -527,19 +569,28 @@
"daemon_update_download_install": "Télécharger et installer",
"daemon_update_downloading": "Téléchargement…",
"daemon_update_failed": "Échec de la mise à jour",
"daemon_update_install_this": "Installer cette version",
"daemon_update_installed": "Installé :",
"daemon_update_installed_ok": "Nœud installé",
"daemon_update_installing": "Installation…",
"daemon_update_keep": "Conserver l'actuel",
"daemon_update_later": "Plus tard",
"daemon_update_latest": "Dernière :",
"daemon_update_latest_badge": "dernière",
"daemon_update_latest_tt": "Dernière version disponible",
"daemon_update_loading": "Chargement des versions…",
"daemon_update_no_notes": "Aucune note de version pour cette version.",
"daemon_update_now": "Mettre à jour",
"daemon_update_prerelease_tt": "Version préliminaire / de test",
"daemon_update_prompt_title": "Mettre à jour le démon du nœud ?",
"daemon_update_reinstall": "Réinstaller",
"daemon_update_released": "Publiée :",
"daemon_update_restart_note": "Redémarrez le daemon pour lancer la nouvelle version.",
"daemon_update_restart_now": "Redémarrer le daemon maintenant",
"daemon_update_safe": "Votre portefeuille, vos clés et vos données de blockchain ne sont pas touchés — seuls les fichiers du programme daemon sont remplacés. Si vous exécutez délibérément un nœud personnalisé, choisissez Conserver l'actuel.",
"daemon_update_status_current": "Cette version est installée",
"daemon_update_status_latest": "Dernière version",
"daemon_update_status_older": "Antérieure à votre version installée",
"daemon_update_title": "Mettre à jour le daemon du nœud ?",
"daemon_update_unavailable_body": "Aucune version du nœud n'est disponible pour cette plateforme.",
"daemon_update_unavailable_title": "Mises à jour du nœud indisponibles",
@@ -555,6 +606,13 @@
"data_stale_tooltip": "Le solde est peut-être obsolète — le portefeuille n'a pas reçu de mise à jour récente. Vérifiez la connexion à votre nœud.",
"date": "Date",
"date_label": "Date :",
"debug_gate_confirm": "Afficher les options de débogage",
"debug_gate_incorrect": "PIN ou phrase secrète incorrect.",
"debug_gate_pass_prompt": "Entrez la phrase secrète de votre portefeuille pour continuer :",
"debug_gate_pin_prompt": "Entrez votre PIN de déverrouillage pour continuer :",
"debug_gate_title": "Afficher les options de débogage ?",
"debug_gate_verifying": "Vérification…",
"debug_gate_warning": "Ce sont des options avancées de diagnostic et de réglage du nœud. Elles peuvent exposer des informations sensibles et modifier le fonctionnement de votre nœud. Ne continuez que si vous savez ce que vous faites.",
"debug_logging": "JOURNALISATION DE DÉBOGAGE",
"decrypt_desc": "Le portefeuille sera exporté, le daemon redémarré avec un nouveau portefeuille non chiffré, et toutes les clés réimportées. Cela peut prendre plusieurs minutes selon la taille du portefeuille.",
"decrypt_error_title": "Échec du déchiffrement",
@@ -577,9 +635,15 @@
"disconnected": "Déconnecté",
"dismiss": "Ignorer",
"display": "Affichage",
"done": "Terminé",
"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",
@@ -630,6 +694,7 @@
"explorer_tx_outputs": "Sorties",
"explorer_tx_size": "Taille",
"explorer_txid": "TxID :",
"explorer_urls_hdr": "URLS",
"export": "Exporter",
"export_csv": "Exporter en CSV",
"export_keys_btn": "Exporter les clés",
@@ -655,9 +720,155 @@
"export_tx_success": "Transactions exportées avec succès",
"export_tx_title": "Exporter les transactions en CSV",
"export_viewing_key": "Exporter la clé de visualisation",
"failed_create_mining_address": "Impossible de créer l'adresse de minage",
"failed_create_shielded": "Échec de la création de l'adresse blindée",
"failed_create_transparent": "Échec de la création de l'adresse transparente",
"faq": "FAQ",
"faq_d_mgmt_1_a": "Le nœud intégré démarre automatiquement avec le portefeuille et s'arrête quand il le faut, donc vous n'avez normalement pas besoin de le gérer manuellement. Les contrôles avancés (y compris le redémarrage du démon) se trouvent dans Paramètres > Nœud et sécurité.",
"faq_d_mgmt_1_q": "Comment démarrer ou arrêter le nœud ?",
"faq_d_mgmt_2_a": "Si vous connectez le portefeuille à un nœud que vous avez démarré vous-même, cette option détermine si la fermeture du portefeuille arrête aussi ce nœud. Laissez-la désactivée si vous voulez que votre nœud continue de fonctionner après la fermeture du portefeuille.",
"faq_d_mgmt_2_q": "À quoi sert « fermer le démon externe à la sortie » ?",
"faq_d_mgmt_3_a": "Les données du nœud et du portefeuille se trouvent dans le répertoire de données DragonX sous votre profil utilisateur (par exemple, dans AppData sous Windows ou votre dossier personnel sous Linux/macOS). La blockchain en est la partie volumineuse ; gardez assez d'espace disque libre pour sa croissance.",
"faq_d_mgmt_3_q": "Où sont stockées la blockchain et les données du portefeuille ?",
"faq_d_mgmt_title": "Gestion du nœud",
"faq_d_mine_1_a": "L'onglet Minage vous permet de miner avec votre processeur (CPU). DragonX utilise l'algorithme RandomX, conçu pour les processeurs classiques. Définissez le nombre de threads et démarrez — les récompenses s'accumulent dans votre portefeuille.",
"faq_d_mine_1_q": "Comment miner du DragonX ?",
"faq_d_mine_2_a": "Le minage solo vous envoie les récompenses directement, mais ne paie que lorsque vous trouvez un bloc, ce qui est peu fréquent sauf si vous avez beaucoup de puissance de calcul. Le minage en pool partage le travail avec d'autres pour des gains plus petits mais plus réguliers. L'onglet Minage prend en charge les deux.",
"faq_d_mine_2_q": "Minage solo ou en pool — lequel choisir ?",
"faq_d_mine_3_a": "La section pool de l'onglet Minage propose un bouton « Mettre à jour le mineur… » qui télécharge, vérifie et installe la dernière version optimisée du mineur DragonX. Comme pour la mise à jour du nœud, chaque téléchargement est vérifié par somme de contrôle et signature avant l'installation.",
"faq_d_mine_3_q": "Comment mettre à jour le mineur ?",
"faq_d_mine_4_a": "Le nœud actuel peut exécuter un serveur stratum intégré afin que d'autres mineurs puissent se connecter à votre machine. Une option se trouve dans Paramètres > Nœud et sécurité. Par défaut, il n'écoute qu'en local ; l'exposer à d'autres ordinateurs nécessite de l'ouvrir volontairement.",
"faq_d_mine_4_q": "Puis-je héberger une pool de minage ?",
"faq_d_mine_title": "Minage et pools",
"faq_d_net_1_a": "Juste après le lancement, le nœud a besoin d'un instant pour trouver des pairs, donc afficher brièvement « 0 pair » est normal. Si cela persiste, vérifiez votre connexion internet et qu'un pare-feu ne bloque pas le nœud. Le nœud trouve automatiquement des pairs via des graines intégrées.",
"faq_d_net_1_q": "Le portefeuille n'affiche aucun pair ni connexion — que se passe-t-il ?",
"faq_d_net_2_a": "Oui — les nœuds DragonX communiquent entre eux via TLS, donc les connexions entre pairs sont chiffrées. Le portefeuille communique aussi avec son propre nœud via un canal local sécurisé.",
"faq_d_net_2_q": "Ma connexion au réseau est-elle chiffrée ?",
"faq_d_net_title": "Pairs et réseau",
"faq_d_node_1_a": "Le démon, dragonxd, est le nœud complet de DragonX. Il télécharge et vérifie l'intégralité de la blockchain, relaie les transactions vers le réseau et répond aux requêtes du portefeuille. Exécuter votre propre nœud signifie que vous n'avez à faire confiance au serveur de personne d'autre.",
"faq_d_node_1_q": "Qu'est-ce que le démon (dragonxd) ?",
"faq_d_node_2_a": "Par défaut, le portefeuille lance et gère son propre nœud intégré (embarqué) — vous n'avez rien à faire. Si vous exécutez déjà dragonxd vous-même, le portefeuille peut se connecter à ce nœud externe à la place. Le portefeuille détecte la situation et se comporte en conséquence.",
"faq_d_node_2_q": "Nœud intégré ou externe — quelle est la différence ?",
"faq_d_node_3_a": "Un nœud complet valide indépendamment chaque règle de la blockchain, de sorte que votre portefeuille fait confiance aux mathématiques plutôt qu'à un tiers. Cela renforce aussi le réseau. La contrepartie est l'espace disque et une synchronisation initiale.",
"faq_d_node_3_q": "Pourquoi exécuter un nœud complet ?",
"faq_d_node_title": "Le nœud complet",
"faq_d_perf_1_a": "La blockchain est l'élément le plus volumineux et croît avec le temps, donc gardez plusieurs gigaoctets libres. Le nœud utilise un cache de base de données pour la rapidité ; les valeurs par défaut intégrées sont adaptées aux machines courantes. Les utilisateurs avancés peuvent ajuster le cache de base de données du nœud en modifiant dbcache dans son fichier de configuration (DRAGONX.conf).",
"faq_d_perf_1_q": "Combien d'espace disque le nœud utilise-t-il, et puis-je régler la mémoire ?",
"faq_d_perf_2_a": "Activez le téléchargement du bootstrap (proposé dans l'assistant de configuration) pour récupérer une copie récente et vérifiée de la chaîne au lieu de valider chaque bloc depuis le début. Un disque rapide (SSD) aide aussi beaucoup.",
"faq_d_perf_2_q": "Comment accélérer la synchronisation initiale ?",
"faq_d_perf_title": "Stockage et performances",
"faq_d_sync_1_a": "Au lancement, le nœud charge et valide les blocs avant de pouvoir servir le portefeuille. L'écran « Traitement des blocs / Application des blocs pour construire l'état actuel de la chaîne » correspond à cette phase de préparation. C'est normal ; le portefeuille devient utilisable une fois que le nœud a terminé et signalé sa hauteur.",
"faq_d_sync_1_q": "Que signifie « Traitement des blocs » au démarrage ?",
"faq_d_sync_2_a": "Pour vos fonds blindés, le nœud conserve des « témoins » cryptographiques qui vous permettent de dépenser en privé. Après certains redémarrages ou mises à jour, il reconstruit ce cache en réanalysant les blocs récents. Sur un portefeuille avec de nombreuses transactions, cela peut prendre plusieurs minutes et utiliser de la mémoire supplémentaire.\n\nCe n'est pas bloqué — laissez-le terminer. Forcer l'arrêt en plein milieu ne fait que redémarrer la reconstruction la prochaine fois.",
"faq_d_sync_2_q": "Qu'est-ce que « Reconstruction du cache de témoins » ?",
"faq_d_sync_3_a": "Près de la pointe de la chaîne, le travail qui analyse votre portefeuille (vérification des soldes et des notes) est en concurrence avec le nœud pour le même verrou interne, ce qui peut ralentir la connexion des derniers blocs sur un gros portefeuille. Les versions récentes limitent cette analyse pour laisser le nœud rattraper son retard. Rester sur un onglet plus léger (ou simplement attendre) permet de terminer plus vite.",
"faq_d_sync_3_q": "Pourquoi la synchronisation est-elle lente alors que je suis presque à jour ?",
"faq_d_sync_4_a": "La première synchronisation télécharge et vérifie toute la chaîne et peut prendre du temps selon votre connexion et votre disque. Vous pouvez l'accélérer considérablement en activant le téléchargement du bootstrap dans l'assistant de configuration, qui récupère une copie récente et vérifiée des données de la chaîne.",
"faq_d_sync_4_q": "Combien de temps dure la synchronisation initiale ?",
"faq_d_sync_title": "Synchronisation et blockchain",
"faq_d_trbl_1_a": "Généralement non. Sur un gros portefeuille, le nœud peut passer plusieurs minutes à reconstruire les témoins blindés ou à appliquer des blocs, période durant laquelle il semble en pause et peut répondre lentement. Laissez-lui du temps — le journal de débogage (Paramètres) montre une progression régulière s'il fonctionne.\n\nÉvitez de forcer l'arrêt pendant cette phase ; cela redémarre le travail depuis le début au prochain lancement.",
"faq_d_trbl_1_q": "Le nœud semble bloqué sur « Activation de la meilleure chaîne » — est-il figé ?",
"faq_d_trbl_2_a": "Si un fichier de portefeuille est récupéré mais qu'une partie de ses données de clés est manquante, le nœud peut l'ouvrir dans un mode « dégradé » limité où il ne peut pas dériver de nouvelles adresses ni blinder des fonds. La solution la plus sûre est de restaurer à partir de votre phrase de récupération dans un nouveau portefeuille.",
"faq_d_trbl_2_q": "Qu'est-ce que le « mode dégradé » ?",
"faq_d_trbl_3_a": "Une réanalyse fait relire la chaîne par le portefeuille pour redécouvrir vos transactions (utile après l'import de clés). Une réindexation reconstruit la base de données blockchain du nœud et n'est nécessaire que si cette base est corrompue ou incompatible après un changement de version. Les deux peuvent prendre du temps ; lancez-les depuis Paramètres > Nœud et sécurité.",
"faq_d_trbl_3_q": "Quand dois-je réindexer ou réanalyser ?",
"faq_d_trbl_4_a": "L'utilisation de la mémoire augmente pendant un travail intensif comme la reconstruction des témoins ou une synchronisation initiale, puis redescend une fois terminé. Une mémoire élevée soutenue au repos est inhabituelle — redémarrer le portefeuille la libère. Sur une machine à faible RAM, vérifiez que d'autres applications gourmandes en mémoire ne sont pas en concurrence.",
"faq_d_trbl_4_q": "Le nœud utilise beaucoup de mémoire — est-ce normal ?",
"faq_d_trbl_title": "Dépannage",
"faq_d_upd_1_a": "Ouvrez Paramètres > Nœud et sécurité > Binaire du démon et utilisez « Rechercher des mises à jour… ». Le portefeuille télécharge la dernière version vérifiée du nœud et l'installe ; la nouvelle version prend effet au prochain démarrage du démon.",
"faq_d_upd_1_q": "Comment mettre à jour le nœud ?",
"faq_d_upd_2_a": "Oui — la boîte de dialogue de mise à jour liste toutes les versions publiées afin que vous puissiez en choisir une spécifique ou plus ancienne. Soyez prudent en cas de rétrogradation : un nœud plus ancien peut ne pas accepter des données blockchain écrites par une version plus récente et pourrait nécessiter une réindexation.",
"faq_d_upd_2_q": "Puis-je installer une version spécifique du nœud ?",
"faq_d_upd_3_a": "Chaque archive de nœud téléchargée est vérifiée par une somme de contrôle et une signature cryptographique avant d'être installée. Si la vérification échoue, le portefeuille refuse la mise à jour.",
"faq_d_upd_3_q": "La mise à jour est-elle sûre ?",
"faq_d_upd_title": "Mise à jour du nœud",
"faq_group_daemon": "Démon",
"faq_group_wallet": "Portefeuille",
"faq_intro": "Réponses sur votre portefeuille et le nœud DragonX. Cherchez, ou parcourez par sujet ci-dessous.",
"faq_intro_lite": "Réponses sur votre portefeuille Lite. Cherchez, ou parcourez par sujet ci-dessous.",
"faq_l_gs_1_a": "ObsidianDragonLite est le portefeuille DragonX allégé. Au lieu d'exécuter un nœud complet, il se connecte à un serveur DragonX lite, ce qui lui permet de démarrer rapidement et de ne pas télécharger toute la blockchain.\n\nIl gère vos clés, vos soldes et vos envois tout comme le portefeuille complet — voir la section « Portefeuille Lite » ci-dessous pour comprendre le fonctionnement de la connexion au serveur et le compromis en matière de confidentialité.",
"faq_l_gs_1_q": "Qu'est-ce qu'ObsidianDragonLite ?",
"faq_l_lite_1_a": "Le portefeuille Lite communique avec un serveur DragonX lite au lieu d'exécuter son propre nœud complet. Cela le rend rapide à démarrer et léger en disque et en bande passante, car il ne télécharge ni ne vérifie lui-même toute la blockchain. Vos clés privées restent toujours sur votre appareil — elles ne sont jamais envoyées au serveur.",
"faq_l_lite_1_q": "En quoi le portefeuille Lite diffère-t-il du portefeuille complet ?",
"faq_l_lite_2_a": "Ouvrez Paramètres et choisissez un serveur. Vous pouvez changer de serveur à tout moment et le portefeuille se resynchronise avec le nouveau. Si un serveur est lent ou inaccessible, essayez-en un autre.",
"faq_l_lite_2_q": "Comment choisir ou changer de serveur ?",
"faq_l_lite_3_a": "Vos clés ne quittent jamais votre appareil, et les montants blindés restent chiffrés. Mais le serveur lite voit quelles adresses votre portefeuille interroge ainsi que votre adresse IP, ce qui lui permet de relier vos adresses entre elles — un compromis entre confidentialité et commodité. Pour un maximum de confidentialité, utilisez le portefeuille à nœud complet, qui vérifie lui-même la chaîne et ne révèle pas vos adresses à un serveur.",
"faq_l_lite_3_q": "Le portefeuille Lite est-il aussi privé que le nœud complet ?",
"faq_l_lite_title": "Portefeuille Lite",
"faq_l_sec_1_a": "Ouvrez Paramètres > Portefeuille et définissez une phrase secrète dans la section sécurité. Le chiffrement protège vos clés privées sur le disque, de sorte qu'une personne ayant accès aux fichiers ne puisse toujours pas dépenser vos pièces.\n\nChoisissez une phrase secrète forte et ne la perdez pas — il n'existe aucun moyen de récupérer un portefeuille chiffré sans elle.",
"faq_no_results": "Aucun résultat. Essayez un autre terme de recherche.",
"faq_open_tooltip": "Aide et FAQ",
"faq_search_hint": "Rechercher dans la FAQ…",
"faq_title": "Aide et FAQ",
"faq_w_addr_1_a": "Les adresses blindées (adresses z, commençant par zs) sont celles que vous utilisez sur DragonX. Elles reposent sur une cryptographie à divulgation nulle de connaissance : le montant, l'expéditeur et le destinataire sont chiffrés sur la blockchain, et votre solde reste privé.\n\nLes adresses transparentes (commençant par R) sont des adresses de minage. DragonX est une blockchain entièrement privée : elles ne peuvent recevoir que des récompenses de minage — le réseau refuse tout paiement qui leur est envoyé. Les récompenses qui y arrivent sont publiques jusqu'à ce qu'elles soient blindées, ce que le portefeuille fait automatiquement.",
"faq_w_addr_1_q": "Quelle est la différence entre les adresses transparentes et blindées ?",
"faq_w_addr_2_a": "Utilisez une adresse blindée (zs) pour tout ce que vous envoyez et recevez — sur DragonX, c'est le seul type d'adresse qui puisse être payé.\n\nLes adresses transparentes (de minage) servent uniquement aux récompenses de minage en solo. Ne la communiquez jamais pour un paiement : le réseau le refuserait.",
"faq_w_addr_2_q": "Quel type d'adresse dois-je utiliser ?",
"faq_w_addr_3_a": "Réutiliser une adresse blindée ne pose aucun problème et ne révèle pas votre historique. Vous pouvez tout de même en créer de nouvelles depuis l'onglet Recevoir, par exemple une par contact pour savoir qui vous a payé. Les adresses de minage servent uniquement aux récompenses de minage ; vous aurez rarement besoin d'en créer une nouvelle.",
"faq_w_addr_3_q": "Dois-je réutiliser une adresse ou en créer une nouvelle ?",
"faq_w_addr_4_a": "Le blindage déplace des pièces d'une adresse transparente (de minage) vers une adresse blindée, ce qui les rend privées. Les pièces fraîchement minées arrivent sur une adresse de minage et sont blindées automatiquement une fois arrivées à maturité.\n\nVous pouvez aussi blinder manuellement depuis le portefeuille ; un blindage n'est qu'une transaction particulière, il prend donc le temps de confirmation habituel.",
"faq_w_addr_4_q": "Qu'est-ce que le « blindage » ?",
"faq_w_addr_title": "Adresses et confidentialité",
"faq_w_bal_1_a": "Tant que votre portefeuille est encore en cours de synchronisation, votre solde est incomplet et peut afficher 0 — il n'a pas encore analysé toutes vos transactions. Il se complète une fois la synchronisation terminée.\n\nSurveillez la barre d'état en bas : elle indique l'état de la connexion et la hauteur de bloc. Une fois la synchronisation terminée, votre solde est exact.",
"faq_w_bal_1_q": "Pourquoi mon solde est-il à 0 ou ne se met-il pas à jour ?",
"faq_w_bal_2_a": "Chaque nouveau bloc miné au-dessus du bloc contenant votre transaction ajoute une confirmation. Plus il y a de confirmations, plus le paiement est fermement établi. Les fonds reçus deviennent dépensables après la première confirmation.",
"faq_w_bal_2_q": "Que sont les confirmations ?",
"faq_w_bal_3_a": "Le total inclut les fonds encore en cours de confirmation ou temporairement verrouillés (par exemple, des pièces en cours de blindage). Le solde dépensable est ce que vous pouvez envoyer immédiatement. Les deux convergent au fur et à mesure que les transactions se confirment.",
"faq_w_bal_3_q": "Quelle est la différence entre le solde total et le solde dépensable ?",
"faq_w_bal_title": "Solde et synchronisation",
"faq_w_chat_1_a": "Le chat est une messagerie chiffrée intégrée au portefeuille, fonctionnant sur la chaîne. Les messages sont envoyés sous forme de mémos blindés privés, de sorte que seuls vous et votre contact pouvez les lire.",
"faq_w_chat_1_q": "Qu'est-ce que la fonction Chat ?",
"faq_w_chat_2_a": "Votre identité de chat est dérivée de la phrase de récupération de votre portefeuille, elle vous suit donc avec votre portefeuille — restaurez le portefeuille et votre identité revient. Vous ne créez pas de compte ou de mot de passe distinct.",
"faq_w_chat_2_q": "Comment fonctionne mon identité de chat ?",
"faq_w_chat_3_a": "Ajoutez un contact via son adresse dans l'onglet Contacts, avec éventuellement un nom et un avatar. Les contacts peuvent rester privés au portefeuille actuel ou être partagés entre tous les portefeuilles que vous ouvrez sur cet ordinateur.",
"faq_w_chat_3_q": "Comment fonctionnent les contacts ?",
"faq_w_chat_4_a": "Ouvrez une conversation et utilisez les icônes de son en-tête. Masquer (l'œil) la retire de la liste mais conserve tous les messages — un nouveau message la fait réapparaître. Supprimer (la corbeille) ouvre une boîte de dialogue : « Supprimer » efface l'historique sur cet appareil (si cette personne vous écrit à nouveau, la conversation revient), tandis que « Supprimer et bloquer » arrête aussi ses futurs messages jusqu'à ce que vous la débloquiez depuis la liste « Bloqués » au-dessus de la liste des conversations. Tout ceci est local à cet appareil — les messages restent sur la blockchain et l'autre personne conserve sa propre copie, ce n'est donc pas un « retrait de message ».",
"faq_w_chat_4_q": "Comment masquer, supprimer ou bloquer une conversation ?",
"faq_w_chat_title": "Chat et contacts",
"faq_w_gs_1_a": "ObsidianDragon est un portefeuille à nœud complet pour DragonX (DRGX). Il gère vos pièces et, sur la version bureau, exécute un nœud complet DragonX (le démon) en arrière-plan afin que votre portefeuille vérifie lui-même la blockchain plutôt que de faire confiance à un serveur tiers.\n\nVoyez-le comme deux parties fonctionnant ensemble : le portefeuille (clés, soldes, envoi) et le nœud (la blockchain et le réseau). Les questions ici sont réparties de la même façon — voir les onglets Portefeuille et Démon ci-dessus.",
"faq_w_gs_1_q": "Qu'est-ce qu'ObsidianDragon ?",
"faq_w_gs_2_a": "Au premier lancement, l'assistant de configuration crée automatiquement un portefeuille pour vous. Les nouveaux portefeuilles sont sécurisés par une phrase de récupération secrète (une liste de mots). Notez cette phrase et conservez-la hors ligne — c'est le seul moyen de restaurer vos fonds si cet ordinateur est perdu.\n\nVous pouvez consulter ou sauvegarder la phrase à tout moment depuis Paramètres > Sauvegarde et données > Phrase de récupération.",
"faq_w_gs_2_q": "Comment créer un nouveau portefeuille ?",
"faq_w_gs_3_a": "Utilisez le flux de restauration par phrase de récupération si vous en avez une. (Sur la version à nœud complet, vous pouvez aussi copier un fichier wallet.dat existant dans le dossier de données du portefeuille avant le lancement.)\n\nAprès la restauration, le portefeuille se réanalyse pour retrouver vos transactions passées ; votre solde et votre historique peuvent donc mettre un moment à apparaître la première fois.",
"faq_w_gs_3_q": "Comment restaurer un portefeuille que j'ai déjà ?",
"faq_w_gs_4_a": "L'assistant vous permet de choisir une apparence/un thème, de configurer le chiffrement et un code PIN, et — sur la version à nœud complet — de télécharger en option un bootstrap pour accélérer la première synchronisation. Vous pouvez ignorer n'importe quelle étape et modifier tout cela plus tard dans Paramètres.",
"faq_w_gs_4_q": "Que fait la configuration au premier lancement ?",
"faq_w_gs_title": "Prise en main",
"faq_w_sec_1_a": "Ouvrez Paramètres > Nœud et sécurité et définissez une phrase secrète (section SÉCURITÉ). Le chiffrement protège vos clés privées sur le disque, de sorte qu'une personne ayant accès aux fichiers ne puisse toujours pas dépenser vos pièces.\n\nChoisissez une phrase secrète forte et ne la perdez pas — il n'existe aucun moyen de récupérer un portefeuille chiffré sans elle.",
"faq_w_sec_1_q": "Comment chiffrer mon portefeuille ?",
"faq_w_sec_2_a": "Le code PIN verrouille l'écran de l'application afin que les soldes et actions soient masqués lorsque vous vous absentez. C'est un verrouillage de confort qui s'ajoute au chiffrement ; vous le configurez pendant l'assistant ou dans Paramètres, après avoir chiffré le portefeuille.",
"faq_w_sec_2_q": "Qu'est-ce que le code PIN / l'écran de verrouillage ?",
"faq_w_sec_3_a": "Non. Le chiffrement du portefeuille ne peut pas être contourné. Si vous avez encore votre phrase de récupération, vous pouvez restaurer le portefeuille à partir de celle-ci dans un nouveau portefeuille et définir une nouvelle phrase secrète. Sans la phrase secrète ni la phrase de récupération, les fonds ne peuvent pas être récupérés.",
"faq_w_sec_3_q": "J'ai oublié ma phrase secrète — peut-elle être récupérée ?",
"faq_w_sec_title": "Sécurité et chiffrement",
"faq_w_seed_1_a": "Allez dans Paramètres > Sauvegarde et données > Phrase de récupération. Notez les mots sur papier dans l'ordre et conservez-les en lieu sûr et hors ligne. Toute personne possédant la phrase peut dépenser vos pièces, ne la stockez donc jamais dans une photo, un e-mail ou une note dans le cloud.",
"faq_w_seed_1_q": "Comment sauvegarder ma phrase de récupération ?",
"faq_w_seed_2_a": "Les anciens portefeuilles (hérités) n'étaient pas basés sur une phrase de récupération. Le portefeuille peut migrer un portefeuille hérité vers un portefeuille moderne à phrase de récupération : il crée un nouveau portefeuille à phrase et y transfère vos fonds. Cherchez « Migrer vers un portefeuille à phrase… » dans Paramètres > Sauvegarde et données. Cette fonctionnalité nécessite la version actuelle du démon.",
"faq_w_seed_2_q": "Mon portefeuille n'a pas de phrase de récupération — puis-je en ajouter une ?",
"faq_w_seed_3_a": "Utilisez le flux de restauration par phrase de récupération lors de la configuration d'un portefeuille et saisissez vos mots dans l'ordre. Votre portefeuille se réanalyse ensuite pour reconstruire votre solde et votre historique, ce qui peut prendre un moment la première fois.",
"faq_w_seed_3_q": "Comment restaurer à partir de ma phrase de récupération ?",
"faq_w_seed_4_a": "Votre phrase de récupération est la sauvegarde principale et suffit à tout restaurer. Une copie de votre fichier de portefeuille est une sauvegarde secondaire pratique qui conserve aussi les libellés et les paramètres. Conservez toute sauvegarde hors ligne et en lieu privé.",
"faq_w_seed_4_q": "Dois-je aussi sauvegarder wallet.dat ?",
"faq_w_seed_title": "Phrase de récupération et sauvegarde",
"faq_w_send_1_a": "Ouvrez l'onglet Envoyer, collez l'adresse du destinataire, saisissez un montant et confirmez. Si le portefeuille est chiffré, on vous demandera de le déverrouiller pour la transaction.\n\nLes paiements vont vers des adresses blindées (zs) et restent privés. Le portefeuille n'envoie pas vers une adresse transparente (de minage) : le réseau refuse les paiements vers elles.",
"faq_w_send_1_q": "Comment envoyer des fonds ?",
"faq_w_send_2_a": "Les frais DragonX sont très faibles et définis automatiquement. Les frais sont affichés avant que vous ne confirmiez. Vous n'avez normalement pas besoin de les modifier.",
"faq_w_send_2_q": "Quels frais dois-je payer ?",
"faq_w_send_3_a": "Oui — lors de l'envoi vers une adresse blindée (Z), vous pouvez inclure un mémo chiffré. Seul le destinataire peut le lire.",
"faq_w_send_3_q": "Puis-je joindre un message à un paiement ?",
"faq_w_send_4_a": "Une transaction est « en attente » jusqu'à ce qu'elle soit minée dans un bloc et gagne des confirmations. Cela prend généralement une minute ou deux. Les transactions blindées nécessitent aussi que votre portefeuille soit synchronisé pour construire la preuve.\n\nSi un envoi reste en attente anormalement longtemps, vérifiez que votre portefeuille est connecté et synchronisé (voir la barre d'état en bas de la fenêtre).",
"faq_w_send_4_q": "J'ai envoyé un paiement mais il indique « en attente » — pourquoi ?",
"faq_w_send_title": "Envoi et réception",
"faq_w_set_1_a": "Ouvrez Paramètres > Apparence pour choisir un thème et un accent. Plusieurs habillages clairs et sombres sont inclus, et des effets comme le flou peuvent être désactivés pour les machines moins puissantes.",
"faq_w_set_1_q": "Comment changer le thème ?",
"faq_w_set_2_a": "Oui — Paramètres > Apparence possède un sélecteur de langue. Le portefeuille est fourni avec plusieurs traductions ; ce qui n'est pas encore traduit revient à l'anglais.",
"faq_w_set_2_q": "Puis-je changer la langue ?",
"faq_w_set_3_a": "Utilisez l'option d'échelle de police dans Paramètres > Apparence. Elle redimensionne toute l'interface, ce qui est pratique sur les écrans haute résolution (HiDPI) où l'application pourrait sinon s'afficher trop petite.",
"faq_w_set_3_q": "Le texte est trop petit (ou trop grand) — puis-je le redimensionner ?",
"faq_w_set_4_a": "Activez la récupération des prix dans Paramètres. Le portefeuille affiche alors une valeur fiduciaire approximative à côté des soldes et peut afficher un petit graphique de marché. Les prix sont fournis à titre informatif uniquement.",
"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 :",
@@ -665,14 +876,9 @@
"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",
"force_quit": "Forcer la fermeture",
"force_quit_confirm_msg": "Cela tuera immédiatement le daemon sans arrêt propre.\nCela peut corrompre l'index de la blockchain et nécessiter une resynchronisation.",
"force_quit_confirm_title": "Forcer la fermeture ?",
"force_quit_warning": "Cela tuera immédiatement le daemon sans arrêt propre. Peut nécessiter une resynchronisation de la blockchain.",
"force_quit_yes": "Forcer la fermeture",
"from": "De",
"from_upper": "DE",
"full_details": "Tous les détails",
@@ -741,6 +947,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",
@@ -815,11 +1022,13 @@
"lite_birthday_backup": "Date de création : %llu (à sauvegarder également)",
"lite_birthday_hint": "Hauteur de bloc à partir de laquelle commencer l'analyse. Laissez 0 si inconnue (analyse complète plus lente).",
"lite_birthday_label": "Bloc de création",
"lite_confirm_label": "Confirmer",
"lite_console_backend_commands": "Commandes du backend :",
"lite_console_help_passthrough": "Toute autre entrée est exécutée comme une commande de la console du portefeuille lite.",
"lite_copy": "Copier",
"lite_could_not_start": "Impossible de démarrer l'opération",
"lite_could_not_write": "Impossible d'écrire ",
"lite_encrypt_offline_warning": "Toute personne disposant du fichier du portefeuille peut essayer des phrases secrètes hors ligne. Sauvegardez d'abord votre phrase de récupération et utilisez une phrase secrète longue.",
"lite_encrypt_wallet": "Chiffrer le portefeuille",
"lite_encryption_removed": "Chiffrement supprimé",
"lite_enter_all_seed_words": "Entrez les 24 mots de la phrase de récupération (%d obtenus)",
@@ -827,6 +1036,7 @@
"lite_hide_wipe": "Masquer et effacer",
"lite_import": "Importer",
"lite_import_key_label": "Importer une clé",
"lite_key_import_failed": "Impossible d'importer la clé — vérifiez-la, déverrouillez le portefeuille (s'il est chiffré) et réessayez.",
"lite_key_imported": "Clé importée — lancez une synchronisation pour analyser son historique",
"lite_lock_failed": "Échec du verrouillage",
"lite_lock_now": "Verrouiller maintenant",
@@ -960,11 +1170,67 @@
"memo_optional": "MÉMO (OPTIONNEL)",
"memo_upper": "MÉMO",
"memo_z_only": "Note : Les mémos ne sont disponibles que lors de l'envoi vers des adresses blindées (z)",
"merge_addr_created": "Adresse blindée créée.",
"merge_advanced": "Avancé",
"merge_back": "Retour",
"merge_batch_fmt": "Fusionne jusqu'à %d entrées par passage — répétez pour terminer le reste.",
"merge_confirm_btn": "Confirmer",
"merge_confirm_fmt": "Consolider ~%s DRGX depuis %d entrée(s) vers %s ?",
"merge_create_zaddr": "Créer une adresse blindée",
"merge_creating": "Création de l'adresse…",
"merge_description": "Fusionnez plusieurs UTXOs en une seule adresse blindée. Cela peut réduire la taille du portefeuille et améliorer la confidentialité.",
"merge_fee_hint": "Frais réseau pour cette transaction.",
"merge_funds": "Fusionner les fonds",
"merge_into_one": "Vers une seule adresse",
"merge_keep_each": "Garder dans chaque adresse",
"merge_keep_each_batches_fmt": "Les adresses de plus de %d notes sont fusionnées en plusieurs lots ; relancez l'opération une fois ceux-ci confirmés pour fusionner le reste.",
"merge_keep_each_confirm_fmt": "Consolider %d notes dans %d adresses ? Chaque adresse garde ses propres fonds. Frais : environ %s DRGX (un par lot).",
"merge_keep_each_desc": "Les notes de chaque adresse sont refusionnées dans cette même adresse : les fonds que vous gardez séparés sous des libellés différents restent séparés. Adresses blindées uniquement — les fonds transparents ne peuvent qu'être blindés (Vers une seule adresse).",
"merge_keep_each_none": "Chaque adresse blindée ne contient déjà qu'une seule note — rien à consolider.",
"merge_keep_each_nothing_submitted": "Rien n'a été consolidé — le nœud a refusé chaque fusion.",
"merge_keep_each_row_fmt": "%s · %d notes · %s DRGX",
"merge_keep_each_run_again": "Les adresses fusionnées en plusieurs lots contiennent maintenant une note par lot. Relancez l'opération une fois ceux-ci confirmés pour les fusionner en une seule.",
"merge_keep_each_select": "Cochez au moins une adresse à consolider.",
"merge_keep_each_some_failed": "Certaines fusions ont échoué — voir la liste ci-dessus.",
"merge_keep_each_status_done": "%s — terminé : %d notes fusionnées en %d lot(s)",
"merge_keep_each_status_failed": "%s — échec : %s",
"merge_keep_each_status_not_started": "%s — non démarré : %s",
"merge_keep_each_status_running": "%s — fusion de %d notes (%d lots sur %d terminés)…",
"merge_keep_each_status_unconfirmed": "%s — statut non confirmé",
"merge_keep_each_too_small": "Trop faible pour être consolidé : les frais absorberaient ces fonds.",
"merge_max_inputs": "Nombre max d'entrées par lot",
"merge_no_spendable": "Aucune entrée dépensable à consolider pour l'instant.",
"merge_progress": "Consolidation en cours… cela peut prendre quelques minutes. Vous pouvez fermer cette fenêtre.",
"merge_run_aborted_toast": "La connexion au nœud a été réinitialisée, la consolidation s'est donc arrêtée. Relancez-la pour continuer.",
"merge_run_busy": "Une consolidation est en cours. Attendez qu'elle se termine ou arrêtez-la d'abord.",
"merge_run_chip_fmt": "Consolidation %d/%d",
"merge_run_err_timeout": "Le nœud n'a pas répondu à temps ; le lot est peut-être encore en file d'attente.",
"merge_run_halted_fmt": "Consolidation arrêtée après des échecs répétés : %s",
"merge_run_migration_pending": "Terminez ou annulez la migration de la phrase de récupération avant de consolider.",
"merge_run_progress_fmt": "Consolidation en cours… lot %d sur environ %d, %d notes fusionnées jusqu'ici. Vous pouvez fermer cette fenêtre ; l'opération continue en arrière-plan.",
"merge_run_start_failed": "Impossible de démarrer la consolidation — le nœud n'est pas connecté.",
"merge_run_status_not_run": "%s — non consolidé (%d notes)",
"merge_run_status_waiting": "%s — en attente (%d notes)",
"merge_run_stop": "Arrêter",
"merge_run_stopped_toast": "Consolidation arrêtée. Relancez-la pour fusionner le reste.",
"merge_run_stopping": "Arrêt après les lots déjà envoyés…",
"merge_run_wait_batch": "Espacement des lots pour que le nœud reste réactif…",
"merge_run_wait_send": "En pause jusqu'à ce que votre transaction en attente soit passée.",
"merge_run_wait_sync": "En pause : le nœud est encore en cours de synchronisation.",
"merge_scope_failed_fmt": "Impossible de lire vos entrées : %s",
"merge_scope_fmt": "%d entrées transparentes + %d blindées · ~%s DRGX dépensables",
"merge_scope_loading": "Vérification de vos entrées…",
"merge_send_failed": "Échec de la fusion : ",
"merge_source": "Consolider",
"merge_src_both": "Les deux",
"merge_src_shielded": "Blindées",
"merge_src_transparent": "Transparentes",
"merge_started": "Opération de fusion démarrée",
"merge_submit_uncertain": "Le nœud n'a pas confirmé la fusion avant l'interruption de la connexion. Elle peut tout de même aboutir — vérifiez l'Historique après la reconnexion avant de fusionner à nouveau.",
"merge_title": "Fusionner vers une adresse",
"merge_wait_locked": "Le portefeuille est verrouillé. Déverrouillez-le pour continuer.",
"merge_waiting_node": "En attente du nœud…",
"merge_where": "Où doivent aller les fonds ?",
"mig_adopt": "Faire de ceci mon portefeuille",
"mig_adopt_now": "Adopter le portefeuille à phrase",
"mig_adopting": "Installation de votre nouveau portefeuille et rescan en cours…",
@@ -1016,6 +1282,8 @@
"miner_fee": "Frais de mineur",
"mining": "Minage",
"mining_active": "Actif",
"mining_addr_explainer": "Ne reçoit que des récompenses de minage en solo — le réseau refuse les paiements vers cette adresse. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
"mining_address": "Adresse de minage",
"mining_address_copied": "Adresse de minage copiée",
"mining_all_time": "Tout le temps",
"mining_already_saved": "URL du pool déjà enregistrée",
@@ -1029,6 +1297,8 @@
"mining_benchmark_testing": "Test",
"mining_benchmark_tooltip": "Trouver le nombre optimal de threads pour ce CPU",
"mining_block_copied": "Hash du bloc copié",
"mining_card_click_to_shield": "Cliquez pour blinder dès maintenant les récompenses arrivées à maturité.",
"mining_card_tooltip": "Les récompenses de minage en solo arrivent sur vos adresses de minage (transparentes, R…). Ces adresses ne peuvent pas recevoir de paiements. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
"mining_chart_1m_ago": "il y a 1m",
"mining_chart_5m_ago": "il y a 5m",
"mining_chart_now": "Maintenant",
@@ -1044,6 +1314,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",
@@ -1073,6 +1345,7 @@
"mining_payout_address": "Adresse de paiement",
"mining_payout_foreign": "⚠ Cette adresse de paiement ne fait pas partie de votre portefeuille actuel — les récompenses minées iraient vers un autre portefeuille. Mettez-la à jour si vous avez changé de portefeuille.",
"mining_payout_invalid": "Adresse DragonX invalide — corrigez-la avant de démarrer, sinon les récompenses de minage sont perdues.",
"mining_payout_needs_shielded": "Les pools paient avec des transactions ordinaires, qui ne peuvent pas atteindre une adresse de minage (transparente, R…) sur DragonX. Utilisez une adresse blindée (elle commence par zs) comme adresse de paiement.",
"mining_payout_tooltip": "Adresse pour recevoir les récompenses de minage",
"mining_pool": "Pool",
"mining_pool_fee": "Frais",
@@ -1085,6 +1358,8 @@
"mining_refresh": "Actualiser",
"mining_remove": "Supprimer",
"mining_reset_defaults": "Réinitialiser les paramètres",
"mining_rewards": "Récompenses de minage",
"mining_rewards_unshielded": "Pas encore blindées",
"mining_save_payout_address": "Enregistrer l'adresse de paiement",
"mining_save_pool_url": "Enregistrer l'URL du pool",
"mining_saved_addresses": "Adresses enregistrées :",
@@ -1118,10 +1393,10 @@
"network_fee": "FRAIS RÉSEAU",
"network_hashrate": "Hashrate du réseau",
"new": "+ Nouveau",
"new_mining_address": "Nouvelle adresse de minage",
"new_mining_address_created": "Nouvelle adresse de minage créée",
"new_shielded_created": "Nouvelle adresse blindée créée",
"new_t_address": "Nouvelle adresse T",
"new_t_transparent": "Nouvelle adresse t (Transparente)",
"new_transparent_created": "Nouvelle adresse transparente créée",
"new_z_address": "Nouvelle adresse Z",
"new_z_shielded": "Nouvelle adresse z (Blindée)",
"no_addresses": "Aucune adresse trouvée. Créez-en une avec les boutons ci-dessus.",
@@ -1141,7 +1416,19 @@
"node_banner_offline_title": "Non connecté au nœud DragonX",
"node_banner_reconnect": "Reconnecter",
"node_banner_restart": "Redémarrer le nœud",
"node_busy_rebuild_desc": "En cours depuis %s. Pour un gros portefeuille, cela peut prendre environ une heure, et le nœud ne peut pas répondre au portefeuille avant la fin.",
"node_busy_rebuild_extending": "Extension des témoins (le nœud ne signale aucune progression pour cette étape).",
"node_busy_rebuild_reading": "Lecture des blocs : %ld / %ld",
"node_busy_rebuild_title": "Nœud occupé : reconstruction des témoins de notes",
"node_busy_replay_desc": "En cours depuis %s. Le nœud réapplique les blocs reçus avant son dernier arrêt.",
"node_busy_replay_title": "Nœud occupé : rattrapage des blocs",
"node_busy_rpc_desc": "Le nœud fonctionne mais est trop occupé pour répondre au portefeuille depuis %s, généralement à cause d'une longue opération comme la reconstruction des témoins.",
"node_busy_rpc_title": "Nœud occupé : ne répond pas encore",
"node_busy_running": "En cours depuis %s",
"node_security": "NŒUD & SÉCURITÉ",
"node_starting_busy_desc": "Le nœud est joignable mais occupé et ne répond pas encore. C'est normal après une mise à jour ou au premier lancement ; cela peut prendre quelques minutes.",
"node_starting_launch_desc": "Lancement de dragonxd, en attente de sa disponibilité…",
"node_starting_title": "Démarrage du nœud DragonX…",
"noise": "Bruit",
"not_connected": "Non connecté au daemon...",
"not_connected_to_daemon": "Non connecté au daemon",
@@ -1202,6 +1489,13 @@
"peers_upper": "PAIRS",
"peers_version": "Version",
"pending": "En attente",
"picker_dat_count": "%d fichier(s) de portefeuille dans ce dossier",
"picker_dat_none": "Aucun fichier de portefeuille directement dans ce dossier",
"picker_empty": "Ce dossier ne contient aucun sous-dossier ni fichier de portefeuille.",
"picker_home": "Dossier personnel",
"picker_select": "Analyser ce dossier",
"picker_title": "Sélectionnez un dossier à analyser",
"picker_up": "Remonter d'un niveau",
"pin_change_desc": "Modifiez votre code PIN de déverrouillage. Vous avez besoin de votre code PIN actuel et d'un nouveau code PIN.",
"pin_confirm_new_label": "Confirmer le nouveau code PIN :",
"pin_current_label": "Code PIN actuel :",
@@ -1287,6 +1581,8 @@
"ram_wallet_gb": "Portefeuille : %.1f Go",
"ram_wallet_mb": "Portefeuille : %.0f Mo",
"receive": "Recevoir",
"receive_not_for_payments": "Pas pour les paiements",
"receive_use_shielded": "Utiliser une adresse blindée",
"received": "reçu",
"received_filter": "Reçu",
"received_label": "Reçu",
@@ -1301,6 +1597,7 @@
"reduce_motion": "Réduire les animations",
"refresh": "Actualiser",
"refresh_now": "Actualiser maintenant",
"refresh_waits_for_sync": "Le nœud est encore en cours de synchronisation. Les données du portefeuille seront actualisées une fois celle-ci terminée.",
"remove_favorite": "Retirer des favoris",
"repair_wallet": "Réparer le portefeuille",
"report_bug": "Signaler un bug",
@@ -1315,12 +1612,12 @@
"request_select_address": "Sélectionner une adresse...",
"request_shielded_addrs": "-- Adresses blindées --",
"request_title": "Demander un paiement",
"request_transparent_addrs": "-- Adresses transparentes --",
"request_uri_copied": "URI de paiement copiée dans le presse-papiers",
"rescan": "Re-scanner",
"rescan_bootstrapped_msg": "Votre nœud a été initialisé par bootstrap, les blocs situés en dessous de l'instantané ne sont donc pas sur le disque et un rescan depuis la genèse échouerait. Effectuez un rescan à partir d'une hauteur incluse dans votre instantané pour réconcilier le solde dépensé de votre portefeuille. Votre fichier wallet.dat et vos données de chaîne ne sont pas supprimés.",
"rescan_detecting": "Vérification des blocs présents sur le disque de votre nœud…",
"rescan_from_height": "Rescanner à partir de la hauteur de bloc :",
"rescan_probe_failed": "Impossible de vérifier quels blocs votre nœud a sur le disque (il est occupé ou une requête a expiré). Si ce nœud a été initialisé par bootstrap, annulez et réessayez dans une minute : le redémarrage complet avec -rescan ci-dessous échoue sur un nœud initialisé par bootstrap.",
"reset_to_defaults": "Réinitialiser les paramètres",
"restarting_after_encryption": "Redémarrage du daemon après chiffrement...",
"restore_address": "Restaurer l'adresse",
@@ -1338,6 +1635,7 @@
"save_z_transactions": "Enregistrer les Z-tx dans la liste",
"sb_auth_failed": "Authentification échouée — vérifiez rpcuser/rpcpassword",
"sb_block": "Bloc : %d",
"sb_block_db_unreadable": "Base de données des blocs illisible — reconstruction requise",
"sb_building_witnesses": "Configuration des témoins",
"sb_building_witnesses_pct": "Reconstruction des témoins %.0f%%",
"sb_connecting_daemon": "Connexion à dragonxd...",
@@ -1353,6 +1651,7 @@
"sb_dragonxd_stopped": "dragonxd arrêté",
"sb_dragonxd_stopping": "Arrêt de dragonxd...",
"sb_extracting_sapling": "Extraction des paramètres Sapling...",
"sb_finishing_repair": "Finalisation de la réparation du portefeuille — nouvelle analyse en cours…",
"sb_importing_keys": "Importation des clés",
"sb_loading_config": "Chargement de la configuration...",
"sb_mining_hs": "%.1f H/s",
@@ -1361,6 +1660,7 @@
"sb_net_khs": "Rés: %.2f KH/s",
"sb_net_mhs": "Rés: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf introuvable",
"sb_node_busy": "Nœud occupé",
"sb_peers": "Pairs : %zu",
"sb_plaintext_remote_blocked": "Refus d'envoyer les identifiants RPC en clair vers un hôte distant. Ajoutez rpcallowplaintext=1 à DRAGONX.conf pour l'autoriser, ou activez TLS avec rpctls=1.",
"sb_rescanning": "Rescan",
@@ -1374,6 +1674,7 @@
"sb_waiting_config": "En attente de la configuration du daemon...",
"sb_waiting_daemon": "En attente de dragonxd...",
"sb_waiting_daemon_err": "En attente de dragonxd — %s",
"sb_wallet_needs_recovery": "Réparation du portefeuille disponible",
"sb_warming_up": "Démarrage...",
"sb_witness_cache": "Reconstruction des témoins",
"scale_effects": "ÉCHELLE ET EFFETS",
@@ -1381,6 +1682,7 @@
"screenshot_sweep": "Lancer la capture d'écran",
"screenshot_sweep_desc": "Parcourt chaque thème sur chaque onglet et enregistre une capture d'écran de chacun dans des sous-dossiers par onglet, sous le dossier screenshots du répertoire de configuration (en écrasant le balayage précédent). Dure quelques secondes.",
"screenshot_sweep_full": "Balayage complet de l'interface",
"screenshot_sweep_refused": "Balayage complet de l'interface refusé ; voir le journal de débogage",
"search_icons": "Rechercher des icônes...",
"search_placeholder": "Rechercher...",
"sec_changing_passphrase": "Modification de la phrase secrète...",
@@ -1402,15 +1704,19 @@
"sec_mode_passphrase": " Phrase secrète",
"sec_not_connected_to_daemon": "Non connecté au démon",
"sec_not_connected_to_daemon_pin": "Non connecté au démon",
"sec_passphrase_changed_pin_stale": "Phrase secrète modifiée ; votre PIN est obsolète",
"sec_passphrase_changed_successfully": "Phrase secrète modifiée avec succès",
"sec_pin_changed_successfully": "PIN modifié avec succès",
"sec_pin_removed": "PIN supprimé",
"sec_pin_set_successfully": "PIN défini avec succès",
"sec_pin_stale_msg": "PIN obsolète. Utilisez votre phrase secrète.",
"sec_pin_unreadable": "Fichier du PIN illisible. Utilisez votre phrase secrète.",
"sec_restart_daemon_for_encryption": "Veuillez redémarrer votre démon pour que le chiffrement prenne effet.",
"sec_too_many_attempts_wait": "Trop de tentatives. Patientez %.0f secondes...",
"sec_total_elapsed_fmt": "Temps total écoulé : %dm %02ds",
"sec_unlock_button": "Déverrouiller",
"sec_unlock_failed_prefix": "Échec du déverrouillage : ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Déverrouillage%s",
"sec_use_passphrase_instead": "Utiliser une phrase secrète",
"sec_use_pin_instead": "Utiliser un PIN",
@@ -1445,6 +1751,7 @@
"send_amount": "Montant",
"send_amount_details": "DÉTAILS DU MONTANT",
"send_amount_upper": "MONTANT",
"send_available_note": "disponible",
"send_cannot_send_now": "Envoi impossible pour le moment — vérifiez la connexion, la synchronisation et le solde disponible.",
"send_clear_fields": "Effacer tous les champs du formulaire ?",
"send_contacts_button": "Choisir parmi les contacts",
@@ -1462,6 +1769,8 @@
"send_from_this_address": "Envoyer depuis cette adresse",
"send_go_to_receive": "Aller à Recevoir",
"send_keep": "Conserver",
"send_mining_addr_blocked": "Ceci est une adresse de minage (transparente). Sur DragonX, elles ne peuvent recevoir que des récompenses de minage — le réseau refuse les paiements vers elles. Demandez au destinataire une adresse blindée (elle commence par zs).",
"send_mining_addr_short": "Adresse de minage — ne peut pas recevoir de paiements",
"send_network_fee": "FRAIS RÉSEAU",
"send_no_balance": "Pas de solde",
"send_no_recent": "Aucun envoi récent",
@@ -1489,7 +1798,6 @@
"send_txid_copied": "TxID copié dans le presse-papiers",
"send_txid_label": "TxID : %s",
"send_valid_shielded": "Adresse blindée valide",
"send_valid_transparent": "Adresse transparente valide",
"send_wallet_empty": "Votre portefeuille est vide",
"send_yes_clear": "Oui, effacer",
"sender_balance": "Expéditeur : %.8f → %.8f DRGX",
@@ -1552,6 +1860,7 @@
"settings_open_log_folder": "Ouvrir le dossier des journaux",
"settings_other": "Autres",
"settings_pin_active": "PIN",
"settings_pin_out_of_date": "PIN obsolète",
"settings_privacy": "Confidentialité",
"settings_quick_unlock_pin": "PIN de déverrouillage rapide",
"settings_reduce_transparency": "Réduire la transparence",
@@ -1617,11 +1926,24 @@
"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",
"showing_transactions": "Affichage %d–%d sur %d transactions (total : %zu)",
"showing_x_of_y": "Affichage de %d sur %d adresses",
"shutdown_kill_confirm_msg": "Le nœud ne montre plus d'activité processeur ni disque depuis longtemps et est peut-être bloqué.\nLe tuer fait perdre tout ce qu'il n'a pas enregistré ; son prochain démarrage peut être beaucoup plus long.",
"shutdown_kill_confirm_title": "Tuer le nœud ?",
"shutdown_kill_confirm_yes": "Tuer le nœud",
"shutdown_kill_node": "Tuer le nœud (risqué)",
"shutdown_kill_node_tip": "Arrête le nœud immédiatement. Tout ce qu'il n'a pas enregistré est perdu, et au prochain démarrage il rejouera les blocs depuis son dernier enregistrement.",
"shutdown_leave_running": "Fermer le portefeuille — laisser le nœud actif",
"shutdown_leave_running_tip": "Ferme le portefeuille maintenant. Le nœud continue de fonctionner seul et termine ce qu'il fait ; rien n'est perdu.",
"shutdown_stop_asking": "Demande d'arrêt envoyée au nœud…",
"shutdown_stop_cpu_suffix": " · CPU %d%%",
"shutdown_stop_delivered": "Le nœud s'arrête et enregistre son état",
"shutdown_stop_idle": "Aucune activité processeur ni disque depuis %d min — le nœud est peut-être bloqué",
"shutdown_stop_queued": "Le nœud a reçu la demande d'arrêt mais il est occupé — il s'arrêtera dès que possible",
"simple_background": "Arrière-plan simple",
"slider_off": "Désactivé",
"sort_amount_high": "Montant le plus élevé",
@@ -1629,14 +1951,29 @@
"sort_date_newest": "Plus récent d'abord",
"sort_date_oldest": "Plus ancien d'abord",
"start_mining": "Démarrer le minage",
"startup_files_preparing": "Préparation des fichiers du portefeuille... %d%%",
"startup_files_verifying": "Vérification des fichiers du portefeuille...",
"status": "Statut",
"stop_external": "Arrêter le daemon externe",
"stop_mining": "Arrêter le minage",
"stopconfirm_busy_body": "Il est trop occupé pour répondre au portefeuille depuis %s, probablement à cause d'une longue opération comme la reconstruction des témoins. L'arrêter maintenant peut obliger son prochain démarrage à refaire ce travail.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
"stopconfirm_busy_title": "Le nœud est occupé",
"stopconfirm_rebuild_body": "Elle est en cours depuis %s, et pour un gros portefeuille elle peut prendre environ une heure. Arrêter le nœud maintenant attend la fin de la reconstruction, ou oblige son prochain démarrage à la refaire, rattrapage compris.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
"stopconfirm_rebuild_title": "Le nœud reconstruit les témoins de notes",
"stopconfirm_stop_wait": "Arrêter le nœud et attendre",
"stopconfirm_walletop_body": "Le nœud analyse la chaîne pour une importation de clé ou une réanalyse. L'arrêter maintenant met fin à cette analyse plus tôt, et les fonds qu'elle n'a pas encore atteints n'apparaîtront qu'après Paramètres → Re-scanner.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera l'analyse seul.",
"stopconfirm_walletop_title": "Une importation de clé ou une réanalyse est en cours",
"stratum_allowip_hint": "Autoriser les mineurs depuis une IP ou un CIDR (vide = cet ordinateur uniquement)",
"stratum_expose_warn": "Ouvre un port de minage vers le réseau que vous autorisez — à utiliser uniquement sur un réseau local de confiance.",
"stratum_host": "Héberger un pool de minage (stratum)",
"stratum_host_hint": "Les mineurs se connectent à cet ordinateur sur le port 22769 (port RPC + 1000) avec un mineur stratum RandomX. Redémarrez le démon pour appliquer.",
"stratum_host_section": "HÉBERGEMENT DE POOL DE MINAGE",
"submitting_transaction": "Soumission de la transaction...",
"success": "Succès",
"summary": "Résumé",
"sweep_button": "Balayer",
"sweep_caveat": "Importe la clé pour signer une transaction qui déplace tous ses fonds vers votre adresse. La clé reste dans votre portefeuille avec un solde vide.",
"sweep_current_theme_only": "Thème actuel uniquement",
"sweep_dest_label": "Envoyer les fonds balayés vers",
"sweep_dest_new": "Nouvelle adresse blindée (recommandé)",
"sweep_done": "Terminé — fonds balayés vers votre adresse.",
@@ -1672,7 +2009,6 @@
"switch_stopnode_title": "Arrêter le nœud en cours d'exécution ?",
"switch_stopnode_warn": "Un nœud que ce portefeuille n'a pas démarré est déjà en cours d'exécution.",
"syncing": "Synchronisation...",
"t_address": "Adresse T",
"t_addresses": "Adresses T",
"test_connection": "Tester",
"theme": "Thème",
@@ -1694,6 +2030,7 @@
"tools": "OUTILS",
"tools_actions": "Outils & Actions...",
"tools_actions_hdr": "OUTILS ET ACTIONS",
"tor_never_applied_notice": "L'option « Utiliser Tor » n'a jamais fonctionné : vos connexions n'ont PAS été acheminées via Tor. Elle a été désactivée. Consultez son info-bulle dans Paramètres pour des alternatives.",
"total": "Total",
"total_balance_label": "Solde total",
"transaction_id": "ID DE TRANSACTION",
@@ -1707,8 +2044,6 @@
"transfer_sent": "Transfert envoyé",
"transfer_sent_desc": "Votre transfert a été soumis au réseau.",
"transfer_to": "Transférer à :",
"transparent": "Transparent",
"transparent_address": "Adresse transparente",
"try_again": "Réessayer",
"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",
@@ -1740,10 +2075,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",
@@ -1752,6 +2093,7 @@
"tt_keep_daemon": "Le daemon s'arrêtera lors de l'exécution de l'assistant de configuration",
"tt_language": "Langue de l'interface du portefeuille",
"tt_layout_hotkey": "Raccourci : touches fléchées gauche/droite pour changer les dispositions de Balance",
"tt_lite_confirm_pass": "Saisissez à nouveau la nouvelle phrase secrète ; elle doit correspondre et comporter au moins 8 caractères",
"tt_lite_copy": "Copie le secret révélé dans le presse-papiers",
"tt_lite_decrypt_pass": "Saisissez votre phrase de passe pour retirer le chiffrement du portefeuille",
"tt_lite_encrypt": "Chiffre le portefeuille avec la phrase de passe ci-dessus ; il se verrouille immédiatement et requiert la phrase pour se déverrouiller",
@@ -1780,11 +2122,16 @@
"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",
"tt_open_data_dir": "Ouvrir le dossier contenant les données de votre portefeuille et de la blockchain dans le gestionnaire de fichiers",
"tt_open_dir": "Cliquer pour ouvrir dans l'explorateur de fichiers",
"tt_open_log_folder": "Ouvre le dossier contenant les journaux de débogage et de plantage",
"tt_pin_out_of_date": "La phrase secrète du portefeuille a changé après la définition de ce PIN ; le PIN ne peut plus le déverrouiller. Supprimez le PIN puis définissez-le à nouveau.",
"tt_reduce_motion": "Désactiver les transitions animées et le lerp de solde pour l'accessibilité",
"tt_remove_encrypt": "Supprimer le chiffrement et stocker le portefeuille sans protection",
"tt_remove_pin": "Supprimer le PIN et exiger la phrase secrète pour déverrouiller",
@@ -1814,14 +2161,20 @@
"tt_simple_bg": "Utiliser un dégradé simple pour l'arrière-plan\\nRaccourci : Ctrl+Up",
"tt_simple_bg_alt": "Utiliser une version dégradée de l'image d'arrière-plan du thème\\nRaccourci : Ctrl+Up",
"tt_stop_external": "S'applique lors de la connexion à un daemon\\ndémarré en dehors de ce portefeuille",
"tt_stratum_host": "Exécute un serveur de pool stratum RandomX sur ce nœud afin que d'autres mineurs RandomX puissent pointer vers cet ordinateur. Nécessite un nœud v1.3.0+ et un redémarrage du démon pour appliquer.",
"tt_sweep_current_theme_only": "Balayer uniquement le thème actif au lieu de parcourir tous les thèmes",
"tt_test_conn": "Vérifier la connexion RPC au daemon",
"tt_theme_effects": "Scintillement, lueur, cycle de teinte par thème",
"tt_theme_hotkey": "Raccourci : Ctrl+Gauche/Droite pour changer de thème",
"tt_tor": "Acheminer les connexions du daemon via le réseau Tor pour l'anonymat",
"tt_tor_unavailable": "Pas encore disponible : ce portefeuille n'achemine aucun trafic via Tor. Pour le nœud uniquement, vous pouvez ajouter proxy=127.0.0.1:9050 à DRAGONX.conf et redémarrer le nœud. Tor doit être en cours d'exécution, sinon le nœud ne trouve aucun pair. Les requêtes de prix, de mise à jour, de bootstrap et de pool se connectent toujours directement ; pour celles-ci, utilisez un VPN/Tor à l'échelle du système.",
"tt_tor_unavailable_lite": "Pas encore disponible : ce portefeuille n'achemine aucun trafic via Tor. Utilisez un VPN/Tor à l'échelle du système.",
"tt_tx_url": "URL de base pour consulter les transactions dans un explorateur de blocs",
"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_view_transactions": "Afficher les transactions impliquant cette adresse dans l'onglet Historique",
"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_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)",
@@ -1838,6 +2191,7 @@
"tx_loading_queued": "Actualisation des transactions en file d'attente",
"tx_loading_refreshing_cached": "Actualisation de l'historique du portefeuille (%d en cache)",
"tx_loading_scanning_shielded": "Analyse de l'historique blindé (%d adresses)",
"tx_loading_waits_for_sync": "L'historique sera mis à jour une fois le nœud synchronisé",
"tx_mined": "MINÉ",
"tx_progress_balances": "Actualisation des soldes",
"tx_progress_finalizing": "Finalisation de la transaction",
@@ -1869,6 +2223,7 @@
"validate_description": "Entrez une adresse DragonX pour vérifier si elle est valide et si elle appartient à ce portefeuille.",
"validate_invalid": "INVALIDE",
"validate_is_mine": "Ce portefeuille possède cette adresse",
"validate_mining_type": "Adresse de minage (transparente)",
"validate_not_mine": "N'appartient pas à ce portefeuille",
"validate_ownership": "Propriété :",
"validate_results": "Résultats :",
@@ -1876,7 +2231,6 @@
"validate_shielded_type": "Blindée (z-adresse)",
"validate_status": "Statut :",
"validate_title": "Valider l'adresse",
"validate_transparent_type": "Transparente (t-adresse)",
"validate_type": "Type :",
"validate_valid": "VALIDE",
"validating": "Validation...",
@@ -1886,10 +2240,71 @@
"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.",
"wallet_diagnostics_hdr": "DIAGNOSTICS",
"wallet_empty": "Votre portefeuille est vide",
"wallet_empty_hint": "Passez à Recevoir pour obtenir votre adresse et commencer à recevoir des fonds.",
"wallet_options_hdr": "OPTIONS",
"wallet_rebuild_failed": "La reconstruction n'a pas produit de portefeuille valide, donc rien n'a été modifié. Votre portefeuille est intact.",
"wallet_rebuild_install_failed": "Impossible d'installer le portefeuille reconstruit ; votre portefeuille actuel a été laissé en place.",
"wallet_rebuild_no_helper": "L'assistant de reconstruction du portefeuille n'est pas disponible dans cette version. Utilisez Restaurer, ou reconstruisez manuellement.",
"wallet_rebuild_no_source": "Impossible de trouver un portefeuille lisible à reconstruire. Rien n'a été modifié.",
"wallet_rebuild_ok": "Base de données du portefeuille reconstruite — le nœud la charge et effectue une nouvelle analyse pour retrouver votre solde.",
"wallet_rebuild_started": "Réparation de votre fichier de portefeuille en cours — veuillez ne pas fermer cette fenêtre.",
"wallet_recovered_body": "Rien n'a été supprimé. Votre portefeuille d'origine est toujours enregistré sur votre ordinateur sous forme de fichier de sauvegarde daté, et chaque option ci-dessous se contente de copier ou de renommer des fichiers — elle n'en efface jamais un. Vos clés ne sont jamais régénérées, seulement relues.\n\nLa copie réparée actuellement chargée peut ne pas inclure quelques transactions récentes ; votre solde peut donc paraître légèrement bas jusqu'à ce que vous terminiez ci-dessous et qu'il se re-synchronise.",
"wallet_recovered_dismiss": "Pas maintenant — conserver la copie réparée",
"wallet_recovered_dismiss_sub": "Aucun fichier ne change. Vous pouvez rouvrir ceci à tout moment depuis la barre d'état ; votre original reste sauvegardé dans tous les cas.",
"wallet_recovered_notify": "Votre fichier de portefeuille avait besoin d'une réparation — votre original a été sauvegardé en toute sécurité. Ouvrez l'application pour examiner vos options.",
"wallet_recovered_open_folder": "Montrez-moi les fichiers",
"wallet_recovered_open_folder_sub": "Ouvre le dossier de données du portefeuille pour que vous puissiez inspecter vous-même les fichiers de sauvegarde — rien n'est modifié.",
"wallet_recovered_rebuild": "Réparer automatiquement (recommandé)",
"wallet_recovered_rebuild_sub": "Relit chaque enregistrement récupérable de votre fichier de portefeuille le plus complet et en écrit un nouveau propre, puis redémarre. L'option la plus complète — votre fichier actuel est conservé comme sauvegarde datée dans tous les cas.",
"wallet_recovered_restore": "Restaurer le fichier original à la place",
"wallet_recovered_restore_sub": "Remet en place votre plus grande sauvegarde intacte, telle quelle, puis relance l'analyse — un peu plus rapide, mais seulement aussi complet que ce fichier l'était. Votre fichier actuel est conservé comme sauvegarde datée dans tous les cas.",
"wallet_recovered_safety": "Vos DRGX sont en sécurité.",
"wallet_recovered_title": "Votre fichier de portefeuille a besoin d'une réparation rapide",
"wallet_recovered_warn": "Au démarrage de l'application, elle a détecté que votre fichier de portefeuille ne passait pas son contrôle de cohérence — cela arrive généralement après une mise à jour de l'application ou un arrêt brutal. L'application a déjà protégé vos données : elle a mis de côté l'ancien fichier et chargé une copie réparée pour que vous ne soyez pas bloqué.",
"wallet_recovery_decide_later": "Décider plus tard",
"wallet_recovery_decide_later_tip": "Ferme ceci et conserve la copie actuellement chargée. Rien n'est modifié, et vous pouvez réparer à tout moment — la barre d'état garde un lien « Réparation du portefeuille disponible ».",
"wallet_recovery_details_label": "Afficher les détails techniques",
"wallet_recovery_done": "Terminé",
"wallet_recovery_failure_body": "La réparation s'est exécutée, mais son résultat n'a pas passé la vérification (il était illisible ou ne contenait aucune adresse), il a donc été automatiquement rejeté avant de remplacer quoi que ce soit. Votre portefeuille est exactement comme avant que vous cliquiez — rien n'a changé sur le disque.",
"wallet_recovery_failure_title": "La réparation n'a pas abouti",
"wallet_recovery_files_detail": "Votre portefeuille original est toujours là — l'application l'a renommé en sauvegarde datée (wallet.<numbers>.bak) dans votre dossier de données et n'a rien supprimé. « Réparer » lit chaque enregistrement de votre fichier le plus complet vers un tout nouveau fichier propre. « Restaurer » recopie votre plus grande sauvegarde exactement telle quelle. La copie actuellement chargée est elle aussi conservée comme sauvegarde.",
"wallet_recovery_files_label": "Qu'advient-il de mes fichiers ?",
"wallet_recovery_notnow_short": "Pas maintenant",
"wallet_recovery_other_options": "Autres options",
"wallet_recovery_rebuild_card": "Réparer automatiquement",
"wallet_recovery_rebuild_card_desc": "Lit chaque enregistrement récupérable vers un fichier propre, puis redémarre. L'option la plus complète.",
"wallet_recovery_recommended": "RECOMMANDÉ",
"wallet_recovery_repair_go": "Réparer",
"wallet_recovery_rescan_body": "Réanalyse de la blockchain pour reconstruire votre solde et l'historique des transactions. Cela peut prendre plusieurs minutes — vous n'avez rien à faire, et merci de ne pas redémarrer le nœud pendant l'opération.",
"wallet_recovery_rescan_elapsed": "En cours depuis %s",
"wallet_recovery_rescan_size": "Le portefeuille reconstruit fait maintenant %s et continue de se remplir",
"wallet_recovery_rescan_slow": "Cela prend plus de temps que d'habitude, ce qui est normal pour un grand portefeuille. Vous pouvez le laisser tourner en toute sécurité — vous pouvez suivre la progression sous Avancé ▸ Console.",
"wallet_recovery_rescan_title": "Finalisation de la réparation de votre portefeuille",
"wallet_recovery_restore_card": "Restaurer l'original",
"wallet_recovery_restore_card_desc": "Remet en place votre plus grande sauvegarde intacte, telle quelle — seulement aussi complète que ce fichier l'était.",
"wallet_recovery_restore_go": "Restaurer",
"wallet_recovery_success_body": "L'application a chargé votre portefeuille réparé et réanalyse la blockchain pour totaliser votre solde — cela peut prendre quelques minutes. Elle lit chaque enregistrement possible, mais sur de rares fichiers endommagés, elle peut récupérer légèrement moins — ou parfois plus — d'adresses qu'avant. Une fois la réanalyse terminée, vérifiez que votre solde et votre historique semblent corrects.",
"wallet_recovery_success_restore": "Votre plus grande sauvegarde est remise en place et se charge maintenant, et l'application réanalyse la blockchain. Elle est restaurée telle quelle, donc aussi complète que ce fichier l'était — vérifiez votre solde une fois la réanalyse terminée.",
"wallet_recovery_try_other": "Essayer l'autre option",
"wallet_recovery_whats_happened": "Que s'est-il passé ?",
"wallet_recovery_working_label": "En cours",
"wallet_restore_bad_backup": "Le fichier de sauvegarde du portefeuille semble illisible, il N'A DONC PAS été restauré — votre portefeuille actuel n'a pas changé. Restaurez plutôt à partir de votre propre sauvegarde.",
"wallet_restore_busy": "Le nœud est occupé à redémarrer — réessayez dans un instant.",
"wallet_restore_copy_failed": "Impossible d'installer le portefeuille de sauvegarde ; votre portefeuille actuel a été laissé en place.",
"wallet_restore_move_failed": "Impossible de mettre de côté le portefeuille actuel — rien n'a été modifié.",
"wallet_restore_no_backup": "Impossible de trouver un fichier wallet.<timestamp>.bak à restaurer. Rien n'a été modifié.",
"wallet_restore_no_restart": "Votre portefeuille original a été restauré, mais le nœud n'a pas redémarré — démarrez-le depuis les Paramètres.",
"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_consolidate": "Consolider les notes…",
"wallet_size_warn": "Ce fichier de portefeuille est volumineux. Consolider vos notes peut freiner sa croissance.",
"wallets_active": "Actif",
"wallets_add": "Ajouter le dossier",
"wallets_add_folder": "Analyser aussi un autre dossier à la recherche de fichiers de portefeuille :",
@@ -1932,7 +2347,9 @@
"wallets_open_inplace_tt": "Ouvre ce portefeuille à son emplacement — lié au répertoire de données (sans copie)",
"wallets_remove_folder": "Ne plus analyser ce dossier",
"wallets_reveal": "Afficher le dossier",
"wallets_scan_folder": "Analyser un autre dossier pour des portefeuilles…",
"wallets_scanned_folders": "Dossiers analysés :",
"wallets_scanning": "Analyse des dossiers…",
"wallets_sort_addresses": "Adresses",
"wallets_sort_asc": "Croissant (plus ancien / moins / plus petit d'abord)",
"wallets_sort_by": "Trier :",
@@ -2001,19 +2418,30 @@
"wiz_welcome_sub": "Quelques choix rapides et votre nœud complet est prêt.",
"wiz_welcome_title": "Bienvenue dans ObsidianDragon !",
"wizard_daemon_start_failed": "Échec du démarrage du daemon — sera réessayé automatiquement",
"xmrig_active_tt": "Le mineur actuellement installé (actif)",
"xmrig_browse_releases": "Parcourir toutes les versions…",
"xmrig_checking": "Recherche du dernier mineur…",
"xmrig_current": "Actuel :",
"xmrig_downgrade_note": "Installer une autre version du mineur remplace la version actuelle ; arrêtez d'abord le minage, l'effet prendra place au prochain démarrage.",
"xmrig_download_install": "Télécharger et installer",
"xmrig_downloading": "Téléchargement…",
"xmrig_install_this": "Installer cette version",
"xmrig_installed": "Installé :",
"xmrig_installed_ok": "Mineur installé",
"xmrig_installing": "Installation…",
"xmrig_latest": "Dernière :",
"xmrig_latest_badge": "la plus récente",
"xmrig_latest_tt": "Version la plus récente disponible",
"xmrig_loading_releases": "Chargement des versions…",
"xmrig_no_notes": "Aucune note de version pour cette version.",
"xmrig_none": "aucun",
"xmrig_prerelease_tt": "Version préliminaire / de test",
"xmrig_reinstall": "Réinstaller",
"xmrig_released": "Publiée le :",
"xmrig_releases": "versions de xmrig",
"xmrig_status_current": "Cette version est installée",
"xmrig_status_latest": "Dernière version",
"xmrig_status_older": "Plus ancienne que votre version installée",
"xmrig_stop_mining_first": "Arrêtez le minage avant de mettre à jour le mineur.",
"xmrig_unavailable_body": "Aucune version du mineur n'est disponible pour cette plateforme.",
"xmrig_unavailable_title": "Mises à jour du mineur indisponibles",
+456 -25
View File
@@ -19,11 +19,13 @@
"about_license_text": "本ソフトウェアはGNU General Public License v3 (GPLv3)の下で公開されています。ライセンス条項に従い、自由に使用、変更、配布できます。",
"about_peers_count": "%zu ピア",
"about_release": "リリース",
"about_source": "ソース",
"about_title": "ObsidianDragonについて",
"about_version": "バージョン:",
"about_website": "ウェブサイト",
"acrylic": "アクリル",
"add": "追加",
"addr_type_mining": "マイニング",
"address": "アドレス",
"address_book_add": "アドレスを追加",
"address_book_add_new": "新規追加",
@@ -67,6 +69,7 @@
"appx_blockchain_maintenance_in_progress": "ブロックチェーンのメンテナンス操作がすでに進行中です。",
"appx_blockchain_rescan_complete": "ブロックチェーンの再スキャンが完了しました",
"appx_bootstrap_complete_reconciling": "ブートストラップが完了しました。新しいチェーンデータとウォレットを照合しています。",
"appx_bootstrap_reconcile_probe_failed": "ブートストラップのチェーンデータとウォレットを照合できませんでした。ノードが処理中のため、保有しているブロックを確認できませんでした。同期が追いついたら、設定から再スキャンを実行してください。",
"appx_cancel": "キャンセル",
"appx_cleaning_up": "クリーンアップしています...",
"appx_confirm_your_backup": "バックアップを確認",
@@ -77,11 +80,15 @@
"appx_creating_your_wallet": "ウォレットを作成しています…",
"appx_daemon_error": "デーモンエラー",
"appx_daemon_reinstall_in_progress": "デーモンの再インストールがすでに進行中です。",
"appx_daemon_reinstall_node_busy": "ノードがまだ実行中のため、デーモンは再インストールされませんでした。停止してからもう一度お試しください。",
"appx_disconnecting": "切断しています...",
"appx_done": "完了",
"appx_dragonxd_output": "dragonxd の出力",
"appx_encrypting_wallet": "ウォレットを暗号化しています...",
"appx_fullnode_lifecycle_unavailable_lite": "フルノードのライフサイクル操作はライトビルドでは利用できません",
"appx_import_cancelled": "キャンセルしました:ノードはまだスキャン中の可能性があります。鍵の資金が表示されない場合は、設定 → 再スキャン を実行してください。",
"appx_import_interrupted": "インポートが中断されたため、鍵はすでにウォレットにある可能性があります。資金が表示されない場合は、設定 → 再スキャン を実行してください。",
"appx_import_refused": "ノードが要求を拒否したため、鍵はインポートされませんでした。",
"appx_installing_bundled_daemon": "同梱デーモンをインストールしています。ノードが停止、更新、再起動されます...",
"appx_invalid_payment_uri_prefix": "無効な支払いURI: ",
"appx_ive_written_it_down": "書き留めました",
@@ -98,7 +105,8 @@
"appx_no_bundled_daemon_to_install": "このビルドにはインストールできる同梱デーモンがありません",
"appx_no_embedded_daemon_to_install": "このビルドにはインストールできる組み込みデーモンがありません",
"appx_node_busy_restarting": "ノードが再起動中でビジー状態です。少し待ってから再試行してください。",
"appx_node_rebuilding_witness_cache": "ノードがウィットネスキャッシュを再構築しています",
"appx_node_busy_try_later": "ノードは長時間の処理(ノートウィットネスの再構築など)を実行中です。完了してからもう一度お試しください。今停止すると、その処理を最初からやり直すことがあります。",
"appx_node_not_stopped": "ノードが時間内に停止しなかったため、何も変更されませんでした。数分後にもう一度お試しください。",
"appx_not_next_word": " — 次の単語ではありません",
"appx_payment_request_loaded": "支払いリクエストを読み込みました",
"appx_pool_miner_connected_and_hashing": "プールマイナーが接続し、ハッシュ計算中です。",
@@ -119,11 +127,7 @@
"appx_simple_background_enabled": "シンプル背景を有効にしました",
"appx_skip": "スキップ",
"appx_skip_anyway": "それでもスキップ",
"appx_still_status_prefix": "まだ「",
"appx_still_status_suffix": "」です — 今強制終了するとチェーンデータが破損する可能性があります。",
"appx_stop_anyway_and_quit": "それでも停止して終了",
"appx_stopping_daemon_deleting_blockchain": "デーモンを停止し、ブロックチェーンデータを削除しています...",
"appx_stopping_node_discards_rebuild": "今ノードを停止すると、進行中の再構築が破棄され、次回ウォレットを開いたときに再度実行されます(数分かかります)。代わりにノードを起動したままにすることもできます。",
"appx_stopping_pool_miner": "プールマイナーを停止しています...",
"appx_syncing_pct_block_n_of_n": "同期中 %.1f%% — ブロック %d / %d",
"appx_tap_words_in_order": "保存したことを確認するため、単語を正しい順番でタップしてください。",
@@ -133,8 +137,14 @@
"appx_use_settings_restart_daemon_hint": "設定 > デーモンを再起動 から再試行してください",
"appx_waiting_for_daemon_to_encrypt_wallet": "デーモンによるウォレットの暗号化を待っています...",
"appx_wallet_created_and_backed_up": "ウォレットを作成し、バックアップしました。",
"appx_wallet_op_node_busy": "ノードは処理中で応答していません。完了してからインポートまたは再スキャンをもう一度お試しください。",
"appx_wallet_op_running": "鍵のインポートまたは再スキャンがまだ実行中です。完了してからもう一度お試しください。",
"appx_wallet_op_shutting_down": "ウォレットを終了しています。",
"appx_wallet_open_failed_prefix": "ウォレットを開けませんでした: ",
"auto_shield": "マイニング自動シールド",
"autoshield_by_node": "あなたのノードは、マイニングしたコインベースを自動的にシールドします。",
"autoshield_off_backup_seed": "マイニングしたコインベースはまだ自動シールドされていません。ノードが安全に有効化できるよう、シードフレーズをバックアップしてください(設定 → バックアップとデータ)。",
"autoshield_off_generic": "現在、あなたのノードはマイニングしたコインベースを自動シールドしていません。",
"av_intro": "マイニングソフトウェアは、望ましくない可能性があるものとしてフラグが立てられることがよくあります。プールマイニングを有効にするには、次の手順に従ってください。",
"av_open_security": "Windows セキュリティを開く",
"av_step1": "ステップ1: 除外の追加",
@@ -170,21 +180,20 @@
"balance_history_collecting": "残高履歴 — データを収集中...",
"balance_layout": "残高レイアウト",
"balance_layout_switched": "レイアウト: %s",
"balance_mining_fmt": "マイニング報酬:%.8f",
"balance_mining_rate": "マイニング中 %s",
"balance_shielded_fmt": "シールド: %.8f",
"balance_syncing_pct": "同期中 %.1f%%",
"balance_transparent_fmt": "透明: %.8f",
"baltab_market": "市場",
"baltab_market_price_4dp": "市場価格: $%.4f",
"baltab_market_price_8dp": "市場価格: $%.8f",
"baltab_mining_addresses_count": "マイニングアドレス %d 件",
"baltab_mining_amount": "マイニング報酬 %.8f",
"baltab_pct_change_24h": "%s%.1f%% 24h",
"baltab_pct_of_total_zaddr": "全体の%.0f%% · %d Z-addr",
"baltab_shielded": "シールド",
"baltab_shielded_amount": "シールド %.8f",
"baltab_t_addresses_count": "%d 個のT-address",
"baltab_total_balance": "合計残高",
"baltab_transparent": "透明",
"baltab_transparent_amount": "透明 %.8f",
"ban": "ブロック",
"banned_peers": "ブロック済みピア",
"block": "ブロック",
@@ -192,6 +201,12 @@
"block_click_copy": "クリックしてコピー",
"block_click_next": "クリックして次のブロックを表示",
"block_click_prev": "クリックして前のブロックを表示",
"block_db_reindex_body": "これは通常、デーモンの更新でディスク上のフォーマットが変わった後、またはブロックインデックスが破損している場合に発生します。ウォレットとコインは安全です — ノードがチェーンを読み込めないだけなので、残高は0と表示されます。\n\n再構築は既存のブロックファイルを再読み込みするもので、時間がかかることがあります(ウォレットの再スキャンも行います)。ダウンロードは発生しません。",
"block_db_reindex_confirm": "ブロックデータベースを再構築",
"block_db_reindex_notify": "ノードがブロックデータベースを読み込めません(デーモンの更新後によく発生します)。残高を復元するには再構築してください — プロンプトを確認するか、設定 › ノードへ。",
"block_db_reindex_started": "保存済みのブロックからブロックデータベースを再構築しています — 時間がかかる場合があります。",
"block_db_reindex_title": "ブロックデータベースを再構築しますか?",
"block_db_reindex_warn": "ノードがブロックデータベースを読み込めません。",
"block_explorer": "ブロックエクスプローラー",
"block_get_info": "ブロック情報を取得",
"block_hash": "ブロックハッシュ:",
@@ -228,6 +243,7 @@
"change_pass_confirm": "新しいパスフレーズ(確認):",
"change_pass_current": "現在のパスフレーズ:",
"change_pass_new": "新しいパスフレーズ:",
"change_pass_pin_note": "このウォレットにはクイックアンロック PIN が設定されています。変更後は古くなるため、設定で PIN を削除してから再設定してください。",
"change_pass_title": "パスフレーズを変更",
"characters": "文字",
"chat": "チャット",
@@ -239,6 +255,10 @@
"chat_accent_theme": "テーマ",
"chat_add_contact": "連絡先に追加",
"chat_awaiting_key": "返信待ち",
"chat_blocked_desc": "ブロックされた会話は削除され、ブロック解除するまでメッセージ(過去・新規とも)は反映されません。ブロックを解除すると、チェーンから会話が再インポートされます。",
"chat_blocked_manage": "ブロック中",
"chat_blocked_title": "ブロックされた会話",
"chat_blocked_toast": "会話を削除してブロックしました",
"chat_bubble_minimal": "ミニマル",
"chat_bubble_rounded": "角丸",
"chat_bubble_square": "角ばった",
@@ -251,6 +271,16 @@
"chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます",
"chat_contact_request": "連絡リクエスト",
"chat_copy_address_tip": "クリックしてアドレスをコピー",
"chat_delete": "会話を削除",
"chat_delete_block": "削除してブロック",
"chat_delete_body_prefix": "次の相手との会話のローカルコピーを削除しますか:",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "削除",
"chat_delete_failed": "会話を削除できませんでした — 変更はありません。",
"chat_delete_local_note": "これはこの端末のみに影響します — メッセージはブロックチェーン上に残り、相手も自分のコピーを保持し続けます。",
"chat_delete_revive_note": "「削除」はこの端末上の履歴を消去します。相手から再度メッセージが届くと、会話は復活します。",
"chat_delete_title": "会話を削除しますか?",
"chat_deleted_toast": "会話を削除しました",
"chat_density_comfortable": "ゆったり",
"chat_density_compact": "コンパクト",
"chat_emoji_color": "カラー",
@@ -274,6 +304,7 @@
"chat_new_button": "新しいチャット",
"chat_new_message": "メッセージ",
"chat_new_message_toast": "新しい暗号化チャットメッセージ",
"chat_new_needs_zaddr": "チャットにはシールド(z)アドレスが必要です — 透過(t)アドレスは暗号化メッセージを受信できません。",
"chat_new_send": "リクエストを送信",
"chat_new_title": "新しいチャット",
"chat_new_zaddr": "宛先Zアドレス",
@@ -319,6 +350,8 @@
"chat_ts_24h": "24時間",
"chat_ts_global": "全体設定に従う",
"chat_ts_global_short": "全体",
"chat_unblock": "ブロック解除",
"chat_unblocked_toast": "会話のブロックを解除しました",
"chat_unhide": "再表示",
"chat_unmute": "ミュート解除",
"chat_verify_key": "識別鍵 — 照合して確認",
@@ -371,6 +404,7 @@
"console": "コンソール",
"console_accents": "カラーアクセント",
"console_app": "アプリ",
"console_auto_focus": "開いたときに入力欄へフォーカス",
"console_auto_scroll": "自動スクロール",
"console_available_commands": "利用可能なコマンド:",
"console_backend_reference": "バックエンドコマンドリファレンス",
@@ -465,11 +499,15 @@
"console_tab_completion": "Tabで補完",
"console_text_colors": "テキスト色",
"console_toggle_accents": "行のカラーアクセントを切り替え",
"console_toggle_auto_focus": "コンソールタブを開いたときにコマンド入力欄にカーソルを置きます",
"console_toggle_text_color": "行のテキスト色を切り替え",
"console_type_help": "'help'と入力して利用可能なコマンドを表示",
"console_welcome": "ObsidianDragonコンソールへようこそ",
"console_zoom_in": "拡大",
"console_zoom_out": "縮小",
"consolidate_desc": "多数の小さな入力を1つのシールドノートにまとめます。ノート数が減るとウォレットファイルが小さくなり、プライバシーも向上します。",
"consolidate_funds_btn": "統合",
"consolidate_title": "資金を統合",
"contact_avatar": "アバター",
"contact_avatar_bad_image": "その画像を読み込めませんでした。",
"contact_avatar_badge": "バッジ",
@@ -481,7 +519,7 @@
"contact_avatar_image_hint": "画像はアプリ内にコピーされ、元のファイルが移動しても利用できます。",
"contact_avatar_remove": "削除",
"contact_avatar_shielded": "シールド",
"contact_avatar_transparent": "透明",
"contact_chip_mining": "マイニング専用",
"contact_global": "すべてのウォレットで表示(グローバル連絡先)",
"contact_global_badge_tt": "グローバル連絡先 — すべてのウォレットで表示",
"contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。",
@@ -515,9 +553,13 @@
"daemon_maintenance_label": "メンテナンス",
"daemon_none_bundled": "このビルドにはなし",
"daemon_not_installed": "未インストール",
"daemon_status_diff": "バージョンが異なります",
"daemon_status_differ": "インストール済みのバイナリはバンドル版と異なります。",
"daemon_status_match": "インストール済みのバイナリはバンドル版と一致しています。",
"daemon_status_missing": "デーモンがインストールされていません — バンドル版をインストールしてください。",
"daemon_status_none": "未インストール",
"daemon_status_ok": "最新です",
"daemon_update_active_tt": "現在インストールされている(有効な)ノード",
"daemon_update_available": "新しいノードバージョンが利用可能です",
"daemon_update_body": "このウォレットビルドには、現在インストールされているものよりも新しいDragonXノードがバンドルされています。更新すると、インストール済みのdragonxd(およびdragonx-cli/dragonx-tx)が置き換えられ、その後ノードを停止して再起動し、新しいバージョンが有効になります。新しいノードには、古いものにない機能(例:シードフレーズのサポート)が追加されている場合があるため、更新を推奨します。",
"daemon_update_browse": "すべてのリリースを表示…",
@@ -527,19 +569,28 @@
"daemon_update_download_install": "ダウンロードしてインストール",
"daemon_update_downloading": "ダウンロード中…",
"daemon_update_failed": "更新に失敗しました",
"daemon_update_install_this": "このバージョンをインストール",
"daemon_update_installed": "インストール済み:",
"daemon_update_installed_ok": "ノードをインストールしました",
"daemon_update_installing": "インストール中…",
"daemon_update_keep": "現在のまま使用",
"daemon_update_later": "後で",
"daemon_update_latest": "最新:",
"daemon_update_latest_badge": "最新",
"daemon_update_latest_tt": "利用可能な最新バージョン",
"daemon_update_loading": "リリースを読み込み中…",
"daemon_update_no_notes": "このバージョンのリリースノートはありません。",
"daemon_update_now": "今すぐ更新",
"daemon_update_prerelease_tt": "プレリリース/テストビルド",
"daemon_update_prompt_title": "ノードデーモンを更新しますか?",
"daemon_update_reinstall": "再インストール",
"daemon_update_released": "リリース日:",
"daemon_update_restart_note": "新しいバージョンを実行するにはデーモンを再起動してください。",
"daemon_update_restart_now": "今すぐデーモンを再起動",
"daemon_update_safe": "ウォレット、鍵、ブロックチェーンデータには影響しません — 置き換えられるのはデーモンのプログラムファイルのみです。意図的にカスタムノードを実行している場合は、「現在のまま使用」を選択してください。",
"daemon_update_status_current": "このバージョンはインストール済みです",
"daemon_update_status_latest": "最新バージョン",
"daemon_update_status_older": "インストール済みバージョンより古いです",
"daemon_update_title": "ノードデーモンを更新しますか?",
"daemon_update_unavailable_body": "このプラットフォーム向けのノードビルドはありません。",
"daemon_update_unavailable_title": "ノードの更新は利用できません",
@@ -555,6 +606,13 @@
"data_stale_tooltip": "残高が最新でない可能性があります。ウォレットは最近更新を受信していません。ノード接続を確認してください。",
"date": "日付",
"date_label": "日付:",
"debug_gate_confirm": "デバッグオプションを表示",
"debug_gate_incorrect": "PINまたはパスフレーズが正しくありません。",
"debug_gate_pass_prompt": "続行するにはウォレットのパスフレーズを入力してください:",
"debug_gate_pin_prompt": "続行するにはロック解除PINを入力してください:",
"debug_gate_title": "デバッグオプションを表示しますか?",
"debug_gate_verifying": "確認中…",
"debug_gate_warning": "これらは高度な診断・ノード調整用オプションです。機密情報が露出したり、ノードの動作が変わったりする可能性があります。内容を理解している場合のみ続行してください。",
"debug_logging": "デバッグログ",
"decrypt_desc": "ウォレットがエクスポートされ、デーモンが新しい暗号化されていないウォレットで再起動し、すべての鍵が再インポートされます。ウォレットのサイズによっては数分かかる場合があります。",
"decrypt_error_title": "復号に失敗しました",
@@ -577,9 +635,15 @@
"disconnected": "切断済み",
"dismiss": "閉じる",
"display": "表示",
"done": "完了",
"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": "ウォレットマネージャーを開く",
@@ -630,6 +694,7 @@
"explorer_tx_outputs": "出力",
"explorer_tx_size": "サイズ",
"explorer_txid": "TxID:",
"explorer_urls_hdr": "URL",
"export": "エクスポート",
"export_csv": "CSVエクスポート",
"export_keys_btn": "鍵をエクスポート",
@@ -655,9 +720,155 @@
"export_tx_success": "取引のエクスポートに成功しました",
"export_tx_title": "取引をCSVにエクスポート",
"export_viewing_key": "閲覧鍵をエクスポート",
"failed_create_mining_address": "マイニングアドレスを作成できませんでした",
"failed_create_shielded": "シールドアドレスの作成に失敗しました",
"failed_create_transparent": "透明アドレスの作成に失敗しました",
"faq": "FAQ",
"faq_d_mgmt_1_a": "組み込みノードはウォレットと同時に自動的に起動し、適切なタイミングで停止するため、通常は手動で管理する必要はありません。高度な操作(デーモンの再起動を含む)は設定 > ノードとセキュリティにあります。",
"faq_d_mgmt_1_q": "ノードの起動・停止はどうすればいいですか?",
"faq_d_mgmt_2_a": "自分で起動したノードにウォレットを接続している場合、このオプションはウォレットを閉じたときにそのノードも停止するかどうかを決めます。ウォレットを閉じた後もノードを動かし続けたい場合はオフのままにしてください。",
"faq_d_mgmt_2_q": "「終了時に外部デーモンを閉じる」とは何をする機能ですか?",
"faq_d_mgmt_3_a": "ノードとウォレットのデータは、ユーザープロファイル配下のDragonXデータディレクトリに保存されます(例:WindowsではAppData内、Linux/macOSではホームフォルダ内)。ブロックチェーンがその大部分を占め、増え続けるので十分な空き容量を確保してください。",
"faq_d_mgmt_3_q": "ブロックチェーンとウォレットのデータはどこに保存されますか?",
"faq_d_mgmt_title": "ノード管理",
"faq_d_mine_1_a": "「マイニング」タブでは、CPUを使ってマイニングできます。DragonXは、一般的なプロセッサ向けに設計されたRandomXアルゴリズムを使用します。スレッド数を設定して開始すると、報酬がウォレットに蓄積されます。",
"faq_d_mine_1_q": "DragonXをマイニングするには?",
"faq_d_mine_2_a": "ソロマイニングは報酬を直接受け取れますが、ブロックを見つけたときにしか支払われないため、十分なハッシュレートがない限り頻度は低くなります。プールマイニングは他の参加者と作業を分担し、小さいながらも安定した支払いを受けられます。マイニングタブは両方に対応しています。",
"faq_d_mine_2_q": "ソロマイニングとプールマイニング、どちらを選べばいい?",
"faq_d_mine_3_a": "マイニングタブのプールセクションにある「マイナーを更新…」ボタンから、最新の最適化されたDragonXマイナービルドをダウンロード・検証・インストールできます。ノードのアップデータと同様に、ダウンロードごとにチェックサムと署名を検証してからインストールします。",
"faq_d_mine_3_q": "マイナーを更新するには?",
"faq_d_mine_4_a": "現行のノードは組み込みのstratumサーバーを実行でき、他のマイナーがあなたのマシンに接続できるようになります。Settings > Node & Security に切り替えスイッチがあります。デフォルトではローカルのみで待ち受けており、他のコンピュータに公開するには明示的な設定が必要です。",
"faq_d_mine_4_q": "マイニングプールをホストできますか?",
"faq_d_mine_title": "マイニングとプール",
"faq_d_net_1_a": "起動直後はノードがピアを見つけるまでに少し時間がかかるため、「0ピア」と一時的に表示されるのは正常です。長く続く場合は、インターネット接続とファイアウォールがノードをブロックしていないか確認してください。ノードは組み込みのシードを通じて自動的にピアを見つけます。",
"faq_d_net_1_q": "ウォレットにピアや接続が表示されません。どうすればいい?",
"faq_d_net_2_a": "はい — DragonXノード同士はTLSで通信するため、ピア接続は暗号化されています。ウォレットも自身のノードとは安全なローカルチャネルで通信します。",
"faq_d_net_2_q": "ネットワークへの接続は暗号化されていますか?",
"faq_d_net_title": "ピアとネットワーク",
"faq_d_node_1_a": "デーモン(dragonxd)は、DragonXのフルノードです。ブロックチェーン全体をダウンロード・検証し、取引をネットワークに中継し、ウォレットの問い合わせに応答します。自分のノードを実行することで、他者のサーバーを信頼する必要がなくなります。",
"faq_d_node_1_q": "デーモン(dragonxd)とは何ですか?",
"faq_d_node_2_a": "デフォルトでは、ウォレットは同梱された独自のノード(組み込み)を起動・管理するため、あなたが何かする必要はありません。すでに自分でdragonxdを実行している場合は、ウォレットをその外部ノードに接続することもできます。ウォレットはどちらの状況かを検出し、それに応じて動作します。",
"faq_d_node_2_q": "組み込みノードと外部ノードの違いは?",
"faq_d_node_3_a": "フルノードはブロックチェーンのすべてのルールを独自に検証するため、あなたのウォレットは第三者ではなく数学を信頼します。ネットワーク自体の強化にもつながります。トレードオフはディスク容量と初回同期です。",
"faq_d_node_3_q": "そもそもなぜフルノードを実行するのですか?",
"faq_d_node_title": "フルノードについて",
"faq_d_perf_1_a": "ブロックチェーンが最も容量を占め、時間とともに増加するため、数ギガバイトの空き容量を確保しておいてください。ノードは高速化のためにデータベースキャッシュを使用しており、同梱のデフォルト値は一般的なマシン向けに調整されています。上級者は設定ファイル(DRAGONX.conf)のdbcacheを編集して、ノードのデータベースキャッシュを調整できます。",
"faq_d_perf_1_q": "ノードはどれくらいディスクを使いますか?メモリは調整できますか?",
"faq_d_perf_2_a": "セットアップウィザードで提供されるブートストラップダウンロードを有効にすると、すべてのブロックをゼロから検証する代わりに、最近の検証済みチェーンのコピーを取得できます。高速なディスク(SSD)も大いに役立ちます。",
"faq_d_perf_2_q": "初回同期を速くするには?",
"faq_d_perf_title": "ストレージとパフォーマンス",
"faq_d_sync_1_a": "起動時、ノードはウォレットに応答できるようになる前にブロックを読み込んで検証します。「ブロックを処理中/現在のチェーン状態を構築するためにブロックを適用中」という画面はそのウォームアップです。これは正常な動作で、ノードが処理を終えて高さを報告すると、ウォレットは使用可能になります。",
"faq_d_sync_1_q": "起動時の「ブロックを処理中」とはどういう意味ですか?",
"faq_d_sync_2_a": "シールドされた資金については、ノードはプライベートに送金できるよう暗号学的な「ウィットネス」を保持しています。再起動やアップデートの後、最近のブロックを再スキャンしてこのキャッシュを再構築することがあります。取引の多いウォレットでは、これに数分かかり、追加のメモリを使用することがあります。\n\nこれは停止しているわけではないので、完了するまでお待ちください。途中で強制終了すると、次回は再構築を最初からやり直すことになります。",
"faq_d_sync_2_q": "「ウィットネスキャッシュを再構築中」とは何ですか?",
"faq_d_sync_3_a": "チェーンの先端付近では、ウォレットをスキャンする処理(残高やノートの確認)がノードと同じ内部ロックを奪い合うため、大きなウォレットでは最後のブロックの接続が遅くなることがあります。最近のバージョンではこのスキャンを制限し、ノードが追いつけるようにしています。負荷の軽いタブにとどまる(または単に待つ)ことで、より速く完了します。",
"faq_d_sync_3_q": "ほぼ追いついているのに同期が遅いのはなぜですか?",
"faq_d_sync_4_a": "初回同期ではチェーン全体をダウンロード・検証するため、回線とディスクの性能によっては時間がかかることがあります。セットアップウィザードでブートストラップダウンロードを有効にすると、最近の検証済みチェーンデータのコピーを取得できるため、大幅に高速化できます。",
"faq_d_sync_4_q": "初回同期にはどれくらい時間がかかりますか?",
"faq_d_sync_title": "同期とブロックチェーン",
"faq_d_trbl_1_a": "通常は問題ありません。大きなウォレットでは、ノードがシールドウィットネスの再構築やブロックの適用に数分かかることがあり、その間は一時停止しているように見え、応答が遅くなることがあります。しばらくお待ちください — 動作中であれば、デバッグログ(Settings)に着実な進捗が表示されます。\n\nこの段階での強制終了は避けてください。次回起動時に作業が最初からやり直しになります。",
"faq_d_trbl_1_q": "ノードが「最良チェーンを有効化中」で止まっているようです。フリーズしていますか?",
"faq_d_trbl_2_a": "ウォレットファイルが復旧されたものの、鍵データの一部が欠けている場合、ノードは新しいアドレスの生成やシールドができない限定的な「デグレード」モードで開くことがあります。最も安全な対処法は、シードリカバリーフレーズから新しいウォレットに復元することです。",
"faq_d_trbl_2_q": "「デグレードモード」とは何ですか?",
"faq_d_trbl_3_a": "再スキャンは、ウォレットにチェーンを再読み込みさせて取引を再検出するもので(鍵をインポートした後などに便利です)。再インデックスは、ノードのブロックチェーンデータベースを再構築するもので、そのデータベースが破損している、またはバージョン変更後に非互換になっている場合にのみ必要です。どちらも時間がかかることがあります。Settings > Node & Security から開始してください。",
"faq_d_trbl_3_q": "再インデックスや再スキャンはいつ行うべきですか?",
"faq_d_trbl_4_a": "ウィットネスの再構築や初回同期のような重い処理の間、メモリ使用量が急増し、完了すると元に戻ります。アイドル時にメモリ使用量が高いままなのは通常ではありません — ウォレットを再起動すると解消します。低メモリのマシンでは、他のメモリを大量に使うアプリと競合していないか確認してください。",
"faq_d_trbl_4_q": "ノードのメモリ使用量が多いのですが、正常ですか?",
"faq_d_trbl_title": "トラブルシューティング",
"faq_d_upd_1_a": "Settings > Node & Security > Daemon binary を開き、「アップデートを確認…」を使用してください。ウォレットが最新の検証済みノードビルドをダウンロードしてインストールし、新しいバージョンは次回デーモン起動時に有効になります。",
"faq_d_upd_1_q": "ノードを更新するには?",
"faq_d_upd_2_a": "はい — アップデートダイアログにはすべてのリリースが一覧表示されるため、特定のバージョンや古いビルドを選べます。ダウングレードには注意してください。古いノードは新しいノードが書き込んだブロックチェーンデータを受け付けない場合があり、再インデックスが必要になることがあります。",
"faq_d_upd_2_q": "特定のノードバージョンをインストールできますか?",
"faq_d_upd_3_a": "ダウンロードされたノードアーカイブは、インストール前に必ずチェックサムと暗号署名で検証されます。検証に失敗した場合、ウォレットはアップデートを拒否します。",
"faq_d_upd_3_q": "このアップデートは安全ですか?",
"faq_d_upd_title": "ノードの更新",
"faq_group_daemon": "デーモン",
"faq_group_wallet": "ウォレット",
"faq_intro": "ウォレットとDragonXノードに関する回答です。検索するか、下のトピックから参照してください。",
"faq_intro_lite": "Liteウォレットに関する回答です。検索するか、下のトピックから参照してください。",
"faq_l_gs_1_a": "ObsidianDragonLiteは軽量版のDragonXウォレットです。フルノードを実行する代わりに、DragonX Liteサーバーに接続するため、起動が速く、ブロックチェーン全体をダウンロードする必要がありません。\n\n鍵、残高、送金の管理はフルウォレットと同じように行えます。サーバー接続の仕組みとプライバシー上のトレードオフについては、下記の「Liteウォレット」セクションをご覧ください。",
"faq_l_gs_1_q": "ObsidianDragonLiteとは何ですか?",
"faq_l_lite_1_a": "Liteウォレットは、自身でフルノードを実行する代わりにDragonX Liteサーバーと通信します。ブロックチェーン全体を自分でダウンロード・検証しないため、起動が速く、ディスクと帯域の消費も軽くなります。秘密鍵は常にあなたのデバイス内にとどまり、サーバーに送信されることはありません。",
"faq_l_lite_1_q": "Liteウォレットはフルウォレットとどう違いますか?",
"faq_l_lite_2_a": "Settingsを開いてサーバーを選択してください。サーバーはいつでも切り替えられ、ウォレットは新しいサーバーから再同期します。サーバーの応答が遅い、または到達できない場合は、別のサーバーを試してください。",
"faq_l_lite_2_q": "サーバーの選び方や変更方法は?",
"faq_l_lite_3_a": "あなたの鍵がデバイスの外に出ることはなく、シールドされた金額は暗号化されたままです。ただし、Liteサーバーはあなたのウォレットがどのアドレスについて問い合わせているかとあなたのIPアドレスを把握できるため、あなたのアドレス同士を関連付けられる可能性があります — 利便性とプライバシーのトレードオフです。最もプライバシーを重視する場合は、チェーンを自身で検証し、アドレスをサーバーに明かさないフルノードウォレットをご利用ください。",
"faq_l_lite_3_q": "Liteウォレットはフルノードと同じくらいプライベートですか?",
"faq_l_lite_title": "Liteウォレット",
"faq_l_sec_1_a": "設定 > ウォレット を開き、セキュリティのセクションでパスフレーズを設定してください。暗号化はディスク上の秘密鍵を保護するので、ファイルにアクセスできる人がいてもコインを送金することはできません。\n\n強力なパスフレーズを選び、絶対に紛失しないでください。パスフレーズなしでは暗号化されたウォレットを復元する方法はありません。",
"faq_no_results": "該当する結果がありません。別のキーワードでお試しください。",
"faq_open_tooltip": "ヘルプ&FAQ",
"faq_search_hint": "FAQを検索…",
"faq_title": "ヘルプ&FAQ",
"faq_w_addr_1_a": "シールドアドレス(zs で始まる z アドレス)は、DragonX で使用するアドレスです。ゼロ知識暗号を使用するため、金額・送信者・受信者はチェーン上で暗号化され、残高は非公開に保たれます。\n\n透明アドレス(R で始まる)はマイニングアドレスです。DragonX は完全にプライベートなチェーンのため、マイニング報酬しか受け取れず、送られた支払いはネットワークによって拒否されます。そこに届いた報酬はシールドされるまで公開されますが、シールドはウォレットが自動的に行います。",
"faq_w_addr_1_q": "透明アドレスとシールドアドレスの違いは何ですか?",
"faq_w_addr_2_a": "送受信にはすべてシールド(zs)アドレスを使用してください。DragonX で支払いを受け取れるのはこの種類だけです。\n\n透明(マイニング)アドレスはソロマイニング報酬専用です。支払いのために他人に教えないでください。ネットワークが拒否します。",
"faq_w_addr_2_q": "どちらのアドレスタイプを使うべきですか?",
"faq_w_addr_3_a": "シールドアドレスを再利用しても問題はなく、履歴が漏れることもありません。それでも「受信」タブで新しいアドレスを作成できます。たとえば連絡先ごとに 1 つ作れば、誰が支払ったかがわかります。マイニングアドレスはマイニング報酬専用で、新しく作る必要はほとんどありません。",
"faq_w_addr_3_q": "アドレスは使い回すべきですか、それとも新しく作るべきですか?",
"faq_w_addr_4_a": "シールドとは、透明(マイニング)アドレスのコインをシールドアドレスに移して非公開にすることです。新たにマイニングされたコインはマイニングアドレスに届き、成熟すると自動的にシールドされます。\n\nウォレットから手動でシールドすることもできます。シールドは特別なトランザクションにすぎないため、通常の承認時間がかかります。",
"faq_w_addr_4_q": "「シールド化」とは何ですか?",
"faq_w_addr_title": "アドレスとプライバシー",
"faq_w_bal_1_a": "ウォレットがまだ同期中の場合、残高は不完全で0と表示されることがあります。まだすべてのトランザクションをスキャンしていないためです。同期が完了すると反映されます。\n\n画面下部のステータスバーを確認してください。接続状態とブロック高が表示されます。同期が完了すれば残高は正確になります。",
"faq_w_bal_1_q": "残高が0のまま、または更新されないのはなぜですか?",
"faq_w_bal_2_a": "あなたのトランザクションを含むブロックの上に新しいブロックがマイニングされるたびに、確認数が1つ増えます。確認数が多いほど、支払いはより確実に確定します。受け取った資金は最初の確認後に送金可能になります。",
"faq_w_bal_2_q": "確認数とは何ですか?",
"faq_w_bal_3_a": "合計残高には、まだ確認中の資金や一時的にロックされている資金(例えばシールド化の途中のコイン)も含まれます。送金可能残高は今すぐ送金できる金額です。トランザクションが確認されるにつれて両者は一致していきます。",
"faq_w_bal_3_q": "合計残高と送金可能残高の違いは何ですか?",
"faq_w_bal_title": "残高と同期",
"faq_w_chat_1_a": "チャットはウォレットに組み込まれた、暗号化されたオンチェーンのメッセンジャー機能です。メッセージはシールドされたメモとして送信されるため、あなたと相手だけが読むことができます。",
"faq_w_chat_1_q": "チャット機能とは何ですか?",
"faq_w_chat_2_a": "チャットのアイデンティティはウォレットのリカバリーフレーズから導出されるため、ウォレットと一緒に持ち運ばれます。ウォレットを復元すればアイデンティティも戻ってきます。別途アカウントやパスワードを作成する必要はありません。",
"faq_w_chat_2_q": "チャットのアイデンティティはどのように機能しますか?",
"faq_w_chat_3_a": "連絡先タブでアドレスを入力して連絡先を追加します。名前やアバターの設定は任意です。連絡先は現在のウォレットのみに限定することも、このコンピューター上で開くすべてのウォレットで共有することもできます。",
"faq_w_chat_3_q": "連絡先はどのように機能しますか?",
"faq_w_chat_4_a": "会話を開き、ヘッダーのアイコンを使用してください。非表示(目のアイコン)はリストから削除しますが、すべてのメッセージは保持されます。新しいメッセージが届くと再表示されます。削除(ゴミ箱のアイコン)はダイアログを開きます。「削除」はこの端末上の履歴を消去します(相手から再度メッセージが届くと会話は復活します)。「削除してブロック」はさらに、会話リスト上部の「ブロック済み」リストから解除するまで相手からの今後のメッセージも停止します。ここでの操作はすべてこの端末のみに影響し、メッセージはブロックチェーン上に残り、相手も自分のコピーを保持し続けるため、「送信取り消し」ではありません。",
"faq_w_chat_4_q": "会話を非表示、削除、またはブロックするにはどうすればよいですか?",
"faq_w_chat_title": "チャットと連絡先",
"faq_w_gs_1_a": "ObsidianDragonはDragonX(DRGX)のフルノードウォレットです。コインを管理し、デスクトップ版ではバックグラウンドでDragonXフルノード(デーモン)を実行するため、サードパーティのサーバーを信頼することなく、ウォレット自身がブロックチェーンを検証します。\n\nウォレット(鍵、残高、送金)とノード(ブロックチェーンとネットワーク)という2つの部分が連携して動作するイメージです。ここでの質問も同様に分かれています。上部の「ウォレット」タブと「デーモン」タブをご覧ください。",
"faq_w_gs_1_q": "ObsidianDragonとは何ですか?",
"faq_w_gs_2_a": "初回起動時、セットアップウィザードが自動的にウォレットを作成します。新しいウォレットは秘密のリカバリーフレーズ(単語のリスト)でバックアップされます。このフレーズを書き留めてオフラインで保管してください。このコンピューターを失った場合、資金を復元する唯一の方法です。\n\nフレーズはいつでも 設定 > バックアップとデータ > シードフレーズ から確認・バックアップできます。",
"faq_w_gs_2_q": "新しいウォレットを作成するにはどうすればよいですか?",
"faq_w_gs_3_a": "リカバリーフレーズをお持ちの場合は、シードフレーズ復元フローを使用してください。(フルノード版では、起動前に既存の wallet.dat ファイルをウォレットのデータディレクトリにコピーすることもできます。)\n\n復元後、ウォレットは過去のトランザクションを見つけるために再スキャンを行うため、初回は残高と履歴が表示されるまで時間がかかることがあります。",
"faq_w_gs_3_q": "すでに持っているウォレットを復元するにはどうすればよいですか?",
"faq_w_gs_4_a": "ウィザードでは、外観/テーマの選択、暗号化とPINの設定を行うことができ、フルノード版ではさらに初回同期を高速化するためのブートストラップのダウンロードを任意で選択できます。どのステップもスキップでき、後から設定でいつでも変更できます。",
"faq_w_gs_4_q": "初回起動時のセットアップでは何が行われますか?",
"faq_w_gs_title": "はじめに",
"faq_w_sec_1_a": "設定 > ノードとセキュリティ を開き、パスフレーズを設定してください(セキュリティのセクション)。暗号化はディスク上の秘密鍵を保護するので、ファイルにアクセスできる人がいてもコインを送金することはできません。\n\n強力なパスフレーズを選び、絶対に紛失しないでください。パスフレーズなしでは暗号化されたウォレットを復元する方法はありません。",
"faq_w_sec_1_q": "ウォレットを暗号化するにはどうすればよいですか?",
"faq_w_sec_2_a": "PINはアプリの画面をロックし、離席中に残高や操作を非表示にします。これは暗号化に加えた利便性のためのロックで、ウォレットを暗号化した後、ウィザード中または設定で設定します。",
"faq_w_sec_2_q": "PIN/ロック画面とは何ですか?",
"faq_w_sec_3_a": "いいえ。ウォレットの暗号化を回避する方法はありません。シードリカバリーフレーズをまだお持ちであれば、そこから新しいウォレットに復元し、新しいパスフレーズを設定できます。パスフレーズとシードフレーズのどちらも失った場合、資金を復元することはできません。",
"faq_w_sec_3_q": "パスフレーズを忘れました — 復元できますか?",
"faq_w_sec_title": "セキュリティと暗号化",
"faq_w_seed_1_a": "設定 > バックアップとデータ > シードフレーズ に移動してください。単語を順番どおりに紙に書き留め、安全でオフラインの場所に保管してください。フレーズを持つ人は誰でもあなたのコインを送金できるため、写真、メール、クラウドメモには絶対に保存しないでください。",
"faq_w_seed_1_q": "リカバリーフレーズはどうやってバックアップすればよいですか?",
"faq_w_seed_2_a": "古い(レガシー)ウォレットはシードベースではありませんでした。このウォレットは、レガシーウォレットを最新のシードベースのウォレットへ移行できます。新しいシードウォレットを作成し、資金をそこへスイープします。設定 > バックアップとデータ の「シードウォレットへ移行…」を探してください。この機能には現在のデーモンバージョンが必要です。",
"faq_w_seed_2_q": "ウォレットにリカバリーフレーズがありません — 追加できますか?",
"faq_w_seed_3_a": "ウォレットのセットアップ時にシード復元フローを使用し、単語を順番どおりに入力してください。ウォレットは残高と履歴を再構築するために再スキャンを行うため、初回は時間がかかることがあります。",
"faq_w_seed_3_q": "リカバリーフレーズから復元するにはどうすればよいですか?",
"faq_w_seed_4_a": "シードフレーズは主要なバックアップであり、これだけですべてを復元できます。ウォレットファイルのコピーは便利な二次バックアップで、ラベルや設定も保持されます。どちらのバックアップもオフラインかつ非公開の場所に保管してください。",
"faq_w_seed_4_q": "wallet.dat もバックアップすべきですか?",
"faq_w_seed_title": "シードフレーズとバックアップ",
"faq_w_send_1_a": "送金タブを開き、受取人のアドレスを貼り付け、金額を入力して確認してください。ウォレットが暗号化されている場合、トランザクションのためにロック解除を求められます。\n\n支払いはシールド(zs)アドレスに送られ、非公開に保たれます。ウォレットは透明(マイニング)アドレスには送金しません。ネットワークがそのアドレスへの支払いを拒否するためです。",
"faq_w_send_1_q": "資金を送金するにはどうすればよいですか?",
"faq_w_send_2_a": "DragonXの手数料は非常に低く、自動的に設定されます。手数料は確認前に表示されます。通常は変更する必要はありません。",
"faq_w_send_2_q": "どのくらいの手数料がかかりますか?",
"faq_w_send_3_a": "はい — シールド(z)アドレスへ送金する際は、暗号化されたメモを添付できます。読めるのは受取人だけです。",
"faq_w_send_3_q": "支払いにメッセージを添付できますか?",
"faq_w_send_4_a": "トランザクションはブロックに取り込まれ、確認数を得るまで「保留中」となります。通常1~2分ほどかかります。シールドトランザクションでは、証明を生成するためにウォレットが同期済みである必要もあります。\n\n送金が異常に長く保留中のままの場合は、ウォレットが接続され、同期済みであることを確認してください(ウィンドウ下部のステータスバーを参照)。",
"faq_w_send_4_q": "支払いを送りましたが「保留中」のままです — なぜですか?",
"faq_w_send_title": "送受金",
"faq_w_set_1_a": "設定 > 外観 でテーマとアクセントカラーを選べます。ライト/ダークの複数のスキンが含まれており、性能の低いマシン向けにぼかしなどのエフェクトをオフにすることもできます。",
"faq_w_set_1_q": "テーマを変更するには?",
"faq_w_set_2_a": "はい — 設定 > 外観 に言語選択があります。ウォレットには複数の翻訳が同梱されており、未翻訳の部分は英語のまま表示されます。",
"faq_w_set_2_q": "言語を変更できますか?",
"faq_w_set_3_a": "設定 > 外観 のフォントスケールオプションを使います。インターフェース全体を拡大縮小でき、高解像度(HiDPI)ディスプレイでアプリの表示が小さくなりすぎる場合に便利です。",
"faq_w_set_3_q": "文字が小さすぎる(または大きすぎる)のですが、拡大縮小できますか?",
"faq_w_set_4_a": "設定で価格取得を有効にしてください。残高と併せて概算の法定通貨価値が表示され、小さな相場チャートも表示できるようになります。価格は参考情報にすぎません。",
"faq_w_set_4_q": "法定通貨での価格を表示するには?",
"faq_w_set_title": "設定と外観",
"favorite_address": "お気に入りに追加",
"favorite_opacity": "お気に入りハイライトの不透明度",
"fee": "手数料",
"fee_high": "高い",
"fee_label": "手数料:",
@@ -665,14 +876,9 @@
"fee_normal": "通常",
"fetch_prices": "価格を取得",
"file": "ファイル",
"file_save_location": "ファイルの保存先:~/.config/ObsidianDragon/",
"file_save_location": "ファイルの保存先:",
"filter": "フィルター...",
"font_scale": "フォントサイズ",
"force_quit": "強制終了",
"force_quit_confirm_msg": "クリーンシャットダウンなしでデーモンを即座に終了します。\nブロックチェーンインデックスが破損し、再同期が必要になる可能性があります。",
"force_quit_confirm_title": "強制終了しますか?",
"force_quit_warning": "クリーンシャットダウンなしでデーモンを即座に終了します。ブロックチェーンの再同期が必要になる場合があります。",
"force_quit_yes": "強制終了",
"from": "送信元",
"from_upper": "送信元",
"full_details": "詳細情報",
@@ -741,6 +947,7 @@
"hidden_tag": " (非表示)",
"hide": "非表示",
"hide_address": "アドレスを非表示",
"hide_earnings": "収益を非表示",
"hide_qr": "QRを非表示",
"hide_zero_balances": "残高0を非表示",
"history": "履歴",
@@ -815,11 +1022,13 @@
"lite_birthday_backup": "誕生日:%llu (これもバックアップしてください)",
"lite_birthday_hint": "スキャンを開始するブロック高。不明な場合は0のままにしてください(完全スキャンが遅くなります)。",
"lite_birthday_label": "バースデー",
"lite_confirm_label": "確認",
"lite_console_backend_commands": "バックエンドコマンド:",
"lite_console_help_passthrough": "その他の入力はライトウォレットのコンソールコマンドとして実行されます。",
"lite_copy": "コピー",
"lite_could_not_start": "操作を開始できませんでした",
"lite_could_not_write": "書き込めませんでした ",
"lite_encrypt_offline_warning": "ウォレットファイルを持つ人は誰でも、オフラインでパスフレーズを試せます。先にシードフレーズをバックアップし、長いパスフレーズを使ってください。",
"lite_encrypt_wallet": "ウォレットを暗号化",
"lite_encryption_removed": "暗号化を解除しました",
"lite_enter_all_seed_words": "復元するには24個のシードワードをすべて入力してください(現在 %d 個)",
@@ -827,6 +1036,7 @@
"lite_hide_wipe": "非表示にして消去",
"lite_import": "インポート",
"lite_import_key_label": "鍵をインポート",
"lite_key_import_failed": "鍵をインポートできませんでした。鍵を確認し、ウォレットのロックを解除して(暗号化されている場合)再試行してください。",
"lite_key_imported": "鍵をインポートしました — 履歴をスキャンするには同期を実行してください",
"lite_lock_failed": "ロックに失敗しました",
"lite_lock_now": "今すぐロック",
@@ -960,11 +1170,67 @@
"memo_optional": "メモ(任意)",
"memo_upper": "メモ",
"memo_z_only": "注:メモはシールド (z) アドレスへの送金時のみ利用可能です",
"merge_addr_created": "シールドアドレスを作成しました。",
"merge_advanced": "詳細設定",
"merge_back": "戻る",
"merge_batch_fmt": "1回の実行で最大%d件の入力を統合します — 残りは繰り返し実行して完了させてください。",
"merge_confirm_btn": "確認",
"merge_confirm_fmt": "~%s DRGXを入力%d件から%sへ統合しますか?",
"merge_create_zaddr": "シールドアドレスを作成",
"merge_creating": "アドレスを作成中…",
"merge_description": "複数のUTXOを単一のシールドアドレスに統合します。ウォレットサイズの縮小とプライバシーの向上に役立ちます。",
"merge_fee_hint": "この取引にかかるネットワーク手数料です。",
"merge_funds": "資金を統合",
"merge_into_one": "1 つのアドレスへ",
"merge_keep_each": "各アドレスに残す",
"merge_keep_each_batches_fmt": "ノートが %d 件を超えるアドレスは複数回に分けて統合されます。承認後にもう一度実行すると残りが統合されます。",
"merge_keep_each_confirm_fmt": "%d 件のノートを %d 個のアドレスで統合しますか?各アドレスの資金はそのまま残ります。手数料:約 %s DRGX(1 回ごとに 1 件)。",
"merge_keep_each_desc": "各アドレスのノートはそのアドレス自身に統合されるため、ラベルごとに分けている資金は分かれたままです。シールドアドレスのみ対象です。透明アドレスの資金はシールドすることしかできません(1 つのアドレスへ)。",
"merge_keep_each_none": "すべてのシールドアドレスのノートはすでに 1 つです。統合するものはありません。",
"merge_keep_each_nothing_submitted": "何も統合されませんでした。ノードがすべての統合を拒否しました。",
"merge_keep_each_row_fmt": "%s · ノート %d 件 · %s DRGX",
"merge_keep_each_run_again": "複数回に分けて統合したアドレスには、回数分のノートが残っています。承認後にもう一度実行すると 1 つにまとめられます。",
"merge_keep_each_select": "統合するアドレスを 1 つ以上選んでください。",
"merge_keep_each_some_failed": "一部の統合に失敗しました。上の一覧を確認してください。",
"merge_keep_each_status_done": "%s — 完了:%d 件のノートを %d 回で統合",
"merge_keep_each_status_failed": "%s — 失敗:%s",
"merge_keep_each_status_not_started": "%s — 開始されませんでした:%s",
"merge_keep_each_status_running": "%s — %d 件のノートを統合中(%d / %d 回完了)…",
"merge_keep_each_status_unconfirmed": "%s — ステータスを確認できませんでした",
"merge_keep_each_too_small": "少なすぎて統合できません。手数料でこの資金がなくなります。",
"merge_max_inputs": "バッチあたりの最大入力数",
"merge_no_spendable": "統合できる使用可能な入力がまだありません。",
"merge_progress": "統合中… 数分かかることがあります。このウィンドウは閉じても構いません。",
"merge_run_aborted_toast": "ノードとの接続がリセットされたため、統合を停止しました。続けるには再度実行してください。",
"merge_run_busy": "統合を実行中です。完了を待つか、先に停止してください。",
"merge_run_chip_fmt": "統合中 %d/%d",
"merge_run_err_timeout": "ノードが時間内に応答しませんでした。この回はまだキューに入っている可能性があります。",
"merge_run_halted_fmt": "失敗が続いたため統合を停止しました:%s",
"merge_run_migration_pending": "統合する前に、シードの移行を完了するかキャンセルしてください。",
"merge_run_progress_fmt": "統合中… %d 回目(全体で約 %d 回)、これまでに %d 件のノートを統合しました。このウィンドウを閉じてもバックグラウンドで続行します。",
"merge_run_start_failed": "統合を開始できませんでした — ノードが接続されていません。",
"merge_run_status_not_run": "%s — 未統合(%d 件のノート)",
"merge_run_status_waiting": "%s — 待機中(%d 件のノート)",
"merge_run_stop": "停止",
"merge_run_stopped_toast": "統合を停止しました。残りを統合するには再度実行してください。",
"merge_run_stopping": "送信済みの回が終わったら停止します…",
"merge_run_wait_batch": "ノードの応答性を保つため、間隔を空けて実行しています…",
"merge_run_wait_send": "保留中の取引が完了するまで一時停止しています。",
"merge_run_wait_sync": "一時停止中:ノードはまだ同期中です。",
"merge_scope_failed_fmt": "入力を読み取れませんでした: %s",
"merge_scope_fmt": "透明%d件 + シールド%d件の入力 · 使用可能 約%s DRGX",
"merge_scope_loading": "入力を確認中…",
"merge_send_failed": "マージに失敗しました: ",
"merge_source": "統合",
"merge_src_both": "両方",
"merge_src_shielded": "シールド",
"merge_src_transparent": "透明",
"merge_started": "統合操作を開始しました",
"merge_submit_uncertain": "接続が中断される前に、ノードはマージを確認しませんでした。マージは実行される可能性があります — 再接続後、もう一度マージする前に「履歴」を確認してください。",
"merge_title": "アドレスに統合",
"merge_wait_locked": "ウォレットがロックされています。続行するにはロックを解除してください。",
"merge_waiting_node": "ノードを待っています…",
"merge_where": "資金の移動先",
"mig_adopt": "これを自分のウォレットにする",
"mig_adopt_now": "シードウォレットを採用",
"mig_adopting": "新しいウォレットをインストールして再スキャン中…",
@@ -1016,6 +1282,8 @@
"miner_fee": "マイナー手数料",
"mining": "マイニング",
"mining_active": "アクティブ",
"mining_addr_explainer": "ソロマイニング報酬のみを受け取ります。ネットワークはこのアドレスへの支払いを拒否します。報酬は成熟すると自動的にシールドされます。",
"mining_address": "マイニングアドレス",
"mining_address_copied": "マイニングアドレスをコピーしました",
"mining_all_time": "全期間",
"mining_already_saved": "プールURLは既に保存済みです",
@@ -1029,6 +1297,8 @@
"mining_benchmark_testing": "テスト中",
"mining_benchmark_tooltip": "このCPUに最適なスレッド数を検出",
"mining_block_copied": "ブロックハッシュをコピーしました",
"mining_card_click_to_shield": "クリックすると成熟した報酬を今すぐシールドします。",
"mining_card_tooltip": "ソロマイニング報酬はマイニング(透明、R…)アドレスに届きます。これらのアドレスは支払いを受け取れません。報酬は成熟すると自動的にシールドされます。",
"mining_chart_1m_ago": "1分前",
"mining_chart_5m_ago": "5分前",
"mining_chart_now": "現在",
@@ -1044,6 +1314,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": "プール収益のみ表示",
@@ -1073,6 +1345,7 @@
"mining_payout_address": "支払いアドレス",
"mining_payout_foreign": "⚠ この支払いアドレスは現在のウォレットに含まれていません — マイニング報酬が別のウォレットに送られます。ウォレットを切り替えた場合は更新してください。",
"mining_payout_invalid": "有効な DragonX アドレスではありません — 開始前に修正してください。さもないとマイニング報酬が失われます。",
"mining_payout_needs_shielded": "プールは通常のトランザクションで支払いますが、DragonX ではそれがマイニング(透明、R…)アドレスに届きません。支払い先にはシールドアドレス(zs で始まる)を使用してください。",
"mining_payout_tooltip": "マイニング報酬の受取アドレス",
"mining_pool": "プール",
"mining_pool_fee": "手数料",
@@ -1085,6 +1358,8 @@
"mining_refresh": "更新",
"mining_remove": "削除",
"mining_reset_defaults": "デフォルトにリセット",
"mining_rewards": "マイニング報酬",
"mining_rewards_unshielded": "未シールド",
"mining_save_payout_address": "支払いアドレスを保存",
"mining_save_pool_url": "プールURLを保存",
"mining_saved_addresses": "保存済みアドレス:",
@@ -1118,10 +1393,10 @@
"network_fee": "ネットワーク手数料",
"network_hashrate": "ネットワークハッシュレート",
"new": "+ 新規",
"new_mining_address": "新しいマイニングアドレス",
"new_mining_address_created": "新しいマイニングアドレスを作成しました",
"new_shielded_created": "新しいシールドアドレスを作成しました",
"new_t_address": "新しいTアドレス",
"new_t_transparent": "新しいtアドレス(透明)",
"new_transparent_created": "新しい透明アドレスを作成しました",
"new_z_address": "新しいZアドレス",
"new_z_shielded": "新しいzアドレス(シールド)",
"no_addresses": "アドレスが見つかりません。上のボタンを使用して作成してください。",
@@ -1141,7 +1416,19 @@
"node_banner_offline_title": "DragonX ノードに接続されていません",
"node_banner_reconnect": "再接続",
"node_banner_restart": "ノードを再起動",
"node_busy_rebuild_desc": "経過時間 %s。大きなウォレットでは約 1 時間かかることがあり、完了するまでノードはウォレットに応答できません。",
"node_busy_rebuild_extending": "ウィットネスを拡張しています(この段階ではノードは進捗を報告しません)。",
"node_busy_rebuild_reading": "ブロックを読み込み中:%ld / %ld",
"node_busy_rebuild_title": "ノード処理中:ノートウィットネスを再構築しています",
"node_busy_replay_desc": "経過時間 %s。ノードは前回停止する前に受信したブロックを再適用しています。",
"node_busy_replay_title": "ノード処理中:ブロックを追いかけています",
"node_busy_rpc_desc": "ノードは実行中ですが、%s の間ウォレットに応答できないほど処理中です。通常はウィットネスの再構築などの長時間の処理が原因です。",
"node_busy_rpc_title": "ノード処理中:まだ応答していません",
"node_busy_running": "経過時間 %s",
"node_security": "ノードとセキュリティ",
"node_starting_busy_desc": "ノードには接続できますが処理中のため、まだ応答していません。更新後や初回起動時には正常な状態で、数分かかることがあります。",
"node_starting_launch_desc": "dragonxd を起動し、オンラインになるのを待っています…",
"node_starting_title": "DragonX ノードを起動しています…",
"noise": "ノイズ",
"not_connected": "デーモンに未接続...",
"not_connected_to_daemon": "デーモンに未接続",
@@ -1202,6 +1489,13 @@
"peers_upper": "ピア",
"peers_version": "バージョン",
"pending": "保留中",
"picker_dat_count": "このフォルダー内のウォレットファイル:%d件",
"picker_dat_none": "このフォルダー直下にウォレットファイルはありません",
"picker_empty": "このフォルダーにはサブフォルダーもウォレットファイルもありません。",
"picker_home": "ホームフォルダー",
"picker_select": "このフォルダーをスキャン",
"picker_title": "スキャンするフォルダーを選択",
"picker_up": "1つ上の階層へ",
"pin_change_desc": "ロック解除PINを変更します。現在のPINと新しいPINが必要です。",
"pin_confirm_new_label": "新しいPINの確認:",
"pin_current_label": "現在のPIN:",
@@ -1287,6 +1581,8 @@
"ram_wallet_gb": "ウォレット:%.1f GB",
"ram_wallet_mb": "ウォレット:%.0f MB",
"receive": "受信",
"receive_not_for_payments": "支払いには使えません",
"receive_use_shielded": "シールドアドレスを使う",
"received": "受信済み",
"received_filter": "受信済み",
"received_label": "受信済み",
@@ -1301,6 +1597,7 @@
"reduce_motion": "モーションを減らす",
"refresh": "更新",
"refresh_now": "今すぐ更新",
"refresh_waits_for_sync": "ノードはまだ同期中です。同期が完了するとウォレットのデータが更新されます。",
"remove_favorite": "お気に入りを削除",
"repair_wallet": "ウォレットを修復",
"report_bug": "バグを報告",
@@ -1315,12 +1612,12 @@
"request_select_address": "アドレスを選択...",
"request_shielded_addrs": "-- シールドアドレス --",
"request_title": "支払いを請求",
"request_transparent_addrs": "-- 透明アドレス --",
"request_uri_copied": "支払いURIをクリップボードにコピーしました",
"rescan": "再スキャン",
"rescan_bootstrapped_msg": "ノードはブートストラップされているため、スナップショットより下のブロックはディスク上になく、ジェネシスからの再スキャンは失敗します。ウォレットの使用済み残高を照合するには、スナップショットに含まれる高さから再スキャンしてください。wallet.dat とチェーンデータは削除されません。",
"rescan_detecting": "ノードがディスク上に保有しているブロックを確認中…",
"rescan_from_height": "ブロック高から再スキャン:",
"rescan_probe_failed": "ノードがディスク上に保有しているブロックを確認できませんでした(ノードが処理中か、リクエストがタイムアウトしました)。このノードがブートストラップされている場合は、キャンセルして1分後に再試行してください。ブートストラップされたノードでは、下の -rescan による再起動は失敗します。",
"reset_to_defaults": "デフォルトにリセット",
"restarting_after_encryption": "暗号化後にデーモンを再起動中...",
"restore_address": "アドレスを復元",
@@ -1338,6 +1635,7 @@
"save_z_transactions": "Z取引を取引リストに保存",
"sb_auth_failed": "認証失敗 — rpcuser/rpcpassword を確認してください",
"sb_block": "ブロック: %d",
"sb_block_db_unreadable": "ブロックデータベースを読み込めません — 再構築が必要です",
"sb_building_witnesses": "証人を設定中",
"sb_building_witnesses_pct": "ウィットネスを再構築中 %.0f%%",
"sb_connecting_daemon": "dragonxd に接続中...",
@@ -1345,12 +1643,15 @@
"sb_connecting_external": "外部デーモンに接続中...",
"sb_connecting_generic": "デーモンに接続中...",
"sb_daemon_crashed": "デーモンが %d 回クラッシュしました",
"sb_daemon_extract_failed": "デーモンファイルの書き込みに失敗しました — 空きディスク容量と権限を確認してください。",
"sb_daemon_files_failed": "デーモンファイルを%sに書き込めませんでした — 空きディスク容量と権限を確認してください。",
"sb_daemon_not_found": "デーモンが見つかりません",
"sb_daemon_start_failed": "dragonxd を起動できませんでした",
"sb_dragonxd_running": "dragonxd 実行中",
"sb_dragonxd_stopped": "dragonxd 停止",
"sb_dragonxd_stopping": "dragonxd を停止中...",
"sb_extracting_sapling": "Sapling パラメータを展開中...",
"sb_finishing_repair": "ウォレットの修復を仕上げています — 再スキャン中…",
"sb_importing_keys": "鍵をインポート中",
"sb_loading_config": "設定を読み込み中...",
"sb_mining_hs": "%.1f H/s",
@@ -1359,7 +1660,9 @@
"sb_net_khs": "ネット: %.2f KH/s",
"sb_net_mhs": "ネット: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf が見つかりません",
"sb_node_busy": "ノード処理中",
"sb_peers": "ピア: %zu",
"sb_plaintext_remote_blocked": "リモートホストへRPC認証情報を平文で送信することを拒否しました。許可するにはDRAGONX.confにrpcallowplaintext=1を追加するか、rpctls=1でTLSを有効にしてください。",
"sb_rescanning": "再スキャン",
"sb_rescanning_pct": "再スキャン %.0f%%",
"sb_restarting_daemon": "デーモンを再起動中...",
@@ -1371,6 +1674,7 @@
"sb_waiting_config": "デーモン設定を待機中...",
"sb_waiting_daemon": "dragonxd を待機中...",
"sb_waiting_daemon_err": "dragonxd を待機中 — %s",
"sb_wallet_needs_recovery": "ウォレットの修復が利用可能です",
"sb_warming_up": "ウォームアップ中...",
"sb_witness_cache": "ウィットネスを再構築中",
"scale_effects": "スケールとエフェクト",
@@ -1378,6 +1682,7 @@
"screenshot_sweep": "スクリーンショットスイープを実行",
"screenshot_sweep_desc": "すべてのテーマをすべてのタブで巡回し、それぞれのスクリーンショットを設定ディレクトリの screenshots フォルダ内のタブごとのサブフォルダに保存します(前回のスイープを上書きします)。数秒間実行されます。",
"screenshot_sweep_full": "UI全体スイープ",
"screenshot_sweep_refused": "UI全体スイープは拒否されました。デバッグログを確認してください",
"search_icons": "アイコンを検索...",
"search_placeholder": "検索...",
"sec_changing_passphrase": "パスフレーズを変更しています...",
@@ -1399,15 +1704,19 @@
"sec_mode_passphrase": " パスフレーズ",
"sec_not_connected_to_daemon": "デーモンに接続されていません",
"sec_not_connected_to_daemon_pin": "デーモンに接続されていません",
"sec_passphrase_changed_pin_stale": "パスフレーズを変更しました。PIN は古くなりました",
"sec_passphrase_changed_successfully": "パスフレーズを変更しました",
"sec_pin_changed_successfully": "PINを変更しました",
"sec_pin_removed": "PINを削除しました",
"sec_pin_set_successfully": "PINを設定しました",
"sec_pin_stale_msg": "PIN が古くなっています。パスフレーズを使用してください。",
"sec_pin_unreadable": "PIN ファイルが読めません。パスフレーズで解除してください。",
"sec_restart_daemon_for_encryption": "暗号化を有効にするには、デーモンを再起動してください。",
"sec_too_many_attempts_wait": "試行回数が多すぎます。%.0f秒お待ちください...",
"sec_total_elapsed_fmt": "合計経過時間: %d分 %02d秒",
"sec_unlock_button": "ロック解除",
"sec_unlock_failed_prefix": "ロック解除に失敗しました: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "ロック解除中%s",
"sec_use_passphrase_instead": "代わりにパスフレーズを使用",
"sec_use_pin_instead": "代わりにPINを使用",
@@ -1442,6 +1751,7 @@
"send_amount": "金額",
"send_amount_details": "金額の詳細",
"send_amount_upper": "金額",
"send_available_note": "利用可能",
"send_cannot_send_now": "現在送信できません — 接続、同期、利用可能な残高を確認してください。",
"send_clear_fields": "すべてのフォームフィールドをクリアしますか?",
"send_contacts_button": "連絡先から選択",
@@ -1459,6 +1769,8 @@
"send_from_this_address": "このアドレスから送金",
"send_go_to_receive": "受信へ移動",
"send_keep": "保持",
"send_mining_addr_blocked": "これはマイニング(透明)アドレスです。DragonX ではこのアドレスはマイニング報酬しか受け取れず、ネットワークはこのアドレスへの支払いを拒否します。受取人にシールドアドレス(zs で始まる)を依頼してください。",
"send_mining_addr_short": "マイニングアドレス — 支払いを受け取れません",
"send_network_fee": "ネットワーク手数料",
"send_no_balance": "残高なし",
"send_no_recent": "最近の送信なし",
@@ -1486,7 +1798,6 @@
"send_txid_copied": "TxIDをクリップボードにコピーしました",
"send_txid_label": "TxID:%s",
"send_valid_shielded": "有効なシールドアドレス",
"send_valid_transparent": "有効な透明アドレス",
"send_wallet_empty": "ウォレットは空です",
"send_yes_clear": "はい、クリア",
"sender_balance": "送信者: %.8f → %.8f DRGX",
@@ -1549,6 +1860,7 @@
"settings_open_log_folder": "ログフォルダを開く",
"settings_other": "その他",
"settings_pin_active": "PIN",
"settings_pin_out_of_date": "PIN が古くなっています",
"settings_privacy": "プライバシー",
"settings_quick_unlock_pin": "クイックアンロック PIN",
"settings_reduce_transparency": "透明度を下げる",
@@ -1614,11 +1926,24 @@
"shielded_type": "シールド",
"shielding_notice": "注意:トランスペアレント (T) アドレスからプライベート (Z) アドレスへ資金をシールドします。",
"show": "表示",
"show_earnings": "収益を表示",
"show_hidden": "非表示を表示 (%d)",
"show_qr": "QRを表示",
"show_qr_code": "QRコードを表示",
"showing_transactions": "%d–%d / %d 件の取引を表示中(合計:%zu)",
"showing_x_of_y": "%d / %d アドレスを表示",
"shutdown_kill_confirm_msg": "ノードは長時間 CPU やディスクの活動がなく、停止している可能性があります。\n強制終了すると保存されていない内容はすべて失われ、次回の起動に大幅に時間がかかることがあります。",
"shutdown_kill_confirm_title": "ノードを強制終了しますか?",
"shutdown_kill_confirm_yes": "強制終了",
"shutdown_kill_node": "ノードを強制終了(危険)",
"shutdown_kill_node_tip": "ノードを直ちに終了します。保存されていない内容はすべて失われ、次回の起動時に前回の保存以降のブロックを再処理します。",
"shutdown_leave_running": "ウォレットを閉じる — ノードは実行したまま",
"shutdown_leave_running_tip": "ウォレットをすぐに閉じます。ノードは単独で実行を続け、処理中の作業を完了します。何も失われません。",
"shutdown_stop_asking": "ノードに停止を要求しています…",
"shutdown_stop_cpu_suffix": " · CPU %d%%",
"shutdown_stop_delivered": "ノードを終了し、状態を保存しています",
"shutdown_stop_idle": "%d 分間 CPU やディスクの活動がありません — ノードが停止している可能性があります",
"shutdown_stop_queued": "ノードは停止要求を受け取りましたが処理中です — 可能になり次第停止します",
"simple_background": "シンプル背景",
"slider_off": "オフ",
"sort_amount_high": "金額が大きい順",
@@ -1626,14 +1951,29 @@
"sort_date_newest": "新しい順",
"sort_date_oldest": "古い順",
"start_mining": "マイニング開始",
"startup_files_preparing": "ウォレットファイルを準備しています... %d%%",
"startup_files_verifying": "ウォレットファイルを検証しています...",
"status": "ステータス",
"stop_external": "外部デーモンを停止",
"stop_mining": "マイニング停止",
"stopconfirm_busy_body": "%s の間、ウォレットに応答できないほど処理中です。ウィットネスの再構築などの長時間の処理が原因と考えられます。今停止すると、次回の起動時にその処理をやり直すことがあります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
"stopconfirm_busy_title": "ノードは処理中です",
"stopconfirm_rebuild_body": "経過時間は %s で、大きなウォレットでは約 1 時間かかることがあります。今ノードを停止すると、再構築の完了まで待つか、次回の起動時に再構築とブロックの追いつきをやり直すことになります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
"stopconfirm_rebuild_title": "ノードはノートウィットネスを再構築しています",
"stopconfirm_stop_wait": "ノードを停止して待つ",
"stopconfirm_walletop_body": "ノードは鍵のインポートまたは再スキャンのためにチェーンをスキャンしています。今停止するとスキャンが途中で終了し、まだスキャンされていない資金は 設定 → 再スキャン の後にのみ表示されます。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的にスキャンを完了します。",
"stopconfirm_walletop_title": "鍵のインポートまたは再スキャンを実行中です",
"stratum_allowip_hint": "マイナーの接続を許可するIPまたはCIDR(空欄=このコンピューターのみ)",
"stratum_expose_warn": "許可したネットワークにマイニングポートを公開します — 信頼できるLANでのみ使用してください。",
"stratum_host": "マイニングプールをホストする(stratum)",
"stratum_host_hint": "マイナーはポート22769(RPCポート+1000)でこのコンピューターにRandomX stratumマイナーとして接続します。適用するにはデーモンを再起動してください。",
"stratum_host_section": "マイニングプールのホスティング",
"submitting_transaction": "取引を送信中...",
"success": "成功",
"summary": "概要",
"sweep_button": "集約",
"sweep_caveat": "鍵をインポートして、すべての資金をあなたのアドレスへ移す取引に署名します。鍵は残高ゼロでウォレットに残ります。",
"sweep_current_theme_only": "現在のテーマのみ",
"sweep_dest_label": "集約先",
"sweep_dest_new": "新しいシールドアドレス(推奨)",
"sweep_done": "完了 — 資金をあなたのアドレスに集約しました。",
@@ -1669,7 +2009,6 @@
"switch_stopnode_title": "実行中のノードを停止しますか?",
"switch_stopnode_warn": "このウォレットが起動していないノードがすでに実行中です。",
"syncing": "同期中...",
"t_address": "Tアドレス",
"t_addresses": "Tアドレス",
"test_connection": "テスト",
"theme": "テーマ",
@@ -1691,6 +2030,7 @@
"tools": "ツール",
"tools_actions": "ツールとアクション...",
"tools_actions_hdr": "ツールと操作",
"tor_never_applied_notice": "「Torを使用」オプションは一度も機能していませんでした:接続は Tor 経由でルーティングされていませんでした。このオプションはオフにしました。代替手段については、設定にあるこのオプションのツールチップをご覧ください。",
"total": "合計",
"total_balance_label": "総残高",
"transaction_id": "取引ID",
@@ -1704,8 +2044,6 @@
"transfer_sent": "送金完了",
"transfer_sent_desc": "送金がネットワークに送信されました。",
"transfer_to": "送金先:",
"transparent": "透明",
"transparent_address": "トランスペアレントアドレス",
"try_again": "再試行",
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
"tt_address_book": "クイック送信用の保存済みアドレスを管理",
@@ -1737,10 +2075,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": "マイニング開始前の待機時間",
@@ -1749,6 +2093,7 @@
"tt_keep_daemon": "セットアップウィザード実行時にデーモンは停止します",
"tt_language": "ウォレット UI のインターフェース言語",
"tt_layout_hotkey": "ホットキー:左右矢印キーでバランスレイアウトを切り替え",
"tt_lite_confirm_pass": "新しいパスフレーズをもう一度入力してください。一致し、8 文字以上である必要があります",
"tt_lite_copy": "表示された秘密情報をクリップボードにコピーします",
"tt_lite_decrypt_pass": "ウォレットの暗号化を解除するためにパスフレーズを入力します",
"tt_lite_encrypt": "上のパスフレーズでウォレットを暗号化します。すぐにロックされ、解除にはパスフレーズが必要です",
@@ -1777,11 +2122,16 @@
"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 フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
"tt_open_dir": "クリックしてファイルエクスプローラーで開く",
"tt_open_log_folder": "デバッグログとクラッシュログが入ったフォルダを開きます",
"tt_pin_out_of_date": "この PIN の設定後にウォレットのパスフレーズが変更されたため、PIN ではウォレットをアンロックできません。PIN を削除して再設定してください。",
"tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする",
"tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存",
"tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求",
@@ -1811,14 +2161,20 @@
"tt_simple_bg": "背景にシンプルなグラデーションを使用\\nホットキー:Ctrl+Up",
"tt_simple_bg_alt": "テーマ背景画像のグラデーション版を使用\\nホットキー:Ctrl+Up",
"tt_stop_external": "このウォレット外で起動された\\nデーモンに接続する場合に適用",
"tt_stratum_host": "このノード上でRandomX stratumプールサーバーを実行し、他のRandomXマイナーがこのコンピューターに接続できるようにします。v1.3.0以降のノードが必要で、適用にはデーモンの再起動が必要です。",
"tt_sweep_current_theme_only": "全テーマを順に処理せず、有効なテーマのみをスイープします",
"tt_test_conn": "デーモンへの RPC 接続を確認",
"tt_theme_effects": "テーマごとのシマー、グロー、色相サイクル",
"tt_theme_hotkey": "ホットキー:Ctrl+左/右でテーマを切り替え",
"tt_tor": "匿名性のためにデーモン接続を Tor ネットワーク経由でルーティング",
"tt_tor_unavailable": "まだ利用できません:このウォレットはどの通信も Tor 経由でルーティングしません。ノードに限り、DRAGONX.conf に proxy=127.0.0.1:9050 を追加してノードを再起動できます。Tor が起動している必要があり、起動していないとノードはピアを見つけられません。価格、アップデート、ブートストラップ、プールのリクエストは引き続き直接接続されます。これらにはシステム全体の VPN/Tor を使用してください。",
"tt_tor_unavailable_lite": "まだ利用できません:このウォレットはどの通信も Tor 経由でルーティングしません。システム全体の VPN/Tor を使用してください。",
"tt_tx_url": "ブロックエクスプローラーでトランザクションを表示するためのベース URL",
"tt_ui_opacity": "カードとサイドバーの不透明度(100% = 完全不透明、低い = より透過)",
"tt_validate": "DragonX アドレスが有効かどうかを確認",
"tt_verbose": "詳細な接続診断、デーモン状態、\\nポート所有者情報をコンソールタブに記録",
"tt_view_transactions": "このアドレスに関連する取引を履歴タブに表示",
"tt_wallet_size_warn": "シールドウォレットはノートごとのウィットネスデータによって肥大化します — 多数のノートを1つのアドレスに統合するとサイズを抑えられます。事前にバックアップしてください。",
"tt_wallets_button": "ウォレットファイルを一覧表示して切り替えます",
"tt_website": "DragonX ウェブサイトを開く",
"tt_window_opacity": "背景の不透明度(低い = デスクトップがウィンドウ越しに見える)",
@@ -1835,6 +2191,7 @@
"tx_loading_queued": "取引の更新を待機中",
"tx_loading_refreshing_cached": "ウォレット履歴を更新中(%d 件キャッシュ済み)",
"tx_loading_scanning_shielded": "シールド履歴をスキャン中 (%d アドレス)",
"tx_loading_waits_for_sync": "ノードの同期が完了すると履歴が更新されます",
"tx_mined": "採掘済み",
"tx_progress_balances": "残高を更新中",
"tx_progress_finalizing": "トランザクションを確定中",
@@ -1866,6 +2223,7 @@
"validate_description": "DragonXアドレスを入力して、有効かどうか、そしてこのウォレットに属しているかどうかを確認します。",
"validate_invalid": "無効",
"validate_is_mine": "このウォレットがこのアドレスを所有しています",
"validate_mining_type": "マイニングアドレス(透明)",
"validate_not_mine": "このウォレットに属していません",
"validate_ownership": "所有者:",
"validate_results": "結果:",
@@ -1873,7 +2231,6 @@
"validate_shielded_type": "シールド(zアドレス)",
"validate_status": "ステータス:",
"validate_title": "アドレスを検証",
"validate_transparent_type": "透明(tアドレス)",
"validate_type": "タイプ:",
"validate_valid": "有効",
"validating": "検証中...",
@@ -1883,10 +2240,71 @@
"view_all": "すべて表示",
"view_details": "詳細を表示",
"view_on_explorer": "エクスプローラーで表示",
"view_transactions": "取引を表示",
"waiting_for_daemon": "デーモン接続を待機中...",
"wallet": "ウォレット",
"wallet_degraded_notify": "ウォレットは機能制限モードで開かれました。既存の資金は安全で使用可能ですが、新しいアドレスの作成とシールドは無効になっています。ウォレットを完全に修復するには、シードフレーズをバックアップしてそこから復元してください。",
"wallet_diagnostics_hdr": "診断",
"wallet_empty": "ウォレットは空です",
"wallet_empty_hint": "受信に切り替えてアドレスを取得し、資金の受け取りを開始してください。",
"wallet_options_hdr": "オプション",
"wallet_rebuild_failed": "再構築で有効なウォレットが生成されなかったため、変更は行われませんでした。ウォレットはそのままです。",
"wallet_rebuild_install_failed": "再構築したウォレットをインストールできませんでした。現在のウォレットはそのまま残されています。",
"wallet_rebuild_no_helper": "このビルドではウォレット再構築ヘルパーを利用できません。「復元」を使うか、手動で再構築してください。",
"wallet_rebuild_no_source": "再構築可能な読み取り可能なウォレットが見つかりませんでした。変更は行われませんでした。",
"wallet_rebuild_ok": "ウォレットデータベースを再構築しました — ノードが読み込み、残高の再スキャンを行っています。",
"wallet_rebuild_started": "ウォレットファイルを修復しています — このウィンドウを閉じないでください。",
"wallet_recovered_body": "何も削除されていません。元のウォレットは日付付きのバックアップファイルとしてこのコンピューターに保存されたままで、以下のどの選択肢もファイルをコピーまたは名前変更するだけで、削除することはありません。鍵が再生成されることはなく、再読み込みされるだけです。\n\n現在読み込まれている修復済みコピーには、直近の一部の取引が含まれていない可能性があるため、以下を完了して再スキャンが終わるまでは残高が少し低く見えることがあります。",
"wallet_recovered_dismiss": "今はしない — 修復済みコピーのまま使う",
"wallet_recovered_dismiss_sub": "ファイルは変更されません。ステータスバーからいつでもこの画面を再度開けます。どちらを選んでも、オリジナルは安全にバックアップされたままです。",
"wallet_recovered_notify": "ウォレットファイルの修復が必要でした — オリジナルは安全にバックアップされています。アプリを開いてオプションを確認してください。",
"wallet_recovered_open_folder": "ファイルを表示",
"wallet_recovered_open_folder_sub": "ウォレットデータフォルダを開き、バックアップファイルを自分で確認できます — 何も変更されません。",
"wallet_recovered_rebuild": "自動修復する(推奨)",
"wallet_recovered_rebuild_sub": "最も内容の多いウォレットファイルから復元可能なすべてのレコードを読み込み、新しいクリーンなファイルを作成してから再起動します。最も徹底したオプションです — いずれにせよ現在のファイルは日付付きバックアップとして保存されます。",
"wallet_recovered_restore": "代わりにオリジナルファイルを復元する",
"wallet_recovered_restore_sub": "手つかずの最も内容の多いバックアップをそのまま元に戻し、再スキャンします — やや高速ですが、そのファイルにあった範囲までしか復元されません。いずれにせよ現在のファイルは日付付きバックアップとして保存されます。",
"wallet_recovered_safety": "コインは安全です。",
"wallet_recovered_title": "ウォレットファイルの簡単な修復が必要です",
"wallet_recovered_warn": "アプリの起動時に、ウォレットファイルが整合性チェックに合格しないことが判明しました — これはアプリの更新後や、正常終了しなかった場合によく起こります。アプリはすでにデータを保護しています。古いファイルは退避され、修復済みのコピーを読み込んだので、行き詰まることはありません。",
"wallet_recovery_decide_later": "後で決める",
"wallet_recovery_decide_later_tip": "この画面を閉じ、現在読み込まれているコピーをそのまま使用します。何も変更されず、いつでも修復できます — ステータスバーに「ウォレットの修復が可能」というリンクが表示され続けます。",
"wallet_recovery_details_label": "技術的な詳細を表示",
"wallet_recovery_done": "完了",
"wallet_recovery_failure_body": "修復は実行されましたが、その結果が検証に合格しませんでした(読み込めなかった、またはアドレスがありませんでした)。そのため何かを置き換える前に自動的に破棄されました。ウォレットはクリックする前とまったく同じ状態です — ディスク上には何も変更されていません。",
"wallet_recovery_failure_title": "修復は完了しませんでした",
"wallet_recovery_files_detail": "オリジナルのウォレットはまだここにあります — アプリはそれをデータフォルダ内の日付付きバックアップ(wallet.<番号>.bak)にリネームしただけで、何も削除していません。「修復」は最も内容の多いウォレットからすべてのレコードを読み込み、まったく新しいクリーンなファイルを作成します。「復元」は最も内容の多いバックアップをそのままコピーして元に戻します。現在読み込まれているコピーもバックアップとして保存されます。",
"wallet_recovery_files_label": "自分のファイルはどうなりますか?",
"wallet_recovery_notnow_short": "後で",
"wallet_recovery_other_options": "他のオプション",
"wallet_recovery_rebuild_card": "自動修復する",
"wallet_recovery_rebuild_card_desc": "復元可能なすべてのレコードをクリーンなファイルに読み込んでから再起動します。最も徹底したオプションです。",
"wallet_recovery_recommended": "推奨",
"wallet_recovery_repair_go": "修復",
"wallet_recovery_rescan_body": "ブロックチェーンを再スキャンして残高と取引履歴を再構築しています。数分かかる場合があります — 何もする必要はありません。実行中はノードを再起動しないでください。",
"wallet_recovery_rescan_elapsed": "%s 経過",
"wallet_recovery_rescan_size": "再構築されたウォレットは現在 %s で、まだ増加中です",
"wallet_recovery_rescan_slow": "通常より時間がかかっていますが、大きなウォレットでは正常です。実行したままにしておいて問題ありません — 進捗は「詳細設定 ▸ コンソール」で確認できます。",
"wallet_recovery_rescan_title": "ウォレットの修復を完了しています",
"wallet_recovery_restore_card": "オリジナルを復元",
"wallet_recovery_restore_card_desc": "手つかずの最も内容の多いバックアップをそのまま元に戻します — そのファイルにあった範囲までしか復元されません。",
"wallet_recovery_restore_go": "復元",
"wallet_recovery_success_body": "アプリは修復済みのウォレットを読み込み、残高を集計するために再スキャンしています — 数分かかる場合があります。読み込める限りすべてのレコードを読み込みますが、まれに破損したファイルでは、以前よりわずかに少ない(または多い)アドレスが復元されることがあります。再スキャンが完了したら、残高と履歴が正しいか確認してください。",
"wallet_recovery_success_restore": "最も内容の多いバックアップファイルが元の場所に戻され、現在読み込み中です。アプリは再スキャンしています。そのまま復元されているため、そのファイルにあった範囲まで正確です — 再スキャンが完了したら残高を確認してください。",
"wallet_recovery_try_other": "別のオプションを試す",
"wallet_recovery_whats_happened": "何が起きましたか?",
"wallet_recovery_working_label": "処理中",
"wallet_restore_bad_backup": "バックアップのウォレットファイルは読み込めないようです。そのため復元されませんでした — 現在のウォレットは変更されていません。ご自身のバックアップから復元してください。",
"wallet_restore_busy": "ノードは再起動中でビジー状態です — しばらくしてからもう一度お試しください。",
"wallet_restore_copy_failed": "バックアップウォレットをインストールできませんでした。現在のウォレットはそのまま残されました。",
"wallet_restore_move_failed": "現在のウォレットを退避できませんでした — 何も変更されていません。",
"wallet_restore_no_backup": "復元できる wallet.<timestamp>.bak が見つかりませんでした。何も変更されていません。",
"wallet_restore_no_restart": "オリジナルのウォレットは復元されましたが、ノードは再起動しませんでした — 設定から起動してください。",
"wallet_restore_ok": "オリジナルのウォレットを復元しました。ノードが読み込み中です。",
"wallet_restore_started": "オリジナルファイルを復元しています — このウィンドウを閉じないでください。",
"wallet_restore_stop_failed": "ノードが時間内に停止しなかったため、何も変更されていません。もう一度お試しください。",
"wallet_size_consolidate": "ノートを統合…",
"wallet_size_warn": "このウォレットファイルは大きくなっています。ノートを統合すると、さらなる増加を抑えられます。",
"wallets_active": "アクティブ",
"wallets_add": "フォルダを追加",
"wallets_add_folder": "ウォレットファイルを検索する別のフォルダを追加:",
@@ -1929,7 +2347,9 @@
"wallets_open_inplace_tt": "このウォレットをその場で開きます — データディレクトリにリンク(コピーなし)",
"wallets_remove_folder": "このフォルダーのスキャンを停止",
"wallets_reveal": "フォルダを開く",
"wallets_scan_folder": "別のフォルダをスキャンしてウォレットを検索…",
"wallets_scanned_folders": "スキャン対象フォルダー:",
"wallets_scanning": "フォルダをスキャンしています…",
"wallets_sort_addresses": "アドレス",
"wallets_sort_asc": "昇順(古い/少ない/小さい順)",
"wallets_sort_by": "並べ替え:",
@@ -1998,19 +2418,30 @@
"wiz_welcome_sub": "いくつか選ぶだけでフルノードの準備が整います。",
"wiz_welcome_title": "ObsidianDragon へようこそ!",
"wizard_daemon_start_failed": "デーモンの起動に失敗しました — 自動的に再試行されます",
"xmrig_active_tt": "現在インストールされている(有効な)マイナー",
"xmrig_browse_releases": "すべてのリリースを表示…",
"xmrig_checking": "最新のマイナーを確認中…",
"xmrig_current": "現在:",
"xmrig_downgrade_note": "別のバージョンのマイナーをインストールすると、現在のものが置き換わります。まずマイニングを停止してください。変更は次回の起動時に反映されます。",
"xmrig_download_install": "ダウンロードしてインストール",
"xmrig_downloading": "ダウンロード中…",
"xmrig_install_this": "このバージョンをインストール",
"xmrig_installed": "インストール済み:",
"xmrig_installed_ok": "マイナーをインストールしました",
"xmrig_installing": "インストール中…",
"xmrig_latest": "最新:",
"xmrig_latest_badge": "最新",
"xmrig_latest_tt": "利用可能な最新バージョン",
"xmrig_loading_releases": "リリースを読み込み中…",
"xmrig_no_notes": "このバージョンのリリースノートはありません。",
"xmrig_none": "なし",
"xmrig_prerelease_tt": "プレリリース/テストビルド",
"xmrig_reinstall": "再インストール",
"xmrig_released": "リリース日:",
"xmrig_releases": "xmrig リリース",
"xmrig_status_current": "このバージョンはインストール済みです",
"xmrig_status_latest": "最新バージョン",
"xmrig_status_older": "インストール済みのバージョンより古い",
"xmrig_stop_mining_first": "マイナーを更新する前にマイニングを停止してください。",
"xmrig_unavailable_body": "このプラットフォーム向けのマイナービルドは利用できません。",
"xmrig_unavailable_title": "マイナーの更新は利用できません",
+454 -25
View File
@@ -19,11 +19,13 @@
"about_license_text": "본 소프트웨어는 GNU General Public License v3 (GPLv3) 하에 배포됩니다. 라이선스 조건에 따라 자유롭게 사용, 수정 및 배포할 수 있습니다.",
"about_peers_count": "%zu 피어",
"about_release": "릴리스",
"about_source": "소스",
"about_title": "ObsidianDragon 정보",
"about_version": "버전:",
"about_website": "웹사이트",
"acrylic": "아크릴",
"add": "추가",
"addr_type_mining": "채굴",
"address": "주소",
"address_book_add": "주소 추가",
"address_book_add_new": "새로 추가",
@@ -67,6 +69,7 @@
"appx_blockchain_maintenance_in_progress": "블록체인 유지 관리 작업이 이미 진행 중입니다.",
"appx_blockchain_rescan_complete": "블록체인 다시 스캔 완료",
"appx_bootstrap_complete_reconciling": "부트스트랩 완료 — 새 체인 데이터와 지갑을 대조하는 중입니다.",
"appx_bootstrap_reconcile_probe_failed": "부트스트랩 체인 데이터와 지갑을 대조하지 못했습니다. 노드가 사용 중이어서 보유한 블록을 확인할 수 없었습니다. 동기화가 따라잡으면 설정에서 재스캔을 실행하세요.",
"appx_cancel": "취소",
"appx_cleaning_up": "정리하는 중...",
"appx_confirm_your_backup": "백업 확인",
@@ -77,11 +80,15 @@
"appx_creating_your_wallet": "지갑을 생성하는 중…",
"appx_daemon_error": "데몬 오류",
"appx_daemon_reinstall_in_progress": "데몬 재설치가 이미 진행 중입니다.",
"appx_daemon_reinstall_node_busy": "노드가 아직 실행 중이라 데몬을 다시 설치하지 않았습니다. 노드가 멈춘 뒤 다시 시도하세요.",
"appx_disconnecting": "연결 해제 중...",
"appx_done": "완료",
"appx_dragonxd_output": "dragonxd 출력",
"appx_encrypting_wallet": "지갑 암호화 중...",
"appx_fullnode_lifecycle_unavailable_lite": "풀노드 수명 주기 작업은 라이트 빌드에서 사용할 수 없습니다",
"appx_import_cancelled": "취소됨: 노드가 아직 스캔 중일 수 있습니다. 키의 자금이 보이지 않으면 설정 → 재스캔을 실행하세요.",
"appx_import_interrupted": "가져오기가 중단되어 키가 이미 지갑에 있을 수 있습니다. 자금이 보이지 않으면 설정 → 재스캔을 실행하세요.",
"appx_import_refused": "노드가 요청을 거부하여 키를 가져오지 않았습니다.",
"appx_installing_bundled_daemon": "번들 데몬을 설치하는 중 — 노드가 중지, 업데이트 후 다시 시작됩니다...",
"appx_invalid_payment_uri_prefix": "잘못된 결제 URI: ",
"appx_ive_written_it_down": "적어 두었습니다",
@@ -98,7 +105,8 @@
"appx_no_bundled_daemon_to_install": "이 빌드에는 설치할 번들 데몬이 없습니다",
"appx_no_embedded_daemon_to_install": "이 빌드에는 설치할 내장 데몬이 없습니다",
"appx_node_busy_restarting": "노드가 재시작 중입니다 — 잠시 후 다시 시도하세요.",
"appx_node_rebuilding_witness_cache": "노드가 위트니스 캐시를 재구성하는 중입니다",
"appx_node_busy_try_later": "노드가 긴 작업(노트 위트니스 재구성 등)을 마무리하는 중입니다. 끝난 뒤 다시 시도하세요. 지금 중지하면 그 작업을 처음부터 다시 할 수 있습니다.",
"appx_node_not_stopped": "노드가 제때 중지되지 않아 아무것도 변경되지 않았습니다. 몇 분 후 다시 시도하세요.",
"appx_not_next_word": " — 다음 단어가 아닙니다",
"appx_payment_request_loaded": "결제 요청을 불러왔습니다",
"appx_pool_miner_connected_and_hashing": "풀 채굴기가 연결되어 해싱 중입니다.",
@@ -119,11 +127,7 @@
"appx_simple_background_enabled": "단순 배경 활성화됨",
"appx_skip": "건너뛰기",
"appx_skip_anyway": "그래도 건너뛰기",
"appx_still_status_prefix": "아직 \"",
"appx_still_status_suffix": "\" 상태입니다 — 지금 강제 종료하면 체인 데이터가 손상될 수 있습니다.",
"appx_stop_anyway_and_quit": "그래도 중지하고 종료",
"appx_stopping_daemon_deleting_blockchain": "데몬을 중지하고 블록체인 데이터를 삭제하는 중...",
"appx_stopping_node_discards_rebuild": "지금 노드를 중지하면 진행 중인 재구성이 취소되며, 다음에 지갑을 열 때 다시 시작됩니다(몇 분 소요). 대신 노드를 계속 실행할 수 있습니다.",
"appx_stopping_pool_miner": "풀 채굴기를 중지하는 중...",
"appx_syncing_pct_block_n_of_n": "동기화 중 %.1f%% — 블록 %d / %d",
"appx_tap_words_in_order": "저장한 내용을 확인하도록 단어를 올바른 순서대로 탭하세요.",
@@ -133,8 +137,14 @@
"appx_use_settings_restart_daemon_hint": "설정 > 데몬 다시 시작을 사용해 다시 시도하세요",
"appx_waiting_for_daemon_to_encrypt_wallet": "데몬이 지갑을 암호화하기를 기다리는 중...",
"appx_wallet_created_and_backed_up": "지갑이 생성되고 백업되었습니다.",
"appx_wallet_op_node_busy": "노드가 바빠서 응답하지 않습니다. 끝난 뒤 가져오기 또는 재스캔을 다시 시도하세요.",
"appx_wallet_op_running": "키 가져오기 또는 재스캔이 아직 실행 중입니다. 끝난 뒤 다시 시도하세요.",
"appx_wallet_op_shutting_down": "지갑을 종료하는 중입니다.",
"appx_wallet_open_failed_prefix": "지갑 열기 실패: ",
"auto_shield": "채굴 자동 차폐",
"autoshield_by_node": "노드가 채굴한 코인베이스를 자동으로 보호 처리합니다.",
"autoshield_off_backup_seed": "채굴한 코인베이스가 아직 자동으로 보호 처리되지 않고 있습니다. 노드가 안전하게 이를 활성화할 수 있도록 시드 문구를 백업하세요 (설정 → 백업 & 데이터).",
"autoshield_off_generic": "현재 노드가 채굴한 코인베이스를 자동으로 보호 처리하지 않고 있습니다.",
"av_intro": "채굴 소프트웨어는 종종 잠재적으로 원치 않는 항목으로 표시됩니다. 풀 채굴을 활성화하려면 다음 단계를 따르세요:",
"av_open_security": "Windows 보안 열기",
"av_step1": "1단계: 제외 추가",
@@ -170,21 +180,20 @@
"balance_history_collecting": "잔액 내역 — 데이터 수집 중...",
"balance_layout": "잔액 레이아웃",
"balance_layout_switched": "레이아웃: %s",
"balance_mining_fmt": "채굴 보상: %.8f",
"balance_mining_rate": "채굴 중 %s",
"balance_shielded_fmt": "차폐: %.8f",
"balance_syncing_pct": "동기화 중 %.1f%%",
"balance_transparent_fmt": "투명: %.8f",
"baltab_market": "시세",
"baltab_market_price_4dp": "시세: $%.4f",
"baltab_market_price_8dp": "시세: $%.8f",
"baltab_mining_addresses_count": "채굴 주소 %d개",
"baltab_mining_amount": "채굴 보상 %.8f",
"baltab_pct_change_24h": "%s%.1f%% 24시간",
"baltab_pct_of_total_zaddr": "전체의 %.0f%% · Z-addr %d개",
"baltab_shielded": "실드됨",
"baltab_shielded_amount": "실드됨 %.8f",
"baltab_t_addresses_count": "T-address %d개",
"baltab_total_balance": "총 잔액",
"baltab_transparent": "투명",
"baltab_transparent_amount": "투명 %.8f",
"ban": "차단",
"banned_peers": "차단된 피어",
"block": "블록",
@@ -192,6 +201,12 @@
"block_click_copy": "복사하려면 클릭",
"block_click_next": "클릭하여 다음 블록 보기",
"block_click_prev": "클릭하여 이전 블록 보기",
"block_db_reindex_body": "이 현상은 보통 데몬 업데이트로 디스크 저장 형식이 바뀌었거나, 블록 색인이 손상된 경우에 발생합니다. 지갑과 코인은 안전합니다 — 노드가 체인을 불러오지 못할 뿐이라 잔액이 0으로 표시됩니다.\n\n재구축은 기존 블록 파일을 다시 읽어 들이며 시간이 다소 걸릴 수 있습니다(지갑도 다시 스캔합니다). 아무것도 다운로드하지 않습니다.",
"block_db_reindex_confirm": "블록 데이터베이스 재구축",
"block_db_reindex_notify": "노드가 블록 데이터베이스를 읽을 수 없습니다(흔히 데몬 업데이트 후 발생). 잔액을 복구하려면 재구축하세요 — 안내창을 확인하거나 설정 › 노드로 이동하세요.",
"block_db_reindex_started": "블록에서 블록 데이터베이스를 재구축하는 중입니다 — 시간이 다소 걸릴 수 있습니다.",
"block_db_reindex_title": "블록 데이터베이스를 재구축할까요?",
"block_db_reindex_warn": "노드가 블록 데이터베이스를 읽을 수 없습니다.",
"block_explorer": "블록 탐색기",
"block_get_info": "블록 정보 조회",
"block_hash": "블록 해시:",
@@ -228,6 +243,7 @@
"change_pass_confirm": "새 암호 확인:",
"change_pass_current": "현재 암호:",
"change_pass_new": "새 암호:",
"change_pass_pin_note": "이 지갑에는 빠른 잠금 해제 PIN이 있습니다. 변경 후에는 PIN이 오래된 상태가 되므로 설정에서 PIN을 제거한 뒤 다시 설정하세요.",
"change_pass_title": "암호 변경",
"characters": "문자",
"chat": "채팅",
@@ -239,6 +255,10 @@
"chat_accent_theme": "테마",
"chat_add_contact": "연락처 추가",
"chat_awaiting_key": "답장 대기 중",
"chat_blocked_desc": "차단된 대화는 삭제되며, 차단을 해제하기 전까지 기존 및 새 메시지가 모두 무시됩니다. 차단을 해제하면 체인에서 대화를 다시 가져옵니다.",
"chat_blocked_manage": "차단됨",
"chat_blocked_title": "차단된 대화",
"chat_blocked_toast": "대화를 삭제하고 차단했습니다",
"chat_bubble_minimal": "미니멀",
"chat_bubble_rounded": "둥근",
"chat_bubble_square": "각진",
@@ -251,6 +271,16 @@
"chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요",
"chat_contact_request": "연락 요청",
"chat_copy_address_tip": "클릭하여 주소 복사",
"chat_delete": "대화 삭제",
"chat_delete_block": "삭제 및 차단",
"chat_delete_body_prefix": "다음 상대와의 대화 로컬 사본을 삭제할까요: ",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "삭제",
"chat_delete_failed": "대화를 삭제하지 못했습니다 — 변경된 내용이 없습니다.",
"chat_delete_local_note": "이 기기에만 영향을 줍니다 — 메시지는 블록체인에 남아 있고 상대방도 자신의 사본을 계속 보관합니다.",
"chat_delete_revive_note": "\"삭제\"는 이 기기의 기록만 지웁니다. 상대방이 다시 메시지를 보내면 대화가 되살아납니다.",
"chat_delete_title": "대화를 삭제할까요?",
"chat_deleted_toast": "대화를 삭제했습니다",
"chat_density_comfortable": "편안하게",
"chat_density_compact": "촘촘하게",
"chat_emoji_color": "컬러",
@@ -274,6 +304,7 @@
"chat_new_button": "새 채팅",
"chat_new_message": "메시지",
"chat_new_message_toast": "새 암호화 채팅 메시지",
"chat_new_needs_zaddr": "채팅에는 보호 (z) 주소가 필요합니다 — 투명 (t) 주소는 암호화된 메시지를 받을 수 없습니다.",
"chat_new_send": "요청 보내기",
"chat_new_title": "새 채팅",
"chat_new_zaddr": "받는 사람 z-주소",
@@ -319,6 +350,8 @@
"chat_ts_24h": "24시간",
"chat_ts_global": "전역 설정 따르기",
"chat_ts_global_short": "전역",
"chat_unblock": "차단 해제",
"chat_unblocked_toast": "대화 차단을 해제했습니다",
"chat_unhide": "다시 표시",
"chat_unmute": "음소거 해제",
"chat_verify_key": "신원 키 — 비교하여 확인",
@@ -371,6 +404,7 @@
"console": "콘솔",
"console_accents": "색상 강조",
"console_app": "앱",
"console_auto_focus": "열 때 입력창에 포커스",
"console_auto_scroll": "자동 스크롤",
"console_available_commands": "사용 가능한 명령어:",
"console_backend_reference": "백엔드 명령어 참조",
@@ -465,11 +499,15 @@
"console_tab_completion": "Tab으로 자동 완성",
"console_text_colors": "텍스트 색상",
"console_toggle_accents": "줄 색상 강조 전환",
"console_toggle_auto_focus": "콘솔 탭을 열 때 명령 입력창에 커서를 놓습니다",
"console_toggle_text_color": "줄 텍스트 색상 전환",
"console_type_help": "'help'를 입력하여 사용 가능한 명령어 보기",
"console_welcome": "ObsidianDragon 콘솔에 오신 것을 환영합니다",
"console_zoom_in": "확대",
"console_zoom_out": "축소",
"consolidate_desc": "여러 개의 작은 입력을 하나의 보호된 노트로 합칩니다. 노트 수가 줄면 지갑 파일이 작아지고 프라이버시도 향상됩니다.",
"consolidate_funds_btn": "통합",
"consolidate_title": "자금 통합",
"contact_avatar": "아바타",
"contact_avatar_bad_image": "그 이미지를 불러올 수 없습니다.",
"contact_avatar_badge": "배지",
@@ -481,7 +519,7 @@
"contact_avatar_image_hint": "이미지는 앱에 복사되어 원본이 이동해도 계속 사용할 수 있습니다.",
"contact_avatar_remove": "제거",
"contact_avatar_shielded": "보호",
"contact_avatar_transparent": "투명",
"contact_chip_mining": "채굴 전용",
"contact_global": "모든 지갑에 표시(전역 연락처)",
"contact_global_badge_tt": "전역 연락처 — 모든 지갑에서 표시됨",
"contact_global_tt": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.",
@@ -515,9 +553,13 @@
"daemon_maintenance_label": "유지 관리",
"daemon_none_bundled": "이 빌드에 없음",
"daemon_not_installed": "설치되지 않음",
"daemon_status_diff": "버전이 다름",
"daemon_status_differ": "설치된 바이너리가 번들 버전과 다릅니다.",
"daemon_status_match": "설치된 바이너리가 번들 버전과 일치합니다.",
"daemon_status_missing": "설치된 데몬 없음 — 번들 버전을 설치하세요.",
"daemon_status_none": "설치되지 않음",
"daemon_status_ok": "최신 상태",
"daemon_update_active_tt": "현재 설치되어(활성화되어) 있는 노드",
"daemon_update_available": "새 노드 버전이 있습니다",
"daemon_update_body": "이 지갑 빌드에는 현재 설치된 것보다 새로운 DragonX 노드가 번들로 포함되어 있습니다. 업데이트하면 설치된 dragonxd(및 dragonx-cli/dragonx-tx)가 교체된 후, 새 버전이 적용되도록 노드를 중지했다가 다시 시작합니다. 새 노드는 기존 노드에 없는 기능(예: 시드 문구 지원)을 추가할 수 있으므로 권장합니다.",
"daemon_update_browse": "모든 릴리스 보기…",
@@ -527,19 +569,28 @@
"daemon_update_download_install": "다운로드 및 설치",
"daemon_update_downloading": "다운로드 중…",
"daemon_update_failed": "업데이트 실패",
"daemon_update_install_this": "이 버전 설치",
"daemon_update_installed": "설치됨:",
"daemon_update_installed_ok": "노드가 설치되었습니다",
"daemon_update_installing": "설치 중…",
"daemon_update_keep": "현재 버전 유지",
"daemon_update_later": "나중에",
"daemon_update_latest": "최신:",
"daemon_update_latest_badge": "최신",
"daemon_update_latest_tt": "사용 가능한 최신 버전",
"daemon_update_loading": "릴리스 불러오는 중…",
"daemon_update_no_notes": "이 버전에는 릴리스 노트가 없습니다.",
"daemon_update_now": "지금 업데이트",
"daemon_update_prerelease_tt": "사전 릴리스 / 테스트 빌드",
"daemon_update_prompt_title": "노드 데몬을 업데이트하시겠습니까?",
"daemon_update_reinstall": "다시 설치",
"daemon_update_released": "출시일:",
"daemon_update_restart_note": "새 버전을 실행하려면 데몬을 재시작하세요.",
"daemon_update_restart_now": "지금 데몬 재시작",
"daemon_update_safe": "지갑, 키, 블록체인 데이터는 그대로이며 — 데몬 프로그램 파일만 교체됩니다. 커스텀 노드를 일부러 실행 중이라면 현재 버전 유지를 선택하세요.",
"daemon_update_status_current": "이 버전이 설치되어 있습니다",
"daemon_update_status_latest": "최신 버전",
"daemon_update_status_older": "설치된 버전보다 이전 버전입니다",
"daemon_update_title": "노드 데몬을 업데이트할까요?",
"daemon_update_unavailable_body": "이 플랫폼에 사용할 수 있는 노드 빌드가 없습니다.",
"daemon_update_unavailable_title": "노드 업데이트를 사용할 수 없음",
@@ -555,6 +606,13 @@
"data_stale_tooltip": "잔액이 오래되었을 수 있습니다 — 지갑이 최근에 업데이트를 받지 못했습니다. 노드 연결을 확인하세요.",
"date": "날짜",
"date_label": "날짜:",
"debug_gate_confirm": "디버그 옵션 표시",
"debug_gate_incorrect": "PIN 또는 암호가 올바르지 않습니다.",
"debug_gate_pass_prompt": "계속하려면 지갑 암호를 입력하세요:",
"debug_gate_pin_prompt": "계속하려면 잠금 해제 PIN을 입력하세요:",
"debug_gate_title": "디버그 옵션을 표시할까요?",
"debug_gate_verifying": "확인 중…",
"debug_gate_warning": "이 옵션들은 고급 진단 및 노드 튜닝 옵션입니다. 민감한 정보를 노출하거나 노드 동작 방식을 변경할 수 있습니다. 무엇을 하는지 정확히 알고 있을 때만 계속하세요.",
"debug_logging": "디버그 로깅",
"decrypt_desc": "지갑을 내보내고 데몬을 새 암호화되지 않은 지갑으로 다시 시작한 후 모든 키를 다시 가져옵니다. 지갑 크기에 따라 몇 분 정도 걸릴 수 있습니다.",
"decrypt_error_title": "복호화 실패",
@@ -577,9 +635,15 @@
"disconnected": "연결 끊김",
"dismiss": "닫기",
"display": "디스플레이",
"done": "완료",
"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": "지갑 관리자 열기",
@@ -630,6 +694,7 @@
"explorer_tx_outputs": "출력",
"explorer_tx_size": "크기",
"explorer_txid": "TxID:",
"explorer_urls_hdr": "URL",
"export": "내보내기",
"export_csv": "CSV 내보내기",
"export_keys_btn": "키 내보내기",
@@ -655,9 +720,155 @@
"export_tx_success": "거래 내보내기 성공",
"export_tx_title": "거래를 CSV로 내보내기",
"export_viewing_key": "조회 키 내보내기",
"failed_create_mining_address": "채굴 주소를 만들지 못했습니다",
"failed_create_shielded": "차폐 주소 생성 실패",
"failed_create_transparent": "투명 주소 생성 실패",
"faq": "FAQ",
"faq_d_mgmt_1_a": "내장 노드는 지갑과 함께 자동으로 시작되고 적절한 시점에 중지되므로, 보통 직접 관리할 필요가 없습니다. 고급 제어 기능(데몬 재시작 포함)은 설정 > 노드 및 보안에 있습니다.",
"faq_d_mgmt_1_q": "노드를 시작하거나 중지하려면 어떻게 하나요?",
"faq_d_mgmt_2_a": "직접 실행한 노드에 지갑을 연결한 경우, 이 옵션은 지갑을 닫을 때 해당 노드도 함께 종료할지를 결정합니다. 지갑을 닫은 후에도 노드를 계속 실행하고 싶다면 꺼두세요.",
"faq_d_mgmt_2_q": "'종료 시 외부 데몬 닫기'는 무엇을 하나요?",
"faq_d_mgmt_3_a": "노드와 지갑 데이터는 사용자 프로필 아래 DragonX 데이터 디렉터리에 저장됩니다(예: Windows에서는 AppData, Linux/macOS에서는 홈 폴더). 블록체인이 가장 많은 용량을 차지하므로, 성장할 만큼 충분한 여유 디스크 공간을 확보하세요.",
"faq_d_mgmt_3_q": "블록체인과 지갑 데이터는 어디에 저장되나요?",
"faq_d_mgmt_title": "노드 관리",
"faq_d_mine_1_a": "마이닝 탭에서 CPU로 채굴할 수 있습니다. DragonX는 일반 프로세서용으로 설계된 RandomX 알고리즘을 사용합니다. 스레드 수를 설정하고 시작하면 보상이 지갑에 쌓입니다.",
"faq_d_mine_1_q": "DragonX는 어떻게 채굴하나요?",
"faq_d_mine_2_a": "솔로 채굴은 보상을 바로 받지만 해시레이트가 크지 않으면 드뭅니다. 풀 채굴은 다른 사람들과 작업을 나누어 더 작지만 꾸준한 보상을 받습니다. 마이닝 탭은 두 방식 모두 지원합니다.",
"faq_d_mine_2_q": "솔로 채굴과 풀 채굴, 무엇을 선택해야 하나요?",
"faq_d_mine_3_a": "마이닝 탭의 풀 섹션에 있는 '마이너 업데이트…' 버튼은 최신 최적화된 DragonX 마이너 빌드를 다운로드, 검증, 설치합니다. 노드 업데이트와 마찬가지로 각 다운로드는 설치 전 체크섬과 서명 검증을 거칩니다.",
"faq_d_mine_3_q": "마이너는 어떻게 업데이트하나요?",
"faq_d_mine_4_a": "현재 노드는 내장 stratum 서버를 실행할 수 있어 다른 채굴자가 이 컴퓨터를 가리킬 수 있습니다. 설정 > 노드 및 보안에 관련 토글이 있습니다. 기본적으로 로컬에서만 대기하며, 다른 컴퓨터에 노출하려면 직접 열어야 합니다.",
"faq_d_mine_4_q": "마이닝 풀을 호스팅할 수 있나요?",
"faq_d_mine_title": "채굴 및 풀",
"faq_d_net_1_a": "실행 직후에는 노드가 피어를 찾는 데 잠시 시간이 걸리므로 '피어 0'이 잠깐 나타나는 것은 정상입니다. 계속되면 인터넷 연결과 방화벽이 노드를 차단하고 있지 않은지 확인하세요. 노드는 내장된 시드를 통해 자동으로 피어를 찾습니다.",
"faq_d_net_1_q": "지갑에 피어나 연결이 표시되지 않습니다 — 무엇이 문제인가요?",
"faq_d_net_2_a": "예 — DragonX 노드는 TLS를 통해 서로 통신하므로 피어 연결이 암호화됩니다. 지갑도 자신의 노드와 안전한 로컬 채널로 통신합니다.",
"faq_d_net_2_q": "네트워크 연결이 암호화되어 있나요?",
"faq_d_net_title": "피어 및 네트워크",
"faq_d_node_1_a": "데몬 dragonxd는 DragonX 풀 노드입니다. 전체 블록체인을 다운로드하고 검증하며, 트랜잭션을 네트워크에 중계하고, 지갑의 요청에 응답합니다. 자신의 노드를 운영하면 다른 서버를 신뢰할 필요가 없습니다.",
"faq_d_node_1_q": "데몬(dragonxd)이란 무엇인가요?",
"faq_d_node_2_a": "기본적으로 지갑은 번들된 자체 노드를 실행하고 관리합니다(내장형) — 별도로 할 일은 없습니다. 이미 직접 dragonxd를 실행 중이라면 지갑이 그 외부 노드에 연결할 수도 있습니다. 지갑은 현재 상황을 감지해 그에 맞게 동작합니다.",
"faq_d_node_2_q": "내장 노드와 외부 노드의 차이는 무엇인가요?",
"faq_d_node_3_a": "풀 노드는 블록체인의 모든 규칙을 독립적으로 검증하므로 지갑이 제3자가 아닌 수학을 신뢰하게 됩니다. 네트워크도 강화합니다. 대신 디스크 공간과 초기 동기화가 필요합니다.",
"faq_d_node_3_q": "왜 굳이 풀 노드를 운영해야 하나요?",
"faq_d_node_title": "풀 노드",
"faq_d_perf_1_a": "블록체인이 가장 큰 부분이며 시간이 지나면서 커지므로 여유 공간을 몇 기가바이트 남겨두세요. 노드는 속도를 위해 데이터베이스 캐시를 사용하며, 번들된 기본값은 일반적인 기기에 맞춰져 있습니다. 고급 사용자는 설정 파일(DRAGONX.conf)에서 dbcache를 수정해 노드의 데이터베이스 캐시를 조정할 수 있습니다.",
"faq_d_perf_1_q": "노드가 디스크를 얼마나 사용하나요? 메모리를 조정할 수 있나요?",
"faq_d_perf_2_a": "모든 블록을 처음부터 검증하는 대신 설정 마법사에서 제공되는 부트스트랩 다운로드를 활성화하면 최근의 검증된 체인 사본을 받아올 수 있습니다. 빠른 디스크(SSD)도 큰 도움이 됩니다.",
"faq_d_perf_2_q": "초기 동기화를 더 빠르게 하려면 어떻게 하나요?",
"faq_d_perf_title": "저장 공간 및 성능",
"faq_d_sync_1_a": "실행 시 노드는 지갑에 서비스를 제공하기 전에 블록을 불러오고 검증합니다. '블록 처리 중 / 현재 체인 상태를 구성하기 위해 블록 적용 중' 화면이 바로 이 준비 과정입니다. 정상적인 동작이며, 노드가 완료되고 높이를 보고하면 지갑을 사용할 수 있게 됩니다.",
"faq_d_sync_1_q": "시작 시 나타나는 '블록 처리 중'은 무슨 의미인가요?",
"faq_d_sync_2_a": "보호 자금을 위해 노드는 비공개로 송금할 수 있게 해주는 암호학적 '증인(witness)'을 유지합니다. 일부 재시작이나 업데이트 후에는 최근 블록을 다시 스캔하여 이 캐시를 재구축합니다. 트랜잭션이 많은 지갑에서는 몇 분이 걸리고 추가 메모리를 사용할 수 있습니다.\n\n멈춘 것이 아니니 완료될 때까지 기다리세요. 도중에 강제 종료하면 다음 실행 시 재구축이 처음부터 다시 시작될 뿐입니다.",
"faq_d_sync_2_q": "'증인 캐시 재구축 중'이란 무엇인가요?",
"faq_d_sync_3_a": "체인 끝부분에 가까워지면 지갑을 스캔하는 작업(잔액과 노트 확인)이 노드와 동일한 내부 잠금을 두고 경쟁하게 되어, 대형 지갑에서는 마지막 블록 연결 속도가 느려질 수 있습니다. 최근 버전은 이 스캔을 제한해 노드가 따라잡을 수 있게 합니다. 가벼운 탭에 머물거나 그냥 기다리면 더 빨리 끝납니다.",
"faq_d_sync_3_q": "거의 다 따라잡았는데 왜 동기화가 느린가요?",
"faq_d_sync_4_a": "첫 동기화는 전체 체인을 다운로드하고 검증하며, 연결 상태와 디스크에 따라 시간이 걸릴 수 있습니다. 설정 마법사에서 부트스트랩 다운로드를 활성화하면 최근의 검증된 체인 데이터 사본을 받아와 크게 단축할 수 있습니다.",
"faq_d_sync_4_q": "초기 동기화는 얼마나 걸리나요?",
"faq_d_sync_title": "동기화 및 블록체인",
"faq_d_trbl_1_a": "대개는 아닙니다. 대형 지갑에서는 노드가 보호 증인을 재구축하거나 블록을 적용하는 데 몇 분이 걸릴 수 있으며, 이 동안 멈춘 것처럼 보이거나 응답이 느릴 수 있습니다. 시간을 두고 기다려 주세요 — 디버그 로그(설정)를 보면 정상 작동 중이라면 꾸준한 진행이 보입니다.\n\n이 단계에서 강제 종료하지 마세요. 다음 실행 시 작업이 처음부터 다시 시작됩니다.",
"faq_d_trbl_1_q": "노드가 '최적 체인 활성화 중'에서 멈춘 것 같습니다 — 멈춘 건가요?",
"faq_d_trbl_2_a": "지갑 파일이 복구되었지만 키 데이터 일부가 없으면 노드가 새 주소를 생성하거나 자금을 보호할 수 없는 제한된 '저하 모드'로 열 수 있습니다. 가장 안전한 해결책은 시드 복구 문구로 새 지갑을 복원하는 것입니다.",
"faq_d_trbl_2_q": "'저하 모드'란 무엇인가요?",
"faq_d_trbl_3_a": "재스캔은 지갑이 체인을 다시 읽어 트랜잭션을 재발견하게 합니다(키를 가져온 후 유용). 재색인은 노드의 블록체인 데이터베이스를 재구축하며, 해당 데이터베이스가 손상되었거나 버전 변경 후 호환되지 않을 때만 필요합니다. 둘 다 시간이 걸릴 수 있으며, 설정 > 노드 및 보안에서 시작할 수 있습니다.",
"faq_d_trbl_3_q": "언제 재색인이나 재스캔을 해야 하나요?",
"faq_d_trbl_4_a": "증인 재구축이나 초기 동기화 같은 무거운 작업 중에는 메모리 사용량이 급증했다가 완료되면 다시 낮아집니다. 유휴 상태에서 계속 높은 메모리 사용은 이례적이며, 지갑을 재시작하면 해소됩니다. 메모리가 적은 기기에서는 다른 메모리 사용량이 많은 앱과 경쟁하지 않는지 확인하세요.",
"faq_d_trbl_4_q": "노드가 메모리를 많이 사용하고 있습니다 — 정상인가요?",
"faq_d_trbl_title": "문제 해결",
"faq_d_upd_1_a": "설정 > 노드 및 보안 > 데몬 바이너리를 열고 '업데이트 확인…'을 사용하세요. 지갑이 최신 검증된 노드 빌드를 다운로드해 설치하며, 새 버전은 데몬이 다음에 시작될 때 적용됩니다.",
"faq_d_upd_1_q": "노드는 어떻게 업데이트하나요?",
"faq_d_upd_2_a": "예 — 업데이트 대화상자에 모든 릴리스가 나열되어 특정 버전이나 이전 빌드를 선택할 수 있습니다. 다운그레이드는 주의하세요. 이전 노드는 더 새로운 노드가 작성한 블록체인 데이터를 받아들이지 못해 재색인이 필요할 수 있습니다.",
"faq_d_upd_2_q": "특정 노드 버전을 설치할 수 있나요?",
"faq_d_upd_3_a": "다운로드된 모든 노드 아카이브는 설치 전에 체크섬과 암호화 서명으로 검증됩니다. 검증에 실패하면 지갑이 업데이트를 거부합니다.",
"faq_d_upd_3_q": "업데이트는 안전한가요?",
"faq_d_upd_title": "노드 업데이트",
"faq_group_daemon": "데몬",
"faq_group_wallet": "지갑",
"faq_intro": "지갑과 DragonX 노드에 관한 답변입니다. 검색하거나 아래 주제별로 살펴보세요.",
"faq_intro_lite": "Lite 지갑에 관한 답변입니다. 검색하거나 아래 주제별로 살펴보세요.",
"faq_l_gs_1_a": "ObsidianDragonLite는 경량 DragonX 지갑입니다. 풀 노드를 실행하는 대신 DragonX lite 서버에 연결하므로 빠르게 시작되고 전체 블록체인을 다운로드하지 않습니다.\n\n일반 지갑과 마찬가지로 키, 잔액, 송금을 관리합니다 — 서버 연결 방식과 프라이버시 절충에 대해서는 아래의 'Lite 지갑' 섹션을 참고하세요.",
"faq_l_gs_1_q": "ObsidianDragonLite란 무엇인가요?",
"faq_l_lite_1_a": "Lite 지갑은 자체 풀 노드를 실행하는 대신 DragonX lite 서버와 통신합니다. 전체 블록체인을 직접 다운로드하거나 검증하지 않으므로 빠르게 시작되고 디스크와 대역폭 사용이 적습니다. 개인 키는 항상 기기에 남아 있으며 서버로 전송되지 않습니다.",
"faq_l_lite_1_q": "Lite 지갑은 풀 지갑과 어떻게 다른가요?",
"faq_l_lite_2_a": "설정을 열고 서버를 선택하세요. 언제든 서버를 전환할 수 있으며 지갑이 새 서버에서 다시 동기화합니다. 서버가 느리거나 연결되지 않으면 다른 서버를 시도해 보세요.",
"faq_l_lite_2_q": "서버는 어떻게 선택하거나 변경하나요?",
"faq_l_lite_3_a": "키는 절대 기기를 벗어나지 않으며 보호된 금액은 암호화된 상태로 유지됩니다. 다만 lite 서버는 지갑이 조회하는 주소와 IP 주소를 볼 수 있어 주소들을 서로 연결 지을 수 있습니다 — 편의성과 프라이버시를 맞바꾸는 셈입니다. 최고 수준의 프라이버시를 원한다면 체인을 직접 검증하고 서버에 주소를 노출하지 않는 풀 노드 지갑을 사용하세요.",
"faq_l_lite_3_q": "Lite 지갑은 풀 노드만큼 프라이버시가 보장되나요?",
"faq_l_lite_title": "라이트 지갑",
"faq_l_sec_1_a": "설정 > 지갑으로 이동해 보안 섹션에서 암호를 설정하세요. 암호화는 디스크에 저장된 개인 키를 보호하므로, 파일에 접근할 수 있는 사람이라도 코인을 사용할 수 없습니다.\n\n강력한 암호를 선택하고 잊어버리지 마세요 — 암호 없이는 암호화된 지갑을 복구할 방법이 없습니다.",
"faq_no_results": "결과가 없습니다. 다른 검색어를 시도해 보세요.",
"faq_open_tooltip": "도움말 및 FAQ",
"faq_search_hint": "FAQ 검색…",
"faq_title": "도움말 및 FAQ",
"faq_w_addr_1_a": "차폐 주소(zs로 시작하는 z 주소)는 DragonX에서 사용하는 주소입니다. 영지식 암호를 사용하므로 금액, 보낸 사람, 받는 사람이 체인에서 암호화되고 잔액이 비공개로 유지됩니다.\n\n투명 주소(R로 시작)는 채굴 주소입니다. DragonX는 완전한 프라이빗 체인이므로 이 주소는 채굴 보상만 받을 수 있으며, 네트워크가 이 주소로 보내는 모든 결제를 거부합니다. 그곳에 들어온 보상은 차폐되기 전까지 공개되며, 차폐는 지갑이 자동으로 처리합니다.",
"faq_w_addr_1_q": "투명 주소와 보호 주소의 차이는 무엇인가요?",
"faq_w_addr_2_a": "보내고 받는 모든 거래에 차폐(zs) 주소를 사용하세요. DragonX에서는 이 주소만 결제를 받을 수 있습니다.\n\n투명(채굴) 주소는 솔로 채굴 보상 전용입니다. 결제용으로 절대 알려주지 마세요. 네트워크가 거부합니다.",
"faq_w_addr_2_q": "어떤 주소 유형을 사용해야 하나요?",
"faq_w_addr_3_a": "차폐 주소는 재사용해도 괜찮으며 기록이 노출되지 않습니다. 그래도 받기 탭에서 새 주소를 만들 수 있습니다. 예를 들어 연락처마다 하나씩 만들면 누가 보냈는지 알 수 있습니다. 채굴 주소는 채굴 보상 전용이라 새로 만들 일은 거의 없습니다.",
"faq_w_addr_3_q": "주소를 재사용해야 하나요, 아니면 새로 만들어야 하나요?",
"faq_w_addr_4_a": "차폐는 투명(채굴) 주소의 코인을 차폐 주소로 옮겨 비공개로 만드는 것입니다. 새로 채굴된 코인은 채굴 주소로 들어오며, 성숙하면 자동으로 차폐됩니다.\n\n지갑에서 직접 차폐할 수도 있습니다. 차폐는 특별한 트랜잭션일 뿐이라 일반적인 확인 시간이 걸립니다.",
"faq_w_addr_4_q": "'보호(shielding)'란 무엇인가요?",
"faq_w_addr_title": "주소 및 프라이버시",
"faq_w_bal_1_a": "지갑이 아직 동기화 중일 때는 잔액이 불완전하며 0으로 표시될 수 있습니다 — 아직 모든 트랜잭션을 스캔하지 않았기 때문입니다. 동기화가 끝나면 채워집니다.\n\n하단 상태 표시줄을 확인하세요. 연결 상태와 블록 높이가 표시됩니다. 동기화가 완료되면 잔액이 정확해집니다.",
"faq_w_bal_1_q": "잔액이 0이거나 업데이트되지 않는 이유는 무엇인가요?",
"faq_w_bal_2_a": "트랜잭션이 포함된 블록 위에 새 블록이 채굴될 때마다 확인이 하나씩 늘어납니다. 확인 수가 많을수록 결제가 더 확실하게 확정된 것입니다. 수신 자금은 첫 확인 후 사용할 수 있게 됩니다.",
"faq_w_bal_2_q": "확인이란 무엇인가요?",
"faq_w_bal_3_a": "총 잔액에는 아직 확인 중이거나 일시적으로 잠긴 자금(예: 보호 처리 중인 코인)이 포함됩니다. 사용 가능 잔액은 지금 바로 보낼 수 있는 금액입니다. 트랜잭션이 확인되면 두 값이 같아집니다.",
"faq_w_bal_3_q": "총 잔액과 사용 가능 잔액의 차이는 무엇인가요?",
"faq_w_bal_title": "잔액 및 동기화",
"faq_w_chat_1_a": "채팅은 지갑에 내장된 암호화 온체인 메신저입니다. 메시지는 비공개 보호 메모 형태로 전송되므로 본인과 상대방만 읽을 수 있습니다.",
"faq_w_chat_1_q": "채팅 기능이란 무엇인가요?",
"faq_w_chat_2_a": "채팅 아이디는 지갑의 복구 문구에서 파생되므로 지갑과 함께 이동합니다 — 지갑을 복원하면 아이디도 함께 돌아옵니다. 별도의 계정이나 비밀번호를 만들 필요가 없습니다.",
"faq_w_chat_2_q": "채팅 아이디는 어떻게 작동하나요?",
"faq_w_chat_3_a": "연락처 탭에서 주소로 연락처를 추가할 수 있으며, 선택적으로 이름과 아바타를 지정할 수 있습니다. 연락처는 현재 지갑에만 비공개로 유지하거나 이 컴퓨터에서 여는 모든 지갑과 공유할 수 있습니다.",
"faq_w_chat_3_q": "연락처는 어떻게 작동하나요?",
"faq_w_chat_4_a": "대화를 열고 헤더의 아이콘을 사용하세요. 숨기기(눈 모양)는 목록에서 제거하지만 모든 메시지는 유지됩니다 — 새 메시지가 오면 다시 표시됩니다. 삭제(휴지통)는 대화상자를 엽니다. '삭제'는 이 기기의 내역만 지웁니다(상대방이 다시 메시지를 보내면 대화가 다시 나타납니다). '삭제 및 차단'은 상대방 목록 위의 '차단됨' 목록에서 차단을 해제할 때까지 향후 메시지도 막습니다. 이 모든 작업은 이 기기에서만 이루어집니다 — 메시지는 블록체인에 그대로 남아 있고 상대방은 자신의 사본을 유지하므로 '전송 취소'는 아닙니다.",
"faq_w_chat_4_q": "대화를 숨기거나 삭제하거나 차단하려면 어떻게 하나요?",
"faq_w_chat_title": "채팅 및 연락처",
"faq_w_gs_1_a": "ObsidianDragon은 DragonX(DRGX)용 풀노드 지갑입니다. 코인을 관리하며, 데스크톱 빌드에서는 백그라운드에서 DragonX 풀노드(데몬)를 실행하여 지갑이 제3자 서버를 신뢰하는 대신 블록체인을 직접 검증합니다.\n\n지갑(키, 잔액, 전송)과 노드(블록체인, 네트워크)라는 두 부분이 함께 작동한다고 생각하면 됩니다. 여기 질문들도 같은 방식으로 나뉘어 있습니다 — 위의 지갑 및 데몬 탭을 참고하세요.",
"faq_w_gs_1_q": "ObsidianDragon이란 무엇인가요?",
"faq_w_gs_2_a": "처음 실행하면 설정 마법사가 자동으로 지갑을 만들어 줍니다. 새 지갑은 비밀 복구 문구(단어 목록)로 보호됩니다. 이 문구를 적어 오프라인에 안전하게 보관하세요 — 이 컴퓨터를 잃어버렸을 때 자금을 복원할 수 있는 유일한 방법입니다.\n\n설정 > 백업 및 데이터 > 시드 문구에서 언제든지 문구를 확인하거나 백업할 수 있습니다.",
"faq_w_gs_2_q": "새 지갑은 어떻게 만드나요?",
"faq_w_gs_3_a": "복구 문구가 있다면 시드 문구 복원 절차를 사용하세요. (풀노드 빌드에서는 실행 전에 기존 wallet.dat 파일을 지갑의 데이터 디렉터리에 복사할 수도 있습니다.)\n\n복원 후 지갑이 과거 트랜잭션을 찾기 위해 다시 스캔하므로, 처음에는 잔액과 내역이 표시되기까지 시간이 걸릴 수 있습니다.",
"faq_w_gs_3_q": "이미 가지고 있는 지갑은 어떻게 복원하나요?",
"faq_w_gs_4_a": "마법사에서는 모양/테마를 선택하고, 암호화와 PIN을 설정하며, 풀노드 빌드에서는 선택적으로 부트스트랩을 다운로드해 첫 동기화 속도를 높일 수 있습니다. 어떤 단계든 건너뛸 수 있으며 나중에 설정에서 모두 변경할 수 있습니다.",
"faq_w_gs_4_q": "최초 실행 설정은 무엇을 하나요?",
"faq_w_gs_title": "시작하기",
"faq_w_sec_1_a": "설정 > 노드 및 보안으로 이동해 암호를 설정하세요(보안 섹션). 암호화는 디스크에 저장된 개인 키를 보호하므로, 파일에 접근할 수 있는 사람이라도 코인을 사용할 수 없습니다.\n\n강력한 암호를 선택하고 잊어버리지 마세요 — 암호 없이는 암호화된 지갑을 복구할 방법이 없습니다.",
"faq_w_sec_1_q": "지갑을 어떻게 암호화하나요?",
"faq_w_sec_2_a": "PIN은 자리를 비웠을 때 잔액과 작업이 노출되지 않도록 앱 화면을 잠급니다. 암호화 위에 추가된 편의용 잠금이며, 마법사 중이나 지갑을 암호화한 후 설정에서 설정할 수 있습니다.",
"faq_w_sec_2_q": "PIN/잠금 화면이란 무엇인가요?",
"faq_w_sec_3_a": "아니요. 지갑 암호화는 우회할 수 없습니다. 시드 복구 문구가 있다면 새 지갑으로 복원하고 새 암호를 설정할 수 있습니다. 암호와 시드 문구 둘 다 없으면 자금을 복구할 수 없습니다.",
"faq_w_sec_3_q": "암호를 잊어버렸어요 — 복구할 수 있나요?",
"faq_w_sec_title": "보안 및 암호화",
"faq_w_seed_1_a": "설정 > 백업 및 데이터 > 시드 문구로 이동하세요. 단어를 순서대로 종이에 적어 안전한 오프라인 장소에 보관하세요. 이 문구를 가진 사람은 누구든 코인을 사용할 수 있으므로 사진, 이메일, 클라우드 메모에는 절대 저장하지 마세요.",
"faq_w_seed_1_q": "복구 문구는 어떻게 백업하나요?",
"faq_w_seed_2_a": "이전(레거시) 지갑은 시드 기반이 아니었습니다. 지갑은 레거시 지갑을 최신 시드 기반 지갑으로 이전할 수 있습니다. 새 시드 지갑을 만들고 자금을 그곳으로 쓸어 담습니다. 설정 > 백업 및 데이터에서 '시드 지갑으로 이전…'을 찾으세요. 이 기능은 현재 버전의 데몬이 필요합니다.",
"faq_w_seed_2_q": "제 지갑에는 복구 문구가 없어요 — 추가할 수 있나요?",
"faq_w_seed_3_a": "지갑을 설정할 때 시드 복원 절차를 사용해 단어를 순서대로 입력하세요. 그러면 지갑이 다시 스캔해 잔액과 내역을 재구성하며, 처음에는 시간이 걸릴 수 있습니다.",
"faq_w_seed_3_q": "복구 문구로 어떻게 복원하나요?",
"faq_w_seed_4_a": "시드 문구는 기본 백업이며 모든 것을 복원하기에 충분합니다. 지갑 파일 사본은 라벨과 설정까지 보존하는 편리한 보조 백업입니다. 모든 백업은 오프라인에 비공개로 보관하세요.",
"faq_w_seed_4_q": "wallet.dat도 백업해야 하나요?",
"faq_w_seed_title": "시드 문구 및 백업",
"faq_w_send_1_a": "전송 탭을 열고 받는 사람의 주소를 붙여넣은 다음 금액을 입력하고 확인하세요. 지갑이 암호화되어 있다면 트랜잭션을 위해 잠금 해제를 요청받습니다.\n\n결제는 차폐(zs) 주소로 보내지며 비공개로 유지됩니다. 지갑은 투명(채굴) 주소로는 보내지 않습니다. 네트워크가 그런 주소로의 결제를 거부하기 때문입니다.",
"faq_w_send_1_q": "자금은 어떻게 보내나요?",
"faq_w_send_2_a": "DragonX 수수료는 매우 낮으며 자동으로 설정됩니다. 확인 전에 수수료가 표시되며, 보통 변경할 필요는 없습니다.",
"faq_w_send_2_q": "어떤 수수료를 내나요?",
"faq_w_send_3_a": "네 — 보호(z) 주소로 전송할 때 암호화된 메모를 첨부할 수 있습니다. 받는 사람만 읽을 수 있습니다.",
"faq_w_send_3_q": "결제에 메시지를 첨부할 수 있나요?",
"faq_w_send_4_a": "트랜잭션은 블록에 채굴되어 확인을 얻기 전까지 '대기 중'입니다. 보통 1~2분 정도 걸립니다. 보호 트랜잭션은 증명을 생성하기 위해 지갑이 동기화되어 있어야 합니다.\n\n전송이 유난히 오래 대기 중이라면 지갑이 연결되고 동기화되어 있는지 확인하세요(창 하단의 상태 표시줄 참고).",
"faq_w_send_4_q": "결제를 보냈는데 대기 중이라고 나와요 — 왜 그런가요?",
"faq_w_send_title": "송금 및 수신",
"faq_w_set_1_a": "설정 > 화면에서 테마와 강조 색상을 선택하세요. 여러 밝은/어두운 스킨이 포함되어 있으며, 저사양 기기에서는 블러 같은 효과를 끌 수 있습니다.",
"faq_w_set_1_q": "테마를 어떻게 바꾸나요?",
"faq_w_set_2_a": "네 — 설정 > 화면에 언어 선택 항목이 있습니다. 지갑은 여러 언어로 번역되어 있으며, 아직 번역되지 않은 항목은 영어로 표시됩니다.",
"faq_w_set_2_q": "언어를 바꿀 수 있나요?",
"faq_w_set_3_a": "설정 > 화면의 글꼴 배율 옵션을 사용하세요. 전체 인터페이스 크기를 조절하며, 고해상도(HiDPI) 디스플레이에서 앱이 작게 표시될 때 유용합니다.",
"faq_w_set_3_q": "글자가 너무 작거나(또는 커요) — 크기를 조절할 수 있나요?",
"faq_w_set_4_a": "설정에서 가격 조회를 활성화하세요. 그러면 잔액과 함께 대략적인 법정화폐 가치가 표시되고, 작은 시세 차트도 볼 수 있습니다. 가격은 참고용일 뿐입니다.",
"faq_w_set_4_q": "법정화폐 가격을 어떻게 표시하나요?",
"faq_w_set_title": "설정 및 화면",
"favorite_address": "즐겨찾기에 추가",
"favorite_opacity": "즐겨찾기 강조 불투명도",
"fee": "수수료",
"fee_high": "높음",
"fee_label": "수수료:",
@@ -665,14 +876,9 @@
"fee_normal": "보통",
"fetch_prices": "가격 조회",
"file": "파일",
"file_save_location": "파일 저장 위치: ~/.config/ObsidianDragon/",
"file_save_location": "파일 저장 위치:",
"filter": "필터...",
"font_scale": "글꼴 크기",
"force_quit": "강제 종료",
"force_quit_confirm_msg": "정상 종료 없이 데몬을 즉시 종료합니다.\n블록체인 인덱스가 손상되어 재동기화가 필요할 수 있습니다.",
"force_quit_confirm_title": "강제 종료하시겠습니까?",
"force_quit_warning": "정상 종료 없이 데몬을 즉시 종료합니다. 블록체인 재동기화가 필요할 수 있습니다.",
"force_quit_yes": "강제 종료",
"from": "보낸 곳",
"from_upper": "보낸 곳",
"full_details": "전체 세부 정보",
@@ -741,6 +947,7 @@
"hidden_tag": " (숨김)",
"hide": "숨기기",
"hide_address": "주소 숨기기",
"hide_earnings": "수익 숨기기",
"hide_qr": "QR 숨기기",
"hide_zero_balances": "잔액 0 숨기기",
"history": "내역",
@@ -815,11 +1022,13 @@
"lite_birthday_backup": "생성 블록: %llu (이 값도 백업하세요)",
"lite_birthday_hint": "스캔을 시작할 블록 높이입니다. 모르면 0으로 두세요(전체 스캔이 느려짐).",
"lite_birthday_label": "생일 블록",
"lite_confirm_label": "확인",
"lite_console_backend_commands": "백엔드 명령:",
"lite_console_help_passthrough": "그 외 입력은 라이트 지갑 콘솔 명령으로 실행됩니다.",
"lite_copy": "복사",
"lite_could_not_start": "작업을 시작할 수 없습니다",
"lite_could_not_write": "쓸 수 없습니다: ",
"lite_encrypt_offline_warning": "지갑 파일을 가진 사람은 누구나 오프라인으로 암호를 시도할 수 있습니다. 먼저 시드 문구를 백업하고 긴 암호를 사용하세요.",
"lite_encrypt_wallet": "지갑 암호화",
"lite_encryption_removed": "암호화가 제거되었습니다",
"lite_enter_all_seed_words": "복구하려면 24개의 시드 단어를 모두 입력하세요 (현재 %d개)",
@@ -827,6 +1036,7 @@
"lite_hide_wipe": "숨기고 삭제",
"lite_import": "가져오기",
"lite_import_key_label": "키 가져오기",
"lite_key_import_failed": "키를 가져오지 못했습니다. 키를 확인하고 지갑 잠금을 해제한 후(암호화된 경우) 다시 시도하세요.",
"lite_key_imported": "키를 가져왔습니다 — 동기화를 실행하여 기록을 스캔하세요",
"lite_lock_failed": "잠금 실패",
"lite_lock_now": "지금 잠그기",
@@ -909,6 +1119,7 @@
"loading": "로딩 중...",
"loading_addresses": "주소 로딩 중...",
"loading_stall_body": "데몬이 %.0f초 동안 초기화 중입니다. 업데이트 후나 첫 실행 시(블록 인덱스 로드 또는 재스캔)에는 정상일 수 있습니다. 준비되면 자동으로 연결됩니다.",
"loading_stall_hint": "멈췄나요? 설정 → 데몬 재시작을 시도하거나 콘솔을 확인하세요.",
"loading_stall_title": "예상보다 오래 걸리고 있습니다",
"loading_transactions": "거래를 불러오는 중",
"local_hashrate": "로컬 해시레이트",
@@ -959,11 +1170,67 @@
"memo_optional": "메모 (선택)",
"memo_upper": "메모",
"memo_z_only": "참고: 메모는 차폐 (z) 주소로 전송할 때만 사용할 수 있습니다",
"merge_addr_created": "보호 주소가 생성되었습니다.",
"merge_advanced": "고급",
"merge_back": "뒤로",
"merge_batch_fmt": "실행당 최대 %d개의 입력을 병합합니다 — 나머지를 마치려면 반복하세요.",
"merge_confirm_btn": "확인",
"merge_confirm_fmt": "~%s DRGX를 입력 %d개에서 %s(으)로 통합할까요?",
"merge_create_zaddr": "보호 주소 생성",
"merge_creating": "주소 생성 중…",
"merge_description": "여러 UTXO를 단일 차폐 주소로 통합합니다. 지갑 크기를 줄이고 프라이버시를 향상시킵니다.",
"merge_fee_hint": "이 거래에 대한 네트워크 수수료입니다.",
"merge_funds": "자금 통합",
"merge_into_one": "한 주소로",
"merge_keep_each": "각 주소에 유지",
"merge_keep_each_batches_fmt": "노트가 %d개를 넘는 주소는 여러 번에 나눠 병합됩니다. 확인된 후 다시 실행하면 나머지가 병합됩니다.",
"merge_keep_each_confirm_fmt": "노트 %d개를 주소 %d개에서 통합할까요? 각 주소는 자기 자금을 그대로 유지합니다. 수수료: 약 %s DRGX(배치마다 1회).",
"merge_keep_each_desc": "각 주소의 노트는 같은 주소로 다시 병합되므로, 다른 라벨로 나눠 둔 자금은 계속 나뉜 상태로 유지됩니다. 차폐 주소만 해당되며, 투명 자금은 차폐만 할 수 있습니다(한 주소로).",
"merge_keep_each_none": "모든 차폐 주소에 노트가 이미 하나뿐입니다. 통합할 것이 없습니다.",
"merge_keep_each_nothing_submitted": "아무것도 통합되지 않았습니다. 노드가 모든 병합을 거부했습니다.",
"merge_keep_each_row_fmt": "%s · 노트 %d개 · %s DRGX",
"merge_keep_each_run_again": "여러 번에 나눠 병합된 주소에는 배치마다 노트가 하나씩 남아 있습니다. 확인된 후 다시 실행하면 하나로 병합됩니다.",
"merge_keep_each_select": "통합할 주소를 하나 이상 선택하세요.",
"merge_keep_each_some_failed": "일부 병합에 실패했습니다. 위 목록을 확인하세요.",
"merge_keep_each_status_done": "%s — 완료: 노트 %d개를 %d번에 걸쳐 병합",
"merge_keep_each_status_failed": "%s — 실패: %s",
"merge_keep_each_status_not_started": "%s — 시작되지 않음: %s",
"merge_keep_each_status_running": "%s — 노트 %d개 병합 중(%d/%d 배치 완료)…",
"merge_keep_each_status_unconfirmed": "%s — 상태를 확인할 수 없음",
"merge_keep_each_too_small": "너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
"merge_max_inputs": "배치당 최대 입력 수",
"merge_no_spendable": "아직 통합할 사용 가능한 입력이 없습니다.",
"merge_progress": "통합 중… 몇 분 정도 걸릴 수 있습니다. 이 창을 닫아도 됩니다.",
"merge_run_aborted_toast": "노드 연결이 재설정되어 통합이 중지되었습니다. 계속하려면 다시 실행하세요.",
"merge_run_busy": "통합이 진행 중입니다. 완료될 때까지 기다리거나 먼저 중지하세요.",
"merge_run_chip_fmt": "통합 중 %d/%d",
"merge_run_err_timeout": "노드가 제때 응답하지 않았습니다. 배치가 아직 대기열에 있을 수 있습니다.",
"merge_run_halted_fmt": "반복된 실패로 통합이 중지되었습니다: %s",
"merge_run_migration_pending": "통합하기 전에 시드 마이그레이션을 완료하거나 취소하세요.",
"merge_run_progress_fmt": "통합 중… 배치 %d(약 %d개 중), 지금까지 노트 %d개 병합됨. 이 창을 닫아도 백그라운드에서 계속 진행됩니다.",
"merge_run_start_failed": "통합을 시작할 수 없습니다 — 노드가 연결되어 있지 않습니다.",
"merge_run_status_not_run": "%s — 통합 안 됨(노트 %d개)",
"merge_run_status_waiting": "%s — 대기 중(노트 %d개)",
"merge_run_stop": "중지",
"merge_run_stopped_toast": "통합이 중지되었습니다. 나머지를 병합하려면 다시 실행하세요.",
"merge_run_stopping": "이미 보낸 배치가 끝나면 중지합니다…",
"merge_run_wait_batch": "노드가 응답성을 유지하도록 배치 간격을 두는 중…",
"merge_run_wait_send": "보류 중인 거래가 완료될 때까지 일시 중지됩니다.",
"merge_run_wait_sync": "일시 중지됨: 노드가 아직 동기화 중입니다.",
"merge_scope_failed_fmt": "입력을 읽을 수 없습니다: %s",
"merge_scope_fmt": "투명 입력 %d개 + 보호 입력 %d개 · 사용 가능 약 %s DRGX",
"merge_scope_loading": "입력을 확인하는 중…",
"merge_send_failed": "통합 실패: ",
"merge_source": "통합",
"merge_src_both": "둘 다",
"merge_src_shielded": "보호",
"merge_src_transparent": "투명",
"merge_started": "통합 작업이 시작되었습니다",
"merge_submit_uncertain": "연결이 끊기기 전에 노드가 통합을 확인하지 않았습니다. 그래도 처리될 수 있습니다 — 다시 연결한 후 다시 통합하기 전에 내역을 확인하세요.",
"merge_title": "주소로 통합",
"merge_wait_locked": "지갑이 잠겨 있습니다. 계속하려면 잠금을 해제하세요.",
"merge_waiting_node": "노드를 기다리는 중…",
"merge_where": "자금을 어디로 보낼까요?",
"mig_adopt": "이 지갑을 내 지갑으로 사용",
"mig_adopt_now": "시드 지갑 사용",
"mig_adopting": "새 지갑을 설치하고 재스캔하는 중…",
@@ -1015,6 +1282,8 @@
"miner_fee": "채굴 수수료",
"mining": "채굴",
"mining_active": "활성",
"mining_addr_explainer": "솔로 채굴 보상만 받습니다. 네트워크가 이 주소로의 결제를 거부합니다. 보상은 성숙하면 자동으로 차폐됩니다.",
"mining_address": "채굴 주소",
"mining_address_copied": "채굴 주소가 복사되었습니다",
"mining_all_time": "전체 기간",
"mining_already_saved": "풀 URL이 이미 저장되어 있습니다",
@@ -1028,6 +1297,8 @@
"mining_benchmark_testing": "테스트 중",
"mining_benchmark_tooltip": "이 CPU에 최적의 스레드 수 찾기",
"mining_block_copied": "블록 해시가 복사되었습니다",
"mining_card_click_to_shield": "클릭하면 성숙한 보상을 지금 차폐합니다.",
"mining_card_tooltip": "솔로 채굴 보상은 채굴(투명, R…) 주소로 들어옵니다. 이 주소는 결제를 받을 수 없습니다. 보상은 성숙하면 자동으로 차폐됩니다.",
"mining_chart_1m_ago": "1분 전",
"mining_chart_5m_ago": "5분 전",
"mining_chart_now": "현재",
@@ -1043,6 +1314,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": "풀 수익만 표시",
@@ -1072,6 +1345,7 @@
"mining_payout_address": "지급 주소",
"mining_payout_foreign": "⚠ 이 지급 주소는 현재 지갑에 없습니다 — 채굴한 보상이 다른 지갑으로 전송됩니다. 지갑을 전환했다면 주소를 업데이트하세요.",
"mining_payout_invalid": "유효한 DragonX 주소가 아닙니다 — 시작하기 전에 수정하세요. 그렇지 않으면 채굴 보상이 사라집니다.",
"mining_payout_needs_shielded": "풀은 일반 트랜잭션으로 지급하는데, DragonX에서는 이것이 채굴(투명, R…) 주소에 도달할 수 없습니다. 지급 주소로 차폐 주소(zs로 시작)를 사용하세요.",
"mining_payout_tooltip": "채굴 보상 수신 주소",
"mining_pool": "풀",
"mining_pool_fee": "수수료",
@@ -1084,6 +1358,8 @@
"mining_refresh": "새로고침",
"mining_remove": "제거",
"mining_reset_defaults": "기본값으로 재설정",
"mining_rewards": "채굴 보상",
"mining_rewards_unshielded": "아직 차폐되지 않음",
"mining_save_payout_address": "지급 주소 저장",
"mining_save_pool_url": "풀 URL 저장",
"mining_saved_addresses": "저장된 주소:",
@@ -1117,10 +1393,10 @@
"network_fee": "네트워크 수수료",
"network_hashrate": "네트워크 해시레이트",
"new": "+ 새로 만들기",
"new_mining_address": "새 채굴 주소",
"new_mining_address_created": "새 채굴 주소를 만들었습니다",
"new_shielded_created": "새 차폐 주소가 생성되었습니다",
"new_t_address": "새 T 주소",
"new_t_transparent": "새 t 주소 (투명)",
"new_transparent_created": "새 투명 주소가 생성되었습니다",
"new_z_address": "새 Z 주소",
"new_z_shielded": "새 z 주소 (차폐)",
"no_addresses": "주소가 없습니다. 위의 버튼을 사용하여 생성하세요.",
@@ -1140,7 +1416,19 @@
"node_banner_offline_title": "DragonX 노드에 연결되지 않음",
"node_banner_reconnect": "재연결",
"node_banner_restart": "노드 재시작",
"node_busy_rebuild_desc": "%s 동안 진행 중입니다. 큰 지갑은 약 1시간이 걸릴 수 있으며, 끝날 때까지 노드가 지갑에 응답할 수 없습니다.",
"node_busy_rebuild_extending": "위트니스 확장 중(이 단계에서는 노드가 진행 상황을 보고하지 않습니다).",
"node_busy_rebuild_reading": "블록 읽는 중: %ld / %ld",
"node_busy_rebuild_title": "노드 작업 중: 노트 위트니스 재구성 중",
"node_busy_replay_desc": "%s 동안 진행 중입니다. 노드가 마지막으로 멈추기 전에 받은 블록을 다시 적용하고 있습니다.",
"node_busy_replay_title": "노드 작업 중: 블록 따라잡는 중",
"node_busy_rpc_desc": "노드는 실행 중이지만 %s 동안 지갑에 응답할 수 없을 만큼 바쁩니다. 보통 위트니스 재구성 같은 긴 작업 때문입니다.",
"node_busy_rpc_title": "노드 작업 중: 아직 응답하지 않음",
"node_busy_running": "%s 동안 진행 중",
"node_security": "노드 및 보안",
"node_starting_busy_desc": "노드에 연결할 수 있지만 바빠서 아직 응답하지 않습니다. 업데이트 후나 처음 실행할 때는 정상이며, 몇 분 걸릴 수 있습니다.",
"node_starting_launch_desc": "dragonxd를 시작하고 온라인 상태가 되기를 기다리는 중…",
"node_starting_title": "DragonX 노드 시작 중…",
"noise": "노이즈",
"not_connected": "데몬에 연결되지 않음...",
"not_connected_to_daemon": "데몬에 연결되지 않음",
@@ -1201,6 +1489,13 @@
"peers_upper": "피어",
"peers_version": "버전",
"pending": "대기 중",
"picker_dat_count": "이 폴더에 지갑 파일 %d개",
"picker_dat_none": "이 폴더에 직접 있는 지갑 파일이 없습니다",
"picker_empty": "이 폴더에는 하위 폴더나 지갑 파일이 없습니다.",
"picker_home": "홈 폴더",
"picker_select": "이 폴더 검색",
"picker_title": "검색할 폴더 선택",
"picker_up": "한 단계 위로",
"pin_change_desc": "잠금 해제 PIN을 변경합니다. 현재 PIN과 새 PIN이 필요합니다.",
"pin_confirm_new_label": "새 PIN 확인:",
"pin_current_label": "현재 PIN:",
@@ -1286,6 +1581,8 @@
"ram_wallet_gb": "지갑: %.1f GB",
"ram_wallet_mb": "지갑: %.0f MB",
"receive": "수신",
"receive_not_for_payments": "결제용이 아님",
"receive_use_shielded": "차폐 주소 사용",
"received": "수신됨",
"received_filter": "수신됨",
"received_label": "수신됨",
@@ -1300,6 +1597,7 @@
"reduce_motion": "모션 줄이기",
"refresh": "새로고침",
"refresh_now": "지금 새로고침",
"refresh_waits_for_sync": "노드가 아직 동기화 중입니다. 동기화가 끝나면 지갑 데이터가 새로고침됩니다.",
"remove_favorite": "즐겨찾기 제거",
"repair_wallet": "지갑 복구",
"report_bug": "버그 신고",
@@ -1314,12 +1612,12 @@
"request_select_address": "주소 선택...",
"request_shielded_addrs": "-- 차폐 주소 --",
"request_title": "결제 요청",
"request_transparent_addrs": "-- 투명 주소 --",
"request_uri_copied": "결제 URI가 클립보드에 복사되었습니다",
"rescan": "재스캔",
"rescan_bootstrapped_msg": "노드가 부트스트랩되어 스냅샷 이전 블록은 디스크에 없으므로 제네시스부터의 재스캔은 실패합니다. 지갑의 사용된 잔액을 조정하려면 스냅샷에 포함된 높이부터 재스캔하세요. wallet.dat과 체인 데이터는 삭제되지 않습니다.",
"rescan_detecting": "노드가 디스크에 어떤 블록을 가지고 있는지 확인하는 중…",
"rescan_from_height": "재스캔 시작 블록 높이:",
"rescan_probe_failed": "노드가 디스크에 어떤 블록을 가지고 있는지 확인하지 못했습니다(노드가 사용 중이거나 요청 시간이 초과되었습니다). 이 노드가 부트스트랩된 경우 취소하고 1분 후에 다시 시도하세요. 부트스트랩된 노드에서는 아래의 -rescan 재시작이 실패합니다.",
"reset_to_defaults": "기본값으로 재설정",
"restarting_after_encryption": "암호화 후 데몬 재시작 중...",
"restore_address": "주소 복원",
@@ -1337,6 +1635,7 @@
"save_z_transactions": "Z 거래를 거래 목록에 저장",
"sb_auth_failed": "인증 실패 — rpcuser/rpcpassword를 확인하세요",
"sb_block": "블록: %d",
"sb_block_db_unreadable": "블록 데이터베이스를 읽을 수 없음 — 재구축 필요",
"sb_building_witnesses": "증인 설정 중",
"sb_building_witnesses_pct": "위트니스 재생성 중 %.0f%%",
"sb_connecting_daemon": "dragonxd에 연결 중...",
@@ -1352,6 +1651,7 @@
"sb_dragonxd_stopped": "dragonxd 중지됨",
"sb_dragonxd_stopping": "dragonxd 중지 중...",
"sb_extracting_sapling": "Sapling 매개변수 추출 중...",
"sb_finishing_repair": "지갑 복구 마무리 중 — 다시 스캔하는 중…",
"sb_importing_keys": "키 가져오기 중",
"sb_loading_config": "설정 불러오는 중...",
"sb_mining_hs": "%.1f H/s",
@@ -1360,6 +1660,7 @@
"sb_net_khs": "네트: %.2f KH/s",
"sb_net_mhs": "네트: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
"sb_node_busy": "노드 작업 중",
"sb_peers": "피어: %zu",
"sb_plaintext_remote_blocked": "원격 호스트로 RPC 자격 증명을 평문으로 보내는 것을 거부했습니다. 허용하려면 DRAGONX.conf에 rpcallowplaintext=1을 추가하거나 rpctls=1로 TLS를 활성화하세요.",
"sb_rescanning": "재스캔",
@@ -1373,6 +1674,7 @@
"sb_waiting_config": "데몬 설정 대기 중...",
"sb_waiting_daemon": "dragonxd 대기 중...",
"sb_waiting_daemon_err": "dragonxd 대기 중 — %s",
"sb_wallet_needs_recovery": "지갑 복구 가능",
"sb_warming_up": "워밍업 중...",
"sb_witness_cache": "증인 재구축 중",
"scale_effects": "배율 및 효과",
@@ -1380,6 +1682,7 @@
"screenshot_sweep": "스크린샷 스윕 실행",
"screenshot_sweep_desc": "모든 탭에 대해 모든 테마를 순회하며 각각의 스크린샷을 설정 디렉터리의 screenshots 폴더 아래 탭별 하위 폴더에 저장합니다(이전 스윕을 덮어씀). 몇 초 동안 실행됩니다.",
"screenshot_sweep_full": "전체 UI 스윕",
"screenshot_sweep_refused": "전체 UI 스윕이 거부되었습니다. 디버그 로그를 확인하세요",
"search_icons": "아이콘 검색...",
"search_placeholder": "검색...",
"sec_changing_passphrase": "암호문 변경 중...",
@@ -1401,15 +1704,19 @@
"sec_mode_passphrase": " 암호문",
"sec_not_connected_to_daemon": "데몬에 연결되지 않음",
"sec_not_connected_to_daemon_pin": "데몬에 연결되지 않음",
"sec_passphrase_changed_pin_stale": "암호문이 변경되었습니다. PIN이 오래되었습니다",
"sec_passphrase_changed_successfully": "암호문이 성공적으로 변경되었습니다",
"sec_pin_changed_successfully": "PIN이 성공적으로 변경되었습니다",
"sec_pin_removed": "PIN이 제거되었습니다",
"sec_pin_set_successfully": "PIN이 성공적으로 설정되었습니다",
"sec_pin_stale_msg": "PIN이 오래되었습니다. 암호문을 사용하세요.",
"sec_pin_unreadable": "PIN 파일을 읽을 수 없습니다. 암호문을 사용하세요.",
"sec_restart_daemon_for_encryption": "암호화를 적용하려면 데몬을 다시 시작하세요.",
"sec_too_many_attempts_wait": "시도 횟수가 너무 많습니다. %.0f초 기다리세요...",
"sec_total_elapsed_fmt": "총 경과 시간: %d분 %02d초",
"sec_unlock_button": "잠금 해제",
"sec_unlock_failed_prefix": "잠금 해제 실패: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "잠금 해제 중%s",
"sec_use_passphrase_instead": "대신 암호문 사용",
"sec_use_pin_instead": "대신 PIN 사용",
@@ -1444,6 +1751,7 @@
"send_amount": "금액",
"send_amount_details": "금액 상세",
"send_amount_upper": "금액",
"send_available_note": "사용 가능",
"send_cannot_send_now": "지금 보낼 수 없습니다 — 연결, 동기화, 사용 가능한 잔액을 확인하세요.",
"send_clear_fields": "모든 양식 필드를 지우시겠습니까?",
"send_contacts_button": "연락처에서 선택",
@@ -1461,6 +1769,8 @@
"send_from_this_address": "이 주소에서 전송",
"send_go_to_receive": "수신으로 이동",
"send_keep": "유지",
"send_mining_addr_blocked": "채굴(투명) 주소입니다. DragonX에서 이런 주소는 채굴 보상만 받을 수 있으며, 네트워크가 이 주소로의 결제를 거부합니다. 받는 사람에게 차폐 주소(zs로 시작)를 요청하세요.",
"send_mining_addr_short": "채굴 주소 — 결제를 받을 수 없음",
"send_network_fee": "네트워크 수수료",
"send_no_balance": "잔액 없음",
"send_no_recent": "최근 전송 없음",
@@ -1488,7 +1798,6 @@
"send_txid_copied": "TxID가 클립보드에 복사되었습니다",
"send_txid_label": "TxID: %s",
"send_valid_shielded": "유효한 차폐 주소",
"send_valid_transparent": "유효한 투명 주소",
"send_wallet_empty": "지갑이 비어 있습니다",
"send_yes_clear": "예, 지우기",
"sender_balance": "발신자: %.8f → %.8f DRGX",
@@ -1551,6 +1860,7 @@
"settings_open_log_folder": "로그 폴더 열기",
"settings_other": "기타",
"settings_pin_active": "PIN",
"settings_pin_out_of_date": "PIN이 오래됨",
"settings_privacy": "개인 정보",
"settings_quick_unlock_pin": "빠른 잠금 해제 PIN",
"settings_reduce_transparency": "투명도 줄이기",
@@ -1616,11 +1926,24 @@
"shielded_type": "차폐",
"shielding_notice": "참고: 투명 (T) 주소에서 프라이빗 (Z) 주소로 자금을 실딩합니다.",
"show": "표시",
"show_earnings": "수익 표시",
"show_hidden": "숨겨진 항목 표시 (%d)",
"show_qr": "QR 표시",
"show_qr_code": "QR 코드 표시",
"showing_transactions": "%d–%d / %d건의 거래 표시 중 (총: %zu)",
"showing_x_of_y": "%d / %d 주소 표시",
"shutdown_kill_confirm_msg": "노드가 오랫동안 CPU나 디스크 활동이 없어 멈췄을 수 있습니다.\n강제 종료하면 저장되지 않은 내용이 모두 사라지고, 다음 시작이 훨씬 오래 걸릴 수 있습니다.",
"shutdown_kill_confirm_title": "노드를 강제 종료할까요?",
"shutdown_kill_confirm_yes": "강제 종료",
"shutdown_kill_node": "노드 강제 종료(위험)",
"shutdown_kill_node_tip": "노드를 즉시 종료합니다. 저장되지 않은 내용은 모두 사라지며, 다음 시작 시 마지막 저장 이후의 블록을 다시 처리합니다.",
"shutdown_leave_running": "지갑 닫기 — 노드는 계속 실행",
"shutdown_leave_running_tip": "지갑을 지금 닫습니다. 노드는 혼자 계속 실행되며 진행 중인 작업을 마칩니다. 잃는 것은 없습니다.",
"shutdown_stop_asking": "노드에 중지를 요청하는 중…",
"shutdown_stop_cpu_suffix": " · CPU %d%%",
"shutdown_stop_delivered": "노드가 종료 중이며 상태를 저장하고 있습니다",
"shutdown_stop_idle": "%d분 동안 CPU나 디스크 활동이 없습니다 — 노드가 멈췄을 수 있습니다",
"shutdown_stop_queued": "노드가 중지 요청을 받았지만 작업 중입니다 — 가능해지면 중지됩니다",
"simple_background": "단순 배경",
"slider_off": "끔",
"sort_amount_high": "금액 큰 순",
@@ -1628,14 +1951,29 @@
"sort_date_newest": "최신순",
"sort_date_oldest": "오래된 순",
"start_mining": "채굴 시작",
"startup_files_preparing": "지갑 파일 준비 중... %d%%",
"startup_files_verifying": "지갑 파일 확인 중...",
"status": "상태",
"stop_external": "외부 데몬 중지",
"stop_mining": "채굴 중지",
"stopconfirm_busy_body": "%s 동안 지갑에 응답할 수 없을 만큼 바빴습니다. 위트니스 재구성 같은 긴 작업 때문일 가능성이 큽니다. 지금 중지하면 다음 시작 시 그 작업을 다시 해야 할 수 있습니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다.",
"stopconfirm_busy_title": "노드가 작업 중입니다",
"stopconfirm_rebuild_body": "%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다.",
"stopconfirm_rebuild_title": "노드가 노트 위트니스를 재구성하고 있습니다",
"stopconfirm_stop_wait": "노드 중지 후 대기",
"stopconfirm_walletop_body": "노드가 키 가져오기 또는 재스캔을 위해 체인을 스캔하고 있습니다. 지금 중지하면 스캔이 일찍 끝나고, 아직 스캔하지 못한 자금은 설정 → 재스캔 후에만 표시됩니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 스캔을 마칩니다.",
"stopconfirm_walletop_title": "키 가져오기 또는 재스캔이 실행 중입니다",
"stratum_allowip_hint": "채굴자를 허용할 IP 또는 CIDR (비워두면 이 컴퓨터만 허용)",
"stratum_expose_warn": "허용한 네트워크로 채굴 포트를 엽니다 — 신뢰할 수 있는 LAN에서만 사용하세요.",
"stratum_host": "마이닝 풀 호스팅 (stratum)",
"stratum_host_hint": "채굴자는 RandomX stratum 마이너로 포트 22769(RPC 포트 + 1000)에서 이 컴퓨터에 연결합니다. 적용하려면 데몬을 재시작하세요.",
"stratum_host_section": "마이닝 풀 호스팅",
"submitting_transaction": "거래 제출 중...",
"success": "성공",
"summary": "요약",
"sweep_button": "쓸어담기",
"sweep_caveat": "키를 가져와 모든 자금을 내 주소로 옮기는 거래에 서명합니다. 키는 잔액이 0인 상태로 지갑에 남습니다.",
"sweep_current_theme_only": "현재 테마만",
"sweep_dest_label": "쓸어담은 자금을 보낼 주소",
"sweep_dest_new": "새 차폐 주소 (권장)",
"sweep_done": "완료 — 자금을 내 주소로 쓸어담았습니다.",
@@ -1671,7 +2009,6 @@
"switch_stopnode_title": "실행 중인 노드를 중지할까요?",
"switch_stopnode_warn": "이 지갑이 시작하지 않은 노드가 이미 실행 중입니다.",
"syncing": "동기화 중...",
"t_address": "T 주소",
"t_addresses": "T 주소",
"test_connection": "테스트",
"theme": "테마",
@@ -1693,6 +2030,7 @@
"tools": "도구",
"tools_actions": "도구 및 작업...",
"tools_actions_hdr": "도구 및 작업",
"tor_never_applied_notice": "\"Tor 사용\" 옵션은 한 번도 작동하지 않았습니다. 연결이 Tor를 통해 라우팅되지 않았습니다. 이 옵션은 꺼졌습니다. 대안은 설정에서 이 옵션의 설명을 참고하세요.",
"total": "합계",
"total_balance_label": "총 잔액",
"transaction_id": "거래 ID",
@@ -1706,8 +2044,6 @@
"transfer_sent": "이체 전송됨",
"transfer_sent_desc": "이체가 네트워크에 제출되었습니다.",
"transfer_to": "이체 대상:",
"transparent": "투명",
"transparent_address": "투명 주소",
"try_again": "다시 시도",
"tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL",
"tt_address_book": "빠른 전송을 위해 저장된 주소 관리",
@@ -1739,10 +2075,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": "채굴 시작 전 대기 시간",
@@ -1751,6 +2093,7 @@
"tt_keep_daemon": "설정 마법사를 실행하면 데몬이 여전히 중지됩니다",
"tt_language": "지갑 UI 인터페이스 언어",
"tt_layout_hotkey": "단축키: 좌/우 화살표 키로 잔액 레이아웃 전환",
"tt_lite_confirm_pass": "새 암호를 한 번 더 입력하세요. 일치해야 하며 8자 이상이어야 합니다",
"tt_lite_copy": "표시된 비밀을 클립보드에 복사합니다",
"tt_lite_decrypt_pass": "지갑에서 암호화를 제거하려면 암호를 입력하세요",
"tt_lite_encrypt": "위 암호로 지갑을 암호화합니다; 즉시 잠기며 잠금 해제하려면 암호가 필요합니다",
@@ -1779,11 +2122,16 @@
"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 폴더(설정, 테마, 로그)를 엽니다",
"tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다",
"tt_open_dir": "파일 탐색기에서 열려면 클릭",
"tt_open_log_folder": "디버그 및 충돌 로그가 있는 폴더를 엽니다",
"tt_pin_out_of_date": "이 PIN을 설정한 후 지갑 암호문이 변경되어 PIN으로 더 이상 지갑을 잠금 해제할 수 없습니다. PIN을 제거한 뒤 다시 설정하세요.",
"tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화",
"tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장",
"tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구",
@@ -1813,14 +2161,20 @@
"tt_simple_bg": "배경에 단순 그라데이션 사용\\n단축키: Ctrl+Up",
"tt_simple_bg_alt": "테마 배경 이미지의 그라데이션 버전 사용\\n단축키: Ctrl+Up",
"tt_stop_external": "이 지갑 외부에서 시작된\\n데몬에 연결할 때 적용",
"tt_stratum_host": "이 노드에서 RandomX stratum 풀 서버를 실행하여 다른 RandomX 채굴자가 이 컴퓨터를 사용할 수 있게 합니다. v1.3.0 이상 노드와 적용을 위한 데몬 재시작이 필요합니다.",
"tt_sweep_current_theme_only": "모든 테마를 순회하는 대신 활성 테마만 스윕합니다",
"tt_test_conn": "데몬에 대한 RPC 연결 확인",
"tt_theme_effects": "테마별 반짝임, 글로우, 색조 순환",
"tt_theme_hotkey": "단축키: Ctrl+왼쪽/오른쪽으로 테마 전환",
"tt_tor": "익명성을 위해 데몬 연결을 Tor 네트워크를 통해 라우팅",
"tt_tor_unavailable": "아직 사용할 수 없음: 이 지갑은 어떤 통신도 Tor를 통해 라우팅하지 않습니다. 노드에 한해 DRAGONX.conf에 proxy=127.0.0.1:9050을 추가하고 노드를 재시작할 수 있습니다. Tor가 실행 중이어야 하며, 그렇지 않으면 노드가 피어를 찾지 못합니다. 가격, 업데이트, 부트스트랩, 풀 요청은 계속 직접 연결됩니다. 이러한 요청에는 시스템 전체 VPN/Tor를 사용하세요.",
"tt_tor_unavailable_lite": "아직 사용할 수 없음: 이 지갑은 어떤 통신도 Tor를 통해 라우팅하지 않습니다. 시스템 전체 VPN/Tor를 사용하세요.",
"tt_tx_url": "블록 탐색기에서 거래를 보기 위한 기본 URL",
"tt_ui_opacity": "카드 및 사이드바 불투명도 (100% = 완전 불투명, 낮을수록 더 투명)",
"tt_validate": "DragonX 주소가 유효한지 확인",
"tt_verbose": "콘솔 탭에 상세 연결 진단,\\n데몬 상태 및 포트 소유자 정보 기록",
"tt_view_transactions": "이 주소와 관련된 거래를 기록 탭에 표시",
"tt_wallet_size_warn": "보호 지갑은 각 노트의 위트니스 데이터로 인해 커집니다 — 여러 노트를 하나의 주소로 병합하면 크기를 줄일 수 있습니다. 먼저 백업하세요.",
"tt_wallets_button": "지갑 파일 목록을 보고 전환합니다",
"tt_website": "DragonX 웹사이트 열기",
"tt_window_opacity": "배경 불투명도 (낮을수록 = 창을 통해 바탕 화면이 보임)",
@@ -1837,6 +2191,7 @@
"tx_loading_queued": "거래 새로고침 대기 중",
"tx_loading_refreshing_cached": "지갑 내역을 새로고침하는 중 (%d개 캐시됨)",
"tx_loading_scanning_shielded": "차폐 내역 스캔 중 (%d개 주소)",
"tx_loading_waits_for_sync": "노드 동기화가 끝나면 내역이 업데이트됩니다",
"tx_mined": "채굴됨",
"tx_progress_balances": "잔액을 새로 고치는 중",
"tx_progress_finalizing": "트랜잭션 마무리 중",
@@ -1868,6 +2223,7 @@
"validate_description": "DragonX 주소를 입력하여 유효한지 그리고 이 지갑에 속하는지 확인합니다.",
"validate_invalid": "유효하지 않음",
"validate_is_mine": "이 지갑이 이 주소를 소유합니다",
"validate_mining_type": "채굴 주소(투명)",
"validate_not_mine": "이 지갑에 속하지 않음",
"validate_ownership": "소유자:",
"validate_results": "결과:",
@@ -1875,7 +2231,6 @@
"validate_shielded_type": "차폐 (z 주소)",
"validate_status": "상태:",
"validate_title": "주소 검증",
"validate_transparent_type": "투명 (t 주소)",
"validate_type": "유형:",
"validate_valid": "유효함",
"validating": "검증 중...",
@@ -1885,10 +2240,71 @@
"view_all": "모두 보기",
"view_details": "상세 보기",
"view_on_explorer": "탐색기에서 보기",
"view_transactions": "거래 보기",
"waiting_for_daemon": "데몬 연결 대기 중...",
"wallet": "지갑",
"wallet_degraded_notify": "지갑이 기능 제한 모드로 열렸습니다. 기존 자금은 안전하며 사용할 수 있지만, 새 주소 생성과 보호 처리는 비활성화되어 있습니다. 시드 문구를 백업하고 이를 통해 복원하면 지갑을 완전히 복구할 수 있습니다.",
"wallet_diagnostics_hdr": "진단",
"wallet_empty": "지갑이 비어 있습니다",
"wallet_empty_hint": "수신으로 전환하여 주소를 받고 자금 수신을 시작하세요.",
"wallet_options_hdr": "옵션",
"wallet_rebuild_failed": "재구축 결과가 유효한 지갑이 아니어서 아무것도 변경되지 않았습니다. 지갑은 그대로입니다.",
"wallet_rebuild_install_failed": "재구축된 지갑을 설치하지 못했습니다. 기존 지갑은 그대로 유지되었습니다.",
"wallet_rebuild_no_helper": "이 빌드에서는 지갑 재구축 도우미를 사용할 수 없습니다. 복원을 사용하거나 수동으로 재구축하세요.",
"wallet_rebuild_no_source": "재구축할 읽을 수 있는 지갑을 찾지 못했습니다. 아무것도 변경되지 않았습니다.",
"wallet_rebuild_ok": "지갑 데이터베이스가 재구축되었습니다 — 노드가 이를 불러오고 잔액을 다시 스캔하는 중입니다.",
"wallet_rebuild_started": "지갑 파일을 복구하는 중입니다 — 이 창을 닫지 마세요.",
"wallet_recovered_body": "삭제된 것은 없습니다. 원본 지갑은 날짜가 표시된 백업 파일로 컴퓨터에 그대로 저장되어 있으며, 아래의 모든 옵션은 파일을 복사하거나 이름을 바꿀 뿐 절대 삭제하지 않습니다. 키는 다시 생성되지 않고 다시 읽히기만 합니다.\n\n지금 불러온 복구본에는 최근 거래 몇 건이 누락되었을 수 있어, 아래 작업을 마치고 다시 스캔할 때까지 잔액이 약간 적게 보일 수 있습니다.",
"wallet_recovered_dismiss": "나중에 — 복구본 계속 사용",
"wallet_recovered_dismiss_sub": "파일은 변경되지 않습니다. 상태 표시줄에서 언제든 다시 열 수 있으며, 원본은 어떤 경우에도 안전하게 백업된 상태로 남습니다.",
"wallet_recovered_notify": "지갑 파일을 복구해야 했습니다 — 원본은 안전하게 백업되었습니다. 앱을 열어 옵션을 확인하세요.",
"wallet_recovered_open_folder": "파일 보기",
"wallet_recovered_open_folder_sub": "지갑 데이터 폴더를 열어 백업 파일을 직접 확인할 수 있습니다 — 아무것도 변경되지 않습니다.",
"wallet_recovered_rebuild": "자동으로 복구 (권장)",
"wallet_recovered_rebuild_sub": "가장 온전한 지갑 파일에서 복구 가능한 모든 기록을 다시 읽어 깨끗한 새 파일로 작성한 다음 재시작합니다. 가장 철저한 방법이며 — 현재 파일은 어떤 경우에도 날짜가 표시된 백업으로 보관됩니다.",
"wallet_recovered_restore": "대신 원본 파일 복원",
"wallet_recovered_restore_sub": "가장 큰 원본 백업을 그대로 제자리에 넣은 다음 재스캔합니다 — 조금 더 빠르지만 그 파일이 담고 있던 만큼만 완전합니다. 현재 파일은 어떤 경우에도 날짜가 표시된 백업으로 보관됩니다.",
"wallet_recovered_safety": "코인은 안전합니다.",
"wallet_recovered_title": "지갑 파일에 간단한 복구가 필요합니다",
"wallet_recovered_warn": "앱이 시작될 때 지갑 파일이 무결성 검사를 통과하지 못한 것을 발견했습니다 — 보통 앱 업데이트 후나 비정상 종료 후에 발생합니다. 앱은 이미 데이터를 보호했습니다. 기존 파일을 따로 보관하고 복구된 사본을 불러와서 지갑을 계속 사용할 수 있게 했습니다.",
"wallet_recovery_decide_later": "나중에 결정",
"wallet_recovery_decide_later_tip": "이 창을 닫고 지금 불러온 사본을 그대로 사용합니다. 아무것도 변경되지 않으며, 언제든 복구할 수 있습니다 — 상태 표시줄에 “지갑 복구 가능” 링크가 계속 표시됩니다.",
"wallet_recovery_details_label": "기술 세부정보 표시",
"wallet_recovery_done": "완료",
"wallet_recovery_failure_body": "복구가 실행되었지만 결과가 검증을 통과하지 못했습니다 (읽을 수 없거나 주소가 없음). 그래서 무엇도 교체하기 전에 자동으로 폐기되었습니다. 지갑은 클릭하기 전과 정확히 동일하며 — 디스크에는 아무 변경도 없습니다.",
"wallet_recovery_failure_title": "복구가 완료되지 않았습니다",
"wallet_recovery_files_detail": "원본 지갑은 그대로 있습니다 — 앱이 데이터 폴더에서 원본 이름을 날짜가 표시된 백업(wallet.<숫자>.bak)으로 변경했을 뿐, 아무것도 삭제하지 않았습니다. “복구”는 가장 온전한 지갑의 모든 기록을 읽어 완전히 새로운 깨끗한 파일로 만듭니다. “복원”은 가장 큰 백업을 있는 그대로 정확히 복사합니다. 지금 불러온 사본 역시 백업으로 보관됩니다.",
"wallet_recovery_files_label": "내 파일은 어떻게 되나요?",
"wallet_recovery_notnow_short": "나중에",
"wallet_recovery_other_options": "다른 옵션",
"wallet_recovery_rebuild_card": "자동으로 복구",
"wallet_recovery_rebuild_card_desc": "복구 가능한 모든 기록을 깨끗한 파일로 읽어 들인 다음 재시작합니다. 가장 철저한 방법입니다.",
"wallet_recovery_recommended": "권장",
"wallet_recovery_repair_go": "복구",
"wallet_recovery_rescan_body": "잔액과 거래 내역을 재구성하기 위해 블록체인을 재스캔하고 있습니다. 몇 분 정도 걸릴 수 있습니다 — 아무 작업도 할 필요 없으며, 실행 중에는 노드를 재시작하지 마세요.",
"wallet_recovery_rescan_elapsed": "%s 동안 진행 중",
"wallet_recovery_rescan_size": "복구된 지갑 크기는 현재 %s이며 계속 채워지는 중입니다",
"wallet_recovery_rescan_slow": "평소보다 오래 걸리고 있으며, 지갑이 클 경우 정상입니다. 그대로 실행되도록 두어도 안전합니다 — 고급 ▸ 콘솔에서 진행 상황을 확인할 수 있습니다.",
"wallet_recovery_rescan_title": "지갑 복구 마무리 중",
"wallet_recovery_restore_card": "원본 복원",
"wallet_recovery_restore_card_desc": "가장 큰 원본 백업을 그대로 되돌려 놓습니다 — 그 파일이 담고 있던 만큼만 완전합니다.",
"wallet_recovery_restore_go": "복원",
"wallet_recovery_success_body": "앱이 복구된 지갑을 불러왔으며 잔액을 합산하기 위해 재스캔하고 있습니다 — 몇 분 정도 걸릴 수 있습니다. 가능한 모든 기록을 읽어 들이지만, 드물게 손상된 파일에서는 이전보다 주소가 조금 적게 — 또는 가끔 더 많이 — 복구될 수 있습니다. 재스캔이 끝나면 잔액과 내역이 올바른지 확인하세요.",
"wallet_recovery_success_restore": "가장 큰 백업 파일이 제자리로 돌아와 지금 불러오는 중이며, 앱이 재스캔하고 있습니다. 있는 그대로 복원되었으므로 그 파일이 담고 있던 만큼만 완전합니다 — 재스캔이 끝나면 잔액을 확인하세요.",
"wallet_recovery_try_other": "다른 옵션 시도",
"wallet_recovery_whats_happened": "무슨 일이 있었나요?",
"wallet_recovery_working_label": "작업 중",
"wallet_restore_bad_backup": "백업 지갑 파일을 읽을 수 없어 복원되지 않았습니다 — 현재 지갑은 변경되지 않았습니다. 대신 직접 보관한 백업에서 복원하세요.",
"wallet_restore_busy": "노드가 재시작 중이라 바쁩니다 — 잠시 후 다시 시도하세요.",
"wallet_restore_copy_failed": "백업 지갑을 설치하지 못했습니다. 현재 지갑은 그대로 유지되었습니다.",
"wallet_restore_move_failed": "현재 지갑을 따로 보관하지 못했습니다 — 아무것도 변경되지 않았습니다.",
"wallet_restore_no_backup": "복원할 wallet.<타임스탬프>.bak을 찾을 수 없습니다. 아무것도 변경되지 않았습니다.",
"wallet_restore_no_restart": "원본 지갑은 복원되었지만 노드가 재시작되지 않았습니다 — 설정에서 노드를 시작하세요.",
"wallet_restore_ok": "원본 지갑이 복원되었습니다. 노드가 지금 불러오고 있습니다.",
"wallet_restore_started": "원본 파일을 복원하는 중입니다 — 이 창을 닫지 마세요.",
"wallet_restore_stop_failed": "노드가 제시간에 멈추지 않아 아무것도 변경되지 않았습니다. 다시 시도하세요.",
"wallet_size_consolidate": "노트 통합…",
"wallet_size_warn": "이 지갑 파일이 큽니다. 노트를 통합하면 추가 증가를 억제할 수 있습니다.",
"wallets_active": "활성",
"wallets_add": "폴더 추가",
"wallets_add_folder": "지갑 파일을 검색할 폴더를 추가로 지정:",
@@ -1931,7 +2347,9 @@
"wallets_open_inplace_tt": "이 지갑을 있는 자리에서 엽니다 — 데이터 디렉터리에 링크 (복사 없음)",
"wallets_remove_folder": "이 폴더 스캔 중지",
"wallets_reveal": "폴더 열기",
"wallets_scan_folder": "다른 폴더에서 지갑 검색…",
"wallets_scanned_folders": "스캔한 폴더:",
"wallets_scanning": "폴더 검색 중…",
"wallets_sort_addresses": "주소",
"wallets_sort_asc": "오름차순 (오래된/적은/작은 순)",
"wallets_sort_by": "정렬:",
@@ -2000,19 +2418,30 @@
"wiz_welcome_sub": "몇 가지만 선택하면 풀 노드가 준비됩니다.",
"wiz_welcome_title": "ObsidianDragon에 오신 것을 환영합니다!",
"wizard_daemon_start_failed": "데몬 시작 실패 — 자동으로 재시도됩니다",
"xmrig_active_tt": "현재 설치된 (사용 중인) 채굴기",
"xmrig_browse_releases": "모든 릴리스 보기…",
"xmrig_checking": "최신 채굴기를 확인하는 중…",
"xmrig_current": "현재:",
"xmrig_downgrade_note": "다른 버전의 채굴기를 설치하면 현재 버전을 대체합니다. 먼저 채굴을 중지하면 다음 시작 시 적용됩니다.",
"xmrig_download_install": "다운로드 및 설치",
"xmrig_downloading": "다운로드 중…",
"xmrig_install_this": "이 버전 설치",
"xmrig_installed": "설치됨:",
"xmrig_installed_ok": "채굴기가 설치되었습니다",
"xmrig_installing": "설치 중…",
"xmrig_latest": "최신:",
"xmrig_latest_badge": "최신",
"xmrig_latest_tt": "사용 가능한 최신 버전",
"xmrig_loading_releases": "릴리스를 불러오는 중…",
"xmrig_no_notes": "이 버전에 대한 릴리스 노트가 없습니다.",
"xmrig_none": "없음",
"xmrig_prerelease_tt": "사전 릴리스 / 테스트 빌드",
"xmrig_reinstall": "재설치",
"xmrig_released": "출시일:",
"xmrig_releases": "xmrig 릴리스",
"xmrig_status_current": "이 버전이 설치되어 있습니다",
"xmrig_status_latest": "최신 버전",
"xmrig_status_older": "설치된 버전보다 이전 버전입니다",
"xmrig_stop_mining_first": "채굴기를 업데이트하기 전에 채굴을 중지하세요.",
"xmrig_unavailable_body": "이 플랫폼에서 사용 가능한 채굴기 빌드가 없습니다.",
"xmrig_unavailable_title": "채굴기 업데이트를 사용할 수 없습니다",
+453 -25
View File
@@ -19,11 +19,13 @@
"about_license_text": "Este software é disponibilizado sob a Licença Pública Geral GNU v3 (GPLv3). Você é livre para usar, modificar e distribuir este software sob os termos da licença.",
"about_peers_count": "%zu pares",
"about_release": "Versão",
"about_source": "Origem",
"about_title": "Sobre o ObsidianDragon",
"about_version": "Versão:",
"about_website": "Website",
"acrylic": "Acrílico",
"add": "Adicionar",
"addr_type_mining": "Mineração",
"address": "Endereço",
"address_book_add": "Adicionar Endereço",
"address_book_add_new": "Adicionar Novo",
@@ -67,6 +69,7 @@
"appx_blockchain_maintenance_in_progress": "Uma operação de manutenção da blockchain já está em andamento.",
"appx_blockchain_rescan_complete": "Reescaneamento da blockchain concluído",
"appx_bootstrap_complete_reconciling": "Bootstrap concluído — reconciliando sua carteira com os novos dados da chain.",
"appx_bootstrap_reconcile_probe_failed": "Não foi possível reconciliar sua carteira com os dados da chain do bootstrap: o nó ficou ocupado demais para verificar quais blocos tem. Execute Reescanear nos Ajustes quando ele estiver em dia.",
"appx_cancel": "Cancelar",
"appx_cleaning_up": "Limpando...",
"appx_confirm_your_backup": "Confirme seu backup",
@@ -77,11 +80,15 @@
"appx_creating_your_wallet": "Criando sua carteira…",
"appx_daemon_error": "Erro do Daemon",
"appx_daemon_reinstall_in_progress": "A reinstalação do daemon já está em andamento.",
"appx_daemon_reinstall_node_busy": "O nó ainda está em execução, por isso o daemon não foi reinstalado. Tente novamente quando ele tiver parado.",
"appx_disconnecting": "Desconectando...",
"appx_done": "Concluído",
"appx_dragonxd_output": "saída do dragonxd",
"appx_encrypting_wallet": "Criptografando a carteira...",
"appx_fullnode_lifecycle_unavailable_lite": "As ações de ciclo de vida do nó completo não estão disponíveis na versão lite",
"appx_import_cancelled": "Cancelado: o nó ainda pode estar escaneando. Se os fundos da chave não aparecerem, execute Ajustes → Reescanear.",
"appx_import_interrupted": "A importação foi interrompida, então a chave pode já estar na carteira. Se os fundos dela não aparecerem, execute Ajustes → Reescanear.",
"appx_import_refused": "O nó recusou a solicitação, então a chave não foi importada.",
"appx_installing_bundled_daemon": "Instalando o daemon incluído — o nó vai parar, atualizar e reiniciar...",
"appx_invalid_payment_uri_prefix": "URI de pagamento inválida: ",
"appx_ive_written_it_down": "Já anotei",
@@ -98,7 +105,8 @@
"appx_no_bundled_daemon_to_install": "Esta versão não tem daemon incluído para instalar",
"appx_no_embedded_daemon_to_install": "Esta versão não tem daemon embutido para instalar",
"appx_node_busy_restarting": "O nó está ocupado reiniciando — tente novamente em instantes.",
"appx_node_rebuilding_witness_cache": "O nó está reconstruindo seu cache de testemunhas",
"appx_node_busy_try_later": "O nó está ocupado concluindo uma tarefa longa (como reconstruir as testemunhas de notas). Tente novamente quando terminar; pará-lo agora pode fazê-lo recomeçar esse trabalho.",
"appx_node_not_stopped": "O nó não parou a tempo, por isso nada foi alterado. Tente novamente em alguns minutos.",
"appx_not_next_word": " — essa não é a próxima palavra",
"appx_payment_request_loaded": "Solicitação de pagamento carregada",
"appx_pool_miner_connected_and_hashing": "Minerador de pool conectado e processando hashes.",
@@ -119,11 +127,7 @@
"appx_simple_background_enabled": "Fundo simples ativado",
"appx_skip": "Pular",
"appx_skip_anyway": "Pular mesmo assim",
"appx_still_status_prefix": "Ainda \"",
"appx_still_status_suffix": "\" — forçar o encerramento agora pode corromper os dados da chain.",
"appx_stop_anyway_and_quit": "Parar mesmo assim e sair",
"appx_stopping_daemon_deleting_blockchain": "Parando o daemon e excluindo os dados da blockchain...",
"appx_stopping_node_discards_rebuild": "Parar o nó agora descarta a reconstrução em andamento e a reinicia (vários minutos) na próxima vez que você abrir a carteira. Em vez disso, você pode manter o nó em execução.",
"appx_stopping_pool_miner": "Parando o minerador de pool...",
"appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloco %d / %d",
"appx_tap_words_in_order": "Toque nas palavras na ordem correta para confirmar que você as salvou.",
@@ -133,8 +137,14 @@
"appx_use_settings_restart_daemon_hint": "Use Configurações > Reiniciar Daemon para tentar novamente",
"appx_waiting_for_daemon_to_encrypt_wallet": "Aguardando o daemon criptografar a carteira...",
"appx_wallet_created_and_backed_up": "Carteira criada e com backup feito.",
"appx_wallet_op_node_busy": "O nó está ocupado e não responde. Tente a importação ou o reescaneamento novamente quando terminar.",
"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_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.",
"autoshield_off_generic": "Seu node não está blindando automaticamente o coinbase minerado no momento.",
"av_intro": "Softwares de mineração costumam ser sinalizados como potencialmente indesejados. Siga estes passos para habilitar a mineração em pool:",
"av_open_security": "Abrir Segurança do Windows",
"av_step1": "Passo 1: Adicionar exclusão",
@@ -170,21 +180,20 @@
"balance_history_collecting": "Histórico de saldo — coletando dados...",
"balance_layout": "Layout do Saldo",
"balance_layout_switched": "Layout: %s",
"balance_mining_fmt": "Recompensas de mineração: %.8f",
"balance_mining_rate": "Minerando %s",
"balance_shielded_fmt": "Blindado: %.8f",
"balance_syncing_pct": "Sincronizando %.1f%%",
"balance_transparent_fmt": "Transparente: %.8f",
"baltab_market": "Mercado",
"baltab_market_price_4dp": "Mercado: $%.4f",
"baltab_market_price_8dp": "Mercado: $%.8f",
"baltab_mining_addresses_count": "%d endereços de mineração",
"baltab_mining_amount": "Recompensas de mineração %.8f",
"baltab_pct_change_24h": "%s%.1f%% 24h",
"baltab_pct_of_total_zaddr": "%.0f%% do total · %d Z-addr",
"baltab_shielded": "Blindado",
"baltab_shielded_amount": "Blindado %.8f",
"baltab_t_addresses_count": "%d T-addresses",
"baltab_total_balance": "Saldo Total",
"baltab_transparent": "Transparente",
"baltab_transparent_amount": "Transparente %.8f",
"ban": "Banir",
"banned_peers": "Pares Banidos",
"block": "Bloco",
@@ -192,6 +201,12 @@
"block_click_copy": "Clique para copiar",
"block_click_next": "Clique para ver o próximo bloco",
"block_click_prev": "Clique para ver o bloco anterior",
"block_db_reindex_body": "Isso geralmente acontece após uma atualização do daemon alterar o formato em disco, ou se o índice de blocos estiver danificado. Sua carteira e suas moedas estão seguras — o node apenas não consegue carregar a chain, então os saldos aparecem como zero.\n\nReconstruir relê seus arquivos de blocos existentes e pode levar um tempo (também refaz o rescan da sua carteira). Nada é baixado.",
"block_db_reindex_confirm": "Reconstruir banco de dados de blocos",
"block_db_reindex_notify": "O node não consegue ler seu banco de dados de blocos (geralmente após uma atualização do daemon). Reconstrua-o para restaurar seu saldo — veja o aviso, ou Configurações › Node.",
"block_db_reindex_started": "Reconstruindo o banco de dados de blocos a partir dos seus blocos — isso pode levar um tempo.",
"block_db_reindex_title": "Reconstruir banco de dados de blocos?",
"block_db_reindex_warn": "O node não consegue ler seu banco de dados de blocos.",
"block_explorer": "Explorador de Blocos",
"block_get_info": "Obter Info do Bloco",
"block_hash": "Hash do Bloco:",
@@ -228,6 +243,7 @@
"change_pass_confirm": "Confirmar nova:",
"change_pass_current": "Senha atual:",
"change_pass_new": "Nova senha:",
"change_pass_pin_note": "Esta carteira tem um PIN de desbloqueio rápido. Após a alteração ele ficará desatualizado: remova o PIN em Ajustes e defina-o novamente.",
"change_pass_title": "Alterar senha",
"characters": "caracteres",
"chat": "Chat",
@@ -239,6 +255,10 @@
"chat_accent_theme": "Tema",
"chat_add_contact": "Adicionar contato",
"chat_awaiting_key": "Aguardando resposta",
"chat_blocked_desc": "Conversas bloqueadas são removidas e suas mensagens são descartadas — antigas e novas — até você desbloquear. Desbloquear reimporta a conversa a partir da chain.",
"chat_blocked_manage": "Bloqueadas",
"chat_blocked_title": "Conversas bloqueadas",
"chat_blocked_toast": "Conversa excluída e bloqueada",
"chat_bubble_minimal": "Mínimo",
"chat_bubble_rounded": "Arredondado",
"chat_bubble_square": "Quadrado",
@@ -251,6 +271,16 @@
"chat_contact_added": "Contato adicionado — renomeie em Contatos",
"chat_contact_request": "solicitação de contato",
"chat_copy_address_tip": "Clique para copiar o endereço",
"chat_delete": "Excluir conversa",
"chat_delete_block": "Excluir e bloquear",
"chat_delete_body_prefix": "Excluir sua cópia local da conversa com ",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "Excluir",
"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_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",
"chat_density_comfortable": "Confortável",
"chat_density_compact": "Compacta",
"chat_emoji_color": "Colorido",
@@ -274,6 +304,7 @@
"chat_new_button": "Novo chat",
"chat_new_message": "Mensagem",
"chat_new_message_toast": "Nova mensagem de chat criptografada",
"chat_new_needs_zaddr": "O chat precisa de um endereço blindado (z) — endereços transparentes (t) não podem receber mensagens criptografadas.",
"chat_new_send": "Enviar solicitação",
"chat_new_title": "Novo chat",
"chat_new_zaddr": "Endereço-z do destinatário",
@@ -319,6 +350,8 @@
"chat_ts_24h": "24 horas",
"chat_ts_global": "Seguir global",
"chat_ts_global_short": "Global",
"chat_unblock": "Desbloquear",
"chat_unblocked_toast": "Conversa desbloqueada",
"chat_unhide": "Mostrar",
"chat_unmute": "Reativar som",
"chat_verify_key": "Chave de identidade — compare para verificar",
@@ -371,6 +404,7 @@
"console": "Console",
"console_accents": "Destaques de cor",
"console_app": "App",
"console_auto_focus": "Focar entrada ao abrir",
"console_auto_scroll": "Rolagem automática",
"console_available_commands": "Comandos disponíveis:",
"console_backend_reference": "Referência de Comandos do Backend",
@@ -465,11 +499,15 @@
"console_tab_completion": "Tab para completar",
"console_text_colors": "Cores do texto",
"console_toggle_accents": "Alternar destaques de cor das linhas",
"console_toggle_auto_focus": "Colocar o cursor na caixa de comando ao abrir a aba Console",
"console_toggle_text_color": "Alternar cores do texto das linhas",
"console_type_help": "Digite 'help' para comandos disponíveis",
"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_funds_btn": "Consolidar",
"consolidate_title": "Consolidar fundos",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "Não foi possível carregar essa imagem.",
"contact_avatar_badge": "Selo",
@@ -481,7 +519,7 @@
"contact_avatar_image_hint": "A imagem é copiada para o app para continuar disponível se o original for movido.",
"contact_avatar_remove": "Remover",
"contact_avatar_shielded": "Blindado",
"contact_avatar_transparent": "Transparente",
"contact_chip_mining": "Só mineração",
"contact_global": "Mostrar em todas as carteiras (contato global)",
"contact_global_badge_tt": "Contato global — visível em todas as carteiras",
"contact_global_tt": "Ativado: este contato permanece visível em qualquer carteira que você carregar. Desativado: ele pertence apenas à carteira atual.",
@@ -515,9 +553,13 @@
"daemon_maintenance_label": "MANUTENÇÃO",
"daemon_none_bundled": "nenhum nesta build",
"daemon_not_installed": "não instalado",
"daemon_status_diff": "Versão diferente",
"daemon_status_differ": "O binário instalado difere da versão empacotada.",
"daemon_status_match": "O binário instalado corresponde à versão incluída.",
"daemon_status_missing": "Nenhum daemon instalado — instale a versão incluída.",
"daemon_status_none": "Não instalado",
"daemon_status_ok": "Atualizado",
"daemon_update_active_tt": "O node atualmente instalado (ativo)",
"daemon_update_available": "Uma nova versão do nó está disponível",
"daemon_update_body": "Esta versão da carteira inclui um nó DragonX mais recente do que o atualmente instalado. A atualização substitui o dragonxd instalado (e o dragonx-cli/dragonx-tx), depois para e reinicia o nó para que a nova versão entre em vigor. Recomendado — um nó mais recente pode adicionar recursos (por exemplo, suporte a frase de recuperação) que o antigo não tem.",
"daemon_update_browse": "Ver todos os lançamentos…",
@@ -527,19 +569,28 @@
"daemon_update_download_install": "Baixar e instalar",
"daemon_update_downloading": "Baixando…",
"daemon_update_failed": "Falha na atualização",
"daemon_update_install_this": "Instalar esta versão",
"daemon_update_installed": "Instalado:",
"daemon_update_installed_ok": "Nó instalado",
"daemon_update_installing": "Instalando…",
"daemon_update_keep": "Manter atual",
"daemon_update_later": "Depois",
"daemon_update_latest": "Mais recente:",
"daemon_update_latest_badge": "mais recente",
"daemon_update_latest_tt": "Versão mais recente disponível",
"daemon_update_loading": "Carregando versões…",
"daemon_update_no_notes": "Sem notas de versão para esta versão.",
"daemon_update_now": "Atualizar agora",
"daemon_update_prerelease_tt": "Build de pré-lançamento / teste",
"daemon_update_prompt_title": "Atualizar o daemon do node?",
"daemon_update_reinstall": "Reinstalar",
"daemon_update_released": "Lançado:",
"daemon_update_restart_note": "Reinicie o daemon para começar a executar a nova versão.",
"daemon_update_restart_now": "Reiniciar daemon agora",
"daemon_update_safe": "Sua carteira, chaves e dados da blockchain não são tocados — apenas os arquivos de programa do daemon são substituídos. Se você executa deliberadamente um nó personalizado, escolha Manter atual.",
"daemon_update_status_current": "Esta versão está instalada",
"daemon_update_status_latest": "Versão mais recente",
"daemon_update_status_older": "Mais antiga que sua versão instalada",
"daemon_update_title": "Atualizar o daemon do nó?",
"daemon_update_unavailable_body": "Nenhuma versão do nó está disponível para esta plataforma.",
"daemon_update_unavailable_title": "Atualizações do nó indisponíveis",
@@ -555,6 +606,13 @@
"data_stale_tooltip": "O saldo pode estar desatualizado — a carteira não recebeu uma atualização recente. Verifique a conexão com o seu nó.",
"date": "Data",
"date_label": "Data:",
"debug_gate_confirm": "Mostrar opções de depuração",
"debug_gate_incorrect": "PIN ou senha incorretos.",
"debug_gate_pass_prompt": "Digite a senha da sua carteira para continuar:",
"debug_gate_pin_prompt": "Digite seu PIN de desbloqueio para continuar:",
"debug_gate_title": "Mostrar opções de depuração?",
"debug_gate_verifying": "Verificando…",
"debug_gate_warning": "Estas são opções avançadas de diagnóstico e ajuste do node. Elas podem expor informações sensíveis e alterar o funcionamento do seu node. Só continue se souber o que está fazendo.",
"debug_logging": "REGISTRO DE DEPURAÇÃO",
"decrypt_desc": "A carteira será exportada, o daemon reiniciado com uma nova carteira sem criptografia e todas as chaves reimportadas. Isso pode levar alguns minutos, dependendo do tamanho da carteira.",
"decrypt_error_title": "Falha na descriptografia",
@@ -577,9 +635,15 @@
"disconnected": "Desconectado",
"dismiss": "Dispensar",
"display": "Exibição",
"done": "Concluído",
"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",
@@ -630,6 +694,7 @@
"explorer_tx_outputs": "Saídas",
"explorer_tx_size": "Tamanho",
"explorer_txid": "TxID:",
"explorer_urls_hdr": "URLS",
"export": "Exportar",
"export_csv": "Exportar CSV",
"export_keys_btn": "Exportar Chaves",
@@ -655,9 +720,155 @@
"export_tx_success": "Transações exportadas com sucesso",
"export_tx_title": "Exportar Transações para CSV",
"export_viewing_key": "Exportar Chave de Visualização",
"failed_create_mining_address": "Falha ao criar o endereço de mineração",
"failed_create_shielded": "Falha ao criar endereço blindado",
"failed_create_transparent": "Falha ao criar endereço transparente",
"faq": "Perguntas frequentes",
"faq_d_mgmt_1_a": "O node incorporado inicia automaticamente com a carteira e para quando apropriado, então normalmente você não precisa gerenciá-lo manualmente. Os controles avançados (incluindo reiniciar o daemon) ficam em Configurações > Node e Segurança.",
"faq_d_mgmt_1_q": "Como inicio ou paro o node?",
"faq_d_mgmt_2_a": "Se você conectar a carteira a um node que você mesmo iniciou, esta opção decide se fechar a carteira também encerra esse node. Deixe desativado se quiser que seu node continue rodando após fechar a carteira.",
"faq_d_mgmt_2_q": "O que faz \"fechar daemon externo ao sair\"?",
"faq_d_mgmt_3_a": "Os dados do node e da carteira ficam no diretório de dados do DragonX dentro do seu perfil de usuário (por exemplo, em AppData no Windows ou na sua pasta pessoal no Linux/macOS). A blockchain é a parte grande; mantenha espaço livre em disco suficiente para ela crescer.",
"faq_d_mgmt_3_q": "Onde ficam armazenados a blockchain e os dados da carteira?",
"faq_d_mgmt_title": "Gestão do nó",
"faq_d_mine_1_a": "A aba Mineração permite minerar com sua CPU. O DragonX usa o algoritmo RandomX, projetado para processadores comuns. Defina o número de threads e inicie — as recompensas se acumulam na sua carteira.",
"faq_d_mine_1_q": "Como faço para minerar DragonX?",
"faq_d_mine_2_a": "A mineração solo envia recompensas diretamente para você, mas só paga quando você encontra um bloco, o que é raro a menos que você tenha muito hashrate. A mineração em pool compartilha o trabalho com outros para pagamentos menores e mais constantes. A aba Mineração suporta ambos.",
"faq_d_mine_2_q": "Solo ou pool — qual devo escolher?",
"faq_d_mine_3_a": "A seção de pool da aba Mineração tem um botão 'Atualizar minerador…' que baixa, verifica e instala a versão mais recente do minerador DragonX otimizado. Assim como o atualizador do nó, cada download é verificado por checksum e assinatura antes da instalação.",
"faq_d_mine_3_q": "Como faço para atualizar o minerador?",
"faq_d_mine_4_a": "O nó atual pode executar um servidor stratum embutido para que outros mineradores possam apontar para sua máquina. Há uma opção para isso em Configurações > Nó & Segurança. Por padrão, ele escuta apenas localmente; expô-lo a outros computadores exige abertura deliberada.",
"faq_d_mine_4_q": "Posso hospedar um pool de mineração?",
"faq_d_mine_title": "Mineração e Pools",
"faq_d_net_1_a": "Logo após a inicialização, o nó precisa de um momento para encontrar peers, então um breve '0 peers' é normal. Se persistir, verifique sua conexão com a internet e se um firewall não está bloqueando o nó. O nó encontra peers automaticamente através de seeds embutidos.",
"faq_d_net_1_q": "A carteira mostra nenhum peer ou conexão — o que está errado?",
"faq_d_net_2_a": "Sim — os nós DragonX se comunicam entre si via TLS, então as conexões entre peers são criptografadas. A carteira também se comunica com seu próprio nó por um canal local seguro.",
"faq_d_net_2_q": "Minha conexão com a rede é criptografada?",
"faq_d_net_title": "Peers e Rede",
"faq_d_node_1_a": "O daemon, dragonxd, é o nó completo do DragonX. Ele baixa e verifica toda a blockchain, retransmite transações para a rede e responde às consultas da carteira. Rodar seu próprio nó significa que você não precisa confiar no servidor de outra pessoa.",
"faq_d_node_1_q": "O que é o daemon (dragonxd)?",
"faq_d_node_2_a": "Por padrão, a carteira inicia e gerencia seu próprio nó embutido (embedded) — você não precisa fazer nada. Se você já roda o dragonxd por conta própria, a carteira pode se conectar a esse nó externo. A carteira detecta em qual situação está e se comporta de acordo.",
"faq_d_node_2_q": "Nó embutido vs externo — qual a diferença?",
"faq_d_node_3_a": "Um nó completo valida independentemente cada regra da blockchain, então sua carteira confia em matemática em vez de em terceiros. Isso também fortalece a rede. O custo é espaço em disco e uma sincronização inicial.",
"faq_d_node_3_q": "Por que rodar um nó completo?",
"faq_d_node_title": "O Nó Completo",
"faq_d_perf_1_a": "A blockchain é o maior item e cresce com o tempo, então mantenha vários gigabytes livres. O nó usa um cache de banco de dados para velocidade; os padrões incluídos são ajustados para máquinas comuns. Usuários avançados podem ajustar o cache de banco de dados do nó editando dbcache no arquivo de configuração (DRAGONX.conf).",
"faq_d_perf_1_q": "Quanto disco o nó usa, e posso ajustar a memória?",
"faq_d_perf_2_a": "Ative o download de bootstrap (oferecido no assistente de configuração) para obter uma cópia recente e verificada da chain em vez de validar cada bloco do zero. Um disco rápido (SSD) também ajuda bastante.",
"faq_d_perf_2_q": "Como faço para acelerar a sincronização inicial?",
"faq_d_perf_title": "Armazenamento e Desempenho",
"faq_d_sync_1_a": "Na inicialização, o nó carrega e valida blocos antes de poder atender a carteira. A tela 'Processando blocos / Aplicando blocos para construir o estado atual da chain' é esse aquecimento. É normal; a carteira fica utilizável assim que o nó termina e reporta sua altura.",
"faq_d_sync_1_q": "O que significa 'Processando blocos' na inicialização?",
"faq_d_sync_2_a": "Para seus fundos blindados, o nó mantém 'testemunhas' (witnesses) criptográficas que permitem gastar de forma privada. Após certas reinicializações ou atualizações, ele reconstrói esse cache reescaneando blocos recentes. Em uma carteira com muitas transações isso pode levar vários minutos e usar memória extra.\n\nNão está travado — deixe terminar. Forçar o encerramento no meio disso só faz com que a reconstrução reinicie do zero na próxima vez.",
"faq_d_sync_2_q": "O que é 'Reconstruindo cache de testemunhas'?",
"faq_d_sync_3_a": "Perto do topo da chain, o trabalho que escaneia sua carteira (verificando saldos e notas) disputa com o nó o mesmo bloqueio interno, o que pode retardar a conexão dos últimos blocos em uma carteira grande. Versões recentes limitam esse escaneamento para que o nó possa se atualizar. Ficar em uma aba mais leve (ou apenas esperar) permite que termine mais rápido.",
"faq_d_sync_3_q": "Por que a sincronização está lenta quando estou quase em dia?",
"faq_d_sync_4_a": "A primeira sincronização baixa e verifica toda a chain e pode levar um tempo dependendo da sua conexão e disco. Você pode acelerá-la drasticamente ativando o download de bootstrap no assistente de configuração, que obtém uma cópia recente e verificada dos dados da chain.",
"faq_d_sync_4_q": "Quanto tempo leva a sincronização inicial?",
"faq_d_sync_title": "Sincronização e Blockchain",
"faq_d_trbl_1_a": "Geralmente não. Em uma carteira grande, o nó pode passar vários minutos reconstruindo testemunhas blindadas ou aplicando blocos, durante os quais parece pausado e pode demorar para responder. Dê um tempo — o log de depuração (Configurações) mostra progresso constante se estiver funcionando.\n\nEvite forçar o encerramento durante essa fase; isso reinicia o trabalho do zero na próxima inicialização.",
"faq_d_trbl_1_q": "O nó parece travado em 'Ativando a melhor chain' — está congelado?",
"faq_d_trbl_2_a": "Se um arquivo de carteira for recuperado mas parte de seus dados de chave estiver faltando, o nó pode abri-lo em um modo 'degradado' limitado, no qual não pode derivar novos endereços ou blindar fundos. A correção mais segura é restaurar a partir da sua frase de recuperação para uma carteira nova.",
"faq_d_trbl_2_q": "O que é o 'modo degradado'?",
"faq_d_trbl_3_a": "Um re-scan faz a carteira reler a chain para redescobrir suas transações (útil após importar chaves). Um re-index reconstrói o banco de dados da blockchain do nó e só é necessário se esse banco de dados estiver corrompido ou incompatível após uma mudança de versão. Ambos podem levar um tempo; inicie-os em Configurações > Nó & Segurança.",
"faq_d_trbl_3_q": "Quando devo fazer re-index ou re-scan?",
"faq_d_trbl_4_a": "O uso de memória aumenta durante trabalho pesado, como uma reconstrução de testemunhas ou uma sincronização inicial, e depois cai novamente quando termina. Uso de memória alto e sustentado em repouso é incomum — reiniciar a carteira o limpa. Em uma máquina com pouca RAM, certifique-se de que outros aplicativos pesados em memória não estejam competindo.",
"faq_d_trbl_4_q": "O nó está usando muita memória — isso é normal?",
"faq_d_trbl_title": "Solução de Problemas",
"faq_d_upd_1_a": "Abra Configurações > Nó & Segurança > Binário do daemon e use 'Verificar atualizações…'. A carteira baixa a versão mais recente e verificada do nó e a instala; a nova versão entra em vigor na próxima vez que o daemon iniciar.",
"faq_d_upd_1_q": "Como faço para atualizar o nó?",
"faq_d_upd_2_a": "Sim — a caixa de diálogo de atualização lista todas as versões para que você possa escolher uma específica ou mais antiga. Tenha cuidado ao fazer downgrade: um nó mais antigo pode não aceitar dados da blockchain gravados por um mais novo e pode precisar de re-index.",
"faq_d_upd_2_q": "Posso instalar uma versão específica do nó?",
"faq_d_upd_3_a": "Todo arquivo do nó baixado é verificado por checksum e assinatura criptográfica antes de ser instalado. Se a verificação falhar, a carteira recusa a atualização.",
"faq_d_upd_3_q": "A atualização é segura?",
"faq_d_upd_title": "Atualizando o Nó",
"faq_group_daemon": "Daemon",
"faq_group_wallet": "Carteira",
"faq_intro": "Respostas sobre sua carteira e o nó DragonX. Pesquise, ou navegue por tópico abaixo.",
"faq_intro_lite": "Respostas sobre sua carteira Lite. Pesquise, ou navegue por tópico abaixo.",
"faq_l_gs_1_a": "O ObsidianDragonLite é a carteira leve do DragonX. Em vez de rodar um nó completo, ele se conecta a um servidor lite do DragonX, então inicia rápido e não baixa toda a blockchain.\n\nEle gerencia suas chaves, saldos e envios assim como a carteira completa — veja a seção 'Carteira Lite' abaixo para saber como funciona a conexão com o servidor e a troca envolvendo privacidade.",
"faq_l_gs_1_q": "O que é o ObsidianDragonLite?",
"faq_l_lite_1_a": "A carteira Lite se comunica com um servidor lite do DragonX em vez de rodar seu próprio nó completo. Isso a torna rápida para iniciar e leve em disco e largura de banda, pois ela não baixa nem verifica toda a blockchain sozinha. Suas chaves privadas sempre permanecem no seu dispositivo — nunca são enviadas ao servidor.",
"faq_l_lite_1_q": "Qual a diferença entre a carteira Lite e a carteira completa?",
"faq_l_lite_2_a": "Abra Configurações e escolha um servidor. Você pode trocar de servidor a qualquer momento e a carteira ressincroniza a partir do novo. Se um servidor estiver lento ou inacessível, tente outro.",
"faq_l_lite_2_q": "Como escolho ou altero o servidor?",
"faq_l_lite_3_a": "Suas chaves nunca saem do seu dispositivo, e os valores blindados permanecem criptografados. Mas o servidor lite vê quais endereços sua carteira consulta e seu endereço IP, então ele pode vincular seus endereços entre si — uma troca de conveniência por privacidade. Para máxima privacidade, use a carteira de nó completo, que verifica a chain por conta própria e não revela seus endereços a um servidor.",
"faq_l_lite_3_q": "A carteira Lite é tão privada quanto o nó completo?",
"faq_l_lite_title": "Carteira Lite",
"faq_l_sec_1_a": "Vá em Configurações > Carteira e defina uma senha na seção de segurança. A encriptação protege suas chaves privadas em disco, então quem tiver acesso aos arquivos ainda assim não poderá gastar suas moedas.\n\nEscolha uma senha forte e não a perca — não há como recuperar uma carteira encriptada sem ela.",
"faq_no_results": "Nenhum resultado. Tente outro termo de busca.",
"faq_open_tooltip": "Ajuda e FAQ",
"faq_search_hint": "Buscar no FAQ…",
"faq_title": "Ajuda e FAQ",
"faq_w_addr_1_a": "Os endereços blindados (endereços z, que começam com zs) são os que você usa no DragonX. Eles usam criptografia de conhecimento zero, então o valor, o remetente e o destinatário são criptografados na blockchain e seu saldo permanece privado.\n\nOs endereços transparentes (que começam com R) são endereços de mineração. O DragonX é uma blockchain totalmente privada, então eles só podem receber recompensas de mineração — a rede rejeita qualquer pagamento enviado a eles. As recompensas que chegam ali são públicas até serem blindadas, o que a carteira faz automaticamente.",
"faq_w_addr_1_q": "Qual a diferença entre endereços transparentes e blindados?",
"faq_w_addr_2_a": "Use um endereço blindado (zs) para tudo o que você envia e recebe — no DragonX é o único tipo que pode receber pagamentos.\n\nEndereços transparentes (de mineração) servem apenas para recompensas de mineração solo. Nunca passe um para receber um pagamento: a rede vai rejeitá-lo.",
"faq_w_addr_2_q": "Qual tipo de endereço devo usar?",
"faq_w_addr_3_a": "Reutilizar um endereço blindado não tem problema e não revela seu histórico. Você ainda pode criar novos na aba Receber, por exemplo um por contato para saber quem pagou você. Endereços de mineração servem apenas para recompensas de mineração; raramente você precisará de um novo.",
"faq_w_addr_3_q": "Devo reutilizar um endereço ou criar um novo?",
"faq_w_addr_4_a": "Blindar move moedas de um endereço transparente (de mineração) para um blindado, tornando-as privadas. Moedas recém-mineradas chegam a um endereço de mineração e são blindadas automaticamente quando amadurecem.\n\nVocê também pode blindar manualmente pela carteira; uma blindagem é apenas uma transação especial, então leva o tempo normal de confirmação.",
"faq_w_addr_4_q": "O que é \"blindar\"?",
"faq_w_addr_title": "Endereços e Privacidade",
"faq_w_bal_1_a": "Enquanto sua carteira ainda está sincronizando, seu saldo está incompleto e pode aparecer como 0 — ainda não foram escaneadas todas as suas transações. Ele se completa assim que a sincronização terminar.\n\nObserve a barra de status na parte inferior: ela mostra o estado da conexão e a altura do bloco. Quando a sincronização estiver completa, seu saldo estará correto.",
"faq_w_bal_1_q": "Por que meu saldo está em 0 ou não atualiza?",
"faq_w_bal_2_a": "Cada novo bloco minerado sobre o bloco que contém sua transação adiciona uma confirmação. Mais confirmações significam que o pagamento está mais firmemente consolidado. Fundos recebidos ficam disponíveis para gasto após a primeira confirmação.",
"faq_w_bal_2_q": "O que são confirmações?",
"faq_w_bal_3_a": "O total inclui fundos que ainda estão confirmando ou estão temporariamente bloqueados (por exemplo, moedas no meio do processo de blindagem). O disponível é o que você pode enviar agora mesmo. Eles convergem à medida que as transações são confirmadas.",
"faq_w_bal_3_q": "Qual a diferença entre saldo total e saldo disponível?",
"faq_w_bal_title": "Saldo e Sincronização",
"faq_w_chat_1_a": "O Chat é um mensageiro encriptado, on-chain, integrado à carteira. As mensagens são enviadas como memos blindados privados, então só você e seu contato podem lê-las.",
"faq_w_chat_1_q": "O que é o recurso de Chat?",
"faq_w_chat_2_a": "Sua identidade de chat é derivada da frase de recuperação da sua carteira, então ela acompanha sua carteira — restaure a carteira e sua identidade volta junto. Você não cria uma conta ou senha separada.",
"faq_w_chat_2_q": "Como funciona minha identidade de chat?",
"faq_w_chat_3_a": "Adicione um contato pelo endereço na aba Contatos, opcionalmente com nome e avatar. Os contatos podem ficar privados apenas para a carteira atual ou compartilhados entre todas as carteiras que você abrir neste computador.",
"faq_w_chat_3_q": "Como funcionam os contatos?",
"faq_w_chat_4_a": "Abra uma conversa e use os ícones no cabeçalho dela. Ocultar (o olho) remove a conversa da lista, mas mantém todas as mensagens — uma nova mensagem a torna visível novamente. Excluir (a lixeira) abre um diálogo: \"Excluir\" limpa o histórico neste dispositivo (se essa pessoa te enviar mensagem de novo, a conversa volta), enquanto \"Excluir e bloquear\" também impede mensagens futuras dela até você desbloqueá-la na lista \"Bloqueados\" acima da lista de conversas. Tudo isso é local a este dispositivo — as mensagens permanecem na blockchain e a outra pessoa mantém sua própria cópia, então não é um \"desenvio\".",
"faq_w_chat_4_q": "Como eu oculto, excluo ou bloqueio uma conversa?",
"faq_w_chat_title": "Chat e Contatos",
"faq_w_gs_1_a": "ObsidianDragon é uma carteira de nó completo para DragonX (DRGX). Ela gerencia suas moedas e, na versão desktop, executa um nó completo DragonX (o daemon) em segundo plano, para que sua carteira verifique a blockchain por conta própria em vez de confiar em um servidor de terceiros.\n\nPense nisso como duas partes trabalhando juntas: a carteira (chaves, saldos, envios) e o nó (a blockchain e a rede). As perguntas aqui estão divididas da mesma forma — veja as abas Carteira e Daemon acima.",
"faq_w_gs_1_q": "O que é o ObsidianDragon?",
"faq_w_gs_2_a": "Na primeira execução, o assistente de configuração cria uma carteira para você automaticamente. Novas carteiras são protegidas por uma frase de recuperação secreta (uma lista de palavras). Anote essa frase e guarde-a offline — é a única forma de restaurar seus fundos se este computador for perdido.\n\nVocê pode revisar ou fazer backup da frase a qualquer momento em Configurações > Backup e Dados > Frase de recuperação.",
"faq_w_gs_2_q": "Como eu crio uma nova carteira?",
"faq_w_gs_3_a": "Use o fluxo de restauração por frase de recuperação se você tiver uma. (Na versão de nó completo você também pode copiar um arquivo wallet.dat existente para o diretório de dados da carteira antes de iniciá-la.)\n\nApós restaurar, a carteira reescaneia para encontrar suas transações passadas, então seu saldo e histórico podem levar um tempo para aparecer na primeira vez.",
"faq_w_gs_3_q": "Como eu restauro uma carteira que já tenho?",
"faq_w_gs_4_a": "O assistente permite escolher uma aparência/tema, configurar encriptação e um PIN e — na versão de nó completo — opcionalmente baixar um bootstrap para acelerar a primeira sincronização. Você pode pular qualquer etapa e alterar tudo isso depois em Configurações.",
"faq_w_gs_4_q": "O que o assistente de primeira execução faz?",
"faq_w_gs_title": "Primeiros Passos",
"faq_w_sec_1_a": "Vá em Configurações > Nó e Segurança e defina uma senha (seção SEGURANÇA). A encriptação protege suas chaves privadas em disco, então quem tiver acesso aos arquivos ainda assim não poderá gastar suas moedas.\n\nEscolha uma senha forte e não a perca — não há como recuperar uma carteira encriptada sem ela.",
"faq_w_sec_1_q": "Como eu encripto minha carteira?",
"faq_w_sec_2_a": "O PIN bloqueia a tela do app para que saldos e ações fiquem ocultos quando você se afasta. É um bloqueio de conveniência sobre a encriptação; você o configura durante o assistente ou em Configurações, depois de encriptar a carteira.",
"faq_w_sec_2_q": "O que é o PIN / tela de bloqueio?",
"faq_w_sec_3_a": "Não. A encriptação da carteira não pode ser contornada. Se você ainda tiver sua frase de recuperação, pode restaurar a carteira a partir dela em uma carteira nova e definir uma nova senha. Sem a senha ou a frase de recuperação, os fundos não podem ser recuperados.",
"faq_w_sec_3_q": "Esqueci minha senha — ela pode ser recuperada?",
"faq_w_sec_title": "Segurança e Encriptação",
"faq_w_seed_1_a": "Vá em Configurações > Backup e Dados > Frase de recuperação. Anote as palavras no papel, em ordem, e guarde-as em um lugar seguro e offline. Qualquer pessoa com a frase pode gastar suas moedas, então nunca a guarde em uma foto, e-mail ou nota na nuvem.",
"faq_w_seed_1_q": "Como eu faço backup da minha frase de recuperação?",
"faq_w_seed_2_a": "Carteiras mais antigas (legadas) não eram baseadas em frase semente. A carteira pode migrar uma carteira legada para uma moderna, baseada em frase semente: ela cria uma nova carteira semente e transfere seus fundos para ela. Procure por \"Migrar para carteira semente…\" em Configurações > Backup e Dados. Esse recurso requer a versão atual do daemon.",
"faq_w_seed_2_q": "Minha carteira não tem frase de recuperação — posso adicionar uma?",
"faq_w_seed_3_a": "Use o fluxo de restauração por frase semente ao configurar uma carteira e digite suas palavras em ordem. Sua carteira então reescaneia para reconstruir seu saldo e histórico, o que pode levar um tempo na primeira vez.",
"faq_w_seed_3_q": "Como eu restauro a partir da minha frase de recuperação?",
"faq_w_seed_4_a": "Sua frase de recuperação é o backup principal e é suficiente para restaurar tudo. Uma cópia do arquivo da sua carteira é um backup secundário conveniente que também preserva rótulos e configurações. Mantenha qualquer backup offline e privado.",
"faq_w_seed_4_q": "Devo também fazer backup do wallet.dat?",
"faq_w_seed_title": "Frase de Recuperação e Backup",
"faq_w_send_1_a": "Abra a aba Enviar, cole o endereço do destinatário, informe um valor e confirme. Se a carteira estiver encriptada, você será solicitado a desbloqueá-la para a transação.\n\nOs pagamentos vão para endereços blindados (zs) e continuam privados. A carteira não envia para um endereço transparente (de mineração): a rede rejeita pagamentos para eles.",
"faq_w_send_1_q": "Como eu envio fundos?",
"faq_w_send_2_a": "As taxas do DragonX são muito baixas e definidas automaticamente. A taxa é mostrada antes de você confirmar. Normalmente você não precisa alterá-la.",
"faq_w_send_2_q": "Qual taxa eu pago?",
"faq_w_send_3_a": "Sim — ao enviar para um endereço blindado (z) você pode incluir um memo encriptado. Só o destinatário pode lê-lo.",
"faq_w_send_3_q": "Posso anexar uma mensagem a um pagamento?",
"faq_w_send_4_a": "Uma transação fica \"pendente\" até ser minerada em um bloco e ganhar confirmações. Isso costuma levar um ou dois minutos. Transações blindadas também precisam que sua carteira esteja sincronizada para gerar a prova.\n\nSe um envio permanecer pendente por tempo incomumente longo, verifique se sua carteira está conectada e sincronizada (veja a barra de status na parte inferior da janela).",
"faq_w_send_4_q": "Enviei um pagamento, mas ele diz pendente — por quê?",
"faq_w_send_title": "Envio e recebimento",
"faq_w_set_1_a": "Abra Configurações > Aparência para escolher um tema e um destaque. Vários skins claros e escuros estão incluídos, e efeitos como o desfoque podem ser desativados em máquinas menos potentes.",
"faq_w_set_1_q": "Como mudo o tema?",
"faq_w_set_2_a": "Sim — Configurações > Aparência tem um seletor de idioma. A carteira já vem com várias traduções; o que ainda não foi traduzido volta para o inglês.",
"faq_w_set_2_q": "Posso mudar o idioma?",
"faq_w_set_3_a": "Use a opção de escala de fonte em Configurações > Aparência. Ela redimensiona toda a interface, o que é útil em telas de alta resolução (HiDPI), onde o app pode renderizar pequeno demais.",
"faq_w_set_3_q": "O texto está pequeno demais (ou grande demais) — dá para redimensionar?",
"faq_w_set_4_a": "Ative a busca de preços em Configurações. A carteira passa a mostrar um valor aproximado em moeda fiduciária ao lado dos saldos e pode exibir um pequeno gráfico de mercado. Os preços são apenas informativos.",
"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:",
@@ -665,14 +876,9 @@
"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",
"force_quit": "Forçar Saída",
"force_quit_confirm_msg": "Isso matará imediatamente o daemon sem um desligamento limpo.\nIsso pode corromper o índice da blockchain e exigir uma ressincronização.",
"force_quit_confirm_title": "Forçar Saída?",
"force_quit_warning": "Isso matará imediatamente o daemon sem um desligamento limpo. Pode exigir uma ressincronização da blockchain.",
"force_quit_yes": "Forçar Saída",
"from": "De",
"from_upper": "DE",
"full_details": "Detalhes Completos",
@@ -741,6 +947,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",
@@ -815,11 +1022,13 @@
"lite_birthday_backup": "Aniversário: %llu (faça o backup disto também)",
"lite_birthday_hint": "Altura do bloco a partir da qual começar a escanear. Deixe 0 se desconhecida (escaneamento completo mais lento).",
"lite_birthday_label": "Data de nascimento",
"lite_confirm_label": "Confirmar",
"lite_console_backend_commands": "Comandos do backend:",
"lite_console_help_passthrough": "Qualquer outra entrada é executada como um comando de console da carteira leve.",
"lite_copy": "Copiar",
"lite_could_not_start": "Não foi possível iniciar a operação",
"lite_could_not_write": "Não foi possível gravar ",
"lite_encrypt_offline_warning": "Qualquer pessoa com o arquivo da carteira pode testar frases-passe offline. Faça primeiro o backup da sua frase de recuperação e use uma frase-passe longa.",
"lite_encrypt_wallet": "Criptografar carteira",
"lite_encryption_removed": "Criptografia removida",
"lite_enter_all_seed_words": "Informe todas as 24 palavras-semente para restaurar (obtidas %d)",
@@ -827,6 +1036,7 @@
"lite_hide_wipe": "Ocultar e apagar",
"lite_import": "Importar",
"lite_import_key_label": "Importar chave",
"lite_key_import_failed": "Não foi possível importar a chave — verifique-a, desbloqueie a carteira (se estiver criptografada) e tente novamente.",
"lite_key_imported": "Chave importada — execute uma sincronização para escanear seu histórico",
"lite_lock_failed": "Falha ao bloquear",
"lite_lock_now": "Bloquear agora",
@@ -960,11 +1170,67 @@
"memo_optional": "MEMO (OPCIONAL)",
"memo_upper": "MEMO",
"memo_z_only": "Nota: Memos só estão disponíveis ao enviar para endereços blindados (z)",
"merge_addr_created": "Endereço blindado criado.",
"merge_advanced": "Avançado",
"merge_back": "Voltar",
"merge_batch_fmt": "Mescla até %d entradas por execução — repita para concluir o restante.",
"merge_confirm_btn": "Confirmar",
"merge_confirm_fmt": "Consolidar ~%s DRGX de %d entrada(s) em %s?",
"merge_create_zaddr": "Criar endereço blindado",
"merge_creating": "Criando endereço…",
"merge_description": "Fundir múltiplos UTXOs em um único endereço blindado. Isso pode ajudar a reduzir o tamanho da carteira e melhorar a privacidade.",
"merge_fee_hint": "Taxa de rede para esta transação.",
"merge_funds": "Fundir Fundos",
"merge_into_one": "Para um só endereço",
"merge_keep_each": "Manter em cada endereço",
"merge_keep_each_batches_fmt": "Endereços com mais de %d notas são mesclados em vários lotes; execute de novo depois que forem confirmados para mesclar o restante.",
"merge_keep_each_confirm_fmt": "Consolidar %d notas em %d endereços? Cada endereço mantém seus próprios fundos. Taxas: cerca de %s DRGX (uma por lote).",
"merge_keep_each_desc": "As notas de cada endereço são mescladas de volta nesse mesmo endereço, então os fundos que você mantém separados com rótulos diferentes continuam separados. Só endereços blindados — fundos transparentes só podem ser blindados (Para um só endereço).",
"merge_keep_each_none": "Cada endereço blindado já tem uma única nota — nada a consolidar.",
"merge_keep_each_nothing_submitted": "Nada foi consolidado — o nó recusou todas as mesclagens.",
"merge_keep_each_row_fmt": "%s · %d notas · %s DRGX",
"merge_keep_each_run_again": "Endereços mesclados em vários lotes agora têm uma nota por lote. Execute de novo depois que forem confirmados para mesclá-las em uma.",
"merge_keep_each_select": "Marque pelo menos um endereço para consolidar.",
"merge_keep_each_some_failed": "Algumas mesclagens falharam — veja a lista acima.",
"merge_keep_each_status_done": "%s — concluído: %d notas mescladas em %d lote(s)",
"merge_keep_each_status_failed": "%s — falhou: %s",
"merge_keep_each_status_not_started": "%s — não iniciado: %s",
"merge_keep_each_status_running": "%s — mesclando %d notas (%d de %d lotes concluídos)…",
"merge_keep_each_status_unconfirmed": "%s — não foi possível confirmar o status",
"merge_keep_each_too_small": "Pequeno demais para consolidar: a taxa consumiria esses fundos.",
"merge_max_inputs": "Máx. de entradas por lote",
"merge_no_spendable": "Ainda não há entradas disponíveis para consolidar.",
"merge_progress": "Consolidando… isso pode levar alguns minutos. Você pode fechar esta janela.",
"merge_run_aborted_toast": "A conexão com o nó foi redefinida, então a consolidação parou. Execute-a novamente para continuar.",
"merge_run_busy": "Uma consolidação está em andamento. Aguarde que termine ou interrompa-a primeiro.",
"merge_run_chip_fmt": "Consolidando %d/%d",
"merge_run_err_timeout": "O nó não respondeu a tempo; o lote ainda pode estar na fila.",
"merge_run_halted_fmt": "Consolidação interrompida após falhas repetidas: %s",
"merge_run_migration_pending": "Conclua ou cancele a migração da semente antes de consolidar.",
"merge_run_progress_fmt": "Consolidando… lote %d de cerca de %d, %d notas mescladas até agora. Você pode fechar esta janela; a operação continua em segundo plano.",
"merge_run_start_failed": "Não foi possível iniciar a consolidação — o nó não está conectado.",
"merge_run_status_not_run": "%s — não consolidado (%d notas)",
"merge_run_status_waiting": "%s — aguardando (%d notas)",
"merge_run_stop": "Parar",
"merge_run_stopped_toast": "Consolidação interrompida. Execute-a novamente para mesclar o restante.",
"merge_run_stopping": "Parando após os lotes já enviados…",
"merge_run_wait_batch": "Espaçando os lotes para que o nó continue respondendo…",
"merge_run_wait_send": "Pausado até que sua transação pendente seja concluída.",
"merge_run_wait_sync": "Pausado: o nó ainda está sincronizando.",
"merge_scope_failed_fmt": "Não foi possível ler suas entradas: %s",
"merge_scope_fmt": "%d entradas transparentes + %d blindadas · ~%s DRGX disponíveis",
"merge_scope_loading": "Verificando suas entradas…",
"merge_send_failed": "Falha na fusão: ",
"merge_source": "Consolidar",
"merge_src_both": "Ambos",
"merge_src_shielded": "Blindado",
"merge_src_transparent": "Transparente",
"merge_started": "Operação de fusão iniciada",
"merge_submit_uncertain": "O nó não confirmou a fusão antes de a conexão ser interrompida. Ela ainda pode ser concluída — verifique o Histórico após reconectar antes de fundir novamente.",
"merge_title": "Fundir para Endereço",
"merge_wait_locked": "A carteira está bloqueada. Desbloqueie-a para continuar.",
"merge_waiting_node": "Aguardando o nó…",
"merge_where": "Para onde devem ir os fundos?",
"mig_adopt": "Tornar esta minha carteira",
"mig_adopt_now": "Adotar carteira semente",
"mig_adopting": "Instalando sua nova carteira e reescaneando…",
@@ -1016,6 +1282,8 @@
"miner_fee": "Taxa de Minerador",
"mining": "Mineração",
"mining_active": "Ativo",
"mining_addr_explainer": "Recebe apenas recompensas de mineração solo — a rede rejeita pagamentos para este endereço. As recompensas são blindadas automaticamente quando amadurecem.",
"mining_address": "Endereço de mineração",
"mining_address_copied": "Endereço de mineração copiado",
"mining_all_time": "Todo o Tempo",
"mining_already_saved": "URL do pool já salva",
@@ -1029,6 +1297,8 @@
"mining_benchmark_testing": "Testando",
"mining_benchmark_tooltip": "Encontrar o número ideal de threads para esta CPU",
"mining_block_copied": "Hash do bloco copiado",
"mining_card_click_to_shield": "Clique para blindar agora as recompensas amadurecidas.",
"mining_card_tooltip": "As recompensas de mineração solo chegam aos seus endereços de mineração (transparentes, R…). Esses endereços não podem receber pagamentos. As recompensas são blindadas automaticamente quando amadurecem.",
"mining_chart_1m_ago": "1m atrás",
"mining_chart_5m_ago": "5m atrás",
"mining_chart_now": "Agora",
@@ -1044,6 +1314,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",
@@ -1073,6 +1345,7 @@
"mining_payout_address": "Endereço de Pagamento",
"mining_payout_foreign": "⚠ Este endereço de pagamento não está na sua carteira atual — as recompensas mineradas iriam para uma carteira diferente. Atualize-o se você trocou de carteira.",
"mining_payout_invalid": "Endereço DragonX inválido — corrija antes de iniciar, ou as recompensas de mineração serão perdidas.",
"mining_payout_needs_shielded": "Os pools pagam com transações comuns, que no DragonX não conseguem chegar a um endereço de mineração (transparente, R…). Use um endereço blindado (começa com zs) como endereço de pagamento.",
"mining_payout_tooltip": "Endereço para receber recompensas de mineração",
"mining_pool": "Pool",
"mining_pool_fee": "Taxa",
@@ -1085,6 +1358,8 @@
"mining_refresh": "Atualizar",
"mining_remove": "Remover",
"mining_reset_defaults": "Redefinir Padrões",
"mining_rewards": "Recompensas de mineração",
"mining_rewards_unshielded": "Ainda não blindadas",
"mining_save_payout_address": "Salvar endereço de pagamento",
"mining_save_pool_url": "Salvar URL do pool",
"mining_saved_addresses": "Endereços Salvos:",
@@ -1118,10 +1393,10 @@
"network_fee": "TAXA DA REDE",
"network_hashrate": "Hashrate da Rede",
"new": "+ Novo",
"new_mining_address": "Novo endereço de mineração",
"new_mining_address_created": "Novo endereço de mineração criado",
"new_shielded_created": "Novo endereço blindado criado",
"new_t_address": "Novo Endereço T",
"new_t_transparent": "Novo endereço t (Transparente)",
"new_transparent_created": "Novo endereço transparente criado",
"new_z_address": "Novo Endereço Z",
"new_z_shielded": "Novo endereço z (Blindado)",
"no_addresses": "Nenhum endereço encontrado. Crie um usando os botões acima.",
@@ -1141,7 +1416,19 @@
"node_banner_offline_title": "Não conectado ao nó DragonX",
"node_banner_reconnect": "Reconectar",
"node_banner_restart": "Reiniciar nó",
"node_busy_rebuild_desc": "Em andamento há %s. Em carteiras grandes pode levar cerca de uma hora, e o nó não consegue responder à carteira até terminar.",
"node_busy_rebuild_extending": "Estendendo testemunhas (o nó não informa o progresso desta etapa).",
"node_busy_rebuild_reading": "Lendo blocos: %ld / %ld",
"node_busy_rebuild_title": "Nó ocupado: reconstruindo testemunhas de notas",
"node_busy_replay_desc": "Em andamento há %s. O nó está reaplicando blocos que recebeu antes da última parada.",
"node_busy_replay_title": "Nó ocupado: recuperando blocos",
"node_busy_rpc_desc": "O nó está em execução, mas está ocupado demais para responder à carteira há %s, geralmente por causa de uma operação longa como a reconstrução de testemunhas.",
"node_busy_rpc_title": "Nó ocupado: ainda não responde",
"node_busy_running": "Em andamento há %s",
"node_security": "NÓ & SEGURANÇA",
"node_starting_busy_desc": "O nó está acessível, mas ocupado, e ainda não responde. Isso é normal após uma atualização ou na primeira execução; pode levar alguns minutos.",
"node_starting_launch_desc": "Iniciando o dragonxd e aguardando ficar online…",
"node_starting_title": "Iniciando o nó DragonX…",
"noise": "Ruído",
"not_connected": "Não conectado ao daemon...",
"not_connected_to_daemon": "Não conectado ao daemon",
@@ -1202,6 +1489,13 @@
"peers_upper": "PARES",
"peers_version": "Versão",
"pending": "Pendente",
"picker_dat_count": "%d arquivo(s) de carteira nesta pasta",
"picker_dat_none": "Nenhum arquivo de carteira diretamente nesta pasta",
"picker_empty": "Esta pasta não tem subpastas nem arquivos de carteira.",
"picker_home": "Pasta pessoal",
"picker_select": "Escanear esta pasta",
"picker_title": "Selecione uma pasta para escanear",
"picker_up": "Subir um nível",
"pin_change_desc": "Altere seu PIN de desbloqueio. Você precisa do seu PIN atual e de um novo PIN.",
"pin_confirm_new_label": "Confirmar novo PIN:",
"pin_current_label": "PIN atual:",
@@ -1287,6 +1581,8 @@
"ram_wallet_gb": "Carteira: %.1f GB",
"ram_wallet_mb": "Carteira: %.0f MB",
"receive": "Receber",
"receive_not_for_payments": "Não é para pagamentos",
"receive_use_shielded": "Usar um endereço blindado",
"received": "recebido",
"received_filter": "Recebido",
"received_label": "Recebido",
@@ -1301,6 +1597,7 @@
"reduce_motion": "Reduzir Movimento",
"refresh": "Atualizar",
"refresh_now": "Atualizar Agora",
"refresh_waits_for_sync": "O nó ainda está sincronizando. Os dados da carteira serão atualizados quando terminar.",
"remove_favorite": "Remover favorito",
"repair_wallet": "Reparar Carteira",
"report_bug": "Reportar Bug",
@@ -1315,12 +1612,12 @@
"request_select_address": "Selecionar endereço...",
"request_shielded_addrs": "-- Endereços Blindados --",
"request_title": "Solicitar Pagamento",
"request_transparent_addrs": "-- Endereços Transparentes --",
"request_uri_copied": "URI de pagamento copiada para a área de transferência",
"rescan": "Reescanear",
"rescan_bootstrapped_msg": "Seu nó foi inicializado por bootstrap, então os blocos abaixo do snapshot não estão em disco e um reescaneamento a partir do gênesis falharia. Reescaneie a partir de uma altura que seu snapshot inclua para reconciliar o saldo gasto da sua carteira. Seu wallet.dat e os dados da cadeia não são excluídos.",
"rescan_detecting": "Verificando quais blocos seu nó tem em disco…",
"rescan_from_height": "Reescanear a partir da altura do bloco:",
"rescan_probe_failed": "Não foi possível verificar quais blocos seu nó tem em disco (ele está ocupado ou uma solicitação expirou). Se este nó foi inicializado por bootstrap, cancele e tente novamente em um minuto: a reinicialização completa com -rescan abaixo falha em um nó inicializado por bootstrap.",
"reset_to_defaults": "Redefinir Padrões",
"restarting_after_encryption": "Reiniciando daemon após criptografia...",
"restore_address": "Restaurar endereço",
@@ -1338,6 +1635,7 @@
"save_z_transactions": "Salvar Z-tx na lista de tx",
"sb_auth_failed": "Autenticação falhou — verifique rpcuser/rpcpassword",
"sb_block": "Bloco: %d",
"sb_block_db_unreadable": "Banco de dados de blocos ilegível — reconstrução necessária",
"sb_building_witnesses": "Definindo testemunhas",
"sb_building_witnesses_pct": "Reconstruindo testemunhas %.0f%%",
"sb_connecting_daemon": "Conectando ao dragonxd...",
@@ -1353,6 +1651,7 @@
"sb_dragonxd_stopped": "dragonxd parado",
"sb_dragonxd_stopping": "Parando dragonxd...",
"sb_extracting_sapling": "Extraindo parâmetros Sapling...",
"sb_finishing_repair": "Concluindo o reparo da carteira — reescaneando…",
"sb_importing_keys": "Importando chaves",
"sb_loading_config": "Carregando configuração...",
"sb_mining_hs": "%.1f H/s",
@@ -1361,6 +1660,7 @@
"sb_net_khs": "Rede: %.2f KH/s",
"sb_net_mhs": "Rede: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf não encontrado",
"sb_node_busy": "Nó ocupado",
"sb_peers": "Pares: %zu",
"sb_plaintext_remote_blocked": "Recusando enviar credenciais RPC em texto simples para um host remoto. Adicione rpcallowplaintext=1 ao DRAGONX.conf para permitir, ou habilite TLS com rpctls=1.",
"sb_rescanning": "Reescaneando",
@@ -1374,6 +1674,7 @@
"sb_waiting_config": "Aguardando configuração do daemon...",
"sb_waiting_daemon": "Aguardando dragonxd...",
"sb_waiting_daemon_err": "Aguardando dragonxd — %s",
"sb_wallet_needs_recovery": "Reparo de carteira disponível",
"sb_warming_up": "Aquecendo...",
"sb_witness_cache": "Reconstruindo testemunhas",
"scale_effects": "ESCALA E EFEITOS",
@@ -1381,6 +1682,7 @@
"screenshot_sweep": "Executar varredura de capturas de tela",
"screenshot_sweep_desc": "Percorre cada tema em cada aba e salva uma captura de tela de cada um em subpastas por aba dentro da pasta de capturas de tela do diretório de configuração (sobrescrevendo a varredura anterior). É executado por alguns segundos.",
"screenshot_sweep_full": "Varredura completa da interface",
"screenshot_sweep_refused": "Varredura completa da interface recusada; veja o log de depuração",
"search_icons": "Pesquisar ícones...",
"search_placeholder": "Pesquisar...",
"sec_changing_passphrase": "Alterando a frase-senha...",
@@ -1402,15 +1704,19 @@
"sec_mode_passphrase": " Frase-senha",
"sec_not_connected_to_daemon": "Não conectado ao daemon",
"sec_not_connected_to_daemon_pin": "Não conectado ao daemon",
"sec_passphrase_changed_pin_stale": "Frase-senha alterada; seu PIN está desatualizado",
"sec_passphrase_changed_successfully": "Frase-senha alterada com sucesso",
"sec_pin_changed_successfully": "PIN alterado com sucesso",
"sec_pin_removed": "PIN removido",
"sec_pin_set_successfully": "PIN definido com sucesso",
"sec_pin_stale_msg": "PIN desatualizado. Use sua frase-senha.",
"sec_pin_unreadable": "Arquivo do PIN ilegível. Use sua frase-senha.",
"sec_restart_daemon_for_encryption": "Reinicie o daemon para que a criptografia entre em vigor.",
"sec_too_many_attempts_wait": "Tentativas em excesso. Aguarde %.0f segundos...",
"sec_total_elapsed_fmt": "Tempo total: %dm %02ds",
"sec_unlock_button": "Desbloquear",
"sec_unlock_failed_prefix": "Falha ao desbloquear: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Desbloqueando%s",
"sec_use_passphrase_instead": "Usar frase-senha",
"sec_use_pin_instead": "Usar PIN",
@@ -1445,6 +1751,7 @@
"send_amount": "Valor",
"send_amount_details": "DETALHES DO VALOR",
"send_amount_upper": "VALOR",
"send_available_note": "disponível",
"send_cannot_send_now": "Não é possível enviar agora — verifique a conexão, a sincronização e o saldo disponível.",
"send_clear_fields": "Limpar todos os campos do formulário?",
"send_contacts_button": "Escolher dos contatos",
@@ -1462,6 +1769,8 @@
"send_from_this_address": "Enviar deste endereço",
"send_go_to_receive": "Ir para Receber",
"send_keep": "Manter",
"send_mining_addr_blocked": "Este é um endereço de mineração (transparente). No DragonX eles só podem receber recompensas de mineração — a rede rejeita pagamentos para eles. Peça ao destinatário um endereço blindado (começa com zs).",
"send_mining_addr_short": "Endereço de mineração — não pode receber pagamentos",
"send_network_fee": "TAXA DA REDE",
"send_no_balance": "Sem saldo",
"send_no_recent": "Nenhum envio recente",
@@ -1489,7 +1798,6 @@
"send_txid_copied": "TxID copiado para a área de transferência",
"send_txid_label": "TxID: %s",
"send_valid_shielded": "Endereço blindado válido",
"send_valid_transparent": "Endereço transparente válido",
"send_wallet_empty": "Sua carteira está vazia",
"send_yes_clear": "Sim, Limpar",
"sender_balance": "Remetente: %.8f → %.8f DRGX",
@@ -1552,6 +1860,7 @@
"settings_open_log_folder": "Abrir pasta de logs",
"settings_other": "Outros",
"settings_pin_active": "PIN",
"settings_pin_out_of_date": "PIN desatualizado",
"settings_privacy": "Privacidade",
"settings_quick_unlock_pin": "PIN de desbloqueio rápido",
"settings_reduce_transparency": "Reduzir transparência",
@@ -1617,11 +1926,24 @@
"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",
"showing_transactions": "Mostrando %d–%d de %d transações (total: %zu)",
"showing_x_of_y": "Mostrando %d de %d endereços",
"shutdown_kill_confirm_msg": "O nó não mostra atividade de CPU nem de disco há muito tempo e pode estar travado.\nForçar o encerramento perde tudo o que não foi salvo; o próximo início pode demorar muito mais.",
"shutdown_kill_confirm_title": "Forçar o encerramento do nó?",
"shutdown_kill_confirm_yes": "Forçar encerramento",
"shutdown_kill_node": "Forçar encerramento do nó (inseguro)",
"shutdown_kill_node_tip": "Encerra o nó imediatamente. Tudo o que não foi salvo se perde, e no próximo início ele reprocessa os blocos desde o último salvamento.",
"shutdown_leave_running": "Fechar a carteira e deixar o nó em execução",
"shutdown_leave_running_tip": "Fecha a carteira agora. O nó continua em execução sozinho e termina o que está fazendo; nada é perdido.",
"shutdown_stop_asking": "Pedindo ao nó para parar…",
"shutdown_stop_cpu_suffix": " · CPU %d%%",
"shutdown_stop_delivered": "O nó está encerrando e salvando seu estado",
"shutdown_stop_idle": "Sem atividade de CPU ou disco há %d min; o nó pode estar travado",
"shutdown_stop_queued": "O nó recebeu o pedido de parada, mas está ocupado; ele vai parar assim que puder",
"simple_background": "Fundo simples",
"slider_off": "Desligado",
"sort_amount_high": "Maior valor",
@@ -1629,14 +1951,29 @@
"sort_date_newest": "Mais recentes primeiro",
"sort_date_oldest": "Mais antigas primeiro",
"start_mining": "Iniciar Mineração",
"startup_files_preparing": "Preparando os arquivos da carteira... %d%%",
"startup_files_verifying": "Verificando os arquivos da carteira...",
"status": "Status",
"stop_external": "Parar daemon externo",
"stop_mining": "Parar Mineração",
"stopconfirm_busy_body": "Está ocupado demais para responder à carteira há %s, provavelmente por causa de uma operação longa como a reconstrução de testemunhas. Pará-lo agora pode fazer o próximo início refazer esse trabalho.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
"stopconfirm_busy_title": "O nó está ocupado",
"stopconfirm_rebuild_body": "Está em andamento há %s, e em carteiras grandes pode levar cerca de uma hora. Parar o nó agora ou espera a reconstrução terminar, ou faz o próximo início refazê-la junto com qualquer recuperação.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
"stopconfirm_rebuild_title": "O nó está reconstruindo as testemunhas de notas",
"stopconfirm_stop_wait": "Parar o nó e aguardar",
"stopconfirm_walletop_body": "O nó está escaneando a cadeia para uma importação de chave ou um reescaneamento. Pará-lo agora encerra esse escaneamento antes do tempo, e os fundos que ele ainda não alcançou só aparecerão depois de Ajustes → Reescanear.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina o escaneamento sozinho.",
"stopconfirm_walletop_title": "Uma importação de chave ou um reescaneamento está em andamento",
"stratum_allowip_hint": "Permitir mineradores a partir de IP ou CIDR (em branco = apenas este computador)",
"stratum_expose_warn": "Abre uma porta de mineração para a rede que você permitir — use apenas em uma LAN confiável.",
"stratum_host": "Hospedar um pool de mineração (stratum)",
"stratum_host_hint": "Os mineradores se conectam a este computador na porta 22769 (porta RPC + 1000) com um minerador stratum RandomX. Reinicie o daemon para aplicar.",
"stratum_host_section": "HOSPEDAGEM DE POOL DE MINERAÇÃO",
"submitting_transaction": "Enviando transação...",
"success": "Sucesso",
"summary": "Resumo",
"sweep_button": "Varrer",
"sweep_caveat": "Importa a chave para assinar uma transação que move todos os seus fundos para o seu endereço. A chave permanece na carteira com saldo zero.",
"sweep_current_theme_only": "Apenas o tema atual",
"sweep_dest_label": "Enviar fundos varridos para",
"sweep_dest_new": "Novo endereço blindado (recomendado)",
"sweep_done": "Concluído — fundos varridos para o seu endereço.",
@@ -1672,7 +2009,6 @@
"switch_stopnode_title": "Parar o nó em execução?",
"switch_stopnode_warn": "Já há um nó em execução que esta carteira não iniciou.",
"syncing": "Sincronizando...",
"t_address": "Endereço T",
"t_addresses": "Endereços T",
"test_connection": "Testar",
"theme": "Tema",
@@ -1694,6 +2030,7 @@
"tools": "FERRAMENTAS",
"tools_actions": "Ferramentas e Ações...",
"tools_actions_hdr": "FERRAMENTAS E AÇÕES",
"tor_never_applied_notice": "A opção \"Usar Tor\" nunca funcionou: suas conexões NÃO foram roteadas pelo Tor. Ela foi desativada. Veja a dica dela em Ajustes para alternativas.",
"total": "Total",
"total_balance_label": "Saldo Total",
"transaction_id": "ID DA TRANSAÇÃO",
@@ -1707,8 +2044,6 @@
"transfer_sent": "Transferência Enviada",
"transfer_sent_desc": "Sua transferência foi enviada à rede.",
"transfer_to": "Transferir para:",
"transparent": "Transparente",
"transparent_address": "Endereço Transparente",
"try_again": "Tentar novamente",
"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",
@@ -1740,10 +2075,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",
@@ -1752,6 +2093,7 @@
"tt_keep_daemon": "O daemon será parado ao executar o assistente de configuração",
"tt_language": "Idioma da interface da carteira",
"tt_layout_hotkey": "Atalho: teclas de seta esquerda/direita para alternar layouts de Saldo",
"tt_lite_confirm_pass": "Digite a nova frase-passe novamente; ela deve coincidir e ter pelo menos 8 caracteres",
"tt_lite_copy": "Copiar o segredo revelado para a área de transferência",
"tt_lite_decrypt_pass": "Digite sua frase-senha para remover a criptografia da carteira",
"tt_lite_encrypt": "Criptografar a carteira com a frase-senha acima; ela é bloqueada imediatamente e exige a frase-senha para desbloquear",
@@ -1780,11 +2122,16 @@
"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",
"tt_open_data_dir": "Abrir a pasta com os dados da sua carteira e da blockchain no gerenciador de arquivos",
"tt_open_dir": "Clique para abrir no explorador de arquivos",
"tt_open_log_folder": "Abre a pasta que contém os logs de depuração e de falhas",
"tt_pin_out_of_date": "A frase-senha da carteira mudou depois que este PIN foi definido, então o PIN não consegue mais desbloqueá-la. Remova o PIN e defina-o novamente.",
"tt_reduce_motion": "Desativar transições animadas e lerp de saldo para acessibilidade",
"tt_remove_encrypt": "Remover encriptação e armazenar a carteira desprotegida",
"tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear",
@@ -1814,14 +2161,20 @@
"tt_simple_bg": "Usar um gradiente simples para o fundo\\nAtalho: Ctrl+Up",
"tt_simple_bg_alt": "Usar uma versão gradiente da imagem de fundo do tema\\nAtalho: Ctrl+Up",
"tt_stop_external": "Aplica-se ao conectar a um daemon\\niniciado fora desta carteira",
"tt_stratum_host": "Executa um servidor de pool stratum RandomX neste nó para que outros mineradores RandomX possam apontar para este computador. Requer um nó v1.3.0+ e reinício do daemon para aplicar.",
"tt_sweep_current_theme_only": "Varrer apenas o tema ativo em vez de percorrer todos os temas",
"tt_test_conn": "Verificar a conexão RPC ao daemon",
"tt_theme_effects": "Brilho, luminescência, ciclo de matiz por tema",
"tt_theme_hotkey": "Atalho: Ctrl+Esquerda/Direita para alternar temas",
"tt_tor": "Rotear conexões do daemon através da rede Tor para anonimato",
"tt_tor_unavailable": "Ainda não disponível: esta carteira não encaminha nenhum tráfego pelo Tor. Apenas para o nó, você pode adicionar proxy=127.0.0.1:9050 ao DRAGONX.conf e reiniciar o nó. O Tor precisa estar em execução, ou o nó não encontrará pares. Solicitações de preço, atualização, bootstrap e pool continuam se conectando diretamente; para elas, use uma VPN/Tor em todo o sistema.",
"tt_tor_unavailable_lite": "Ainda não disponível: esta carteira não encaminha nenhum tráfego pelo Tor. Use uma VPN/Tor em todo o sistema.",
"tt_tx_url": "URL base para visualizar transações em um explorador de blocos",
"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_view_transactions": "Mostrar as transações que envolvem este endereço na aba Histórico",
"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_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)",
@@ -1838,6 +2191,7 @@
"tx_loading_queued": "Atualização de transação na fila",
"tx_loading_refreshing_cached": "Atualizando histórico da carteira (%d em cache)",
"tx_loading_scanning_shielded": "Verificando histórico blindado (%d endereços)",
"tx_loading_waits_for_sync": "O histórico será atualizado quando o nó terminar de sincronizar",
"tx_mined": "MINERADO",
"tx_progress_balances": "Atualizando saldos",
"tx_progress_finalizing": "Finalizando transação",
@@ -1869,6 +2223,7 @@
"validate_description": "Digite um endereço DragonX para verificar se é válido e se pertence a esta carteira.",
"validate_invalid": "INVÁLIDO",
"validate_is_mine": "Esta carteira possui este endereço",
"validate_mining_type": "Endereço de mineração (transparente)",
"validate_not_mine": "Não pertence a esta carteira",
"validate_ownership": "Propriedade:",
"validate_results": "Resultados:",
@@ -1876,7 +2231,6 @@
"validate_shielded_type": "Blindado (z-endereço)",
"validate_status": "Status:",
"validate_title": "Validar Endereço",
"validate_transparent_type": "Transparente (t-endereço)",
"validate_type": "Tipo:",
"validate_valid": "VÁLIDO",
"validating": "Validando...",
@@ -1886,10 +2240,71 @@
"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.",
"wallet_diagnostics_hdr": "DIAGNÓSTICOS",
"wallet_empty": "Sua carteira está vazia",
"wallet_empty_hint": "Mude para Receber para obter seu endereço e começar a receber fundos.",
"wallet_options_hdr": "OPÇÕES",
"wallet_rebuild_failed": "A reconstrução não produziu uma carteira válida, então nada foi alterado. Sua carteira permanece intacta.",
"wallet_rebuild_install_failed": "Não foi possível instalar a carteira reconstruída; sua carteira atual foi mantida.",
"wallet_rebuild_no_helper": "O assistente de reconstrução de carteira não está disponível nesta build. Use Restaurar, ou reconstrua manualmente.",
"wallet_rebuild_no_source": "Não foi possível encontrar uma carteira legível para reconstruir. Nada foi alterado.",
"wallet_rebuild_ok": "Banco de dados da carteira reconstruído — o nó está carregando-o e reescaneando para encontrar seu saldo.",
"wallet_rebuild_started": "Reparando seu arquivo de carteira — não feche esta janela.",
"wallet_recovered_body": "Nada foi excluído. Sua carteira original continua salva no seu computador como um arquivo de backup datado, e cada opção abaixo apenas copia ou renomeia arquivos — nunca apaga nenhum. Suas chaves nunca são regeneradas, apenas relidas.\n\nA cópia reparada carregada agora pode estar sem algumas transações recentes, então seu saldo pode parecer um pouco baixo até você concluir abaixo e ele reescanear.",
"wallet_recovered_dismiss": "Agora não — manter a cópia reparada",
"wallet_recovered_dismiss_sub": "Nenhum arquivo é alterado. Você pode reabrir isto a qualquer momento na barra de status; seu original permanece guardado com segurança de qualquer forma.",
"wallet_recovered_notify": "Seu arquivo de carteira precisou de reparo — seu original foi guardado com segurança. Abra o app para ver suas opções.",
"wallet_recovered_open_folder": "Mostrar os arquivos",
"wallet_recovered_open_folder_sub": "Abre a pasta de dados da carteira para você inspecionar os arquivos de backup — nada é alterado.",
"wallet_recovered_rebuild": "Reparar automaticamente (recomendado)",
"wallet_recovered_rebuild_sub": "Relê cada registro recuperável do seu arquivo de carteira mais completo e grava um novo arquivo limpo, depois reinicia. A opção mais completa — seu arquivo atual é mantido como backup datado de qualquer forma.",
"wallet_recovered_restore": "Restaurar o arquivo original em vez disso",
"wallet_recovered_restore_sub": "Coloca de volta seu maior backup intocado, palavra por palavra, depois faz uma nova varredura — um pouco mais rápido, mas só tão completo quanto aquele arquivo era. Seu arquivo atual é mantido como backup datado de qualquer forma.",
"wallet_recovered_safety": "Suas moedas estão seguras.",
"wallet_recovered_title": "Seu arquivo de carteira precisa de um reparo rápido",
"wallet_recovered_warn": "Ao iniciar, o app encontrou que seu arquivo de carteira não passou na verificação de consistência — isso costuma acontecer após uma atualização do app ou um desligamento incorreto. O app já protegeu seus dados: guardou o arquivo antigo à parte e carregou uma cópia reparada para você não ficar preso.",
"wallet_recovery_decide_later": "Decidir depois",
"wallet_recovery_decide_later_tip": "Fecha isto e mantém a cópia carregada agora. Nada é alterado, e você pode reparar quando quiser — a barra de status mantém um link de “Reparo de carteira disponível”.",
"wallet_recovery_details_label": "Mostrar detalhes técnicos",
"wallet_recovery_done": "Concluído",
"wallet_recovery_failure_body": "O reparo foi executado, mas o resultado não passou na verificação (não pôde ser lido, ou não tinha endereços), então foi descartado automaticamente antes de substituir qualquer coisa. Sua carteira está exatamente como estava antes de você clicar — nada no disco mudou.",
"wallet_recovery_failure_title": "O reparo não foi concluído",
"wallet_recovery_files_detail": "Sua carteira original ainda está aqui — o app a renomeou para um backup datado (wallet.<números>.bak) na sua pasta de dados e não apagou nada. “Reparar” lê cada registro do seu arquivo de carteira mais completo em um novo arquivo limpo. “Restaurar” copia seu maior backup de volta exatamente como está. A cópia carregada agora também é mantida como backup.",
"wallet_recovery_files_label": "O que acontece com meus arquivos?",
"wallet_recovery_notnow_short": "Agora não",
"wallet_recovery_other_options": "Outras opções",
"wallet_recovery_rebuild_card": "Reparar automaticamente",
"wallet_recovery_rebuild_card_desc": "Lê cada registro recuperável em um arquivo limpo, depois reinicia. A opção mais completa.",
"wallet_recovery_recommended": "RECOMENDADO",
"wallet_recovery_repair_go": "Reparar",
"wallet_recovery_rescan_body": "Refazendo a varredura da blockchain para reconstruir seu saldo e histórico de transações. Isso pode levar vários minutos — você não precisa fazer nada, e por favor não reinicie o node enquanto estiver em execução.",
"wallet_recovery_rescan_elapsed": "Trabalhando há %s",
"wallet_recovery_rescan_size": "A carteira reconstruída já está em %s e ainda está sendo preenchida",
"wallet_recovery_rescan_slow": "Isso está demorando mais que o normal, o que é comum em uma carteira grande. É seguro deixar em execução — você pode acompanhar o progresso em Avançado ▸ Console.",
"wallet_recovery_rescan_title": "Finalizando o reparo da sua carteira",
"wallet_recovery_restore_card": "Restaurar original",
"wallet_recovery_restore_card_desc": "Coloca de volta seu maior backup intocado, palavra por palavra — só tão completo quanto aquele arquivo era.",
"wallet_recovery_restore_go": "Restaurar",
"wallet_recovery_success_body": "O app carregou sua carteira reparada e está refazendo a varredura para totalizar seu saldo — isso pode levar alguns minutos. Ele lê cada registro que consegue, mas em arquivos danificados raros pode recuperar um pouco menos — ou ocasionalmente mais — endereços do que antes. Depois que a nova varredura terminar, confira se seu saldo e histórico estão corretos.",
"wallet_recovery_success_restore": "Seu maior arquivo de backup foi colocado de volta e está carregando agora, e o app está refazendo a varredura. Foi restaurado palavra por palavra, então está exatamente tão completo quanto aquele arquivo era — confira seu saldo depois que a nova varredura terminar.",
"wallet_recovery_try_other": "Tentar a outra opção",
"wallet_recovery_whats_happened": "O que aconteceu?",
"wallet_recovery_working_label": "Trabalhando",
"wallet_restore_bad_backup": "O arquivo de backup da carteira parece ilegível, então NÃO foi restaurado — sua carteira atual está inalterada. Restaure a partir do seu próprio backup.",
"wallet_restore_busy": "O node está ocupado reiniciando — tente novamente em instantes.",
"wallet_restore_copy_failed": "Não foi possível instalar a carteira de backup; sua carteira atual foi mantida.",
"wallet_restore_move_failed": "Não foi possível colocar a carteira atual de lado — nada foi alterado.",
"wallet_restore_no_backup": "Não foi possível encontrar um wallet.<timestamp>.bak para restaurar. Nada foi alterado.",
"wallet_restore_no_restart": "Sua carteira original foi restaurada, mas o node não reiniciou — inicie-o em Configurações.",
"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_consolidate": "Consolidar notas…",
"wallet_size_warn": "Este arquivo de carteira está grande. Consolidar suas notas pode conter o crescimento.",
"wallets_active": "Ativa",
"wallets_add": "Adicionar pasta",
"wallets_add_folder": "Verificar também outra pasta em busca de arquivos de carteira:",
@@ -1932,7 +2347,9 @@
"wallets_open_inplace_tt": "Abre esta carteira onde está — vinculada ao diretório de dados (sem cópia)",
"wallets_remove_folder": "Parar de verificar esta pasta",
"wallets_reveal": "Mostrar pasta",
"wallets_scan_folder": "Escanear outra pasta em busca de carteiras…",
"wallets_scanned_folders": "Pastas verificadas:",
"wallets_scanning": "Procurando nas pastas…",
"wallets_sort_addresses": "Endereços",
"wallets_sort_asc": "Crescente (mais antigo / menos / menor primeiro)",
"wallets_sort_by": "Ordenar:",
@@ -2001,19 +2418,30 @@
"wiz_welcome_sub": "Algumas escolhas rápidas e o seu full node estará pronto.",
"wiz_welcome_title": "Bem-vindo ao ObsidianDragon!",
"wizard_daemon_start_failed": "Falha ao iniciar o daemon — será tentado novamente automaticamente",
"xmrig_active_tt": "O minerador atualmente instalado (ativo)",
"xmrig_browse_releases": "Navegar por todos os lançamentos…",
"xmrig_checking": "Verificando o minerador mais recente…",
"xmrig_current": "Atual:",
"xmrig_downgrade_note": "Instalar uma versão diferente do minerador substitui a atual; pare de minerar primeiro, e a mudança terá efeito na próxima inicialização.",
"xmrig_download_install": "Baixar e instalar",
"xmrig_downloading": "Baixando…",
"xmrig_install_this": "Instalar esta versão",
"xmrig_installed": "Instalado:",
"xmrig_installed_ok": "Minerador instalado",
"xmrig_installing": "Instalando…",
"xmrig_latest": "Mais recente:",
"xmrig_latest_badge": "mais recente",
"xmrig_latest_tt": "Versão mais recente disponível",
"xmrig_loading_releases": "Carregando versões…",
"xmrig_no_notes": "Sem notas de lançamento para esta versão.",
"xmrig_none": "nenhum",
"xmrig_prerelease_tt": "Build de pré-lançamento / teste",
"xmrig_reinstall": "Reinstalar",
"xmrig_released": "Lançado em:",
"xmrig_releases": "versões do xmrig",
"xmrig_status_current": "Esta versão está instalada",
"xmrig_status_latest": "Versão mais recente",
"xmrig_status_older": "Mais antiga que sua versão instalada",
"xmrig_stop_mining_first": "Pare a mineração antes de atualizar o minerador.",
"xmrig_unavailable_body": "Nenhuma versão do minerador está disponível para esta plataforma.",
"xmrig_unavailable_title": "Atualizações do minerador indisponíveis",
+453 -25
View File
@@ -19,11 +19,13 @@
"about_license_text": "Это программное обеспечение выпущено под лицензией GNU General Public License v3 (GPLv3). Вы можете свободно использовать, изменять и распространять это ПО в соответствии с условиями лицензии.",
"about_peers_count": "%zu узлов",
"about_release": "Релиз",
"about_source": "Источник",
"about_title": "Об ObsidianDragon",
"about_version": "Версия:",
"about_website": "Веб-сайт",
"acrylic": "Акрил",
"add": "Добавить",
"addr_type_mining": "Майнинг",
"address": "Адрес",
"address_book_add": "Добавить адрес",
"address_book_add_new": "Добавить новый",
@@ -67,6 +69,7 @@
"appx_blockchain_maintenance_in_progress": "Операция обслуживания блокчейна уже выполняется.",
"appx_blockchain_rescan_complete": "Повторное сканирование блокчейна завершено",
"appx_bootstrap_complete_reconciling": "Начальная загрузка завершена — сверка кошелька с новыми данными цепи.",
"appx_bootstrap_reconcile_probe_failed": "Не удалось сверить кошелёк с данными цепи из бутстрапа: узел был слишком занят, чтобы проверить свои блоки. Запустите Пересканировать в Настройках, когда узел догонит сеть.",
"appx_cancel": "Отмена",
"appx_cleaning_up": "Очистка...",
"appx_confirm_your_backup": "Подтвердите резервную копию",
@@ -77,11 +80,15 @@
"appx_creating_your_wallet": "Создание кошелька…",
"appx_daemon_error": "Ошибка демона",
"appx_daemon_reinstall_in_progress": "Переустановка демона уже выполняется.",
"appx_daemon_reinstall_node_busy": "Узел всё ещё работает, поэтому демон не был переустановлен. Повторите попытку, когда он остановится.",
"appx_disconnecting": "Отключение...",
"appx_done": "Готово",
"appx_dragonxd_output": "вывод dragonxd",
"appx_encrypting_wallet": "Шифрование кошелька...",
"appx_fullnode_lifecycle_unavailable_lite": "Действия жизненного цикла полного узла недоступны в lite-сборке",
"appx_import_cancelled": "Отменено: узел, возможно, ещё сканирует. Если средства ключа не появятся, запустите Настройки → Пересканировать.",
"appx_import_interrupted": "Импорт был прерван, поэтому ключ, возможно, уже в кошельке. Если его средства не появятся, запустите Настройки → Пересканировать.",
"appx_import_refused": "Узел отклонил запрос, поэтому ключ не был импортирован.",
"appx_installing_bundled_daemon": "Установка встроенного демона — узел остановится, обновится и перезапустится...",
"appx_invalid_payment_uri_prefix": "Неверный платёжный URI: ",
"appx_ive_written_it_down": "Я записал её",
@@ -98,7 +105,8 @@
"appx_no_bundled_daemon_to_install": "В этой сборке нет встроенного демона для установки",
"appx_no_embedded_daemon_to_install": "В этой сборке нет встроенного демона для установки",
"appx_node_busy_restarting": "Узел занят перезапуском — попробуйте ещё раз через мгновение.",
"appx_node_rebuilding_witness_cache": "Узел перестраивает кэш свидетелей",
"appx_node_busy_try_later": "Узел занят долгой задачей (например, перестройкой свидетелей заметок). Повторите попытку, когда она завершится; если остановить его сейчас, эту работу, возможно, придётся начать заново.",
"appx_node_not_stopped": "Узел не остановился вовремя, поэтому ничего не изменено. Повторите попытку через несколько минут.",
"appx_not_next_word": " — это не следующее слово",
"appx_payment_request_loaded": "Запрос на оплату загружен",
"appx_pool_miner_connected_and_hashing": "Пул-майнер подключён и вычисляет хеши.",
@@ -119,11 +127,7 @@
"appx_simple_background_enabled": "Простой фон включён",
"appx_skip": "Пропустить",
"appx_skip_anyway": "Всё равно пропустить",
"appx_still_status_prefix": "Всё ещё \"",
"appx_still_status_suffix": "\" — принудительный выход сейчас может повредить данные цепи.",
"appx_stop_anyway_and_quit": "Всё равно остановить и выйти",
"appx_stopping_daemon_deleting_blockchain": "Остановка демона и удаление данных блокчейна...",
"appx_stopping_node_discards_rebuild": "Остановка узла сейчас отменит текущую перестройку и запустит её заново (несколько минут) при следующем открытии кошелька. Вместо этого можно оставить узел работать.",
"appx_stopping_pool_miner": "Остановка пул-майнера...",
"appx_syncing_pct_block_n_of_n": "Синхронизация %.1f%% — Блок %d / %d",
"appx_tap_words_in_order": "Нажимайте слова в правильном порядке, чтобы подтвердить, что вы их сохранили.",
@@ -133,8 +137,14 @@
"appx_use_settings_restart_daemon_hint": "Откройте Настройки > Перезапустить демон, чтобы повторить попытку",
"appx_waiting_for_daemon_to_encrypt_wallet": "Ожидание шифрования кошелька демоном...",
"appx_wallet_created_and_backed_up": "Кошелёк создан, резервная копия сохранена.",
"appx_wallet_op_node_busy": "Узел занят и не отвечает. Повторите импорт или повторное сканирование, когда он освободится.",
"appx_wallet_op_running": "Импорт ключа или повторное сканирование ещё выполняется. Повторите попытку, когда оно завершится.",
"appx_wallet_op_shutting_down": "Кошелёк завершает работу.",
"appx_wallet_open_failed_prefix": "Не удалось открыть кошелёк: ",
"auto_shield": "Авто-экранирование майнинга",
"autoshield_by_node": "Ваш узел автоматически защищает добытый coinbase за вас.",
"autoshield_off_backup_seed": "Добытый coinbase пока не защищается автоматически. Сделайте резервную копию сид-фразы (Настройки → Резервное копирование и данные), чтобы узел мог безопасно включить эту функцию.",
"autoshield_off_generic": "Ваш узел сейчас не защищает добытый coinbase автоматически.",
"av_intro": "Программы для майнинга часто помечаются как потенциально нежелательные. Выполните эти шаги, чтобы включить пул-майнинг:",
"av_open_security": "Открыть Безопасность Windows",
"av_step1": "Шаг 1: Добавить исключение",
@@ -170,21 +180,20 @@
"balance_history_collecting": "История баланса — сбор данных...",
"balance_layout": "Макет баланса",
"balance_layout_switched": "Раскладка: %s",
"balance_mining_fmt": "Награды за майнинг: %.8f",
"balance_mining_rate": "Майнинг %s",
"balance_shielded_fmt": "Экранировано: %.8f",
"balance_syncing_pct": "Синхронизация %.1f%%",
"balance_transparent_fmt": "Прозрачный: %.8f",
"baltab_market": "Рынок",
"baltab_market_price_4dp": "Рынок: $%.4f",
"baltab_market_price_8dp": "Рынок: $%.8f",
"baltab_mining_addresses_count": "Адресов для майнинга: %d",
"baltab_mining_amount": "Награды за майнинг %.8f",
"baltab_pct_change_24h": "%s%.1f%% за 24ч",
"baltab_pct_of_total_zaddr": "%.0f%% от общего · %d Z-addr",
"baltab_shielded": "Скрытый",
"baltab_shielded_amount": "Скрытый %.8f",
"baltab_t_addresses_count": "%d T-адресов",
"baltab_total_balance": "Общий баланс",
"baltab_transparent": "Прозрачный",
"baltab_transparent_amount": "Прозрачный %.8f",
"ban": "Заблокировать",
"banned_peers": "Заблокированные узлы",
"block": "Блок",
@@ -192,6 +201,12 @@
"block_click_copy": "Нажмите для копирования",
"block_click_next": "Нажмите для следующего блока",
"block_click_prev": "Нажмите для предыдущего блока",
"block_db_reindex_body": "Обычно это происходит после обновления демона, изменившего формат данных на диске, или при повреждении индекса блоков. Ваш кошелёк и монеты в безопасности — узел просто не может загрузить цепочку, поэтому баланс показывается как нулевой.\n\nПересборка заново считывает имеющиеся файлы блоков и может занять некоторое время (также пересканируется кошелёк). Ничего не загружается.",
"block_db_reindex_confirm": "Пересобрать базу данных блоков",
"block_db_reindex_notify": "Узел не может прочитать свою базу данных блоков (часто после обновления демона). Пересоберите её, чтобы восстановить баланс — см. подсказку или Настройки › Узел.",
"block_db_reindex_started": "Пересборка базы данных блоков из ваших блоков — это может занять некоторое время.",
"block_db_reindex_title": "Пересобрать базу данных блоков?",
"block_db_reindex_warn": "Узел не может прочитать свою базу данных блоков.",
"block_explorer": "Обозреватель блоков",
"block_get_info": "Получить информацию о блоке",
"block_hash": "Хэш блока:",
@@ -228,6 +243,7 @@
"change_pass_confirm": "Подтвердите новый:",
"change_pass_current": "Текущий пароль:",
"change_pass_new": "Новый пароль:",
"change_pass_pin_note": "У этого кошелька есть PIN для быстрой разблокировки. После смены он устареет: удалите PIN в настройках и установите его заново.",
"change_pass_title": "Сменить пароль",
"characters": "символов",
"chat": "Чат",
@@ -239,6 +255,10 @@
"chat_accent_theme": "Тема",
"chat_add_contact": "Добавить контакт",
"chat_awaiting_key": "Ожидание ответа",
"chat_blocked_desc": "Заблокированные разговоры удаляются, а их сообщения — старые и новые — отбрасываются, пока вы не разблокируете. Разблокировка заново импортирует разговор из блокчейна.",
"chat_blocked_manage": "Заблокированные",
"chat_blocked_title": "Заблокированные разговоры",
"chat_blocked_toast": "Разговор удалён и заблокирован",
"chat_bubble_minimal": "Минимальный",
"chat_bubble_rounded": "Скруглённый",
"chat_bubble_square": "Прямоугольный",
@@ -251,6 +271,16 @@
"chat_contact_added": "Контакт добавлен — переименуйте его в Контактах",
"chat_contact_request": "запрос контакта",
"chat_copy_address_tip": "Нажмите, чтобы скопировать адрес",
"chat_delete": "Удалить разговор",
"chat_delete_block": "Удалить и заблокировать",
"chat_delete_body_prefix": "Удалить локальную копию разговора с ",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "Удалить",
"chat_delete_failed": "Не удалось удалить разговор — ничего не изменено.",
"chat_delete_local_note": "Это влияет только на это устройство — сообщения остаются в блокчейне, и у собеседника сохранится своя копия.",
"chat_delete_revive_note": "«Удалить» очищает историю на этом устройстве. Если вам снова напишут, разговор появится снова.",
"chat_delete_title": "Удалить разговор?",
"chat_deleted_toast": "Разговор удалён",
"chat_density_comfortable": "Свободная",
"chat_density_compact": "Компактная",
"chat_emoji_color": "Цветной",
@@ -274,6 +304,7 @@
"chat_new_button": "Новый чат",
"chat_new_message": "Сообщение",
"chat_new_message_toast": "Новое зашифрованное сообщение",
"chat_new_needs_zaddr": "Для чата нужен защищённый (z) адрес — прозрачные (t) адреса не могут принимать зашифрованные сообщения.",
"chat_new_send": "Отправить запрос",
"chat_new_title": "Новый чат",
"chat_new_zaddr": "Z-адрес получателя",
@@ -319,6 +350,8 @@
"chat_ts_24h": "24 часа",
"chat_ts_global": "Как глобально",
"chat_ts_global_short": "Общий",
"chat_unblock": "Разблокировать",
"chat_unblocked_toast": "Разговор разблокирован",
"chat_unhide": "Показать",
"chat_unmute": "Включить уведомления",
"chat_verify_key": "Ключ личности — сравните для проверки",
@@ -371,6 +404,7 @@
"console": "Консоль",
"console_accents": "Цветовые акценты",
"console_app": "Прил.",
"console_auto_focus": "Фокус на поле ввода при открытии",
"console_auto_scroll": "Авто-прокрутка",
"console_available_commands": "Доступные команды:",
"console_backend_reference": "Справочник команд бэкенда",
@@ -465,11 +499,15 @@
"console_tab_completion": "Tab для дополнения",
"console_text_colors": "Цвета текста",
"console_toggle_accents": "Переключить цветовые акценты строк",
"console_toggle_auto_focus": "Помещать курсор в поле команд при открытии вкладки Консоль",
"console_toggle_text_color": "Переключить цвета текста строк",
"console_type_help": "Введите 'help' для списка команд",
"console_welcome": "Добро пожаловать в консоль ObsidianDragon",
"console_zoom_in": "Увеличить",
"console_zoom_out": "Уменьшить",
"consolidate_desc": "Объедините множество мелких входов в одну защищённую заметку. Меньше заметок означает файл кошелька меньшего размера и лучшую приватность.",
"consolidate_funds_btn": "Объединить",
"consolidate_title": "Объединение средств",
"contact_avatar": "АВАТАР",
"contact_avatar_bad_image": "Не удалось загрузить это изображение.",
"contact_avatar_badge": "Значок",
@@ -481,7 +519,7 @@
"contact_avatar_image_hint": "Изображение копируется в приложение, чтобы оставаться доступным, если оригинал переместят.",
"contact_avatar_remove": "Удалить",
"contact_avatar_shielded": "Защищённый",
"contact_avatar_transparent": "Прозрачный",
"contact_chip_mining": "Только майнинг",
"contact_global": "Показывать во всех кошельках (глобальный контакт)",
"contact_global_badge_tt": "Глобальный контакт — виден во всех кошельках",
"contact_global_tt": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.",
@@ -515,9 +553,13 @@
"daemon_maintenance_label": "ОБСЛУЖИВАНИЕ",
"daemon_none_bundled": "нет в этой сборке",
"daemon_not_installed": "не установлен",
"daemon_status_diff": "Версия отличается",
"daemon_status_differ": "Установленный бинарный файл отличается от встроенной версии.",
"daemon_status_match": "Установленный бинарник соответствует встроенной версии.",
"daemon_status_missing": "Демон не установлен — установите встроенную версию.",
"daemon_status_none": "Не установлен",
"daemon_status_ok": "Актуальная версия",
"daemon_update_active_tt": "Текущий установленный (активный) узел",
"daemon_update_available": "Доступна новая версия узла",
"daemon_update_body": "В эту сборку кошелька встроена более новая версия узла DragonX, чем установленная сейчас. При обновлении установленный dragonxd (а также dragonx-cli/dragonx-tx) заменяется, после чего узел останавливается и перезапускается, чтобы новая версия вступила в силу. Рекомендуется — более новый узел может добавить возможности (например, поддержку сид-фразы), которых нет в старом.",
"daemon_update_browse": "Просмотреть все релизы…",
@@ -527,19 +569,28 @@
"daemon_update_download_install": "Скачать и установить",
"daemon_update_downloading": "Загрузка…",
"daemon_update_failed": "Ошибка обновления",
"daemon_update_install_this": "Установить эту версию",
"daemon_update_installed": "Установлено:",
"daemon_update_installed_ok": "Узел установлен",
"daemon_update_installing": "Установка…",
"daemon_update_keep": "Оставить текущий",
"daemon_update_later": "Позже",
"daemon_update_latest": "Последняя:",
"daemon_update_latest_badge": "новейшая",
"daemon_update_latest_tt": "Новейшая доступная версия",
"daemon_update_loading": "Загрузка релизов…",
"daemon_update_no_notes": "Нет заметок о выпуске для этой версии.",
"daemon_update_now": "Обновить сейчас",
"daemon_update_prerelease_tt": "Предварительная / тестовая сборка",
"daemon_update_prompt_title": "Обновить демон узла?",
"daemon_update_reinstall": "Переустановить",
"daemon_update_released": "Выпущено:",
"daemon_update_restart_note": "Перезапустите daemon, чтобы начать работу с новой версией.",
"daemon_update_restart_now": "Перезапустить демон сейчас",
"daemon_update_safe": "Ваш кошелёк, ключи и данные блокчейна не затрагиваются — заменяются только программные файлы демона. Если вы намеренно используете собственный узел, выберите «Оставить текущий».",
"daemon_update_status_current": "Эта версия установлена",
"daemon_update_status_latest": "Последняя версия",
"daemon_update_status_older": "Старее установленной версии",
"daemon_update_title": "Обновить демон узла?",
"daemon_update_unavailable_body": "Для этой платформы нет доступной сборки узла.",
"daemon_update_unavailable_title": "Обновления узла недоступны",
@@ -555,6 +606,13 @@
"data_stale_tooltip": "Баланс может быть устаревшим — кошелёк давно не получал обновлений. Проверьте подключение к узлу.",
"date": "Дата",
"date_label": "Дата:",
"debug_gate_confirm": "Показать отладочные параметры",
"debug_gate_incorrect": "Неверный PIN или пароль.",
"debug_gate_pass_prompt": "Введите пароль кошелька, чтобы продолжить:",
"debug_gate_pin_prompt": "Введите PIN разблокировки, чтобы продолжить:",
"debug_gate_title": "Показать отладочные параметры?",
"debug_gate_verifying": "Проверка…",
"debug_gate_warning": "Это расширенные диагностические и настроечные параметры узла. Они могут раскрыть конфиденциальную информацию и изменить работу вашего узла. Продолжайте, только если понимаете, что делаете.",
"debug_logging": "ЖУРНАЛ ОТЛАДКИ",
"decrypt_desc": "Кошелёк будет экспортирован, демон перезапущен с новым незашифрованным кошельком, и все ключи будут повторно импортированы. Это может занять несколько минут в зависимости от размера кошелька.",
"decrypt_error_title": "Не удалось расшифровать",
@@ -577,9 +635,15 @@
"disconnected": "Отключено",
"dismiss": "Отклонить",
"display": "Отображение",
"done": "Готово",
"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": "Открыть менеджер кошельков",
@@ -630,6 +694,7 @@
"explorer_tx_outputs": "Выходы",
"explorer_tx_size": "Размер",
"explorer_txid": "TxID:",
"explorer_urls_hdr": "URL-АДРЕСА",
"export": "Экспорт",
"export_csv": "Экспорт в CSV",
"export_keys_btn": "Экспорт ключей",
@@ -655,9 +720,155 @@
"export_tx_success": "Транзакции успешно экспортированы",
"export_tx_title": "Экспорт транзакций в CSV",
"export_viewing_key": "Экспорт ключа просмотра",
"failed_create_mining_address": "Не удалось создать адрес для майнинга",
"failed_create_shielded": "Не удалось создать экранированный адрес",
"failed_create_transparent": "Не удалось создать прозрачный адрес",
"faq": "ЧаВо",
"faq_d_mgmt_1_a": "Встроенный узел запускается автоматически вместе с кошельком и останавливается, когда это уместно, поэтому обычно управлять им вручную не нужно. Расширенные элементы управления (включая перезапуск демона) находятся в Настройки > Узел и безопасность.",
"faq_d_mgmt_1_q": "Как запустить или остановить узел?",
"faq_d_mgmt_2_a": "Если вы подключаете кошелёк к узлу, который запустили сами, этот параметр определяет, останавливается ли этот узел при закрытии кошелька. Оставьте его выключенным, если хотите, чтобы узел продолжал работать после закрытия кошелька.",
"faq_d_mgmt_2_q": "Что делает параметр «закрывать внешний демон при выходе»?",
"faq_d_mgmt_3_a": "Данные узла и кошелька хранятся в каталоге данных DragonX в профиле вашего пользователя (например, в AppData в Windows или в домашней папке в Linux/macOS). Блокчейн занимает большую часть; оставьте достаточно свободного места на диске для его роста.",
"faq_d_mgmt_3_q": "Где хранятся данные блокчейна и кошелька?",
"faq_d_mgmt_title": "Управление узлом",
"faq_d_mine_1_a": "Вкладка «Майнинг» позволяет майнить на вашем процессоре (CPU). DragonX использует алгоритм RandomX, разработанный для обычных процессоров. Задайте количество потоков и запустите — вознаграждения начисляются на ваш кошелёк.",
"faq_d_mine_1_q": "Как майнить DragonX?",
"faq_d_mine_2_a": "Соло-майнинг отправляет награду прямо вам, но платит только тогда, когда вы находите блок — а это редкость, если у вас нет большого хешрейта. Пул-майнинг делит работу с другими ради меньших, но более стабильных выплат. Вкладка «Майнинг» поддерживает оба варианта.",
"faq_d_mine_2_q": "Соло или пул-майнинг — что выбрать?",
"faq_d_mine_3_a": "В разделе пула на вкладке «Майнинг» есть кнопка «Обновить майнер…», которая скачивает, проверяет и устанавливает последнюю оптимизированную сборку майнера DragonX. Как и в случае с обновлением узла, каждая загрузка проверяется по контрольной сумме и цифровой подписи перед установкой.",
"faq_d_mine_3_q": "Как обновить майнер?",
"faq_d_mine_4_a": "Текущий узел может запускать встроенный stratum-сервер, чтобы другие майнеры могли подключаться к вашему компьютеру. Переключатель для этого находится в Настройки > Узел и безопасность. По умолчанию он слушает только локально; чтобы открыть его для других компьютеров, это нужно включить намеренно.",
"faq_d_mine_4_q": "Можно ли разместить у себя майнинг-пул?",
"faq_d_mine_title": "Майнинг и пулы",
"faq_d_net_1_a": "Сразу после запуска узлу нужно немного времени, чтобы найти пиров, поэтому кратковременное «0 пиров» — это нормально. Если это продолжается долго, проверьте подключение к интернету и убедитесь, что брандмауэр не блокирует узел. Узел находит пиров автоматически через встроенные сиды.",
"faq_d_net_1_q": "Кошелёк показывает отсутствие пиров или подключений — что не так?",
"faq_d_net_2_a": "Да — узлы DragonX общаются друг с другом по TLS, поэтому соединения с пирами зашифрованы. Кошелёк также общается со своим узлом по защищённому локальному каналу.",
"faq_d_net_2_q": "Зашифровано ли моё соединение с сетью?",
"faq_d_net_title": "Пиры и сеть",
"faq_d_node_1_a": "Демон, dragonxd, — это полный узел DragonX. Он скачивает и проверяет весь блокчейн, передаёт транзакции в сеть и отвечает на запросы кошелька. Запуск собственного узла означает, что вам не нужно доверять чужому серверу.",
"faq_d_node_1_q": "Что такое демон (dragonxd)?",
"faq_d_node_2_a": "По умолчанию кошелёк запускает и управляет собственным встроенным узлом (embedded) — вам не нужно ничего делать. Если у вас уже запущен свой dragonxd, кошелёк может подключиться к этому внешнему узлу вместо своего. Кошелёк определяет, какая ситуация имеет место, и действует соответственно.",
"faq_d_node_2_q": "В чём разница между встроенным и внешним узлом?",
"faq_d_node_3_a": "Полный узел независимо проверяет каждое правило блокчейна, поэтому ваш кошелёк доверяет математике, а не третьей стороне. Это также укрепляет сеть. Плата за это — место на диске и первоначальная синхронизация.",
"faq_d_node_3_q": "Зачем вообще запускать полный узел?",
"faq_d_node_title": "Полный узел",
"faq_d_perf_1_a": "Блокчейн — самая большая часть, и он растёт со временем, поэтому держите свободными несколько гигабайт. Узел использует кеш базы данных для скорости; встроенные значения по умолчанию настроены для типичных машин. Продвинутые пользователи могут изменить размер кеша базы данных узла, отредактировав dbcache в файле конфигурации (DRAGONX.conf).",
"faq_d_perf_1_q": "Сколько места на диске использует узел и можно ли настроить память?",
"faq_d_perf_2_a": "Включите загрузку bootstrap (предлагается в мастере настройки), чтобы получить свежую проверенную копию цепочки вместо проверки каждого блока с нуля. Быстрый диск (SSD) также сильно помогает.",
"faq_d_perf_2_q": "Как ускорить первоначальную синхронизацию?",
"faq_d_perf_title": "Хранилище и производительность",
"faq_d_sync_1_a": "При запуске узел загружает и проверяет блоки, прежде чем сможет обслуживать кошелёк. Экран «Обработка блоков / Применение блоков для построения текущего состояния цепочки» — это и есть такой разогрев. Это нормально; кошелёк становится доступен, как только узел закончит и сообщит свою высоту.",
"faq_d_sync_1_q": "Что означает «Обработка блоков» при запуске?",
"faq_d_sync_2_a": "Для ваших защищённых средств узел хранит криптографические «свидетели» (witnesses), которые позволяют тратить их приватно. После некоторых перезапусков или обновлений он перестраивает этот кеш, повторно сканируя недавние блоки. На кошельке со множеством транзакций это может занять несколько минут и потребовать дополнительной памяти.\n\nЭто не зависание — дайте процессу завершиться. Принудительное закрытие в середине процесса просто заставит его перезапустить перестроение в следующий раз.",
"faq_d_sync_2_q": "Что такое «Перестроение кеша свидетелей»?",
"faq_d_sync_3_a": "Вблизи вершины цепочки работа по сканированию кошелька (проверка балансов и заметок) конкурирует с узлом за одну и ту же внутреннюю блокировку, что может замедлить подключение последних блоков на крупном кошельке. Последние версии ограничивают это сканирование, чтобы узел мог наверстать упущенное. Если оставаться на более лёгкой вкладке (или просто подождать), процесс завершится быстрее.",
"faq_d_sync_3_q": "Почему синхронизация медленная, когда я почти догнал сеть?",
"faq_d_sync_4_a": "Первая синхронизация скачивает и проверяет всю цепочку и может занять некоторое время в зависимости от вашего соединения и диска. Вы можете значительно ускорить её, включив загрузку bootstrap в мастере настройки — она получает свежую проверенную копию данных цепочки.",
"faq_d_sync_4_q": "Сколько занимает первоначальная синхронизация?",
"faq_d_sync_title": "Синхронизация и блокчейн",
"faq_d_trbl_1_a": "Обычно нет. На крупном кошельке узел может тратить несколько минут на перестроение защищённых свидетелей или применение блоков, в течение которых он выглядит приостановленным и может медленно отвечать. Дайте ему время — журнал отладки (в настройках) показывает стабильный прогресс, если всё работает.\n\nИзбегайте принудительного закрытия на этом этапе — иначе работа перезапустится с нуля при следующем запуске.",
"faq_d_trbl_1_q": "Узел словно застрял на «Активация лучшей цепочки» — он завис?",
"faq_d_trbl_2_a": "Если файл кошелька был восстановлен, но часть его ключевых данных отсутствует, узел может открыть его в ограниченном «пониженном» режиме, в котором нельзя создавать новые адреса или защищать средства. Самое надёжное решение — восстановить кошелёк из сид-фразы в новый кошелёк.",
"faq_d_trbl_2_q": "Что такое «пониженный режим»?",
"faq_d_trbl_3_a": "Пересканирование заставляет кошелёк заново прочитать цепочку, чтобы заново обнаружить ваши транзакции (полезно после импорта ключей). Переиндексация перестраивает базу данных блокчейна узла и нужна только в том случае, если эта база повреждена или несовместима после смены версии. Оба процесса могут занять некоторое время; запускайте их из Настройки > Узел и безопасность.",
"faq_d_trbl_3_q": "Когда нужно переиндексировать или пересканировать?",
"faq_d_trbl_4_a": "Использование памяти резко возрастает во время тяжёлой работы, например перестроения свидетелей или первоначальной синхронизации, а затем снижается после завершения. Постоянно высокое потребление памяти в состоянии простоя — это необычно; перезапуск кошелька сбрасывает его. На машине с малым объёмом ОЗУ убедитесь, что другие требовательные к памяти приложения не конкурируют за ресурсы.",
"faq_d_trbl_4_q": "Узел использует много памяти — это нормально?",
"faq_d_trbl_title": "Устранение неполадок",
"faq_d_upd_1_a": "Откройте Настройки > Узел и безопасность > Бинарник демона и нажмите «Проверить обновления…». Кошелёк скачивает последнюю проверенную сборку узла и устанавливает её; новая версия вступает в силу при следующем запуске демона.",
"faq_d_upd_1_q": "Как обновить узел?",
"faq_d_upd_2_a": "Да — диалог обновления перечисляет все релизы, чтобы вы могли выбрать конкретную или более старую сборку. Будьте осторожны при откате на более старую версию: старый узел может не принять данные блокчейна, записанные более новым, и может потребоваться переиндексация.",
"faq_d_upd_2_q": "Можно ли установить конкретную версию узла?",
"faq_d_upd_3_a": "Каждый скачанный архив узла проверяется по контрольной сумме и криптографической подписи перед установкой. Если проверка не проходит, кошелёк отклоняет обновление.",
"faq_d_upd_3_q": "Безопасно ли обновление?",
"faq_d_upd_title": "Обновление узла",
"faq_group_daemon": "Демон",
"faq_group_wallet": "Кошелёк",
"faq_intro": "Ответы о вашем кошельке и узле DragonX. Ищите или просматривайте темы ниже.",
"faq_intro_lite": "Ответы о вашем Lite-кошельке. Ищите или просматривайте темы ниже.",
"faq_l_gs_1_a": "ObsidianDragonLite — это облегчённый кошелёк DragonX. Вместо запуска полного узла он подключается к lite-серверу DragonX, поэтому запускается быстро и не скачивает весь блокчейн.\n\nОн управляет вашими ключами, балансами и отправкой так же, как и полный кошелёк — см. раздел «Lite-кошелёк» ниже о том, как работает подключение к серверу, и о компромиссе с приватностью.",
"faq_l_gs_1_q": "Что такое ObsidianDragonLite?",
"faq_l_lite_1_a": "Lite-кошелёк общается с lite-сервером DragonX вместо запуска собственного полного узла. Это делает его быстрым в запуске и лёгким по нагрузке на диск и трафик, поскольку он сам не скачивает и не проверяет весь блокчейн. Ваши приватные ключи всегда остаются на вашем устройстве — они никогда не отправляются на сервер.",
"faq_l_lite_1_q": "Чем Lite-кошелёк отличается от полного кошелька?",
"faq_l_lite_2_a": "Откройте настройки и выберите сервер. Вы можете сменить сервер в любой момент, и кошелёк заново синхронизируется с новым. Если сервер медленный или недоступен, попробуйте другой.",
"faq_l_lite_2_q": "Как выбрать или сменить сервер?",
"faq_l_lite_3_a": "Ваши ключи никогда не покидают устройство, а защищённые суммы остаются зашифрованными. Но lite-сервер видит, какими адресами интересуется ваш кошелёк, и ваш IP-адрес, поэтому он может связать ваши адреса между собой — это компромисс удобства ради приватности. Для максимальной приватности используйте кошелёк с полным узлом, который сам проверяет цепочку и не раскрывает ваши адреса серверу.",
"faq_l_lite_3_q": "Так же ли приватен Lite-кошелёк, как полный узел?",
"faq_l_lite_title": "Lite-кошелёк",
"faq_l_sec_1_a": "Откройте Настройки > Кошелёк и задайте пароль в разделе безопасности. Шифрование защищает ваши приватные ключи на диске, поэтому тот, кто получит доступ к файлам, всё равно не сможет потратить ваши монеты.\n\nВыберите надёжный пароль и не теряйте его — восстановить зашифрованный кошелёк без него невозможно.",
"faq_no_results": "Ничего не найдено. Попробуйте другой запрос.",
"faq_open_tooltip": "Справка и FAQ",
"faq_search_hint": "Поиск по FAQ…",
"faq_title": "Справка и FAQ",
"faq_w_addr_1_a": "Экранированные адреса (z-адреса, начинаются с zs) — это адреса, которыми вы пользуетесь в DragonX. Они используют криптографию с нулевым разглашением: сумма, отправитель и получатель шифруются в блокчейне, а ваш баланс остаётся приватным.\n\nПрозрачные адреса (начинаются с R) — это адреса для майнинга. DragonX — полностью приватная сеть, поэтому они могут получать только награды за майнинг: сеть отклоняет любой платёж на такой адрес. Поступившие туда награды остаются публичными, пока их не экранируют, а кошелёк делает это автоматически.",
"faq_w_addr_1_q": "В чём разница между прозрачными и защищёнными адресами?",
"faq_w_addr_2_a": "Используйте экранированный (zs) адрес для всего, что отправляете и получаете, — в DragonX только на такой адрес можно заплатить.\n\nПрозрачные адреса (для майнинга) предназначены только для наград за соло-майнинг. Никогда не давайте такой адрес для платежа: сеть его отклонит.",
"faq_w_addr_2_q": "Какой тип адреса мне использовать?",
"faq_w_addr_3_a": "Повторно использовать экранированный адрес безопасно — это не раскрывает вашу историю. При желании можно создавать новые на вкладке «Получить», например по одному на контакт, чтобы видеть, кто вам заплатил. Адреса для майнинга нужны только для наград за майнинг; новый понадобится редко.",
"faq_w_addr_3_q": "Использовать один адрес повторно или создавать новый?",
"faq_w_addr_4_a": "Экранирование переводит монеты с прозрачного адреса (для майнинга) на экранированный, делая их приватными. Новые намайненные монеты приходят на адрес для майнинга и экранируются автоматически, когда созревают.\n\nЭкранировать можно и вручную в кошельке; экранирование — это обычная транзакция особого вида, поэтому подтверждается в обычные сроки.",
"faq_w_addr_4_q": "Что такое «экранирование»?",
"faq_w_addr_title": "Адреса и приватность",
"faq_w_bal_1_a": "Пока кошелёк синхронизируется, ваш баланс неполный и может отображаться как 0 — не все транзакции ещё просканированы. Он заполнится после завершения синхронизации.\n\nСледите за строкой состояния внизу окна: она показывает состояние соединения и высоту блока. После завершения синхронизации баланс будет точным.",
"faq_w_bal_1_q": "Почему мой баланс 0 или не обновляется?",
"faq_w_bal_2_a": "Каждый новый блок, добытый поверх блока с вашей транзакцией, добавляет одно подтверждение. Чем больше подтверждений, тем надёжнее закреплён платёж. Полученные средства становятся доступны для отправки после первого подтверждения.",
"faq_w_bal_2_q": "Что такое подтверждения?",
"faq_w_bal_3_a": "Общий баланс включает средства, которые ещё подтверждаются или временно заблокированы (например, монеты в процессе экранирования). Доступный баланс — это то, что можно отправить прямо сейчас. Они сравниваются по мере подтверждения транзакций.",
"faq_w_bal_3_q": "В чём разница между общим и доступным балансом?",
"faq_w_bal_title": "Баланс и синхронизация",
"faq_w_chat_1_a": "Чат — это зашифрованный мессенджер на основе блокчейна, встроенный в кошелёк. Сообщения отправляются как приватные защищённые заметки, поэтому прочитать их могут только вы и ваш собеседник.",
"faq_w_chat_1_q": "Что такое функция чата?",
"faq_w_chat_2_a": "Ваша личность в чате формируется из сид-фразы кошелька, поэтому она всегда следует за кошельком — восстановите кошелёк, и ваша личность вернётся. Отдельный аккаунт или пароль не нужны.",
"faq_w_chat_2_q": "Как работает моя личность в чате?",
"faq_w_chat_3_a": "Добавьте контакт по его адресу на вкладке «Контакты», при желании указав имя и аватар. Контакты можно хранить только для текущего кошелька или использовать во всех кошельках, которые вы открываете на этом компьютере.",
"faq_w_chat_3_q": "Как работают контакты?",
"faq_w_chat_4_a": "Откройте разговор и используйте значки в его заголовке. «Скрыть» (глаз) убирает разговор из списка, но сохраняет все сообщения — новое сообщение снова покажет разговор. «Удалить» (корзина) открывает диалог: «Удалить» очищает историю на этом устройстве (если собеседник снова напишет вам, разговор появится вновь), а «Удалить и заблокировать» также останавливает будущие сообщения от него, пока вы не разблокируете его в списке «Заблокированные» над списком разговоров. Всё это действует только на этом устройстве — сообщения остаются в блокчейне, и у собеседника сохраняется своя копия, поэтому это не «отмена отправки».",
"faq_w_chat_4_q": "Как скрыть, удалить или заблокировать разговор?",
"faq_w_chat_title": "Чат и контакты",
"faq_w_gs_1_a": "ObsidianDragon — это полноузловой кошелёк для DragonX (DRGX). Он управляет вашими монетами и в десктопной сборке запускает полный узел DragonX (демон) в фоне, поэтому кошелёк сам проверяет блокчейн, а не доверяет стороннему серверу.\n\nПредставьте это как две части, работающие вместе: кошелёк (ключи, балансы, отправка) и узел (блокчейн и сеть). Вопросы здесь разделены так же — см. вкладки «Кошелёк» и «Демон» выше.",
"faq_w_gs_1_q": "Что такое ObsidianDragon?",
"faq_w_gs_2_a": "При первом запуске мастер настройки автоматически создаёт для вас кошелёк. Новые кошельки защищены секретной сид-фразой (списком слов). Запишите эту фразу и храните её офлайн — это единственный способ восстановить средства, если этот компьютер будет потерян.\n\nПросмотреть или сохранить фразу можно в любой момент в Настройки > Резервное копирование и данные > Сид-фраза.",
"faq_w_gs_2_q": "Как создать новый кошелёк?",
"faq_w_gs_3_a": "Используйте восстановление по сид-фразе, если у вас есть сид-фраза. (В полноузловой сборке вы также можете скопировать существующий файл wallet.dat в папку данных кошелька перед запуском.)\n\nПосле восстановления кошелёк повторно сканирует блокчейн, чтобы найти ваши прошлые транзакции, поэтому баланс и история могут появиться не сразу.",
"faq_w_gs_3_q": "Как восстановить уже имеющийся кошелёк?",
"faq_w_gs_4_a": "Мастер позволяет выбрать оформление/тему, настроить шифрование и PIN, а также — в полноузловой сборке — при желании загрузить bootstrap для ускорения первой синхронизации. Любой шаг можно пропустить и изменить позже в Настройках.",
"faq_w_gs_4_q": "Что делает первоначальная настройка?",
"faq_w_gs_title": "Начало работы",
"faq_w_sec_1_a": "Откройте Настройки > Узел и безопасность и задайте пароль (раздел БЕЗОПАСНОСТЬ). Шифрование защищает ваши приватные ключи на диске, поэтому тот, кто получит доступ к файлам, всё равно не сможет потратить ваши монеты.\n\nВыберите надёжный пароль и не теряйте его — восстановить зашифрованный кошелёк без него невозможно.",
"faq_w_sec_1_q": "Как зашифровать свой кошелёк?",
"faq_w_sec_2_a": "PIN блокирует экран приложения, чтобы скрыть балансы и действия, когда вы отходите. Это дополнительная блокировка поверх шифрования; вы настраиваете её в мастере настройки или в Настройках после шифрования кошелька.",
"faq_w_sec_2_q": "Что такое PIN / экран блокировки?",
"faq_w_sec_3_a": "Нет. Шифрование кошелька невозможно обойти. Если у вас сохранилась сид-фраза восстановления, вы можете восстановить кошелёк из неё в новый кошелёк и задать новый пароль. Без пароля или сид-фразы средства восстановить нельзя.",
"faq_w_sec_3_q": "Я забыл пароль — можно ли его восстановить?",
"faq_w_sec_title": "Безопасность и шифрование",
"faq_w_seed_1_a": "Перейдите в Настройки > Резервное копирование и данные > Сид-фраза. Запишите слова на бумаге по порядку и храните их в надёжном месте офлайн. Любой, у кого есть эта фраза, может потратить ваши монеты, поэтому никогда не храните её в фото, письме или облачной заметке.",
"faq_w_seed_1_q": "Как сделать резервную копию сид-фразы?",
"faq_w_seed_2_a": "Старые (устаревшие) кошельки не были основаны на сид-фразе. Кошелёк умеет переносить устаревший кошелёк в современный кошелёк с сид-фразой: он создаёт новый сид-кошелёк и переводит в него ваши средства. Ищите «Перейти на сид-кошелёк…» в Настройки > Резервное копирование и данные. Для этой функции нужна текущая версия демона.",
"faq_w_seed_2_q": "У моего кошелька нет сид-фразы — можно ли её добавить?",
"faq_w_seed_3_a": "При настройке кошелька используйте восстановление по сид-фразе и введите слова по порядку. Затем кошелёк повторно сканирует блокчейн, чтобы восстановить баланс и историю, что при первом запуске может занять некоторое время.",
"faq_w_seed_3_q": "Как восстановить кошелёк по сид-фразе?",
"faq_w_seed_4_a": "Ваша сид-фраза — это основная резервная копия, и её достаточно для полного восстановления. Копия файла кошелька — удобная дополнительная резервная копия, которая также сохраняет метки и настройки. Храните любые резервные копии офлайн и в тайне.",
"faq_w_seed_4_q": "Нужно ли также сохранять резервную копию wallet.dat?",
"faq_w_seed_title": "Сид-фраза и резервное копирование",
"faq_w_send_1_a": "Откройте вкладку «Отправить», вставьте адрес получателя, укажите сумму и подтвердите. Если кошелёк зашифрован, вам предложат разблокировать его для транзакции.\n\nПлатежи отправляются на экранированные (zs) адреса и остаются приватными. Кошелёк не отправляет на прозрачный адрес (для майнинга): сеть отклоняет платежи на такие адреса.",
"faq_w_send_1_q": "Как отправить средства?",
"faq_w_send_2_a": "Комиссии DragonX очень низкие и устанавливаются автоматически. Комиссия показывается перед подтверждением. Обычно менять её не нужно.",
"faq_w_send_2_q": "Какую комиссию я плачу?",
"faq_w_send_3_a": "Да — при отправке на защищённый (Z) адрес можно приложить зашифрованную заметку. Прочитать её сможет только получатель.",
"faq_w_send_3_q": "Можно ли прикрепить сообщение к платежу?",
"faq_w_send_4_a": "Транзакция остаётся «в ожидании», пока не будет добыта в блок и не получит подтверждения. Обычно это занимает минуту-две. Защищённым транзакциям также нужно, чтобы кошелёк был синхронизирован, для построения доказательства.\n\nЕсли отправка необычно долго остаётся в ожидании, проверьте, что кошелёк подключён и синхронизирован (см. строку состояния внизу окна).",
"faq_w_send_4_q": "Я отправил платёж, но он в статусе «в ожидании» — почему?",
"faq_w_send_title": "Отправка и получение",
"faq_w_set_1_a": "Откройте Настройки > Внешний вид, чтобы выбрать тему и акцентный цвет. Доступно несколько светлых и тёмных оформлений, а эффекты вроде размытия можно отключить на маломощных компьютерах.",
"faq_w_set_1_q": "Как изменить тему?",
"faq_w_set_2_a": "Да — в Настройки > Внешний вид есть выбор языка. В кошелёк встроено несколько переводов; всё, что ещё не переведено, отображается на английском.",
"faq_w_set_2_q": "Можно ли изменить язык?",
"faq_w_set_3_a": "Используйте параметр масштаба шрифта в Настройки > Внешний вид. Он масштабирует весь интерфейс — это удобно на дисплеях высокого разрешения (HiDPI), где приложение иначе может выглядеть слишком мелким.",
"faq_w_set_3_q": "Текст слишком мелкий (или слишком крупный) — можно ли его масштабировать?",
"faq_w_set_4_a": "Включите получение курса в Настройках. После этого кошелёк будет показывать приблизительную стоимость в фиатной валюте рядом с балансами и сможет отображать небольшой график рынка. Цены приведены только для справки.",
"faq_w_set_4_q": "Как показать цены в фиатной валюте?",
"faq_w_set_title": "Настройки и внешний вид",
"favorite_address": "Добавить в избранное",
"favorite_opacity": "Непрозрачность выделения избранного",
"fee": "Комиссия",
"fee_high": "Высокая",
"fee_label": "Комиссия:",
@@ -665,14 +876,9 @@
"fee_normal": "Обычная",
"fetch_prices": "Получить цены",
"file": "Файл",
"file_save_location": "Файл будет сохранён в: ~/.config/ObsidianDragon/",
"file_save_location": "Файл будет сохранён в:",
"filter": "Фильтр...",
"font_scale": "Масштаб шрифта",
"force_quit": "Принудительный выход",
"force_quit_confirm_msg": "Это немедленно завершит демон без корректного завершения.\nЭто может повредить индекс блокчейна и потребовать повторной синхронизации.",
"force_quit_confirm_title": "Принудительный выход?",
"force_quit_warning": "Это немедленно завершит демон без корректного завершения. Может потребоваться повторная синхронизация блокчейна.",
"force_quit_yes": "Принудительный выход",
"from": "От",
"from_upper": "ОТ",
"full_details": "Полные детали",
@@ -741,6 +947,7 @@
"hidden_tag": " (скрыт)",
"hide": "Скрыть",
"hide_address": "Скрыть адрес",
"hide_earnings": "Скрыть доход",
"hide_qr": "Скрыть QR",
"hide_zero_balances": "Скрыть нулевые балансы",
"history": "История",
@@ -815,11 +1022,13 @@
"lite_birthday_backup": "Дата рождения: %llu (сохраните её тоже)",
"lite_birthday_hint": "Высота блока, с которой начинать сканирование. Оставьте 0, если неизвестно (медленное полное сканирование).",
"lite_birthday_label": "Дата рождения",
"lite_confirm_label": "Повтор",
"lite_console_backend_commands": "Команды бэкенда:",
"lite_console_help_passthrough": "Любой другой ввод выполняется как команда консоли лайт-кошелька.",
"lite_copy": "Копировать",
"lite_could_not_start": "Не удалось запустить операцию",
"lite_could_not_write": "Не удалось записать ",
"lite_encrypt_offline_warning": "Любой, у кого есть файл кошелька, может подбирать парольные фразы офлайн. Сначала сохраните сид-фразу и используйте длинную парольную фразу.",
"lite_encrypt_wallet": "Зашифровать кошелёк",
"lite_encryption_removed": "Шифрование удалено",
"lite_enter_all_seed_words": "Введите все 24 слова seed-фразы для восстановления (введено %d)",
@@ -827,6 +1036,7 @@
"lite_hide_wipe": "Скрыть и стереть",
"lite_import": "Импорт",
"lite_import_key_label": "Импортировать ключ",
"lite_key_import_failed": "Не удалось импортировать ключ — проверьте его, разблокируйте кошелёк (если зашифрован) и повторите попытку.",
"lite_key_imported": "Ключ импортирован — выполните синхронизацию, чтобы просканировать его историю",
"lite_lock_failed": "Не удалось заблокировать",
"lite_lock_now": "Заблокировать сейчас",
@@ -960,11 +1170,67 @@
"memo_optional": "ЗАМЕТКА (НЕОБЯЗАТЕЛЬНО)",
"memo_upper": "ЗАМЕТКА",
"memo_z_only": "Примечание: Заметки доступны только при отправке на экранированные (z) адреса",
"merge_addr_created": "Защищённый адрес создан.",
"merge_advanced": "Дополнительно",
"merge_back": "Назад",
"merge_batch_fmt": "За один запуск объединяется до %d входов — повторите, чтобы завершить остальные.",
"merge_confirm_btn": "Подтвердить",
"merge_confirm_fmt": "Объединить ~%s DRGX из %d входов в %s?",
"merge_create_zaddr": "Создать защищённый адрес",
"merge_creating": "Создание адреса…",
"merge_description": "Объедините несколько UTXO в один экранированный адрес. Это может уменьшить размер кошелька и улучшить конфиденциальность.",
"merge_fee_hint": "Сетевая комиссия за эту транзакцию.",
"merge_funds": "Объединить средства",
"merge_into_one": "На один адрес",
"merge_keep_each": "Оставить в каждом адресе",
"merge_keep_each_batches_fmt": "Адреса с более чем %d заметками объединяются в несколько партий; запустите снова после их подтверждения, чтобы объединить остальное.",
"merge_keep_each_confirm_fmt": "Объединить %d заметок на %d адресах? Каждый адрес сохраняет свои средства. Комиссии: около %s DRGX (по одной на партию).",
"merge_keep_each_desc": "Заметки каждого адреса объединяются обратно в тот же адрес, поэтому средства, которые вы держите раздельно под разными метками, остаются раздельными. Только экранированные адреса — прозрачные средства можно только экранировать («На один адрес»).",
"merge_keep_each_none": "На каждом экранированном адресе уже одна заметка — объединять нечего.",
"merge_keep_each_nothing_submitted": "Ничего не объединено — узел отклонил все объединения.",
"merge_keep_each_row_fmt": "%s · заметок: %d · %s DRGX",
"merge_keep_each_run_again": "На адресах, объединённых в несколько партий, теперь по одной заметке на партию. Запустите снова после их подтверждения, чтобы объединить их в одну.",
"merge_keep_each_select": "Отметьте хотя бы один адрес для объединения.",
"merge_keep_each_some_failed": "Некоторые объединения не удались — см. список выше.",
"merge_keep_each_status_done": "%s — готово: объединено заметок: %d, партий: %d",
"merge_keep_each_status_failed": "%s — ошибка: %s",
"merge_keep_each_status_not_started": "%s — не запущено: %s",
"merge_keep_each_status_running": "%s — объединение %d заметок (готово партий: %d из %d)…",
"merge_keep_each_status_unconfirmed": "%s — статус не удалось подтвердить",
"merge_keep_each_too_small": "Слишком мало для объединения: комиссия съест эти средства.",
"merge_max_inputs": "Макс. входов за партию",
"merge_no_spendable": "Пока нет доступных для расходования входов для объединения.",
"merge_progress": "Объединение… это может занять несколько минут. Вы можете закрыть это окно.",
"merge_run_aborted_toast": "Соединение с узлом было сброшено, поэтому объединение остановлено. Запустите его снова, чтобы продолжить.",
"merge_run_busy": "Идёт объединение. Дождитесь его завершения или сначала остановите его.",
"merge_run_chip_fmt": "Объединение %d/%d",
"merge_run_err_timeout": "Узел не ответил вовремя; партия, возможно, всё ещё в очереди.",
"merge_run_halted_fmt": "Объединение остановлено после повторных сбоев: %s",
"merge_run_migration_pending": "Завершите или отмените перенос сид-фразы перед объединением.",
"merge_run_progress_fmt": "Объединение… партия %d из примерно %d, объединено заметок: %d. Это окно можно закрыть — операция продолжится в фоне.",
"merge_run_start_failed": "Не удалось начать объединение — узел не подключён.",
"merge_run_status_not_run": "%s — не объединено (%d заметок)",
"merge_run_status_waiting": "%s — ожидание (%d заметок)",
"merge_run_stop": "Остановить",
"merge_run_stopped_toast": "Объединение остановлено. Запустите его снова, чтобы объединить остальное.",
"merge_run_stopping": "Остановка после уже отправленных партий…",
"merge_run_wait_batch": "Партии отправляются с паузами, чтобы узел оставался отзывчивым…",
"merge_run_wait_send": "Приостановлено до завершения вашей ожидающей транзакции.",
"merge_run_wait_sync": "Приостановлено: узел ещё синхронизируется.",
"merge_scope_failed_fmt": "Не удалось прочитать ваши входы: %s",
"merge_scope_fmt": "%d прозрачных + %d защищённых входов · ~%s DRGX доступно",
"merge_scope_loading": "Проверка ваших входов…",
"merge_send_failed": "Ошибка объединения: ",
"merge_source": "Объединить",
"merge_src_both": "Оба типа",
"merge_src_shielded": "Защищённые",
"merge_src_transparent": "Прозрачные",
"merge_started": "Операция объединения начата",
"merge_submit_uncertain": "Узел не подтвердил объединение до разрыва соединения. Оно всё ещё может выполниться — после переподключения проверьте Историю, прежде чем объединять снова.",
"merge_title": "Объединить на адрес",
"merge_wait_locked": "Кошелёк заблокирован. Разблокируйте его, чтобы продолжить.",
"merge_waiting_node": "Ожидание узла…",
"merge_where": "Куда направить средства?",
"mig_adopt": "Сделать это моим кошельком",
"mig_adopt_now": "Принять сид-кошелёк",
"mig_adopting": "Установка вашего нового кошелька и пересканирование…",
@@ -1016,6 +1282,8 @@
"miner_fee": "Комиссия майнера",
"mining": "Майнинг",
"mining_active": "Активен",
"mining_addr_explainer": "Принимает только награды за соло-майнинг — сеть отклоняет платежи на этот адрес. Награды экранируются автоматически, когда созревают.",
"mining_address": "Адрес для майнинга",
"mining_address_copied": "Адрес майнинга скопирован",
"mining_all_time": "За всё время",
"mining_already_saved": "URL пула уже сохранён",
@@ -1029,6 +1297,8 @@
"mining_benchmark_testing": "Тестирование",
"mining_benchmark_tooltip": "Найти оптимальное количество потоков для этого процессора",
"mining_block_copied": "Хэш блока скопирован",
"mining_card_click_to_shield": "Нажмите, чтобы экранировать созревшие награды сейчас.",
"mining_card_tooltip": "Награды за соло-майнинг поступают на ваши адреса для майнинга (прозрачные, R…). Эти адреса не принимают платежи. Награды экранируются автоматически, когда созревают.",
"mining_chart_1m_ago": "1м назад",
"mining_chart_5m_ago": "5м назад",
"mining_chart_now": "Сейчас",
@@ -1044,6 +1314,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": "Показать только доходы пула",
@@ -1073,6 +1345,7 @@
"mining_payout_address": "Адрес выплат",
"mining_payout_foreign": "⚠ Этот адрес выплат отсутствует в вашем текущем кошельке — намайненные вознаграждения будут отправлены в другой кошелёк. Обновите его, если вы сменили кошелёк.",
"mining_payout_invalid": "Недействительный адрес DragonX — исправьте перед запуском, иначе награды за майнинг будут потеряны.",
"mining_payout_needs_shielded": "Пулы выплачивают обычными транзакциями, которые в DragonX не могут дойти до адреса для майнинга (прозрачного, R…). Укажите экранированный адрес (начинается с zs) как адрес для выплат.",
"mining_payout_tooltip": "Адрес для получения вознаграждений за майнинг",
"mining_pool": "Пул",
"mining_pool_fee": "Комиссия",
@@ -1085,6 +1358,8 @@
"mining_refresh": "Обновить",
"mining_remove": "Удалить",
"mining_reset_defaults": "Сбросить настройки",
"mining_rewards": "Награды за майнинг",
"mining_rewards_unshielded": "Ещё не экранированы",
"mining_save_payout_address": "Сохранить адрес выплат",
"mining_save_pool_url": "Сохранить URL пула",
"mining_saved_addresses": "Сохранённые адреса:",
@@ -1118,10 +1393,10 @@
"network_fee": "СЕТЕВАЯ КОМИССИЯ",
"network_hashrate": "Хешрейт сети",
"new": "+ Новый",
"new_mining_address": "Новый адрес для майнинга",
"new_mining_address_created": "Новый адрес для майнинга создан",
"new_shielded_created": "Создан новый экранированный адрес",
"new_t_address": "Новый T-адрес",
"new_t_transparent": "Новый t-адрес (Прозрачный)",
"new_transparent_created": "Создан новый прозрачный адрес",
"new_z_address": "Новый Z-адрес",
"new_z_shielded": "Новый z-адрес (Экранированный)",
"no_addresses": "Адреса не найдены. Создайте один, используя кнопки выше.",
@@ -1141,7 +1416,19 @@
"node_banner_offline_title": "Нет подключения к узлу DragonX",
"node_banner_reconnect": "Переподключить",
"node_banner_restart": "Перезапустить узел",
"node_busy_rebuild_desc": "Выполняется %s. Для больших кошельков это может занять около часа, и до окончания узел не может отвечать кошельку.",
"node_busy_rebuild_extending": "Расширение свидетелей (узел не сообщает о ходе этого этапа).",
"node_busy_rebuild_reading": "Чтение блоков: %ld / %ld",
"node_busy_rebuild_title": "Узел занят: перестройка свидетелей заметок",
"node_busy_replay_desc": "Выполняется %s. Узел повторно применяет блоки, полученные до последней остановки.",
"node_busy_replay_title": "Узел занят: догоняет блоки",
"node_busy_rpc_desc": "Узел работает, но уже %s слишком занят, чтобы отвечать кошельку, обычно из-за долгой операции, например перестройки свидетелей.",
"node_busy_rpc_title": "Узел занят: пока не отвечает",
"node_busy_running": "Выполняется %s",
"node_security": "УЗЕЛ И БЕЗОПАСНОСТЬ",
"node_starting_busy_desc": "Узел доступен, но занят и пока не отвечает. Это нормально после обновления или при первом запуске; может занять несколько минут.",
"node_starting_launch_desc": "Запуск dragonxd и ожидание его готовности…",
"node_starting_title": "Запуск узла DragonX…",
"noise": "Шум",
"not_connected": "Не подключено к daemon...",
"not_connected_to_daemon": "Не подключено к daemon",
@@ -1202,6 +1489,13 @@
"peers_upper": "УЗЛЫ",
"peers_version": "Версия",
"pending": "Ожидание",
"picker_dat_count": "Файлов кошелька в этой папке: %d",
"picker_dat_none": "В этой папке нет файлов кошелька",
"picker_empty": "В этой папке нет подпапок или файлов кошелька.",
"picker_home": "Домашняя папка",
"picker_select": "Сканировать эту папку",
"picker_title": "Выберите папку для сканирования",
"picker_up": "На уровень вверх",
"pin_change_desc": "Измените PIN-код разблокировки. Вам понадобится текущий PIN-код и новый PIN-код.",
"pin_confirm_new_label": "Подтвердите новый PIN-код:",
"pin_current_label": "Текущий PIN-код:",
@@ -1287,6 +1581,8 @@
"ram_wallet_gb": "Кошелёк: %.1f ГБ",
"ram_wallet_mb": "Кошелёк: %.0f МБ",
"receive": "Получить",
"receive_not_for_payments": "Не для платежей",
"receive_use_shielded": "Использовать экранированный адрес",
"received": "получено",
"received_filter": "Получено",
"received_label": "Получено",
@@ -1301,6 +1597,7 @@
"reduce_motion": "Уменьшить анимацию",
"refresh": "Обновить",
"refresh_now": "Обновить сейчас",
"refresh_waits_for_sync": "Узел ещё синхронизируется. Данные кошелька обновятся после её завершения.",
"remove_favorite": "Удалить из избранного",
"repair_wallet": "Восстановить кошелёк",
"report_bug": "Сообщить об ошибке",
@@ -1315,12 +1612,12 @@
"request_select_address": "Выбрать адрес...",
"request_shielded_addrs": "-- Экранированные адреса --",
"request_title": "Запрос оплаты",
"request_transparent_addrs": "-- Прозрачные адреса --",
"request_uri_copied": "URI платежа скопирован в буфер обмена",
"rescan": "Пересканировать",
"rescan_bootstrapped_msg": "Ваш узел был инициализирован из бутстрапа, поэтому блоки ниже снимка отсутствуют на диске, и пересканирование с генезиса не удастся. Пересканируйте с высоты, входящей в ваш снимок, чтобы согласовать потраченный баланс кошелька. Ваш wallet.dat и данные блокчейна не удаляются.",
"rescan_detecting": "Проверка, какие блоки уже есть на диске вашего узла…",
"rescan_from_height": "Пересканировать с высоты блока:",
"rescan_probe_failed": "Не удалось проверить, какие блоки есть на диске вашего узла (он занят или истекло время ожидания запроса). Если узел был инициализирован из бутстрапа, отмените и повторите попытку через минуту: полный перезапуск с -rescan ниже на таком узле не удастся.",
"reset_to_defaults": "Сбросить настройки",
"restarting_after_encryption": "Перезапуск демона после шифрования...",
"restore_address": "Восстановить адрес",
@@ -1338,6 +1635,7 @@
"save_z_transactions": "Сохранять Z-tx в списке транзакций",
"sb_auth_failed": "Ошибка авторизации — проверьте rpcuser/rpcpassword",
"sb_block": "Блок: %d",
"sb_block_db_unreadable": "База данных блоков нечитаема — требуется пересборка",
"sb_building_witnesses": "Установка свидетелей",
"sb_building_witnesses_pct": "Перестроение свидетелей %.0f%%",
"sb_connecting_daemon": "Подключение к dragonxd...",
@@ -1353,6 +1651,7 @@
"sb_dragonxd_stopped": "dragonxd остановлен",
"sb_dragonxd_stopping": "Остановка dragonxd...",
"sb_extracting_sapling": "Извлечение параметров Sapling...",
"sb_finishing_repair": "Завершение восстановления кошелька — повторное сканирование…",
"sb_importing_keys": "Импорт ключей",
"sb_loading_config": "Загрузка конфигурации...",
"sb_mining_hs": "%.1f H/s",
@@ -1361,6 +1660,7 @@
"sb_net_khs": "Сеть: %.2f KH/s",
"sb_net_mhs": "Сеть: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf не найден",
"sb_node_busy": "Узел занят",
"sb_peers": "Пиры: %zu",
"sb_plaintext_remote_blocked": "Отправка учётных данных RPC открытым текстом на удалённый узел запрещена. Добавьте rpcallowplaintext=1 в DRAGONX.conf, чтобы разрешить, или включите TLS с помощью rpctls=1.",
"sb_rescanning": "Пересканирование",
@@ -1374,6 +1674,7 @@
"sb_waiting_config": "Ожидание конфигурации демона...",
"sb_waiting_daemon": "Ожидание dragonxd...",
"sb_waiting_daemon_err": "Ожидание dragonxd — %s",
"sb_wallet_needs_recovery": "Доступно восстановление кошелька",
"sb_warming_up": "Прогрев...",
"sb_witness_cache": "Перестроение свидетелей",
"scale_effects": "МАСШТАБ И ЭФФЕКТЫ",
@@ -1381,6 +1682,7 @@
"screenshot_sweep": "Запустить прогон скриншотов",
"screenshot_sweep_desc": "Перебирает каждую тему по всем вкладкам и сохраняет скриншот каждой в подпапки по вкладкам внутри папки screenshots в каталоге конфигурации (перезаписывая предыдущий проход). Выполняется несколько секунд.",
"screenshot_sweep_full": "Полный обход интерфейса",
"screenshot_sweep_refused": "Полный обход интерфейса отклонён; см. журнал отладки",
"search_icons": "Поиск значков...",
"search_placeholder": "Поиск...",
"sec_changing_passphrase": "Смена парольной фразы...",
@@ -1402,15 +1704,19 @@
"sec_mode_passphrase": " Парольная фраза",
"sec_not_connected_to_daemon": "Нет соединения с демоном",
"sec_not_connected_to_daemon_pin": "Нет соединения с демоном",
"sec_passphrase_changed_pin_stale": "Парольная фраза изменена; PIN устарел",
"sec_passphrase_changed_successfully": "Парольная фраза успешно изменена",
"sec_pin_changed_successfully": "PIN успешно изменён",
"sec_pin_removed": "PIN удалён",
"sec_pin_set_successfully": "PIN успешно задан",
"sec_pin_stale_msg": "PIN устарел. Используйте парольную фразу.",
"sec_pin_unreadable": "Файл PIN не читается. Используйте парольную фразу.",
"sec_restart_daemon_for_encryption": "Перезапустите демон, чтобы шифрование вступило в силу.",
"sec_too_many_attempts_wait": "Слишком много попыток. Подождите %.0f сек...",
"sec_total_elapsed_fmt": "Всего прошло: %dм %02dс",
"sec_unlock_button": "Разблокировать",
"sec_unlock_failed_prefix": "Ошибка разблокировки: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Разблокировка%s",
"sec_use_passphrase_instead": "Использовать парольную фразу",
"sec_use_pin_instead": "Использовать PIN",
@@ -1445,6 +1751,7 @@
"send_amount": "Сумма",
"send_amount_details": "ДЕТАЛИ СУММЫ",
"send_amount_upper": "СУММА",
"send_available_note": "доступно",
"send_cannot_send_now": "Сейчас отправить нельзя — проверьте соединение, синхронизацию и доступный баланс.",
"send_clear_fields": "Очистить все поля формы?",
"send_contacts_button": "Выбрать из контактов",
@@ -1462,6 +1769,8 @@
"send_from_this_address": "Отправить с этого адреса",
"send_go_to_receive": "Перейти к получению",
"send_keep": "Сохранить",
"send_mining_addr_blocked": "Это адрес для майнинга (прозрачный). В DragonX такие адреса могут получать только награды за майнинг — сеть отклоняет платежи на них. Попросите у получателя экранированный адрес (начинается с zs).",
"send_mining_addr_short": "Адрес для майнинга — не принимает платежи",
"send_network_fee": "СЕТЕВАЯ КОМИССИЯ",
"send_no_balance": "Нет баланса",
"send_no_recent": "Нет недавних отправлений",
@@ -1489,7 +1798,6 @@
"send_txid_copied": "TxID скопирован в буфер обмена",
"send_txid_label": "TxID: %s",
"send_valid_shielded": "Действительный экранированный адрес",
"send_valid_transparent": "Действительный прозрачный адрес",
"send_wallet_empty": "Ваш кошелёк пуст",
"send_yes_clear": "Да, очистить",
"sender_balance": "Отправитель: %.8f → %.8f DRGX",
@@ -1552,6 +1860,7 @@
"settings_open_log_folder": "Открыть папку журналов",
"settings_other": "Прочее",
"settings_pin_active": "PIN",
"settings_pin_out_of_date": "PIN устарел",
"settings_privacy": "Конфиденциальность",
"settings_quick_unlock_pin": "Быстрый PIN-код разблокировки",
"settings_reduce_transparency": "Уменьшить прозрачность",
@@ -1617,11 +1926,24 @@
"shielded_type": "Экранированный",
"shielding_notice": "Примечание: это переведёт средства с прозрачного (T) адреса на приватный (Z) адрес.",
"show": "Показать",
"show_earnings": "Показать доход",
"show_hidden": "Показать скрытые (%d)",
"show_qr": "Показать QR",
"show_qr_code": "Показать QR-код",
"showing_transactions": "Показано %d–%d из %d транзакций (всего: %zu)",
"showing_x_of_y": "Показано %d из %d адресов",
"shutdown_kill_confirm_msg": "Узел давно не проявляет активности процессора и диска и, возможно, завис.\nПри принудительном завершении всё несохранённое будет потеряно; следующий запуск может занять гораздо больше времени.",
"shutdown_kill_confirm_title": "Принудительно завершить узел?",
"shutdown_kill_confirm_yes": "Завершить узел",
"shutdown_kill_node": "Принудительно завершить узел (небезопасно)",
"shutdown_kill_node_tip": "Немедленно завершает узел. Всё несохранённое будет потеряно, а при следующем запуске он заново обработает блоки с момента последнего сохранения.",
"shutdown_leave_running": "Закрыть кошелёк — оставить узел работать",
"shutdown_leave_running_tip": "Закрывает кошелёк сейчас. Узел продолжит работать сам и завершит текущую операцию; ничего не теряется.",
"shutdown_stop_asking": "Отправка узлу команды остановки…",
"shutdown_stop_cpu_suffix": " · ЦП %d%%",
"shutdown_stop_delivered": "Узел завершает работу и сохраняет своё состояние",
"shutdown_stop_idle": "Нет активности процессора и диска уже %d мин — возможно, узел завис",
"shutdown_stop_queued": "Узел получил команду остановки, но занят — он остановится, как только сможет",
"simple_background": "Простой фон",
"slider_off": "Выкл",
"sort_amount_high": "Наибольшая сумма",
@@ -1629,14 +1951,29 @@
"sort_date_newest": "Сначала новые",
"sort_date_oldest": "Сначала старые",
"start_mining": "Начать майнинг",
"startup_files_preparing": "Подготовка файлов кошелька... %d%%",
"startup_files_verifying": "Проверка файлов кошелька...",
"status": "Статус",
"stop_external": "Остановить внешний daemon",
"stop_mining": "Остановить майнинг",
"stopconfirm_busy_body": "Уже %s он слишком занят, чтобы отвечать кошельку, вероятно, из-за долгой операции, например перестройки свидетелей. Если остановить его сейчас, при следующем запуске эту работу, возможно, придётся выполнить заново.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
"stopconfirm_busy_title": "Узел занят",
"stopconfirm_rebuild_body": "Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
"stopconfirm_rebuild_title": "Узел перестраивает свидетелей заметок",
"stopconfirm_stop_wait": "Остановить узел и подождать",
"stopconfirm_walletop_body": "Узел сканирует цепочку для импорта ключа или повторного сканирования. Если остановить его сейчас, это сканирование закончится раньше, а средства, до которых оно ещё не дошло, появятся только после Настройки → Пересканировать.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит сканирование сам.",
"stopconfirm_walletop_title": "Выполняется импорт ключа или повторное сканирование",
"stratum_allowip_hint": "Разрешить майнерам подключаться с IP или CIDR (пусто = только этот компьютер)",
"stratum_expose_warn": "Открывает порт для майнинга в разрешённую вами сеть — используйте только в доверенной локальной сети.",
"stratum_host": "Разместить майнинг-пул (stratum)",
"stratum_host_hint": "Майнеры подключаются к этому компьютеру на порт 22769 (RPC-порт + 1000) с помощью stratum-майнера RandomX. Для применения перезапустите демон.",
"stratum_host_section": "РАЗМЕЩЕНИЕ МАЙНИНГ-ПУЛА",
"submitting_transaction": "Отправка транзакции...",
"success": "Успешно",
"summary": "Итоги",
"sweep_button": "Перевести",
"sweep_caveat": "Импортирует ключ, чтобы подписать одну транзакцию, переводящую все его средства на ваш адрес. Ключ остаётся в кошельке с нулевым балансом.",
"sweep_current_theme_only": "Только текущая тема",
"sweep_dest_label": "Отправить переведённые средства на",
"sweep_dest_new": "Новый экранированный адрес (рекомендуется)",
"sweep_done": "Готово — средства переведены на ваш адрес.",
@@ -1672,7 +2009,6 @@
"switch_stopnode_title": "Остановить запущенный узел?",
"switch_stopnode_warn": "Уже запущен узел, который не был запущен этим кошельком.",
"syncing": "Синхронизация...",
"t_address": "T-адрес",
"t_addresses": "T-адреса",
"test_connection": "Тест",
"theme": "Тема",
@@ -1694,6 +2030,7 @@
"tools": "УТИЛИТЫ",
"tools_actions": "Инструменты и действия...",
"tools_actions_hdr": "ИНСТРУМЕНТЫ И ДЕЙСТВИЯ",
"tor_never_applied_notice": "Опция «Использовать Tor» никогда не работала: ваши соединения НЕ направлялись через Tor. Она отключена. Альтернативы описаны в её подсказке в Настройках.",
"total": "Итого",
"total_balance_label": "Общий баланс",
"transaction_id": "ID ТРАНЗАКЦИИ",
@@ -1707,8 +2044,6 @@
"transfer_sent": "Перевод отправлен",
"transfer_sent_desc": "Ваш перевод отправлен в сеть.",
"transfer_to": "Перевести на:",
"transparent": "Прозрачный",
"transparent_address": "Прозрачный адрес",
"try_again": "Повторить",
"tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков",
"tt_address_book": "Управление сохранёнными адресами для быстрой отправки",
@@ -1740,10 +2075,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": "Сколько ждать перед началом майнинга",
@@ -1752,6 +2093,7 @@
"tt_keep_daemon": "Демон будет остановлен при запуске мастера настройки",
"tt_language": "Язык интерфейса кошелька",
"tt_layout_hotkey": "Горячая клавиша: стрелки влево/вправо для переключения раскладок Баланса",
"tt_lite_confirm_pass": "Введите новую парольную фразу ещё раз; она должна совпадать и содержать не менее 8 символов",
"tt_lite_copy": "Скопировать показанный секрет в буфер обмена",
"tt_lite_decrypt_pass": "Введите пароль, чтобы снять шифрование с кошелька",
"tt_lite_encrypt": "Зашифровать кошелёк паролем выше; он блокируется сразу же и требует пароль для разблокировки",
@@ -1780,11 +2122,16 @@
"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 (настройки, темы, логи) в файловом менеджере",
"tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна",
"tt_open_dir": "Нажмите, чтобы открыть в проводнике",
"tt_open_log_folder": "Открывает папку с журналами отладки и сбоев",
"tt_pin_out_of_date": "Парольная фраза кошелька изменилась после установки этого PIN, поэтому PIN больше не разблокирует кошелёк. Удалите PIN и установите его заново.",
"tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности",
"tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты",
"tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки",
@@ -1814,14 +2161,20 @@
"tt_simple_bg": "Использовать простой градиент для фона\\nГорячая клавиша: Ctrl+Up",
"tt_simple_bg_alt": "Использовать градиентную версию фонового изображения темы\\nГорячая клавиша: Ctrl+Up",
"tt_stop_external": "Применяется при подключении к демону,\\nзапущенному вне этого кошелька",
"tt_stratum_host": "Запускает на этом узле сервер stratum-пула RandomX, чтобы другие майнеры RandomX могли подключаться к этому компьютеру. Требуется узел версии v1.3.0+ и перезапуск демона для применения.",
"tt_sweep_current_theme_only": "Применить только к активной теме вместо перебора всех тем",
"tt_test_conn": "Проверить RPC-подключение к демону",
"tt_theme_effects": "Мерцание, свечение, циклическая смена оттенка по теме",
"tt_theme_hotkey": "Горячая клавиша: Ctrl+Влево/Вправо для переключения тем",
"tt_tor": "Маршрутизировать подключения демона через сеть Tor для анонимности",
"tt_tor_unavailable": "Пока недоступно: этот кошелёк не направляет никакой трафик через Tor. Только для узла можно добавить proxy=127.0.0.1:9050 в DRAGONX.conf и перезапустить узел. Tor должен быть запущен, иначе узел не найдёт других узлов. Запросы цен, обновлений, bootstrap и пула по-прежнему идут напрямую; для них используйте общесистемный VPN/Tor.",
"tt_tor_unavailable_lite": "Пока недоступно: этот кошелёк не направляет никакой трафик через Tor. Используйте общесистемный VPN/Tor.",
"tt_tx_url": "Базовый URL для просмотра транзакций в обозревателе блоков",
"tt_ui_opacity": "Непрозрачность карточек и боковой панели (100% = полностью непрозрачно, ниже = прозрачнее)",
"tt_validate": "Проверить, действителен ли адрес DragonX",
"tt_verbose": "Записывать подробную диагностику подключений,\\nсостояние демона и информацию о владельце порта\\nна вкладке Консоль",
"tt_view_transactions": "Показать транзакции с этим адресом на вкладке «История»",
"tt_wallet_size_warn": "Защищённые кошельки растут за счёт данных witness каждой записи — объединение множества записей в один адрес уменьшает его размер. Сначала сделайте резервную копию.",
"tt_wallets_button": "Показать файлы кошельков и переключаться между ними",
"tt_website": "Открыть сайт DragonX",
"tt_window_opacity": "Непрозрачность фона (ниже = рабочий стол виден сквозь окно)",
@@ -1838,6 +2191,7 @@
"tx_loading_queued": "Обновление транзакций поставлено в очередь",
"tx_loading_refreshing_cached": "Обновление истории кошелька (в кеше: %d)",
"tx_loading_scanning_shielded": "Сканирование экранированной истории (%d адресов)",
"tx_loading_waits_for_sync": "История обновится, когда узел завершит синхронизацию",
"tx_mined": "ДОБЫТА",
"tx_progress_balances": "Обновление балансов",
"tx_progress_finalizing": "Завершение транзакции",
@@ -1869,6 +2223,7 @@
"validate_description": "Введите адрес DragonX, чтобы проверить его действительность и принадлежность к этому кошельку.",
"validate_invalid": "НЕДЕЙСТВИТЕЛЕН",
"validate_is_mine": "Этот кошелёк владеет этим адресом",
"validate_mining_type": "Адрес для майнинга (прозрачный)",
"validate_not_mine": "Не принадлежит этому кошельку",
"validate_ownership": "Принадлежность:",
"validate_results": "Результаты:",
@@ -1876,7 +2231,6 @@
"validate_shielded_type": "Экранированный (z-адрес)",
"validate_status": "Статус:",
"validate_title": "Проверить адрес",
"validate_transparent_type": "Прозрачный (t-адрес)",
"validate_type": "Тип:",
"validate_valid": "ДЕЙСТВИТЕЛЕН",
"validating": "Проверка...",
@@ -1886,10 +2240,71 @@
"view_all": "Показать всё",
"view_details": "Подробнее",
"view_on_explorer": "Посмотреть в обозревателе",
"view_transactions": "Показать транзакции",
"waiting_for_daemon": "Ожидание подключения к daemon...",
"wallet": "КОШЕЛЁК",
"wallet_degraded_notify": "Ваш кошелёк открыт в режиме с ограниченной функциональностью: существующие средства в безопасности и доступны для расходования, но создание новых адресов и защита средств отключены. Сделайте резервную копию сид-фразы и восстановите из неё кошелёк, чтобы полностью его исправить.",
"wallet_diagnostics_hdr": "ДИАГНОСТИКА",
"wallet_empty": "Ваш кошелёк пуст",
"wallet_empty_hint": "Перейдите к получению, чтобы получить свой адрес и начать получать средства.",
"wallet_options_hdr": "ПАРАМЕТРЫ",
"wallet_rebuild_failed": "Пересборка не дала исправный кошелёк, поэтому ничего не изменилось. Ваш кошелёк не затронут.",
"wallet_rebuild_install_failed": "Не удалось установить пересобранный кошелёк; текущий кошелёк оставлен без изменений.",
"wallet_rebuild_no_helper": "Помощник пересборки кошелька недоступен в этой сборке. Используйте Восстановление или пересоберите вручную.",
"wallet_rebuild_no_source": "Не удалось найти читаемый кошелёк для пересборки. Ничего не изменилось.",
"wallet_rebuild_ok": "База данных кошелька пересобрана — узел загружает её и повторно сканирует для определения вашего баланса.",
"wallet_rebuild_started": "Восстановление файла кошелька — пожалуйста, не закрывайте это окно.",
"wallet_recovered_body": "Ничего не удалено. Ваш исходный кошелёк по-прежнему сохранён на компьютере как резервная копия с датой, а каждый вариант ниже только копирует или переименовывает файлы — и никогда не стирает их. Ваши ключи никогда не создаются заново, только перечитываются.\n\nВ восстановленной копии, загруженной сейчас, может не хватать нескольких последних транзакций, поэтому баланс может выглядеть немного заниженным, пока вы не завершите процесс ниже и не пройдёт повторное сканирование.",
"wallet_recovered_dismiss": "Не сейчас — оставить восстановленную копию",
"wallet_recovered_dismiss_sub": "Файлы не изменяются. Вы можете открыть это снова в любой момент из строки состояния; ваш оригинал в любом случае остаётся в безопасной резервной копии.",
"wallet_recovered_notify": "Файл вашего кошелька потребовал восстановления — оригинал был безопасно сохранён в резервной копии. Откройте приложение, чтобы рассмотреть варианты.",
"wallet_recovered_open_folder": "Показать мне файлы",
"wallet_recovered_open_folder_sub": "Открывает папку данных кошелька, чтобы вы могли сами просмотреть файлы резервных копий — ничего не изменяется.",
"wallet_recovered_rebuild": "Восстановить автоматически (рекомендуется)",
"wallet_recovered_rebuild_sub": "Перечитывает каждую восстанавливаемую запись из вашего самого полного файла кошелька и записывает чистый новый файл, затем перезапускается. Самый тщательный вариант — ваш текущий файл в любом случае сохраняется как резервная копия с датой.",
"wallet_recovered_restore": "Восстановить исходный файл вместо этого",
"wallet_recovered_restore_sub": "Возвращает на место вашу самую большую нетронутую резервную копию, дословно, затем выполняет повторное сканирование — немного быстрее, но настолько полно, насколько был полон этот файл. Ваш текущий файл в любом случае сохраняется как резервная копия с датой.",
"wallet_recovered_safety": "Ваши монеты в безопасности.",
"wallet_recovered_title": "Файл вашего кошелька требует быстрого восстановления",
"wallet_recovered_warn": "При запуске приложение обнаружило, что файл вашего кошелька не прошёл проверку целостности — обычно это происходит после обновления приложения или некорректного завершения работы. Приложение уже защитило ваши данные: оно отложило старый файл и загрузило восстановленную копию, чтобы вы не остались без доступа.",
"wallet_recovery_decide_later": "Решить позже",
"wallet_recovery_decide_later_tip": "Закрывает это окно и оставляет загруженной текущую копию. Ничего не меняется, и вы можете выполнить восстановление в любое время — в строке состояния остаётся ссылка «Доступно восстановление кошелька».",
"wallet_recovery_details_label": "Показать технические подробности",
"wallet_recovery_done": "Готово",
"wallet_recovery_failure_body": "Восстановление было выполнено, но его результат не прошёл проверку (файл не удалось прочитать или в нём не оказалось адресов), поэтому он был автоматически отклонён, прежде чем что-либо заменить. Ваш кошелёк остался точно таким же, как до нажатия — на диске ничего не изменилось.",
"wallet_recovery_failure_title": "Восстановление не удалось",
"wallet_recovery_files_detail": "Ваш исходный кошелёк по-прежнему здесь — приложение переименовало его в резервную копию с датой (wallet.<числа>.bak) в папке данных и ничего не удалило. «Восстановить» считывает каждую запись из вашего самого полного кошелька в совершенно новый чистый файл. «Вернуть» копирует вашу самую большую резервную копию обратно точно в том виде, в каком она есть. Копия, загруженная прямо сейчас, тоже сохраняется как резервная копия.",
"wallet_recovery_files_label": "Что происходит с моими файлами?",
"wallet_recovery_notnow_short": "Не сейчас",
"wallet_recovery_other_options": "Другие варианты",
"wallet_recovery_rebuild_card": "Восстановить автоматически",
"wallet_recovery_rebuild_card_desc": "Считывает каждую восстанавливаемую запись в чистый файл, затем перезапускается. Самый тщательный вариант.",
"wallet_recovery_recommended": "РЕКОМЕНДУЕТСЯ",
"wallet_recovery_repair_go": "Восстановить",
"wallet_recovery_rescan_body": "Выполняется повторное сканирование блокчейна для восстановления вашего баланса и истории транзакций. Это может занять несколько минут — вам не нужно ничего делать, и, пожалуйста, не перезапускайте узел, пока идёт процесс.",
"wallet_recovery_rescan_elapsed": "Выполняется уже %s",
"wallet_recovery_rescan_size": "Восстановленный кошелёк сейчас занимает %s и всё ещё заполняется",
"wallet_recovery_rescan_slow": "Это занимает больше времени, чем обычно, что нормально для крупного кошелька. Оставить процесс работать безопасно — вы можете наблюдать за прогрессом в разделе Дополнительно ▸ Консоль.",
"wallet_recovery_rescan_title": "Завершение восстановления кошелька",
"wallet_recovery_restore_card": "Восстановить оригинал",
"wallet_recovery_restore_card_desc": "Возвращает на место вашу самую большую нетронутую резервную копию, дословно — настолько полную, насколько был полон этот файл.",
"wallet_recovery_restore_go": "Вернуть",
"wallet_recovery_success_body": "Приложение загрузило ваш восстановленный кошелёк и выполняет повторное сканирование для подсчёта баланса — это может занять несколько минут. Оно считывает каждую запись, какую может, но в редких случаях повреждённых файлов может восстановить чуть меньше — или иногда чуть больше — адресов, чем было раньше. По завершении повторного сканирования проверьте, что баланс и история выглядят правильно.",
"wallet_recovery_success_restore": "Ваша самая большая резервная копия возвращена на место и сейчас загружается, приложение выполняет повторное сканирование. Она восстановлена дословно, поэтому настолько полна, насколько был полон этот файл — проверьте баланс по завершении повторного сканирования.",
"wallet_recovery_try_other": "Попробовать другой вариант",
"wallet_recovery_whats_happened": "Что произошло?",
"wallet_recovery_working_label": "Выполняется",
"wallet_restore_bad_backup": "Файл резервной копии кошелька выглядит нечитаемым, поэтому он НЕ был восстановлен — ваш текущий кошелёк не изменён. Восстановите из собственной резервной копии.",
"wallet_restore_busy": "Узел занят перезапуском — повторите попытку через момент.",
"wallet_restore_copy_failed": "Не удалось установить резервную копию кошелька; ваш текущий кошелёк оставлен без изменений.",
"wallet_restore_move_failed": "Не удалось отложить текущий кошелёк — ничего не изменено.",
"wallet_restore_no_backup": "Не удалось найти wallet.<timestamp>.bak для восстановления. Ничего не изменено.",
"wallet_restore_no_restart": "Ваш исходный кошелёк восстановлен, но узел не перезапустился — запустите его из Настроек.",
"wallet_restore_ok": "Исходный кошелёк восстановлен. Узел сейчас его загружает.",
"wallet_restore_started": "Восстановление вашего исходного файла — пожалуйста, не закрывайте это окно.",
"wallet_restore_stop_failed": "Узел не остановился вовремя, поэтому ничего не изменено. Попробуйте снова.",
"wallet_size_consolidate": "Объединить заметки…",
"wallet_size_warn": "Файл этого кошелька велик. Объединение ваших заметок может сдержать дальнейший рост.",
"wallets_active": "Активен",
"wallets_add": "Добавить папку",
"wallets_add_folder": "Также сканировать другую папку на наличие файлов кошельков:",
@@ -1932,7 +2347,9 @@
"wallets_open_inplace_tt": "Открывает этот кошелёк на месте — по ссылке в каталоге данных (без копирования)",
"wallets_remove_folder": "Больше не сканировать эту папку",
"wallets_reveal": "Открыть папку",
"wallets_scan_folder": "Сканировать другую папку на наличие кошельков…",
"wallets_scanned_folders": "Просканированные папки:",
"wallets_scanning": "Поиск в папках…",
"wallets_sort_addresses": "Адреса",
"wallets_sort_asc": "По возрастанию (сначала старые / меньше / меньший)",
"wallets_sort_by": "Сортировка:",
@@ -2001,19 +2418,30 @@
"wiz_welcome_sub": "Несколько быстрых настроек — и ваш полный узел готов.",
"wiz_welcome_title": "Добро пожаловать в ObsidianDragon!",
"wizard_daemon_start_failed": "Не удалось запустить демон — будет повторено автоматически",
"xmrig_active_tt": "Текущий установленный (активный) майнер",
"xmrig_browse_releases": "Просмотреть все релизы…",
"xmrig_checking": "Проверка наличия последней версии майнера…",
"xmrig_current": "Текущая:",
"xmrig_downgrade_note": "Установка другой версии майнера заменяет текущую; сначала остановите майнинг, изменения вступят в силу при следующем запуске.",
"xmrig_download_install": "Скачать и установить",
"xmrig_downloading": "Загрузка…",
"xmrig_install_this": "Установить эту версию",
"xmrig_installed": "Установлено:",
"xmrig_installed_ok": "Майнер установлен",
"xmrig_installing": "Установка…",
"xmrig_latest": "Последняя:",
"xmrig_latest_badge": "последняя",
"xmrig_latest_tt": "Новейшая доступная версия",
"xmrig_loading_releases": "Загрузка релизов…",
"xmrig_no_notes": "Для этой версии нет заметок о выпуске.",
"xmrig_none": "нет",
"xmrig_prerelease_tt": "Предварительная / тестовая сборка",
"xmrig_reinstall": "Переустановить",
"xmrig_released": "Выпущено:",
"xmrig_releases": "релизы xmrig",
"xmrig_status_current": "Эта версия установлена",
"xmrig_status_latest": "Последняя версия",
"xmrig_status_older": "Старее вашей установленной версии",
"xmrig_stop_mining_first": "Остановите майнинг перед обновлением майнера.",
"xmrig_unavailable_body": "Для этой платформы нет доступной сборки майнера.",
"xmrig_unavailable_title": "Обновления майнера недоступны",
+455 -25
View File
@@ -19,11 +19,13 @@
"about_license_text": "本软件根据 GNU 通用公共许可证 v3 (GPLv3) 发布。您可以根据许可证条款自由使用、修改和分发本软件。",
"about_peers_count": "%zu 个节点",
"about_release": "发布版",
"about_source": "源代码",
"about_title": "关于 ObsidianDragon",
"about_version": "版本:",
"about_website": "网站",
"acrylic": "亚克力",
"add": "添加",
"addr_type_mining": "挖矿",
"address": "地址",
"address_book_add": "添加地址",
"address_book_add_new": "添加新地址",
@@ -67,6 +69,7 @@
"appx_blockchain_maintenance_in_progress": "已有区块链维护操作正在进行中。",
"appx_blockchain_rescan_complete": "区块链重新扫描完成",
"appx_bootstrap_complete_reconciling": "引导数据完成——正在将你的钱包与新的链数据进行核对。",
"appx_bootstrap_reconcile_probe_failed": "无法将你的钱包与引导数据的链数据进行核对:节点一直太忙,无法检查其已有的区块。待节点同步完成后,请在设置中运行重新扫描。",
"appx_cancel": "取消",
"appx_cleaning_up": "正在清理…",
"appx_confirm_your_backup": "确认你的备份",
@@ -77,11 +80,15 @@
"appx_creating_your_wallet": "正在创建你的钱包…",
"appx_daemon_error": "守护进程错误",
"appx_daemon_reinstall_in_progress": "守护进程重新安装已在进行中。",
"appx_daemon_reinstall_node_busy": "节点仍在运行,因此未重新安装守护进程。请在它停止后重试。",
"appx_disconnecting": "正在断开连接…",
"appx_done": "完成",
"appx_dragonxd_output": "dragonxd 输出",
"appx_encrypting_wallet": "正在加密钱包…",
"appx_fullnode_lifecycle_unavailable_lite": "全节点生命周期操作在轻量版中不可用",
"appx_import_cancelled": "已取消:节点可能仍在扫描。如果该密钥的资金没有显示,请运行“设置 → 重新扫描”。",
"appx_import_interrupted": "导入被中断,该密钥可能已在钱包中。如果其资金没有显示,请运行“设置 → 重新扫描”。",
"appx_import_refused": "节点拒绝了请求,因此未导入该密钥。",
"appx_installing_bundled_daemon": "正在安装捆绑守护进程——节点将停止、更新并重启…",
"appx_invalid_payment_uri_prefix": "无效的支付 URI:",
"appx_ive_written_it_down": "我已抄写完毕",
@@ -98,7 +105,8 @@
"appx_no_bundled_daemon_to_install": "此版本没有可安装的捆绑守护进程",
"appx_no_embedded_daemon_to_install": "此版本没有可安装的内嵌守护进程",
"appx_node_busy_restarting": "节点正忙于重启——请稍后再试。",
"appx_node_rebuilding_witness_cache": "节点正在重建其见证缓存",
"appx_node_busy_try_later": "节点正忙于完成一项耗时任务(例如重建票据见证)。请在完成后重试;现在停止它可能导致这项工作从头开始。",
"appx_node_not_stopped": "节点未能及时停止,因此未做任何更改。请几分钟后重试。",
"appx_not_next_word": " — 这不是下一个词",
"appx_payment_request_loaded": "支付请求已加载",
"appx_pool_miner_connected_and_hashing": "矿池矿工已连接并正在计算哈希。",
@@ -119,11 +127,7 @@
"appx_simple_background_enabled": "已启用简约背景",
"appx_skip": "跳过",
"appx_skip_anyway": "仍要跳过",
"appx_still_status_prefix": "仍处于“",
"appx_still_status_suffix": "”——现在强制退出可能损坏链数据。",
"appx_stop_anyway_and_quit": "仍要停止并退出",
"appx_stopping_daemon_deleting_blockchain": "正在停止守护进程并删除区块链数据…",
"appx_stopping_node_discards_rebuild": "现在停止节点会丢弃正在进行的重建,下次打开钱包时将重新开始(需要几分钟)。你也可以让节点继续运行。",
"appx_stopping_pool_miner": "正在停止矿池矿工…",
"appx_syncing_pct_block_n_of_n": "正在同步 %.1f%% —— 区块 %d / %d",
"appx_tap_words_in_order": "按正确顺序点击这些词,以确认你已保存。",
@@ -133,8 +137,14 @@
"appx_use_settings_restart_daemon_hint": "请使用 设置 > 重启守护进程 重试",
"appx_waiting_for_daemon_to_encrypt_wallet": "正在等待守护进程加密钱包…",
"appx_wallet_created_and_backed_up": "钱包已创建并完成备份。",
"appx_wallet_op_node_busy": "节点正忙,暂时无法响应。请在完成后重新导入或重新扫描。",
"appx_wallet_op_running": "密钥导入或重新扫描仍在进行中。请在完成后重试。",
"appx_wallet_op_shutting_down": "钱包正在关闭。",
"appx_wallet_open_failed_prefix": "钱包打开失败:",
"auto_shield": "自动屏蔽挖矿",
"autoshield_by_node": "您的节点会自动为您屏蔽挖出的 coinbase。",
"autoshield_off_backup_seed": "挖出的 coinbase 尚未自动屏蔽。请备份您的助记词(设置 → 备份与数据),以便节点可以安全启用此功能。",
"autoshield_off_generic": "您的节点目前未自动屏蔽挖出的 coinbase。",
"av_intro": "挖矿软件经常被标记为潜在有害程序。请按照以下步骤启用矿池挖矿:",
"av_open_security": "打开 Windows 安全中心",
"av_step1": "第 1 步:添加排除项",
@@ -170,21 +180,20 @@
"balance_history_collecting": "余额历史——正在收集数据…",
"balance_layout": "余额布局",
"balance_layout_switched": "布局:%s",
"balance_mining_fmt": "挖矿奖励:%.8f",
"balance_mining_rate": "挖矿中 %s",
"balance_shielded_fmt": "屏蔽:%.8f",
"balance_syncing_pct": "同步中 %.1f%%",
"balance_transparent_fmt": "透明:%.8f",
"baltab_market": "市价",
"baltab_market_price_4dp": "市价:$%.4f",
"baltab_market_price_8dp": "市价:$%.8f",
"baltab_mining_addresses_count": "%d 个挖矿地址",
"baltab_mining_amount": "挖矿奖励 %.8f",
"baltab_pct_change_24h": "%s%.1f%% 24小时",
"baltab_pct_of_total_zaddr": "占总额 %.0f%% · %d 个 Z-addr",
"baltab_shielded": "隐蔽",
"baltab_shielded_amount": "隐蔽 %.8f",
"baltab_t_addresses_count": "%d 个 T-address",
"baltab_total_balance": "总余额",
"baltab_transparent": "透明",
"baltab_transparent_amount": "透明 %.8f",
"ban": "封禁",
"banned_peers": "已封禁节点",
"block": "区块",
@@ -192,6 +201,12 @@
"block_click_copy": "点击复制",
"block_click_next": "点击查看下一个区块",
"block_click_prev": "点击查看上一个区块",
"block_db_reindex_body": "这通常发生在守护进程更新改变了磁盘格式,或区块索引已损坏之后。您的钱包和币是安全的——节点只是无法加载链,因此余额显示为零。\n\n重建会重新读取您现有的区块文件,可能需要一段时间(同时也会重新扫描您的钱包)。不会下载任何数据。",
"block_db_reindex_confirm": "重建区块数据库",
"block_db_reindex_notify": "节点无法读取其区块数据库(通常发生在守护进程更新之后)。重建它以恢复您的余额——请查看提示,或前往 设置 › 节点。",
"block_db_reindex_started": "正在根据您的区块文件重建区块数据库——这可能需要一段时间。",
"block_db_reindex_title": "重建区块数据库?",
"block_db_reindex_warn": "节点无法读取其区块数据库。",
"block_explorer": "区块浏览器",
"block_get_info": "获取区块信息",
"block_hash": "区块哈希:",
@@ -228,6 +243,7 @@
"change_pass_confirm": "确认新密码:",
"change_pass_current": "当前密码短语:",
"change_pass_new": "新密码短语:",
"change_pass_pin_note": "此钱包设有快速解锁 PIN。更改后它将过时:请在设置中移除 PIN 并重新设置。",
"change_pass_title": "更改密码短语",
"characters": "字符",
"chat": "聊天",
@@ -239,6 +255,10 @@
"chat_accent_theme": "主题",
"chat_add_contact": "添加联系人",
"chat_awaiting_key": "等待回复",
"chat_blocked_desc": "被屏蔽的会话会被移除,其消息(无论新旧)都会被丢弃,直至您取消屏蔽。取消屏蔽会从链上重新导入该会话。",
"chat_blocked_manage": "已屏蔽",
"chat_blocked_title": "已屏蔽的会话",
"chat_blocked_toast": "会话已删除并屏蔽",
"chat_bubble_minimal": "极简",
"chat_bubble_rounded": "圆角",
"chat_bubble_square": "方形",
@@ -251,6 +271,16 @@
"chat_contact_added": "已添加联系人——可在联系人中重命名",
"chat_contact_request": "联系人请求",
"chat_copy_address_tip": "点击复制地址",
"chat_delete": "删除会话",
"chat_delete_block": "删除并屏蔽",
"chat_delete_body_prefix": "删除与以下用户会话的本地副本:",
"chat_delete_body_suffix": "?",
"chat_delete_confirm": "删除",
"chat_delete_failed": "无法删除会话——未做任何更改。",
"chat_delete_local_note": "此操作仅影响此设备——消息仍保留在区块链上,对方也仍保有自己的副本。",
"chat_delete_revive_note": "“删除”会清除此设备上的记录。如果对方再次给您发消息,会话会重新出现。",
"chat_delete_title": "删除会话?",
"chat_deleted_toast": "会话已删除",
"chat_density_comfortable": "宽松",
"chat_density_compact": "紧凑",
"chat_emoji_color": "彩色",
@@ -274,6 +304,7 @@
"chat_new_button": "新聊天",
"chat_new_message": "消息",
"chat_new_message_toast": "新的加密聊天消息",
"chat_new_needs_zaddr": "聊天需要隐私(z)地址——透明(t)地址无法接收加密消息。",
"chat_new_send": "发送请求",
"chat_new_title": "新聊天",
"chat_new_zaddr": "收款方 z 地址",
@@ -319,6 +350,8 @@
"chat_ts_24h": "24小时",
"chat_ts_global": "跟随全局",
"chat_ts_global_short": "全局",
"chat_unblock": "取消屏蔽",
"chat_unblocked_toast": "会话已取消屏蔽",
"chat_unhide": "取消隐藏",
"chat_unmute": "取消静音",
"chat_verify_key": "身份密钥 — 对比以验证",
@@ -371,6 +404,7 @@
"console": "控制台",
"console_accents": "颜色强调",
"console_app": "应用",
"console_auto_focus": "打开时聚焦输入框",
"console_auto_scroll": "自动滚动",
"console_available_commands": "可用命令:",
"console_backend_reference": "后端命令参考",
@@ -465,11 +499,15 @@
"console_tab_completion": "Tab 补全",
"console_text_colors": "文本颜色",
"console_toggle_accents": "切换行颜色强调",
"console_toggle_auto_focus": "打开控制台标签页时将光标置于命令框中",
"console_toggle_text_color": "切换行文本颜色",
"console_type_help": "输入 'help' 查看可用命令",
"console_welcome": "欢迎使用 ObsidianDragon 控制台",
"console_zoom_in": "放大",
"console_zoom_out": "缩小",
"consolidate_desc": "将多个小额输入合并为单个隐私票据。票据越少,钱包文件越小,隐私性也越好。",
"consolidate_funds_btn": "合并",
"consolidate_title": "合并资金",
"contact_avatar": "头像",
"contact_avatar_bad_image": "无法加载该图片。",
"contact_avatar_badge": "标记",
@@ -481,7 +519,7 @@
"contact_avatar_image_hint": "图片会复制到应用中,即使原文件移动也能保持可用。",
"contact_avatar_remove": "移除",
"contact_avatar_shielded": "隐蔽",
"contact_avatar_transparent": "透明",
"contact_chip_mining": "仅限挖矿",
"contact_global": "在每个钱包中显示(全局联系人)",
"contact_global_badge_tt": "全局联系人——在每个钱包中可见",
"contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。",
@@ -515,9 +553,13 @@
"daemon_maintenance_label": "维护",
"daemon_none_bundled": "此版本未内置",
"daemon_not_installed": "未安装",
"daemon_status_diff": "版本不一致",
"daemon_status_differ": "已安装的程序文件与内置版本不同。",
"daemon_status_match": "已安装的程序与内置版本一致。",
"daemon_status_missing": "未安装守护进程——请安装内置版本。",
"daemon_status_none": "未安装",
"daemon_status_ok": "已是最新",
"daemon_update_active_tt": "当前已安装(正在使用)的节点",
"daemon_update_available": "有新的节点版本可用",
"daemon_update_body": "此钱包版本内置的 DragonX 节点比当前已安装的更新。更新会替换已安装的 dragonxd(以及 dragonx-cli/dragonx-tx),然后停止并重启节点以使新版本生效。推荐更新——更新的节点可能增加旧版本所缺少的功能(例如助记词支持)。",
"daemon_update_browse": "浏览所有版本…",
@@ -527,19 +569,28 @@
"daemon_update_download_install": "下载并安装",
"daemon_update_downloading": "正在下载…",
"daemon_update_failed": "更新失败",
"daemon_update_install_this": "安装此版本",
"daemon_update_installed": "已安装:",
"daemon_update_installed_ok": "节点已安装",
"daemon_update_installing": "正在安装…",
"daemon_update_keep": "保持当前版本",
"daemon_update_later": "稍后",
"daemon_update_latest": "最新:",
"daemon_update_latest_badge": "最新",
"daemon_update_latest_tt": "可用的最新版本",
"daemon_update_loading": "正在加载版本…",
"daemon_update_no_notes": "此版本没有发行说明。",
"daemon_update_now": "立即更新",
"daemon_update_prerelease_tt": "预发布 / 测试版本",
"daemon_update_prompt_title": "是否更新节点守护进程?",
"daemon_update_reinstall": "重新安装",
"daemon_update_released": "发布时间:",
"daemon_update_restart_note": "重启守护进程以开始运行新版本。",
"daemon_update_restart_now": "立即重启守护进程",
"daemon_update_safe": "您的钱包、密钥和区块链数据不会被改动——仅替换守护进程的程序文件。如果您是有意运行自定义节点,请选择“保持当前版本”。",
"daemon_update_status_current": "此版本已安装",
"daemon_update_status_latest": "最新版本",
"daemon_update_status_older": "早于您已安装的版本",
"daemon_update_title": "更新节点守护进程?",
"daemon_update_unavailable_body": "此平台没有可用的节点版本。",
"daemon_update_unavailable_title": "节点更新不可用",
@@ -555,6 +606,13 @@
"data_stale_tooltip": "余额可能已过时 — 钱包最近未收到更新。请检查您的节点连接。",
"date": "日期",
"date_label": "日期:",
"debug_gate_confirm": "显示调试选项",
"debug_gate_incorrect": "PIN 或密码不正确。",
"debug_gate_pass_prompt": "请输入您的钱包密码以继续:",
"debug_gate_pin_prompt": "请输入您的解锁 PIN 以继续:",
"debug_gate_title": "显示调试选项?",
"debug_gate_verifying": "验证中…",
"debug_gate_warning": "这些是高级诊断和节点调优选项,可能暴露敏感信息并改变节点的运行方式。仅在您了解自己在做什么的情况下继续。",
"debug_logging": "调试日志",
"decrypt_desc": "钱包将被导出,守护进程将使用全新的未加密钱包重新启动,并重新导入所有密钥。根据钱包大小,这可能需要几分钟。",
"decrypt_error_title": "解密失败",
@@ -577,9 +635,15 @@
"disconnected": "已断开",
"dismiss": "关闭",
"display": "显示",
"done": "完成",
"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": "打开钱包管理器",
@@ -630,6 +694,7 @@
"explorer_tx_outputs": "输出",
"explorer_tx_size": "大小",
"explorer_txid": "TxID:",
"explorer_urls_hdr": "网址",
"export": "导出",
"export_csv": "导出 CSV",
"export_keys_btn": "导出密钥",
@@ -655,9 +720,155 @@
"export_tx_success": "交易导出成功",
"export_tx_title": "导出交易到 CSV",
"export_viewing_key": "导出查看密钥",
"failed_create_mining_address": "创建挖矿地址失败",
"failed_create_shielded": "无法创建屏蔽地址",
"failed_create_transparent": "无法创建透明地址",
"faq": "常见问题",
"faq_d_mgmt_1_a": "内置节点会随钱包自动启动,并在适当时机停止,因此通常无需手动管理。高级控制选项(包括重启守护进程)位于 设置 > 节点与安全。",
"faq_d_mgmt_1_q": "如何启动或停止节点?",
"faq_d_mgmt_2_a": "如果您将钱包连接到自己启动的节点,此选项决定关闭钱包时是否也关闭该节点。如果希望关闭钱包后节点继续运行,请保持关闭此选项。",
"faq_d_mgmt_2_q": "“退出时关闭外部守护进程”是做什么的?",
"faq_d_mgmt_3_a": "节点和钱包数据存储在用户配置文件下的 DragonX 数据目录中(例如 Windows 上的 AppData,或 Linux/macOS 上的主文件夹)。区块链占用的空间最大,请为其增长预留足够的磁盘空间。",
"faq_d_mgmt_3_q": "区块链和钱包数据存储在哪里?",
"faq_d_mgmt_title": "节点管理",
"faq_d_mine_1_a": "挖矿标签页可让您使用 CPU 挖矿。DragonX 使用 RandomX 算法,专为普通处理器设计。设置线程数并开始——奖励将累积到您的钱包中。",
"faq_d_mine_1_q": "如何挖 DragonX?",
"faq_d_mine_2_a": "独立挖矿的奖励直接归您所有,但只有在您挖到区块时才会获得,除非您有很大算力,否则并不频繁。矿池挖矿与他人共享算力,可获得较小但更稳定的收益。挖矿标签页同时支持这两种方式。",
"faq_d_mine_2_q": "独立挖矿还是矿池挖矿——我该选哪个?",
"faq_d_mine_3_a": "挖矿标签页的矿池部分有一个“更新矿工…”按钮,可下载、验证并安装最新的优化版 DragonX 矿工程序。与节点更新程序一样,每次下载在安装前都会进行校验和与签名验证。",
"faq_d_mine_3_q": "如何更新矿工程序?",
"faq_d_mine_4_a": "当前节点可以运行内置的 stratum 服务器,让其他矿工连接到您的机器。在“设置 > 节点与安全”下有相应的开关。默认情况下它只监听本地连接;要向其他计算机开放,需要您主动配置。",
"faq_d_mine_4_q": "我可以搭建矿池吗?",
"faq_d_mine_title": "挖矿与矿池",
"faq_d_net_1_a": "节点启动后需要一点时间来寻找对等节点,因此短暂出现“0 个对等节点”是正常现象。如果持续存在,请检查您的网络连接以及防火墙是否阻挡了节点。节点会通过内置种子自动寻找对等节点。",
"faq_d_net_1_q": "钱包显示没有对等节点或连接——出了什么问题?",
"faq_d_net_2_a": "是的——DragonX 节点之间通过 TLS 通信,因此对等节点连接是加密的。钱包与自己的节点之间也通过安全的本地通道通信。",
"faq_d_net_2_q": "我与网络的连接是加密的吗?",
"faq_d_net_title": "对等节点与网络",
"faq_d_node_1_a": "守护进程 dragonxd 是 DragonX 的全节点。它下载并验证整条区块链,向网络中继交易,并响应钱包的查询。运行自己的节点意味着您无需信任任何其他服务器。",
"faq_d_node_1_q": "什么是守护进程(dragonxd)?",
"faq_d_node_2_a": "默认情况下,钱包会启动并管理自己内置的节点(内嵌节点)——您无需做任何操作。如果您已经自行运行了 dragonxd,钱包可以改为连接到该外部节点。钱包会检测当前所处的情况并相应地运作。",
"faq_d_node_2_q": "内嵌节点与外部节点有什么区别?",
"faq_d_node_3_a": "全节点独立验证区块链的每一条规则,因此您的钱包信任的是数学运算,而非第三方。这也增强了网络的健壮性。代价是磁盘空间和一次初始同步。",
"faq_d_node_3_q": "为什么要运行全节点?",
"faq_d_node_title": "全节点",
"faq_d_perf_1_a": "区块链是占用空间最大的部分,且会随时间增长,因此请预留几个 GB 的空闲空间。节点使用数据库缓存来提升速度;内置默认值已针对常见机器进行了调优。高级用户可以通过编辑配置文件(DRAGONX.conf)中的 dbcache 来调整节点的数据库缓存。",
"faq_d_perf_1_q": "节点占用多少磁盘空间?可以调整内存吗?",
"faq_d_perf_2_a": "启用引导包下载(在设置向导中提供),可获取一份最新的、已验证的链数据副本,而无需从头验证每一个区块。使用高速磁盘(SSD)也有很大帮助。",
"faq_d_perf_2_q": "如何加快初始同步速度?",
"faq_d_perf_title": "存储与性能",
"faq_d_sync_1_a": "启动时,节点会先加载并验证区块,然后才能为钱包提供服务。“正在处理区块 / 正在应用区块以构建当前链状态”界面就是这个预热过程。这是正常现象;节点完成并报告其高度后,钱包即可使用。",
"faq_d_sync_1_q": "启动时的“正在处理区块”是什么意思?",
"faq_d_sync_2_a": "对于您的隐私资金,节点会保留加密“见证数据”,让您能够进行隐私花费。在某些重启或更新之后,节点会通过重新扫描最近的区块来重建这个缓存。在交易较多的钱包上,这可能需要几分钟并占用额外内存。\n\n这并非卡死——请让它完成。中途强制退出只会导致下次重新开始重建。",
"faq_d_sync_2_q": "“正在重建见证缓存”是什么意思?",
"faq_d_sync_3_a": "接近链顶时,扫描钱包的工作(检查余额和票据)会与节点争用同一个内部锁,这可能会拖慢大钱包连接最后几个区块的速度。较新版本会限制此类扫描,以便节点能够赶上进度。停留在较轻量的标签页(或耐心等待)可以让它更快完成。",
"faq_d_sync_3_q": "为什么快追上进度时同步反而变慢了?",
"faq_d_sync_4_a": "首次同步会下载并验证整条链,根据您的网络和磁盘情况可能需要一段时间。在设置向导中启用引导包下载可以大幅加快速度,它会获取一份最新的、已验证的链数据副本。",
"faq_d_sync_4_q": "初始同步需要多长时间?",
"faq_d_sync_title": "同步与区块链",
"faq_d_trbl_1_a": "通常不是。在大钱包上,节点重建隐私见证数据或应用区块可能需要几分钟,此期间它看起来像是暂停了,响应也可能较慢。请给它一些时间——调试日志(设置中)会显示持续的进度,如果它正在正常工作。\n\n在此阶段请避免强制退出,否则下次启动会从头开始这项工作。",
"faq_d_trbl_1_q": "节点似乎卡在“正在激活最长链”——是卡死了吗?",
"faq_d_trbl_2_a": "如果钱包文件被恢复,但部分密钥数据丢失,节点可能会以受限的“降级”模式打开它,此时无法生成新地址或进行隐私保护。最安全的解决方法是使用您的助记词恢复短语,将钱包恢复到一个全新的钱包中。",
"faq_d_trbl_2_q": "什么是“降级模式”?",
"faq_d_trbl_3_a": "重新扫描会让钱包重新读取链上数据,以重新发现您的交易记录(在导入密钥后很有用)。重新索引会重建节点的区块链数据库,仅在该数据库损坏或因版本变更而不兼容时才需要。这两个过程都可能需要一段时间;可从“设置 > 节点与安全”中启动它们。",
"faq_d_trbl_3_q": "什么时候应该重新索引或重新扫描?",
"faq_d_trbl_4_a": "在重建见证数据或初始同步等繁重工作期间,内存使用会激增,完成后会回落。空闲时持续高内存占用并不正常——重启钱包可以清除它。在低内存机器上,请确保没有其他占用大量内存的应用与之争抢资源。",
"faq_d_trbl_4_q": "节点占用了大量内存——这正常吗?",
"faq_d_trbl_title": "故障排除",
"faq_d_upd_1_a": "打开“设置 > 节点与安全 > 守护进程二进制文件”,使用“检查更新…”。钱包会下载最新的已验证节点版本并安装;新版本会在守护进程下次启动时生效。",
"faq_d_upd_1_q": "如何更新节点?",
"faq_d_upd_2_a": "是的——更新对话框会列出每一个发行版本,您可以选择特定或较旧的版本。降级时请谨慎:较旧的节点可能无法接受较新节点写入的区块链数据,可能需要重新索引。",
"faq_d_upd_2_q": "我可以安装特定版本的节点吗?",
"faq_d_upd_3_a": "每个下载的节点压缩包在安装前都会通过校验和与加密签名进行验证。如果验证失败,钱包会拒绝该更新。",
"faq_d_upd_3_q": "这次更新安全吗?",
"faq_d_upd_title": "更新节点",
"faq_group_daemon": "守护进程",
"faq_group_wallet": "钱包",
"faq_intro": "关于您的钱包和 DragonX 节点的解答。请搜索,或在下方按主题浏览。",
"faq_intro_lite": "关于您的轻量钱包的解答。请搜索,或在下方按主题浏览。",
"faq_l_gs_1_a": "ObsidianDragonLite 是轻量版 DragonX 钱包。它不运行全节点,而是连接到 DragonX 轻量服务器,因此启动速度快,也无需下载整条区块链。\n\n它像完整版钱包一样管理您的密钥、余额和转账——请参阅下方的“轻量钱包”部分,了解服务器连接的工作原理及隐私权衡。",
"faq_l_gs_1_q": "什么是 ObsidianDragonLite?",
"faq_l_lite_1_a": "轻量钱包连接到 DragonX 轻量服务器,而不是运行自己的全节点。这使它启动迅速,占用的磁盘和带宽也更少,因为它自身不下载或验证整条区块链。您的私钥始终保存在您的设备上——绝不会发送到服务器。",
"faq_l_lite_1_q": "轻量钱包与完整版钱包有什么区别?",
"faq_l_lite_2_a": "打开设置并选择一个服务器。您可以随时切换服务器,钱包会从新服务器重新同步。如果某个服务器响应慢或无法访问,请尝试其他服务器。",
"faq_l_lite_2_q": "如何选择或更改服务器?",
"faq_l_lite_3_a": "您的密钥永远不会离开您的设备,隐私金额也始终保持加密。但轻量服务器确实能看到您的钱包查询了哪些地址以及您的 IP 地址,因此它可以将您的地址关联起来——这是以隐私换取便利的权衡。若追求最高隐私性,请使用全节点钱包,它会自行验证链上数据,不会向服务器暴露您的地址。",
"faq_l_lite_3_q": "轻量钱包的隐私性和全节点一样吗?",
"faq_l_lite_title": "轻量钱包",
"faq_l_sec_1_a": "打开 设置 > 钱包,在安全部分设置密码。加密可保护磁盘上的私钥,这样即使有人拿到了文件,也无法花费您的币。\n\n请选择一个强密码,并妥善保管——一旦丢失,加密钱包将无法恢复。",
"faq_no_results": "没有结果。请尝试其他搜索词。",
"faq_open_tooltip": "帮助与常见问题",
"faq_search_hint": "搜索常见问题…",
"faq_title": "帮助与常见问题",
"faq_w_addr_1_a": "屏蔽地址(z 地址,以 zs 开头)是您在 DragonX 上使用的地址。它们采用零知识密码学,金额、发送方和接收方在链上都是加密的,您的余额保持私密。\n\n透明地址(以 R 开头)是挖矿地址。DragonX 是完全私密的链,因此它们只能接收挖矿奖励——网络会拒绝任何发送给它们的付款。到达那里的奖励在被屏蔽之前是公开的,而钱包会自动完成屏蔽。",
"faq_w_addr_1_q": "透明地址和隐私地址有什么区别?",
"faq_w_addr_2_a": "收发任何款项都请使用屏蔽(zs)地址——在 DragonX 上只有这种地址可以接收付款。\n\n透明(挖矿)地址仅用于单人挖矿奖励。切勿把它提供给他人用于付款:网络会拒绝该付款。",
"faq_w_addr_2_q": "应该使用哪种地址类型?",
"faq_w_addr_3_a": "重复使用屏蔽地址没有问题,也不会泄露您的历史记录。您仍可以在“接收”页创建新地址,例如为每个联系人各建一个,以便知道是谁付的款。挖矿地址仅用于挖矿奖励,您很少需要新建。",
"faq_w_addr_3_q": "应该重复使用地址,还是新建一个?",
"faq_w_addr_4_a": "屏蔽是把币从透明(挖矿)地址转移到屏蔽地址,使其变为私密。新挖出的币会到达挖矿地址,并在成熟后自动屏蔽。\n\n您也可以在钱包中手动屏蔽;屏蔽只是一种特殊交易,因此需要正常的确认时间。",
"faq_w_addr_4_q": "什么是“屏蔽”?",
"faq_w_addr_title": "地址与隐私",
"faq_w_bal_1_a": "在钱包同步完成之前,您的余额是不完整的,可能显示为 0——因为它还没有扫描完您的所有交易。同步完成后余额会自动补齐。\n\n请留意窗口底部的状态栏:它显示连接状态和区块高度。同步完成后,余额即为准确值。",
"faq_w_bal_1_q": "为什么我的余额是 0 或不更新?",
"faq_w_bal_2_a": "在包含您交易的区块之上每挖出一个新区块,就会增加一个确认。确认数越多,代表付款结算得越牢固。收到的资金在获得第一个确认后即可使用。",
"faq_w_bal_2_q": "什么是确认数?",
"faq_w_bal_3_a": "总余额包括仍在确认中或暂时被锁定的资金(例如正在屏蔽过程中的币)。可用余额是您现在可以发送的部分。随着交易确认,两者会趋于一致。",
"faq_w_bal_3_q": "总余额和可用余额有什么区别?",
"faq_w_bal_title": "余额与同步",
"faq_w_chat_1_a": "聊天是内置于钱包中的加密链上通讯功能。消息以私密的隐私备注形式发送,只有您和对方能够读取。",
"faq_w_chat_1_q": "什么是聊天功能?",
"faq_w_chat_2_a": "您的聊天身份是从钱包的助记词派生而来的,因此它会随钱包一起保留——恢复钱包,身份也会随之恢复。您无需另外创建账户或密码。",
"faq_w_chat_2_q": "我的聊天身份是如何工作的?",
"faq_w_chat_3_a": "在“联系人”标签页通过地址添加联系人,可选填写名称和头像。联系人可以仅在当前钱包中保留私有,也可以在您在此电脑上打开的所有钱包间共享。",
"faq_w_chat_3_q": "联系人是如何工作的?",
"faq_w_chat_4_a": "打开一个会话,使用其顶部的图标进行操作。隐藏(眼睛图标)会将其从列表中移除但保留全部消息——收到新消息会自动取消隐藏。删除(垃圾桶图标)会打开一个对话框:“删除”会清除本设备上的记录(如果对方再次给您发消息,会话会重新出现),而“删除并屏蔽”还会阻止对方之后的消息,直到您在会话列表上方的“已屏蔽”列表中取消屏蔽为止。这里的所有操作仅在本设备本地生效——消息仍保留在区块链上,对方也保留着自己的副本,因此这并非“撤回”。",
"faq_w_chat_4_q": "如何隐藏、删除或屏蔽一个会话?",
"faq_w_chat_title": "聊天与联系人",
"faq_w_gs_1_a": "ObsidianDragon 是 DragonX(DRGX)的全节点钱包。它管理您的币,并且在桌面版本中会在后台运行 DragonX 全节点(守护进程),使您的钱包能够自行验证区块链,而无需信任第三方服务器。\n\n可以把它想象成两部分协同工作:钱包(密钥、余额、发送)和节点(区块链与网络)。这里的问题也按同样的方式划分——请参见上方的“钱包”和“守护进程”标签页。",
"faq_w_gs_1_q": "什么是 ObsidianDragon?",
"faq_w_gs_2_a": "首次启动时,设置向导会自动为您创建一个钱包。新钱包由一个助记词(一组单词)作为备份。请把这些词写下来并妥善离线保管——如果这台电脑丢失,这是恢复您资金的唯一方式。\n\n您可以随时在 设置 > 备份与数据 > 助记词 中查看或备份该助记词。",
"faq_w_gs_2_q": "如何创建新钱包?",
"faq_w_gs_3_a": "如果您已有助记词,请使用助记词恢复流程。(在全节点版本中,您也可以在启动前将现有的 wallet.dat 文件复制到钱包的数据目录中。)\n\n恢复后,钱包会重新扫描以找回您以往的交易,因此首次显示余额和历史记录可能需要一些时间。",
"faq_w_gs_3_q": "如何恢复我已有的钱包?",
"faq_w_gs_4_a": "向导可让您选择外观/主题、设置加密和 PIN 码,在全节点版本中还可以选择下载引导数据以加快首次同步。您可以跳过任意步骤,之后随时在设置中更改这些内容。",
"faq_w_gs_4_q": "首次运行的设置向导做了什么?",
"faq_w_gs_title": "新手入门",
"faq_w_sec_1_a": "打开 设置 > 节点与安全,在安全部分设置密码。加密可保护磁盘上的私钥,这样即使有人拿到了文件,也无法花费您的币。\n\n请选择一个强密码,并妥善保管——一旦丢失,加密钱包将无法恢复。",
"faq_w_sec_1_q": "如何加密我的钱包?",
"faq_w_sec_2_a": "PIN 码用于锁定应用界面,让您离开时余额和操作被隐藏。这是加密之上的一层便捷锁定,可以在向导中或在完成钱包加密后于设置中设置。",
"faq_w_sec_2_q": "什么是 PIN 码/锁屏?",
"faq_w_sec_3_a": "不能。钱包加密无法被绕过。如果您仍保存着助记词,可以用它将钱包恢复到一个新钱包并设置新密码。如果密码和助记词都没有,资金将无法找回。",
"faq_w_sec_3_q": "我忘记密码了——能找回吗?",
"faq_w_sec_title": "安全与加密",
"faq_w_seed_1_a": "前往 设置 > 备份与数据 > 助记词。按顺序把这些单词抄写在纸上,并存放在安全的离线地点。任何持有该助记词的人都能花费您的币,因此切勿将其保存在照片、邮件或云笔记中。",
"faq_w_seed_1_q": "如何备份我的助记词?",
"faq_w_seed_2_a": "较旧(传统)的钱包不是基于助记词的。钱包可以将传统钱包迁移为现代的助记词钱包:创建一个新的助记词钱包,并将您的资金转移过去。请在 设置 > 备份与数据 中查找“迁移到助记词钱包…”。此功能需要当前版本的守护进程。",
"faq_w_seed_2_q": "我的钱包没有助记词——可以添加吗?",
"faq_w_seed_3_a": "设置钱包时使用助记词恢复流程,并按顺序输入您的单词。之后钱包会重新扫描以重建余额和交易历史,首次可能需要一些时间。",
"faq_w_seed_3_q": "如何用助记词恢复钱包?",
"faq_w_seed_4_a": "助记词是主要备份,足以恢复一切。钱包文件的副本是一个便捷的次要备份,还能保留标签和设置。请将所有备份妥善离线私密保管。",
"faq_w_seed_4_q": "我还需要备份 wallet.dat 吗?",
"faq_w_seed_title": "助记词与备份",
"faq_w_send_1_a": "打开“发送”标签页,粘贴收款方地址,输入金额并确认。如果钱包已加密,系统会要求您解锁以完成交易。\n\n付款发送到屏蔽(zs)地址,并保持私密。钱包不会向透明(挖矿)地址发送:网络会拒绝向此类地址付款。",
"faq_w_send_1_q": "如何发送资金?",
"faq_w_send_2_a": "DragonX 的手续费非常低,并且是自动设置的。手续费会在您确认前显示。通常无需手动更改。",
"faq_w_send_2_q": "我需要支付多少手续费?",
"faq_w_send_3_a": "可以——向隐私(z)地址发送时,您可以附加一条加密备注。只有收款方能够读取它。",
"faq_w_send_3_q": "我可以在付款时附加消息吗?",
"faq_w_send_4_a": "交易在被打包进区块并获得确认之前处于「待处理」状态。这通常需要一两分钟。屏蔽交易还需要您的钱包已完成同步才能生成证明。\n\n如果一笔发款长时间处于待处理状态,请检查您的钱包是否已连接并同步(见窗口底部的状态栏)。",
"faq_w_send_4_q": "我发送了一笔付款,但显示待处理——为什么?",
"faq_w_send_title": "发送与接收",
"faq_w_set_1_a": "打开 设置 → 外观 以选择主题和强调色。内置多种明暗皮肤,模糊等特效也可在低性能设备上关闭。",
"faq_w_set_1_q": "如何更改主题?",
"faq_w_set_2_a": "可以——设置 → 外观 中有语言选择器。钱包内置多种翻译;尚未翻译的部分会回退为英文。",
"faq_w_set_2_q": "可以更改语言吗?",
"faq_w_set_3_a": "使用设置 → 外观 中的字体缩放选项。它会缩放整个界面,在高分辨率(HiDPI)显示器上非常有用,否则应用可能显示得过小。",
"faq_w_set_3_q": "文字太小(或太大)——可以缩放吗?",
"faq_w_set_4_a": "在设置中启用价格获取。钱包会在余额旁显示大致的法币价值,并可显示简易行情图。价格仅供参考。",
"faq_w_set_4_q": "如何显示法币价格?",
"faq_w_set_title": "设置与外观",
"favorite_address": "收藏地址",
"favorite_opacity": "收藏高亮不透明度",
"fee": "手续费",
"fee_high": "高",
"fee_label": "手续费:",
@@ -665,14 +876,9 @@
"fee_normal": "普通",
"fetch_prices": "获取价格",
"file": "文件",
"file_save_location": "文件将保存至:~/.config/ObsidianDragon/",
"file_save_location": "文件将保存至:",
"filter": "筛选...",
"font_scale": "字体大小",
"force_quit": "强制退出",
"force_quit_confirm_msg": "这将立即终止守护进程而不进行正常关闭。\n这可能会损坏区块链索引并需要重新同步。",
"force_quit_confirm_title": "强制退出?",
"force_quit_warning": "这将立即终止守护进程而不进行正常关闭。可能需要重新同步区块链。",
"force_quit_yes": "强制退出",
"from": "从",
"from_upper": "从",
"full_details": "完整详情",
@@ -741,6 +947,7 @@
"hidden_tag": " (已隐藏)",
"hide": "隐藏",
"hide_address": "隐藏地址",
"hide_earnings": "隐藏收益",
"hide_qr": "隐藏二维码",
"hide_zero_balances": "隐藏零余额",
"history": "历史",
@@ -815,11 +1022,13 @@
"lite_birthday_backup": "生日区块:%llu (也请一并备份)",
"lite_birthday_hint": "开始扫描的区块高度。如未知请保留 0(完整扫描更慢)。",
"lite_birthday_label": "诞生区块",
"lite_confirm_label": "确认",
"lite_console_backend_commands": "后端命令:",
"lite_console_help_passthrough": "其他任何输入都将作为轻钱包控制台命令运行。",
"lite_copy": "复制",
"lite_could_not_start": "无法启动该操作",
"lite_could_not_write": "无法写入 ",
"lite_encrypt_offline_warning": "任何拿到钱包文件的人都可以离线尝试密码。请先备份助记词,并使用较长的密码短语。",
"lite_encrypt_wallet": "加密钱包",
"lite_encryption_removed": "已移除加密",
"lite_enter_all_seed_words": "请输入全部 24 个助记词以恢复(已输入 %d 个)",
@@ -827,6 +1036,7 @@
"lite_hide_wipe": "隐藏并清除",
"lite_import": "导入",
"lite_import_key_label": "导入密钥",
"lite_key_import_failed": "无法导入密钥——请检查密钥,解锁钱包(如已加密)后重试。",
"lite_key_imported": "密钥已导入——运行同步以扫描其历史记录",
"lite_lock_failed": "锁定失败",
"lite_lock_now": "立即锁定",
@@ -909,6 +1119,7 @@
"loading": "加载中...",
"loading_addresses": "正在加载地址...",
"loading_stall_body": "守护进程已初始化 %.0f 秒。更新后或首次启动时(加载区块索引或重新扫描)这可能是正常现象——就绪后会自动连接。",
"loading_stall_hint": "卡住了?前往 设置 → 重启节点,或查看控制台。",
"loading_stall_title": "耗时超出预期",
"loading_transactions": "正在加载交易",
"local_hashrate": "本地算力",
@@ -959,11 +1170,67 @@
"memo_optional": "备注(可选)",
"memo_upper": "备注",
"memo_z_only": "注意:备注仅在发送到屏蔽 (z) 地址时可用",
"merge_addr_created": "屏蔽地址已创建。",
"merge_advanced": "高级",
"merge_back": "返回",
"merge_batch_fmt": "每次最多合并 %d 笔输入——重复执行以完成剩余部分。",
"merge_confirm_btn": "确认",
"merge_confirm_fmt": "将约 %s DRGX 从 %d 笔输入合并到 %s?",
"merge_create_zaddr": "创建屏蔽地址",
"merge_creating": "正在创建地址…",
"merge_description": "将多个 UTXO 合并到一个屏蔽地址。这可以帮助减小钱包大小并提高隐私性。",
"merge_fee_hint": "此交易的网络手续费。",
"merge_funds": "合并资金",
"merge_into_one": "合并到一个地址",
"merge_keep_each": "保留在各自地址",
"merge_keep_each_batches_fmt": "超过 %d 张票据的地址会分多批合并;确认后再运行一次即可合并其余部分。",
"merge_keep_each_confirm_fmt": "整合 %d 张票据,涉及 %d 个地址?每个地址保留各自的资金。手续费:约 %s DRGX(每批一次)。",
"merge_keep_each_desc": "每个地址的票据会合并回该地址本身,因此您用不同标签分开存放的资金仍保持分开。仅限屏蔽地址——透明资金只能被屏蔽(合并到一个地址)。",
"merge_keep_each_none": "每个屏蔽地址都只有一张票据——无需整合。",
"merge_keep_each_nothing_submitted": "没有任何整合——节点拒绝了所有合并。",
"merge_keep_each_row_fmt": "%s · %d 张票据 · %s DRGX",
"merge_keep_each_run_again": "分多批合并的地址现在每批各有一张票据。确认后再运行一次,即可把它们合并为一张。",
"merge_keep_each_select": "请至少勾选一个要整合的地址。",
"merge_keep_each_some_failed": "部分合并失败——请查看上方列表。",
"merge_keep_each_status_done": "%s——完成:已合并 %d 张票据,共 %d 批",
"merge_keep_each_status_failed": "%s——失败:%s",
"merge_keep_each_status_not_started": "%s——未开始:%s",
"merge_keep_each_status_running": "%s——正在合并 %d 张票据(已完成 %d/%d 批)…",
"merge_keep_each_status_unconfirmed": "%s——无法确认状态",
"merge_keep_each_too_small": "金额太小,无法整合:手续费会耗尽这些资金。",
"merge_max_inputs": "每批最大输入数",
"merge_no_spendable": "暂无可合并的可用输入。",
"merge_progress": "正在合并…这可能需要几分钟。您可以关闭此窗口。",
"merge_run_aborted_toast": "节点连接已重置,合并已停止。再次运行以继续。",
"merge_run_busy": "正在进行合并。请等待其完成,或先将其停止。",
"merge_run_chip_fmt": "合并中 %d/%d",
"merge_run_err_timeout": "节点未及时响应;该批次可能仍在队列中。",
"merge_run_halted_fmt": "多次失败后合并已停止:%s",
"merge_run_migration_pending": "请先完成或取消助记词迁移,再进行合并。",
"merge_run_progress_fmt": "正在合并… 第 %d 批(共约 %d 批),已合并 %d 张票据。可以关闭此窗口,合并会在后台继续。",
"merge_run_start_failed": "无法开始合并——节点未连接。",
"merge_run_status_not_run": "%s——未合并(%d 张票据)",
"merge_run_status_waiting": "%s——等待中(%d 张票据)",
"merge_run_stop": "停止",
"merge_run_stopped_toast": "合并已停止。再次运行以合并其余部分。",
"merge_run_stopping": "已发送的批次完成后停止…",
"merge_run_wait_batch": "正在间隔各批次,以保持节点响应…",
"merge_run_wait_send": "已暂停,等待您的待处理交易完成。",
"merge_run_wait_sync": "已暂停:节点仍在同步。",
"merge_scope_failed_fmt": "无法读取您的输入:%s",
"merge_scope_fmt": "%d 笔透明输入 + %d 笔屏蔽输入 ・ 约 %s DRGX 可用",
"merge_scope_loading": "正在检查您的输入…",
"merge_send_failed": "合并失败:",
"merge_source": "合并",
"merge_src_both": "两者",
"merge_src_shielded": "屏蔽",
"merge_src_transparent": "透明",
"merge_started": "合并操作已开始",
"merge_submit_uncertain": "连接中断前节点未确认合并。合并仍可能完成——重新连接后,请先在“历史”中确认,再重新合并。",
"merge_title": "合并到地址",
"merge_wait_locked": "钱包已锁定。请解锁后继续。",
"merge_waiting_node": "正在等待节点…",
"merge_where": "资金应转到哪里?",
"mig_adopt": "将其设为我的钱包",
"mig_adopt_now": "采用助记词钱包",
"mig_adopting": "正在安装您的新钱包并重新扫描…",
@@ -1015,6 +1282,8 @@
"miner_fee": "矿工费",
"mining": "挖矿",
"mining_active": "活跃",
"mining_addr_explainer": "仅接收单人挖矿奖励——网络会拒绝向此地址付款。奖励成熟后会自动屏蔽。",
"mining_address": "挖矿地址",
"mining_address_copied": "挖矿地址已复制",
"mining_all_time": "所有时间",
"mining_already_saved": "矿池 URL 已保存",
@@ -1028,6 +1297,8 @@
"mining_benchmark_testing": "测试中",
"mining_benchmark_tooltip": "为此 CPU 找到最佳线程数",
"mining_block_copied": "区块哈希已复制",
"mining_card_click_to_shield": "点击立即屏蔽已成熟的奖励。",
"mining_card_tooltip": "单人挖矿奖励会发送到您的挖矿(透明,R…)地址。这些地址无法接收付款。奖励成熟后会自动屏蔽。",
"mining_chart_1m_ago": "1分钟前",
"mining_chart_5m_ago": "5分钟前",
"mining_chart_now": "现在",
@@ -1043,6 +1314,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": "仅显示矿池收益",
@@ -1072,6 +1345,7 @@
"mining_payout_address": "支付地址",
"mining_payout_foreign": "⚠ 此支付地址不在您当前的钱包中——挖矿奖励将进入另一个钱包。如果您切换过钱包,请更新它。",
"mining_payout_invalid": "不是有效的 DragonX 地址——启动前请更正,否则挖矿奖励将丢失。",
"mining_payout_needs_shielded": "矿池使用普通交易付款,而在 DragonX 上普通交易无法到达挖矿(透明,R…)地址。请使用屏蔽地址(以 zs 开头)作为收款地址。",
"mining_payout_tooltip": "接收挖矿奖励的地址",
"mining_pool": "矿池",
"mining_pool_fee": "费用",
@@ -1084,6 +1358,8 @@
"mining_refresh": "刷新",
"mining_remove": "移除",
"mining_reset_defaults": "重置默认值",
"mining_rewards": "挖矿奖励",
"mining_rewards_unshielded": "尚未屏蔽",
"mining_save_payout_address": "保存支付地址",
"mining_save_pool_url": "保存矿池 URL",
"mining_saved_addresses": "已保存地址:",
@@ -1117,10 +1393,10 @@
"network_fee": "网络手续费",
"network_hashrate": "全网算力",
"new": "+ 新建",
"new_mining_address": "新建挖矿地址",
"new_mining_address_created": "已创建新的挖矿地址",
"new_shielded_created": "新屏蔽地址已创建",
"new_t_address": "新 T 地址",
"new_t_transparent": "新 t 地址(透明)",
"new_transparent_created": "新透明地址已创建",
"new_z_address": "新 Z 地址",
"new_z_shielded": "新 z 地址(屏蔽)",
"no_addresses": "未找到地址。请使用上方按钮创建一个。",
@@ -1140,7 +1416,19 @@
"node_banner_offline_title": "未连接到 DragonX 节点",
"node_banner_reconnect": "重新连接",
"node_banner_restart": "重启节点",
"node_busy_rebuild_desc": "已运行 %s。大型钱包可能需要约一小时,完成前节点无法响应钱包。",
"node_busy_rebuild_extending": "正在扩展见证(此步骤节点不报告进度)。",
"node_busy_rebuild_reading": "正在读取区块:%ld / %ld",
"node_busy_rebuild_title": "节点繁忙:正在重建票据见证",
"node_busy_replay_desc": "已运行 %s。节点正在重新应用上次停止前收到的区块。",
"node_busy_replay_title": "节点繁忙:正在追赶区块",
"node_busy_rpc_desc": "节点正在运行,但已有 %s 忙于其他任务而无法响应钱包,通常是因为见证重建等耗时操作。",
"node_busy_rpc_title": "节点繁忙:暂未响应",
"node_busy_running": "已运行 %s",
"node_security": "节点与安全",
"node_starting_busy_desc": "节点可以访问,但正忙,暂未响应。更新后或首次启动时属正常现象,可能需要几分钟。",
"node_starting_launch_desc": "正在启动 dragonxd 并等待其上线…",
"node_starting_title": "正在启动 DragonX 节点…",
"noise": "噪点",
"not_connected": "未连接到守护进程...",
"not_connected_to_daemon": "未连接到守护进程",
@@ -1201,6 +1489,13 @@
"peers_upper": "节点",
"peers_version": "版本",
"pending": "待处理",
"picker_dat_count": "此文件夹中有 %d 个钱包文件",
"picker_dat_none": "此文件夹中没有直接存放的钱包文件",
"picker_empty": "此文件夹没有子文件夹或钱包文件。",
"picker_home": "主目录",
"picker_select": "扫描此文件夹",
"picker_title": "选择要扫描的文件夹",
"picker_up": "上一级",
"pin_change_desc": "更改您的解锁 PIN 码。您需要当前 PIN 码和新 PIN 码。",
"pin_confirm_new_label": "确认新 PIN 码:",
"pin_current_label": "当前 PIN 码:",
@@ -1286,6 +1581,8 @@
"ram_wallet_gb": "钱包:%.1f GB",
"ram_wallet_mb": "钱包:%.0f MB",
"receive": "接收",
"receive_not_for_payments": "不用于付款",
"receive_use_shielded": "使用屏蔽地址",
"received": "已接收",
"received_filter": "已接收",
"received_label": "已接收",
@@ -1300,6 +1597,7 @@
"reduce_motion": "减少动画",
"refresh": "刷新",
"refresh_now": "立即刷新",
"refresh_waits_for_sync": "节点仍在同步。同步完成后将刷新钱包数据。",
"remove_favorite": "移除收藏",
"repair_wallet": "修复钱包",
"report_bug": "报告错误",
@@ -1314,12 +1612,12 @@
"request_select_address": "选择地址...",
"request_shielded_addrs": "-- 屏蔽地址 --",
"request_title": "请求付款",
"request_transparent_addrs": "-- 透明地址 --",
"request_uri_copied": "付款 URI 已复制到剪贴板",
"rescan": "重新扫描",
"rescan_bootstrapped_msg": "您的节点使用了引导程序,因此快照以下的区块不在磁盘上,从创世区块重新扫描将失败。请从快照所包含的高度开始重新扫描,以核对钱包的已花费余额。您的 wallet.dat 和区块链数据不会被删除。",
"rescan_detecting": "正在检查您的节点在磁盘上已有哪些区块…",
"rescan_from_height": "从区块高度重新扫描:",
"rescan_probe_failed": "无法检查您的节点在磁盘上已有哪些区块(节点正忙或请求超时)。如果此节点使用了引导程序,请取消并在一分钟后重试:在使用引导程序的节点上,下面的 -rescan 重启将失败。",
"reset_to_defaults": "重置为默认值",
"restarting_after_encryption": "加密后重启守护进程...",
"restore_address": "恢复地址",
@@ -1337,6 +1635,7 @@
"save_z_transactions": "将 Z 交易保存到列表",
"sb_auth_failed": "认证失败 — 请检查 rpcuser/rpcpassword",
"sb_block": "区块: %d",
"sb_block_db_unreadable": "区块数据库无法读取——需要重建",
"sb_building_witnesses": "正在设置见证",
"sb_building_witnesses_pct": "正在重建见证 %.0f%%",
"sb_connecting_daemon": "正在连接 dragonxd...",
@@ -1352,6 +1651,7 @@
"sb_dragonxd_stopped": "dragonxd 已停止",
"sb_dragonxd_stopping": "正在停止 dragonxd...",
"sb_extracting_sapling": "正在提取 Sapling 参数...",
"sb_finishing_repair": "正在完成钱包修复——重新扫描中…",
"sb_importing_keys": "正在导入密钥",
"sb_loading_config": "正在加载配置...",
"sb_mining_hs": "%.1f H/s",
@@ -1360,7 +1660,9 @@
"sb_net_khs": "网络: %.2f KH/s",
"sb_net_mhs": "网络: %.2f MH/s",
"sb_no_conf": "未找到 DRAGONX.conf",
"sb_node_busy": "节点繁忙",
"sb_peers": "节点: %zu",
"sb_plaintext_remote_blocked": "拒绝以明文形式向远程主机发送 RPC 凭据。请在 DRAGONX.conf 中添加 rpcallowplaintext=1 以允许此操作,或启用 rpctls=1 使用 TLS。",
"sb_rescanning": "重新扫描",
"sb_rescanning_pct": "重新扫描 %.0f%%",
"sb_restarting_daemon": "正在重启守护进程...",
@@ -1372,6 +1674,7 @@
"sb_waiting_config": "等待守护进程配置...",
"sb_waiting_daemon": "等待 dragonxd 启动...",
"sb_waiting_daemon_err": "等待 dragonxd — %s",
"sb_wallet_needs_recovery": "可进行钱包修复",
"sb_warming_up": "正在预热...",
"sb_witness_cache": "正在重建见证",
"scale_effects": "缩放与效果",
@@ -1379,6 +1682,7 @@
"screenshot_sweep": "运行截图批处理",
"screenshot_sweep_desc": "遍历每个标签页的每一种主题,并将每一个的截图保存到配置目录 screenshots 文件夹下的各标签页子文件夹中(覆盖上一次的遍历)。运行几秒钟。",
"screenshot_sweep_full": "完整 UI 遍历",
"screenshot_sweep_refused": "完整 UI 遍历被拒绝;请查看调试日志",
"search_icons": "搜索图标...",
"search_placeholder": "搜索...",
"sec_changing_passphrase": "正在更改密码短语…",
@@ -1400,15 +1704,19 @@
"sec_mode_passphrase": " 密码短语",
"sec_not_connected_to_daemon": "未连接到守护进程",
"sec_not_connected_to_daemon_pin": "未连接到守护进程",
"sec_passphrase_changed_pin_stale": "密码短语已更改;PIN 已过时",
"sec_passphrase_changed_successfully": "密码短语更改成功",
"sec_pin_changed_successfully": "PIN 更改成功",
"sec_pin_removed": "PIN 已移除",
"sec_pin_set_successfully": "PIN 设置成功",
"sec_pin_stale_msg": "PIN 已过时。请使用密码短语。",
"sec_pin_unreadable": "无法读取 PIN 文件。请使用密码短语。",
"sec_restart_daemon_for_encryption": "请重启守护进程以使加密生效。",
"sec_too_many_attempts_wait": "尝试次数过多。请等待 %.0f 秒…",
"sec_total_elapsed_fmt": "总耗时:%d 分 %02d 秒",
"sec_unlock_button": "解锁",
"sec_unlock_failed_prefix": "解锁失败:",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "正在解锁%s",
"sec_use_passphrase_instead": "改用密码短语",
"sec_use_pin_instead": "改用 PIN",
@@ -1443,6 +1751,7 @@
"send_amount": "金额",
"send_amount_details": "金额详情",
"send_amount_upper": "金额",
"send_available_note": "可用",
"send_cannot_send_now": "现在无法发送 — 请检查连接、同步和可用余额。",
"send_clear_fields": "清除所有表单字段?",
"send_contacts_button": "从联系人中选择",
@@ -1460,6 +1769,8 @@
"send_from_this_address": "从此地址发送",
"send_go_to_receive": "前往接收",
"send_keep": "保留",
"send_mining_addr_blocked": "这是一个挖矿(透明)地址。在 DragonX 上,这类地址只能接收挖矿奖励——网络会拒绝向其付款。请向收款人索取屏蔽地址(以 zs 开头)。",
"send_mining_addr_short": "挖矿地址——无法接收付款",
"send_network_fee": "网络手续费",
"send_no_balance": "无余额",
"send_no_recent": "没有最近的发送",
@@ -1487,7 +1798,6 @@
"send_txid_copied": "交易ID 已复制到剪贴板",
"send_txid_label": "TxID:%s",
"send_valid_shielded": "有效的屏蔽地址",
"send_valid_transparent": "有效的透明地址",
"send_wallet_empty": "您的钱包是空的",
"send_yes_clear": "是,清除",
"sender_balance": "发送方: %.8f → %.8f DRGX",
@@ -1550,6 +1860,7 @@
"settings_open_log_folder": "打开日志文件夹",
"settings_other": "其他",
"settings_pin_active": "PIN",
"settings_pin_out_of_date": "PIN 已过时",
"settings_privacy": "隐私",
"settings_quick_unlock_pin": "快速解锁 PIN",
"settings_reduce_transparency": "降低透明度",
@@ -1615,11 +1926,24 @@
"shielded_type": "屏蔽",
"shielding_notice": "注意:这将把资金从透明 (T) 地址转移到隐私 (Z) 地址。",
"show": "显示",
"show_earnings": "显示收益",
"show_hidden": "显示已隐藏 (%d)",
"show_qr": "显示二维码",
"show_qr_code": "显示二维码",
"showing_transactions": "显示第 %d–%d 笔,共 %d 笔交易(总计:%zu)",
"showing_x_of_y": "显示 %d / %d 个地址",
"shutdown_kill_confirm_msg": "节点已长时间没有 CPU 或磁盘活动,可能已卡住。\n强制结束会丢失所有未保存的数据;下次启动可能需要更长时间。",
"shutdown_kill_confirm_title": "强制结束节点?",
"shutdown_kill_confirm_yes": "强制结束",
"shutdown_kill_node": "强制结束节点(不安全)",
"shutdown_kill_node_tip": "立即结束节点。所有尚未保存的数据都会丢失,下次启动时会重新处理自上次保存以来的区块。",
"shutdown_leave_running": "关闭钱包——保持节点运行",
"shutdown_leave_running_tip": "立即关闭钱包。节点会继续独立运行并完成当前操作,不会丢失任何数据。",
"shutdown_stop_asking": "正在请求节点停止…",
"shutdown_stop_cpu_suffix": " · CPU %d%%",
"shutdown_stop_delivered": "节点正在关闭并保存其状态",
"shutdown_stop_idle": "已 %d 分钟没有 CPU 或磁盘活动——节点可能已卡住",
"shutdown_stop_queued": "节点已收到停止请求,但正忙——它会尽快停止",
"simple_background": "简单背景",
"slider_off": "关闭",
"sort_amount_high": "金额最大",
@@ -1627,14 +1951,29 @@
"sort_date_newest": "最新优先",
"sort_date_oldest": "最早优先",
"start_mining": "开始挖矿",
"startup_files_preparing": "正在准备钱包文件... %d%%",
"startup_files_verifying": "正在验证钱包文件...",
"status": "状态",
"stop_external": "停止外部守护进程",
"stop_mining": "停止挖矿",
"stopconfirm_busy_body": "节点已有 %s 忙于其他任务而无法响应钱包,可能是因为见证重建等耗时操作。现在停止它,下次启动时可能需要重做这些工作。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
"stopconfirm_busy_title": "节点正忙",
"stopconfirm_rebuild_body": "已运行 %s,大型钱包可能需要约一小时。现在停止节点,要么会等待重建完成,要么下次启动时需要重新进行重建以及区块追赶。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
"stopconfirm_rebuild_title": "节点正在重建票据见证",
"stopconfirm_stop_wait": "停止节点并等待",
"stopconfirm_walletop_body": "节点正在为密钥导入或重新扫描扫描区块链。现在停止它会提前结束这次扫描,尚未扫描到的资金只有在“设置 → 重新扫描”之后才会显示。\n\n建议:关闭钱包并保持节点运行,它会自行完成扫描。",
"stopconfirm_walletop_title": "密钥导入或重新扫描正在进行",
"stratum_allowip_hint": "允许来自指定 IP 或 CIDR 的矿工连接(留空 = 仅限本机)",
"stratum_expose_warn": "会向您允许的网络开放挖矿端口——仅在受信任的局域网中使用。",
"stratum_host": "托管矿池(stratum)",
"stratum_host_hint": "矿工可通过 RandomX stratum 矿机连接本机的 22769 端口(RPC 端口 + 1000)。需重启节点以生效。",
"stratum_host_section": "矿池托管",
"submitting_transaction": "正在提交交易...",
"success": "成功",
"summary": "摘要",
"sweep_button": "归集",
"sweep_caveat": "导入密钥以签署一笔将其所有资金转移到您地址的交易。密钥将保留在钱包中,余额为零。",
"sweep_current_theme_only": "仅当前主题",
"sweep_dest_label": "将归集资金发送到",
"sweep_dest_new": "新的屏蔽地址(推荐)",
"sweep_done": "完成 — 资金已归集到您的地址。",
@@ -1670,7 +2009,6 @@
"switch_stopnode_title": "停止正在运行的节点?",
"switch_stopnode_warn": "已有一个并非由此钱包启动的节点正在运行。",
"syncing": "同步中...",
"t_address": "T 地址",
"t_addresses": "T 地址",
"test_connection": "测试",
"theme": "主题",
@@ -1692,6 +2030,7 @@
"tools": "工具",
"tools_actions": "工具与操作...",
"tools_actions_hdr": "工具与操作",
"tor_never_applied_notice": "“使用 Tor”选项从未生效:您的连接并未通过 Tor 路由。该选项已关闭。其他方法请参阅设置中该选项的提示。",
"total": "合计",
"total_balance_label": "总余额",
"transaction_id": "交易 ID",
@@ -1705,8 +2044,6 @@
"transfer_sent": "转账已发送",
"transfer_sent_desc": "您的转账已提交到网络。",
"transfer_to": "转账至:",
"transparent": "透明",
"transparent_address": "透明地址",
"try_again": "重试",
"tt_addr_url": "在区块浏览器中查看地址的基础 URL",
"tt_address_book": "管理已保存的地址以快速发送",
@@ -1738,10 +2075,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": "开始挖矿前等待多长时间",
@@ -1750,6 +2093,7 @@
"tt_keep_daemon": "运行设置向导时守护进程仍会停止",
"tt_language": "钱包界面语言",
"tt_layout_hotkey": "快捷键:左/右箭头键切换余额布局",
"tt_lite_confirm_pass": "再次输入新的密码短语;两次输入必须一致,且至少 8 个字符",
"tt_lite_copy": "将显示的机密复制到剪贴板",
"tt_lite_decrypt_pass": "输入你的密码以移除钱包的加密",
"tt_lite_encrypt": "用上方的密码加密钱包;加密后立即锁定,需要该密码才能解锁",
@@ -1778,11 +2122,16 @@
"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 文件夹(设置、主题、日志)",
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
"tt_open_dir": "点击在文件管理器中打开",
"tt_open_log_folder": "打开包含调试和崩溃日志的文件夹",
"tt_pin_out_of_date": "设置此 PIN 后钱包密码短语已更改,因此该 PIN 无法再解锁钱包。请移除 PIN 并重新设置。",
"tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性",
"tt_remove_encrypt": "移除加密并以未受保护状态存储钱包",
"tt_remove_pin": "移除 PIN 并要求密码解锁",
@@ -1812,14 +2161,20 @@
"tt_simple_bg": "使用简单渐变作为背景\\n快捷键:Ctrl+Up",
"tt_simple_bg_alt": "使用主题背景图像的渐变版本\\n快捷键:Ctrl+Up",
"tt_stop_external": "适用于连接到在此钱包\\n外部启动的守护进程",
"tt_stratum_host": "在此节点上运行 RandomX stratum 矿池服务器,让其他 RandomX 矿工可以连接到本机。需要 v1.3.0 及以上版本的节点,并需重启节点以生效。",
"tt_sweep_current_theme_only": "仅扫描当前主题,而不是遍历每个主题",
"tt_test_conn": "验证与守护进程的 RPC 连接",
"tt_theme_effects": "每个主题的闪烁、发光、色调循环",
"tt_theme_hotkey": "快捷键:Ctrl+左/右箭头切换主题",
"tt_tor": "通过 Tor 网络路由守护进程连接以实现匿名",
"tt_tor_unavailable": "暂不可用:此钱包不会通过 Tor 路由任何流量。仅就节点而言,您可以在 DRAGONX.conf 中添加 proxy=127.0.0.1:9050 并重启节点。Tor 必须正在运行,否则节点将找不到其他节点。价格、更新、引导和矿池请求仍会直接连接;如需保护这些请求,请使用系统级 VPN/Tor。",
"tt_tor_unavailable_lite": "暂不可用:此钱包不会通过 Tor 路由任何流量。请使用系统级 VPN/Tor。",
"tt_tx_url": "在区块浏览器中查看交易的基础 URL",
"tt_ui_opacity": "卡片和侧边栏不透明度(100% = 完全不透明,越低越透明)",
"tt_validate": "检查 DragonX 地址是否有效",
"tt_verbose": "将详细连接诊断、守护进程状态\\n和端口所有者信息记录到控制台选项卡",
"tt_view_transactions": "在历史记录标签页中显示涉及此地址的交易",
"tt_wallet_size_warn": "屏蔽钱包会随每个票据的见证数据增长——将多个票据合并到一个地址可以缩小体积。请先备份。",
"tt_wallets_button": "列出您的钱包文件并在它们之间切换",
"tt_website": "打开 DragonX 网站",
"tt_window_opacity": "背景不透明度(越低 = 桌面透过窗口可见)",
@@ -1836,6 +2191,7 @@
"tx_loading_queued": "已排队的交易刷新",
"tx_loading_refreshing_cached": "正在刷新钱包历史(已缓存 %d 条)",
"tx_loading_scanning_shielded": "正在扫描屏蔽历史(%d 个地址)",
"tx_loading_waits_for_sync": "节点同步完成后将更新历史记录",
"tx_mined": "已挖得",
"tx_progress_balances": "正在刷新余额",
"tx_progress_finalizing": "正在完成交易",
@@ -1867,6 +2223,7 @@
"validate_description": "输入一个 DragonX 地址来检查它是否有效以及是否属于此钱包。",
"validate_invalid": "无效",
"validate_is_mine": "此钱包拥有该地址",
"validate_mining_type": "挖矿地址(透明)",
"validate_not_mine": "不属于此钱包",
"validate_ownership": "所有权:",
"validate_results": "结果:",
@@ -1874,7 +2231,6 @@
"validate_shielded_type": "屏蔽(z 地址)",
"validate_status": "状态:",
"validate_title": "验证地址",
"validate_transparent_type": "透明(t 地址)",
"validate_type": "类型:",
"validate_valid": "有效",
"validating": "验证中...",
@@ -1884,10 +2240,71 @@
"view_all": "查看全部",
"view_details": "查看详情",
"view_on_explorer": "在浏览器中查看",
"view_transactions": "查看交易",
"waiting_for_daemon": "等待守护进程连接...",
"wallet": "钱包",
"wallet_degraded_notify": "您的钱包以受限功能模式打开:现有资金安全且可用,但无法创建新地址或进行屏蔽操作。请备份助记词并用它恢复钱包以完全修复。",
"wallet_diagnostics_hdr": "诊断",
"wallet_empty": "您的钱包是空的",
"wallet_empty_hint": "切换到接收页面获取您的地址并开始接收资金。",
"wallet_options_hdr": "选项",
"wallet_rebuild_failed": "重建未能生成有效的钱包,因此未做任何更改。您的钱包未受影响。",
"wallet_rebuild_install_failed": "无法安装重建后的钱包;您当前的钱包已保留原样。",
"wallet_rebuild_no_helper": "此版本不提供钱包重建助手。请使用还原功能,或手动重建。",
"wallet_rebuild_no_source": "找不到可读取的钱包用于重建。未做任何更改。",
"wallet_rebuild_ok": "钱包数据库已重建——节点正在加载并重新扫描以恢复您的余额。",
"wallet_rebuild_started": "正在修复您的钱包文件——请不要关闭此窗口。",
"wallet_recovered_body": "没有任何内容被删除。您的原始钱包仍以带日期的备份文件形式保存在您的电脑上,下面的每个选项都只会复制或重命名文件——绝不会删除文件。您的密钥永远不会重新生成,只会被重新读取。\n\n现在加载的修复副本可能会缺少最近的一些交易,因此在您完成下方操作并重新扫描之前,余额可能会显示偏低。",
"wallet_recovered_dismiss": "暂不处理——保留修复副本",
"wallet_recovered_dismiss_sub": "不会更改任何文件。您可以随时从状态栏重新打开此提示;无论如何,您的原始文件都会安全地保留备份。",
"wallet_recovered_notify": "您的钱包文件需要修复——原始文件已安全备份。打开应用查看您的选项。",
"wallet_recovered_open_folder": "显示文件",
"wallet_recovered_open_folder_sub": "打开钱包数据文件夹,方便您自行查看备份文件——不会更改任何内容。",
"wallet_recovered_rebuild": "自动修复(推荐)",
"wallet_recovered_rebuild_sub": "从您最完整的钱包文件中重新读取每条可恢复的记录,并写入一个全新的干净文件,然后重启。这是最彻底的选项——无论如何,您当前的文件都会保留为带日期的备份。",
"wallet_recovered_restore": "改为还原原始文件",
"wallet_recovered_restore_sub": "将您最大的未改动备份原样放回,然后重新扫描——速度稍快,但完整程度仅限于该文件本身。无论如何,您当前的文件都会保留为带日期的备份。",
"wallet_recovered_safety": "您的币是安全的。",
"wallet_recovered_title": "您的钱包文件需要快速修复",
"wallet_recovered_warn": "应用启动时发现您的钱包文件未通过一致性检查——这通常发生在应用更新或非正常关闭之后。应用已保护您的数据:它将旧文件另存一旁,并加载了一个修复副本,因此您不会被卡住。",
"wallet_recovery_decide_later": "稍后决定",
"wallet_recovery_decide_later_tip": "关闭此提示并保留当前已加载的副本。不会更改任何内容,您可以随时进行修复——状态栏会保留“可修复钱包”链接。",
"wallet_recovery_details_label": "显示技术细节",
"wallet_recovery_done": "完成",
"wallet_recovery_failure_body": "修复已运行,但结果未通过验证(无法读取,或没有地址),因此在替换任何内容之前已被自动丢弃。您的钱包与点击前完全一样——磁盘上没有任何改动。",
"wallet_recovery_failure_title": "修复未成功",
"wallet_recovery_files_detail": "您的原始钱包仍在这里——应用已将其重命名为数据文件夹中带日期的备份(wallet.<数字>.bak),未删除任何内容。“修复”会从您最完整的钱包中读取每条记录到一个全新的干净文件中。“还原”会将您最大的备份原样复制回来。当前加载的副本也会保留为备份。",
"wallet_recovery_files_label": "我的文件会怎样?",
"wallet_recovery_notnow_short": "暂不",
"wallet_recovery_other_options": "其他选项",
"wallet_recovery_rebuild_card": "自动修复",
"wallet_recovery_rebuild_card_desc": "将每条可恢复的记录读取到一个干净的文件中,然后重启。这是最彻底的选项。",
"wallet_recovery_recommended": "推荐",
"wallet_recovery_repair_go": "修复",
"wallet_recovery_rescan_body": "正在重新扫描区块链以重建您的余额和交易历史。这可能需要几分钟——您无需进行任何操作,请勿在此过程中重启节点。",
"wallet_recovery_rescan_elapsed": "已运行 %s",
"wallet_recovery_rescan_size": "已重建的钱包目前为 %s,且仍在填充中",
"wallet_recovery_rescan_slow": "这比平时耗时更长,对于较大的钱包这是正常现象。可以放心让它继续运行——您可以在“高级 ▸ 控制台”下查看进度。",
"wallet_recovery_rescan_title": "正在完成钱包修复",
"wallet_recovery_restore_card": "还原原始文件",
"wallet_recovery_restore_card_desc": "将您最大的未改动备份原样放回——完整程度仅限于该文件本身。",
"wallet_recovery_restore_go": "还原",
"wallet_recovery_success_body": "应用已加载您修复后的钱包,正在重新扫描以统计余额——这可能需要几分钟。它会读取所能读取的每条记录,但在极少数损坏文件的情况下,恢复的地址可能会比之前略少——或偶尔略多。重新扫描完成后,请检查您的余额和历史记录是否正确。",
"wallet_recovery_success_restore": "您最大的备份文件已放回原位并正在加载,应用正在重新扫描。它是原样还原的,因此完整程度与该文件本身一致——重新扫描完成后请检查您的余额。",
"wallet_recovery_try_other": "尝试其他选项",
"wallet_recovery_whats_happened": "发生了什么?",
"wallet_recovery_working_label": "处理中",
"wallet_restore_bad_backup": "备份钱包文件似乎无法读取,因此未进行还原——您当前的钱包未被更改。请改用您自己的备份进行还原。",
"wallet_restore_busy": "节点正忙于重启——请稍后再试。",
"wallet_restore_copy_failed": "无法安装备份钱包;您当前的钱包保持不变。",
"wallet_restore_move_failed": "无法将当前钱包移至一旁——未做任何更改。",
"wallet_restore_no_backup": "未找到可还原的 wallet.<时间戳>.bak 文件。未做任何更改。",
"wallet_restore_no_restart": "您的原始钱包已还原,但节点未重启——请从设置中启动节点。",
"wallet_restore_ok": "原始钱包已还原。节点正在加载中。",
"wallet_restore_started": "正在还原您的原始文件——请勿关闭此窗口。",
"wallet_restore_stop_failed": "节点未能及时停止,因此未做任何更改。请重试。",
"wallet_size_consolidate": "合并笔记…",
"wallet_size_warn": "此钱包文件较大。合并您的笔记可以抑制其进一步增长。",
"wallets_active": "活跃",
"wallets_add": "添加文件夹",
"wallets_add_folder": "同时扫描另一个文件夹中的钱包文件:",
@@ -1930,7 +2347,9 @@
"wallets_open_inplace_tt": "在原位置打开此钱包 — 链接到数据目录(不复制)",
"wallets_remove_folder": "停止扫描此文件夹",
"wallets_reveal": "打开文件夹",
"wallets_scan_folder": "扫描其他文件夹以查找钱包…",
"wallets_scanned_folders": "已扫描的文件夹:",
"wallets_scanning": "正在扫描文件夹…",
"wallets_sort_addresses": "地址",
"wallets_sort_asc": "升序(最早/最少/最小优先)",
"wallets_sort_by": "排序:",
@@ -1999,19 +2418,30 @@
"wiz_welcome_sub": "只需几项快速设置,即可启用您的全节点。",
"wiz_welcome_title": "欢迎使用 ObsidianDragon!",
"wizard_daemon_start_failed": "启动守护进程失败 — 将自动重试",
"xmrig_active_tt": "当前已安装(使用中)的矿工程序",
"xmrig_browse_releases": "浏览所有版本…",
"xmrig_checking": "正在检查最新的矿工程序…",
"xmrig_current": "当前:",
"xmrig_downgrade_note": "安装不同版本的矿工程序会替换当前版本;请先停止挖矿,更改将在下次启动时生效。",
"xmrig_download_install": "下载并安装",
"xmrig_downloading": "正在下载…",
"xmrig_install_this": "安装此版本",
"xmrig_installed": "已安装:",
"xmrig_installed_ok": "矿工程序已安装",
"xmrig_installing": "正在安装…",
"xmrig_latest": "最新:",
"xmrig_latest_badge": "最新",
"xmrig_latest_tt": "可用的最新版本",
"xmrig_loading_releases": "正在加载发行版…",
"xmrig_no_notes": "此版本没有发行说明。",
"xmrig_none": "无",
"xmrig_prerelease_tt": "预发布/测试版本",
"xmrig_reinstall": "重新安装",
"xmrig_released": "发布日期:",
"xmrig_releases": "xmrig 版本",
"xmrig_status_current": "此版本已安装",
"xmrig_status_latest": "最新版本",
"xmrig_status_older": "早于您已安装的版本",
"xmrig_stop_mining_first": "更新矿工程序前请先停止挖矿。",
"xmrig_unavailable_body": "此平台没有可用的矿工构建版本。",
"xmrig_unavailable_title": "矿工更新不可用",
+20 -2
View File
@@ -700,6 +700,7 @@ status-pill-bg-alpha = { size = 30 }
status-pill-y-offset = { size = 1 }
confirmed-threshold = { size = 10 }
# NOTE: [banners.*] and [layout] are parsed as flat sections (kFlatSections in ui_schema.cpp).
# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner).
# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action.
[banners.node-status]
@@ -1097,6 +1098,7 @@ fee-input = { width = 150 }
utxo-limit-input = { width = 150 }
shield-button = { width = 120, font = "button" }
cancel-button = { width = 100, font = "button" }
list-max-rows = { size = 7 } # address picker / run rows shown before the list scrolls
[dialogs.request-payment]
width = 500.0
@@ -1202,6 +1204,16 @@ connection-dot = { opacity = 1.0 }
height = 30
padding = [8, 0]
[components.startup-files-chip]
width = { size = 340.0 }
height = { size = 46.0 }
bottom-margin = { size = 38.0 }
rounding = { size = 10.0 }
padding-x = { size = 14.0 }
bar-height = { size = 3.0 }
bar-bottom = { size = 10.0 }
show-delay-seconds = { size = 0.4 }
[components.notifications]
width = 350.0
height = 60.0
@@ -1213,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 }
@@ -1339,6 +1353,10 @@ wallet-btn-padding = { size = 24.0 }
rpc-label-min-width = { size = 70.0 }
rpc-label-width = { size = 85.0 }
security-combo-width = { size = 120.0 }
# Wrap width of the Security band's "PIN out of date" tooltip (a long sentence in every language).
pin-tooltip-wrap-width = { size = 420.0 }
# Wrap width of the disabled "Use Tor" checkbox's tooltip (Settings page and the legacy Settings window).
tor-tooltip-wrap-width = { size = 420.0 }
node-grid-breakpoint = { size = 900.0 }
port-input-min-width = { size = 60.0 }
port-input-width-ratio = { size = 0.4 }
+638
View File
@@ -25,6 +25,644 @@ def load_english_keys():
# All translations for the 158 missing keys
TRANSLATIONS = {
"merge_where": {
"de": "Wohin sollen die Mittel gehen?",
"es": "¿Adónde deben ir los fondos?",
"fr": "Où doivent aller les fonds ?",
"pt": "Para onde devem ir os fundos?",
"ru": "Куда направить средства?",
"zh": "资金应转到哪里?",
"ja": "資金の移動先",
"ko": "자금을 어디로 보낼까요?",
},
"merge_into_one": {
"de": "In eine Adresse",
"es": "A una sola dirección",
"fr": "Vers une seule adresse",
"pt": "Para um só endereço",
"ru": "На один адрес",
"zh": "合并到一个地址",
"ja": "1 つのアドレスへ",
"ko": "한 주소로",
},
"merge_keep_each": {
"de": "In jeder Adresse belassen",
"es": "Mantener en cada dirección",
"fr": "Garder dans chaque adresse",
"pt": "Manter em cada endereço",
"ru": "Оставить в каждом адресе",
"zh": "保留在各自地址",
"ja": "各アドレスに残す",
"ko": "각 주소에 유지",
},
"merge_keep_each_desc": {
"de": "Die Notes jeder Adresse werden in genau diese Adresse zurückgeführt, sodass Mittel, die Sie unter verschiedenen Bezeichnungen getrennt halten, getrennt bleiben. Nur abgeschirmte Adressen – transparente Mittel können nur abgeschirmt werden (In eine Adresse).",
"es": "Las notas de cada dirección se fusionan de nuevo en esa misma dirección, así que los fondos que mantienes separados con distintas etiquetas siguen separados. Solo direcciones blindadas: los fondos transparentes solo pueden blindarse (A una sola dirección).",
"fr": "Les notes de chaque adresse sont refusionnées dans cette même adresse : les fonds que vous gardez séparés sous des libellés différents restent séparés. Adresses blindées uniquement — les fonds transparents ne peuvent qu'être blindés (Vers une seule adresse).",
"pt": "As notas de cada endereço são mescladas de volta nesse mesmo endereço, então os fundos que você mantém separados com rótulos diferentes continuam separados. Só endereços blindados — fundos transparentes só podem ser blindados (Para um só endereço).",
"ru": "Заметки каждого адреса объединяются обратно в тот же адрес, поэтому средства, которые вы держите раздельно под разными метками, остаются раздельными. Только экранированные адреса — прозрачные средства можно только экранировать («На один адрес»).",
"zh": "每个地址的票据会合并回该地址本身,因此您用不同标签分开存放的资金仍保持分开。仅限屏蔽地址——透明资金只能被屏蔽(合并到一个地址)。",
"ja": "各アドレスのノートはそのアドレス自身に統合されるため、ラベルごとに分けている資金は分かれたままです。シールドアドレスのみ対象です。透明アドレスの資金はシールドすることしかできません(1 つのアドレスへ)。",
"ko": "각 주소의 노트는 같은 주소로 다시 병합되므로, 다른 라벨로 나눠 둔 자금은 계속 나뉜 상태로 유지됩니다. 차폐 주소만 해당되며, 투명 자금은 차폐만 할 수 있습니다(한 주소로).",
},
"merge_keep_each_none": {
"de": "Jede abgeschirmte Adresse hat bereits nur eine Note – nichts zu konsolidieren.",
"es": "Cada dirección blindada ya tiene una sola nota: no hay nada que consolidar.",
"fr": "Chaque adresse blindée ne contient déjà qu'une seule note — rien à consolider.",
"pt": "Cada endereço blindado já tem uma única nota — nada a consolidar.",
"ru": "На каждом экранированном адресе уже одна заметка — объединять нечего.",
"zh": "每个屏蔽地址都只有一张票据——无需整合。",
"ja": "すべてのシールドアドレスのノートはすでに 1 つです。統合するものはありません。",
"ko": "모든 차폐 주소에 노트가 이미 하나뿐입니다. 통합할 것이 없습니다.",
},
"merge_keep_each_row_fmt": {
"de": "%s · %d Notes · %s DRGX",
"es": "%s · %d notas · %s DRGX",
"fr": "%s · %d notes · %s DRGX",
"pt": "%s · %d notas · %s DRGX",
"ru": "%s · заметок: %d · %s DRGX",
"zh": "%s · %d 张票据 · %s DRGX",
"ja": "%s · ノート %d 件 · %s DRGX",
"ko": "%s · 노트 %d개 · %s DRGX",
},
"merge_keep_each_too_small": {
"de": "Zu klein zum Konsolidieren: Die Gebühr würde diese Mittel aufbrauchen.",
"es": "Demasiado poco para consolidar: la comisión se llevaría estos fondos.",
"fr": "Trop faible pour être consolidé : les frais absorberaient ces fonds.",
"pt": "Pequeno demais para consolidar: a taxa consumiria esses fundos.",
"ru": "Слишком мало для объединения: комиссия съест эти средства.",
"zh": "金额太小,无法整合:手续费会耗尽这些资金。",
"ja": "少なすぎて統合できません。手数料でこの資金がなくなります。",
"ko": "너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
},
"merge_keep_each_batches_fmt": {
"de": "Adressen mit mehr als %d Notes werden in mehreren Durchgängen zusammengeführt; führen Sie es nach deren Bestätigung erneut aus, um den Rest zusammenzuführen.",
"es": "Las direcciones con más de %d notas se fusionan en varios lotes; vuelve a ejecutarlo cuando se confirmen para fusionar el resto.",
"fr": "Les adresses de plus de %d notes sont fusionnées en plusieurs lots ; relancez l'opération une fois ceux-ci confirmés pour fusionner le reste.",
"pt": "Endereços com mais de %d notas são mesclados em vários lotes; execute de novo depois que forem confirmados para mesclar o restante.",
"ru": "Адреса с более чем %d заметками объединяются в несколько партий; запустите снова после их подтверждения, чтобы объединить остальное.",
"zh": "超过 %d 张票据的地址会分多批合并;确认后再运行一次即可合并其余部分。",
"ja": "ノートが %d 件を超えるアドレスは複数回に分けて統合されます。承認後にもう一度実行すると残りが統合されます。",
"ko": "노트가 %d개를 넘는 주소는 여러 번에 나눠 병합됩니다. 확인된 후 다시 실행하면 나머지가 병합됩니다.",
},
"merge_keep_each_confirm_fmt": {
"de": "%d Notes in %d Adressen konsolidieren? Jede Adresse behält ihre eigenen Mittel. Gebühren: etwa %s DRGX (eine pro Durchgang).",
"es": "¿Consolidar %d notas en %d direcciones? Cada dirección conserva sus propios fondos. Comisiones: unos %s DRGX (una por lote).",
"fr": "Consolider %d notes dans %d adresses ? Chaque adresse garde ses propres fonds. Frais : environ %s DRGX (un par lot).",
"pt": "Consolidar %d notas em %d endereços? Cada endereço mantém seus próprios fundos. Taxas: cerca de %s DRGX (uma por lote).",
"ru": "Объединить %d заметок на %d адресах? Каждый адрес сохраняет свои средства. Комиссии: около %s DRGX (по одной на партию).",
"zh": "整合 %d 张票据,涉及 %d 个地址?每个地址保留各自的资金。手续费:约 %s DRGX(每批一次)。",
"ja": "%d 件のノートを %d 個のアドレスで統合しますか?各アドレスの資金はそのまま残ります。手数料:約 %s DRGX(1 回ごとに 1 件)。",
"ko": "노트 %d개를 주소 %d개에서 통합할까요? 각 주소는 자기 자금을 그대로 유지합니다. 수수료: 약 %s DRGX(배치마다 1회).",
},
"merge_keep_each_select": {
"de": "Wählen Sie mindestens eine Adresse zum Konsolidieren aus.",
"es": "Marca al menos una dirección para consolidar.",
"fr": "Cochez au moins une adresse à consolider.",
"pt": "Marque pelo menos um endereço para consolidar.",
"ru": "Отметьте хотя бы один адрес для объединения.",
"zh": "请至少勾选一个要整合的地址。",
"ja": "統合するアドレスを 1 つ以上選んでください。",
"ko": "통합할 주소를 하나 이상 선택하세요.",
},
"merge_keep_each_nothing_submitted": {
"de": "Nichts wurde konsolidiert – der Node hat jede Zusammenführung abgelehnt.",
"es": "No se consolidó nada: el nodo rechazó todas las fusiones.",
"fr": "Rien n'a été consolidé — le nœud a refusé chaque fusion.",
"pt": "Nada foi consolidado — o nó recusou todas as mesclagens.",
"ru": "Ничего не объединено — узел отклонил все объединения.",
"zh": "没有任何整合——节点拒绝了所有合并。",
"ja": "何も統合されませんでした。ノードがすべての統合を拒否しました。",
"ko": "아무것도 통합되지 않았습니다. 노드가 모든 병합을 거부했습니다.",
},
"merge_keep_each_some_failed": {
"de": "Einige Zusammenführungen sind fehlgeschlagen – siehe Liste oben.",
"es": "Algunas fusiones fallaron; consulta la lista de arriba.",
"fr": "Certaines fusions ont échoué — voir la liste ci-dessus.",
"pt": "Algumas mesclagens falharam — veja a lista acima.",
"ru": "Некоторые объединения не удались — см. список выше.",
"zh": "部分合并失败——请查看上方列表。",
"ja": "一部の統合に失敗しました。上の一覧を確認してください。",
"ko": "일부 병합에 실패했습니다. 위 목록을 확인하세요.",
},
"merge_keep_each_status_not_started": {
"de": "%s – nicht gestartet: %s",
"es": "%s: no iniciado: %s",
"fr": "%s — non démarré : %s",
"pt": "%s — não iniciado: %s",
"ru": "%s — не запущено: %s",
"zh": "%s——未开始:%s",
"ja": "%s — 開始されませんでした:%s",
"ko": "%s — 시작되지 않음: %s",
},
"merge_keep_each_status_failed": {
"de": "%s – fehlgeschlagen: %s",
"es": "%s: falló: %s",
"fr": "%s — échec : %s",
"pt": "%s — falhou: %s",
"ru": "%s — ошибка: %s",
"zh": "%s——失败:%s",
"ja": "%s — 失敗:%s",
"ko": "%s — 실패: %s",
},
"merge_keep_each_status_done": {
"de": "%s – fertig: %d Notes in %d Durchgang/Durchgängen zusammengeführt",
"es": "%s: listo: %d notas fusionadas en %d lote(s)",
"fr": "%s — terminé : %d notes fusionnées en %d lot(s)",
"pt": "%s — concluído: %d notas mescladas em %d lote(s)",
"ru": "%s — готово: объединено заметок: %d, партий: %d",
"zh": "%s——完成:已合并 %d 张票据,共 %d 批",
"ja": "%s — 完了:%d 件のノートを %d 回で統合",
"ko": "%s — 완료: 노트 %d개를 %d번에 걸쳐 병합",
},
"merge_keep_each_status_running": {
"de": "%s – %d Notes werden zusammengeführt (%d von %d Durchgängen fertig)…",
"es": "%s: fusionando %d notas (%d de %d lotes listos)…",
"fr": "%s — fusion de %d notes (%d lots sur %d terminés)…",
"pt": "%s — mesclando %d notas (%d de %d lotes concluídos)…",
"ru": "%s — объединение %d заметок (готово партий: %d из %d)…",
"zh": "%s——正在合并 %d 张票据(已完成 %d/%d 批)…",
"ja": "%s — %d 件のノートを統合中(%d / %d 回完了)…",
"ko": "%s — 노트 %d개 병합 중(%d/%d 배치 완료)…",
},
"merge_keep_each_run_again": {
"de": "Adressen, die in mehreren Durchgängen zusammengeführt wurden, haben jetzt eine Note pro Durchgang. Führen Sie dies nach deren Bestätigung erneut aus, um sie zu einer zusammenzuführen.",
"es": "Las direcciones fusionadas en varios lotes tienen ahora una nota por lote. Vuelve a ejecutarlo cuando se confirmen para fusionarlas en una.",
"fr": "Les adresses fusionnées en plusieurs lots contiennent maintenant une note par lot. Relancez l'opération une fois ceux-ci confirmés pour les fusionner en une seule.",
"pt": "Endereços mesclados em vários lotes agora têm uma nota por lote. Execute de novo depois que forem confirmados para mesclá-las em uma.",
"ru": "На адресах, объединённых в несколько партий, теперь по одной заметке на партию. Запустите снова после их подтверждения, чтобы объединить их в одну.",
"zh": "分多批合并的地址现在每批各有一张票据。确认后再运行一次,即可把它们合并为一张。",
"ja": "複数回に分けて統合したアドレスには、回数分のノートが残っています。承認後にもう一度実行すると 1 つにまとめられます。",
"ko": "여러 번에 나눠 병합된 주소에는 배치마다 노트가 하나씩 남아 있습니다. 확인된 후 다시 실행하면 하나로 병합됩니다.",
},
"mining_addr_explainer": {
"de": "Empfängt nur Solo-Mining-Belohnungen – das Netzwerk lehnt Zahlungen an diese Adresse ab. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
"es": "Solo recibe recompensas de minería en solitario; la red rechaza los pagos a esta dirección. Las recompensas se blindan automáticamente cuando maduran.",
"fr": "Ne reçoit que des récompenses de minage en solo — le réseau refuse les paiements vers cette adresse. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
"pt": "Recebe apenas recompensas de mineração solo — a rede rejeita pagamentos para este endereço. As recompensas são blindadas automaticamente quando amadurecem.",
"ru": "Принимает только награды за соло-майнинг — сеть отклоняет платежи на этот адрес. Награды экранируются автоматически, когда созревают.",
"zh": "仅接收单人挖矿奖励——网络会拒绝向此地址付款。奖励成熟后会自动屏蔽。",
"ja": "ソロマイニング報酬のみを受け取ります。ネットワークはこのアドレスへの支払いを拒否します。報酬は成熟すると自動的にシールドされます。",
"ko": "솔로 채굴 보상만 받습니다. 네트워크가 이 주소로의 결제를 거부합니다. 보상은 성숙하면 자동으로 차폐됩니다.",
},
"receive_use_shielded": {
"de": "Abgeschirmte Adresse verwenden",
"es": "Usar una dirección blindada",
"fr": "Utiliser une adresse blindée",
"pt": "Usar um endereço blindado",
"ru": "Использовать экранированный адрес",
"zh": "使用屏蔽地址",
"ja": "シールドアドレスを使う",
"ko": "차폐 주소 사용",
},
"receive_not_for_payments": {
"de": "Nicht für Zahlungen",
"es": "No para pagos",
"fr": "Pas pour les paiements",
"pt": "Não é para pagamentos",
"ru": "Не для платежей",
"zh": "不用于付款",
"ja": "支払いには使えません",
"ko": "결제용이 아님",
},
"validate_mining_type": {
"de": "Mining-Adresse (transparent)",
"es": "Dirección de minería (transparente)",
"fr": "Adresse de minage (transparente)",
"pt": "Endereço de mineração (transparente)",
"ru": "Адрес для майнинга (прозрачный)",
"zh": "挖矿地址(透明)",
"ja": "マイニングアドレス(透明)",
"ko": "채굴 주소(투명)",
},
"send_mining_addr_short": {
"de": "Mining-Adresse – kann keine Zahlungen empfangen",
"es": "Dirección de minería: no puede recibir pagos",
"fr": "Adresse de minage — ne peut pas recevoir de paiements",
"pt": "Endereço de mineração — não pode receber pagamentos",
"ru": "Адрес для майнинга — не принимает платежи",
"zh": "挖矿地址——无法接收付款",
"ja": "マイニングアドレス — 支払いを受け取れません",
"ko": "채굴 주소 — 결제를 받을 수 없음",
},
"send_mining_addr_blocked": {
"de": "Dies ist eine Mining-Adresse (transparent). Bei DragonX können solche Adressen nur Mining-Belohnungen empfangen – das Netzwerk lehnt Zahlungen an sie ab. Bitten Sie den Empfänger um eine abgeschirmte Adresse (sie beginnt mit zs).",
"es": "Esta es una dirección de minería (transparente). En DragonX solo pueden recibir recompensas de minería; la red rechaza los pagos a ellas. Pide al destinatario una dirección blindada (empieza por zs).",
"fr": "Ceci est une adresse de minage (transparente). Sur DragonX, elles ne peuvent recevoir que des récompenses de minage — le réseau refuse les paiements vers elles. Demandez au destinataire une adresse blindée (elle commence par zs).",
"pt": "Este é um endereço de mineração (transparente). No DragonX eles só podem receber recompensas de mineração — a rede rejeita pagamentos para eles. Peça ao destinatário um endereço blindado (começa com zs).",
"ru": "Это адрес для майнинга (прозрачный). В DragonX такие адреса могут получать только награды за майнинг — сеть отклоняет платежи на них. Попросите у получателя экранированный адрес (начинается с zs).",
"zh": "这是一个挖矿(透明)地址。在 DragonX 上,这类地址只能接收挖矿奖励——网络会拒绝向其付款。请向收款人索取屏蔽地址(以 zs 开头)。",
"ja": "これはマイニング(透明)アドレスです。DragonX ではこのアドレスはマイニング報酬しか受け取れず、ネットワークはこのアドレスへの支払いを拒否します。受取人にシールドアドレス(zs で始まる)を依頼してください。",
"ko": "채굴(투명) 주소입니다. DragonX에서 이런 주소는 채굴 보상만 받을 수 있으며, 네트워크가 이 주소로의 결제를 거부합니다. 받는 사람에게 차폐 주소(zs로 시작)를 요청하세요.",
},
"mining_payout_needs_shielded": {
"de": "Pools zahlen mit gewöhnlichen Transaktionen aus, die bei DragonX keine Mining-Adresse (transparent, R…) erreichen können. Verwenden Sie eine abgeschirmte Adresse (sie beginnt mit zs) als Auszahlungsadresse.",
"es": "Los pools pagan con transacciones normales, que en DragonX no pueden llegar a una dirección de minería (transparente, R…). Usa una dirección blindada (empieza por zs) como dirección de pago.",
"fr": "Les pools paient avec des transactions ordinaires, qui ne peuvent pas atteindre une adresse de minage (transparente, R…) sur DragonX. Utilisez une adresse blindée (elle commence par zs) comme adresse de paiement.",
"pt": "Os pools pagam com transações comuns, que no DragonX não conseguem chegar a um endereço de mineração (transparente, R…). Use um endereço blindado (começa com zs) como endereço de pagamento.",
"ru": "Пулы выплачивают обычными транзакциями, которые в DragonX не могут дойти до адреса для майнинга (прозрачного, R…). Укажите экранированный адрес (начинается с zs) как адрес для выплат.",
"zh": "矿池使用普通交易付款,而在 DragonX 上普通交易无法到达挖矿(透明,R…)地址。请使用屏蔽地址(以 zs 开头)作为收款地址。",
"ja": "プールは通常のトランザクションで支払いますが、DragonX ではそれがマイニング(透明、R…)アドレスに届きません。支払い先にはシールドアドレス(zs で始まる)を使用してください。",
"ko": "풀은 일반 트랜잭션으로 지급하는데, DragonX에서는 이것이 채굴(투명, R…) 주소에 도달할 수 없습니다. 지급 주소로 차폐 주소(zs로 시작)를 사용하세요.",
},
"new_mining_address": {
"de": "Neue Mining-Adresse",
"es": "Nueva dirección de minería",
"fr": "Nouvelle adresse de minage",
"pt": "Novo endereço de mineração",
"ru": "Новый адрес для майнинга",
"zh": "新建挖矿地址",
"ja": "新しいマイニングアドレス",
"ko": "새 채굴 주소",
},
"tt_new_mining_address": {
"de": "Erstellt eine transparente Adresse (R…) für Solo-Mining-Belohnungen. Sie kann keine Zahlungen empfangen – teilen Sie dafür eine abgeschirmte Adresse.",
"es": "Crea una dirección transparente (R…) para recompensas de minería en solitario. No puede recibir pagos; para eso comparte una dirección blindada.",
"fr": "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.",
"pt": "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.",
"ru": "Создаёт прозрачный адрес (R…) для наград за соло-майнинг. Он не принимает платежи — для них сообщайте экранированный адрес.",
"zh": "创建一个用于单人挖矿奖励的透明(R…)地址。它无法接收付款——收款请提供屏蔽地址。",
"ja": "ソロマイニング報酬用の透明(R…)アドレスを作成します。支払いは受け取れません。支払いにはシールドアドレスを共有してください。",
"ko": "솔로 채굴 보상용 투명(R…) 주소를 만듭니다. 결제는 받을 수 없으니, 결제용으로는 차폐 주소를 공유하세요.",
},
"mining_address": {
"de": "Mining-Adresse",
"es": "Dirección de minería",
"fr": "Adresse de minage",
"pt": "Endereço de mineração",
"ru": "Адрес для майнинга",
"zh": "挖矿地址",
"ja": "マイニングアドレス",
"ko": "채굴 주소",
},
"addr_type_mining": {
"de": "Mining",
"es": "Minería",
"fr": "Minage",
"pt": "Mineração",
"ru": "Майнинг",
"zh": "挖矿",
"ja": "マイニング",
"ko": "채굴",
},
"mining_rewards": {
"de": "Mining-Belohnungen",
"es": "Recompensas de minería",
"fr": "Récompenses de minage",
"pt": "Recompensas de mineração",
"ru": "Награды за майнинг",
"zh": "挖矿奖励",
"ja": "マイニング報酬",
"ko": "채굴 보상",
},
"mining_rewards_unshielded": {
"de": "Noch nicht abgeschirmt",
"es": "Aún sin blindar",
"fr": "Pas encore blindées",
"pt": "Ainda não blindadas",
"ru": "Ещё не экранированы",
"zh": "尚未屏蔽",
"ja": "未シールド",
"ko": "아직 차폐되지 않음",
},
"baltab_mining_addresses_count": {
"de": "%d Mining-Adressen",
"es": "%d direcciones de minería",
"fr": "%d adresses de minage",
"pt": "%d endereços de mineração",
"ru": "Адресов для майнинга: %d",
"zh": "%d 个挖矿地址",
"ja": "マイニングアドレス %d 件",
"ko": "채굴 주소 %d개",
},
"baltab_mining_amount": {
"de": "Mining-Belohnungen %.8f",
"es": "Recompensas de minería %.8f",
"fr": "Récompenses de minage %.8f",
"pt": "Recompensas de mineração %.8f",
"ru": "Награды за майнинг %.8f",
"zh": "挖矿奖励 %.8f",
"ja": "マイニング報酬 %.8f",
"ko": "채굴 보상 %.8f",
},
"balance_mining_fmt": {
"de": "Mining-Belohnungen: %.8f",
"es": "Recompensas de minería: %.8f",
"fr": "Récompenses de minage : %.8f",
"pt": "Recompensas de mineração: %.8f",
"ru": "Награды за майнинг: %.8f",
"zh": "挖矿奖励:%.8f",
"ja": "マイニング報酬:%.8f",
"ko": "채굴 보상: %.8f",
},
"mining_card_tooltip": {
"de": "Solo-Mining-Belohnungen gehen an Ihre Mining-Adressen (transparent, R…). Diese Adressen können keine Zahlungen empfangen. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
"es": "Las recompensas de minería en solitario llegan a tus direcciones de minería (transparentes, R…). Esas direcciones no pueden recibir pagos. Las recompensas se blindan automáticamente cuando maduran.",
"fr": "Les récompenses de minage en solo arrivent sur vos adresses de minage (transparentes, R…). Ces adresses ne peuvent pas recevoir de paiements. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
"pt": "As recompensas de mineração solo chegam aos seus endereços de mineração (transparentes, R…). Esses endereços não podem receber pagamentos. As recompensas são blindadas automaticamente quando amadurecem.",
"ru": "Награды за соло-майнинг поступают на ваши адреса для майнинга (прозрачные, R…). Эти адреса не принимают платежи. Награды экранируются автоматически, когда созревают.",
"zh": "单人挖矿奖励会发送到您的挖矿(透明,R…)地址。这些地址无法接收付款。奖励成熟后会自动屏蔽。",
"ja": "ソロマイニング報酬はマイニング(透明、R…)アドレスに届きます。これらのアドレスは支払いを受け取れません。報酬は成熟すると自動的にシールドされます。",
"ko": "솔로 채굴 보상은 채굴(투명, R…) 주소로 들어옵니다. 이 주소는 결제를 받을 수 없습니다. 보상은 성숙하면 자동으로 차폐됩니다.",
},
"mining_card_click_to_shield": {
"de": "Klicken, um gereifte Belohnungen jetzt abzuschirmen.",
"es": "Haz clic para blindar ahora las recompensas maduras.",
"fr": "Cliquez pour blinder dès maintenant les récompenses arrivées à maturité.",
"pt": "Clique para blindar agora as recompensas amadurecidas.",
"ru": "Нажмите, чтобы экранировать созревшие награды сейчас.",
"zh": "点击立即屏蔽已成熟的奖励。",
"ja": "クリックすると成熟した報酬を今すぐシールドします。",
"ko": "클릭하면 성숙한 보상을 지금 차폐합니다.",
},
"contact_chip_mining": {
"de": "Nur Mining",
"es": "Solo minería",
"fr": "Minage seul",
"pt": "Só mineração",
"ru": "Только майнинг",
"zh": "仅限挖矿",
"ja": "マイニング専用",
"ko": "채굴 전용",
},
"failed_create_mining_address": {
"de": "Mining-Adresse konnte nicht erstellt werden",
"es": "No se pudo crear la dirección de minería",
"fr": "Impossible de créer l'adresse de minage",
"pt": "Falha ao criar o endereço de mineração",
"ru": "Не удалось создать адрес для майнинга",
"zh": "创建挖矿地址失败",
"ja": "マイニングアドレスを作成できませんでした",
"ko": "채굴 주소를 만들지 못했습니다",
},
"new_mining_address_created": {
"de": "Neue Mining-Adresse erstellt",
"es": "Nueva dirección de minería creada",
"fr": "Nouvelle adresse de minage créée",
"pt": "Novo endereço de mineração criado",
"ru": "Новый адрес для майнинга создан",
"zh": "已创建新的挖矿地址",
"ja": "新しいマイニングアドレスを作成しました",
"ko": "새 채굴 주소를 만들었습니다",
},
"appx_node_busy_try_later": {
"de": "Der Node ist mit einer langen Aufgabe beschäftigt (etwa dem Neuaufbau der Note-Witnesses). Versuchen Sie es erneut, wenn sie abgeschlossen ist; ein Stoppen jetzt könnte diese Arbeit von vorn beginnen lassen.",
"es": "El nodo está ocupado terminando una tarea larga (como reconstruir los testigos de notas). Vuelve a intentarlo cuando termine; detenerlo ahora podría hacer que empiece ese trabajo de nuevo.",
"fr": "Le nœud termine une longue tâche (comme la reconstruction des témoins de notes). Réessayez une fois qu'elle est finie ; l'arrêter maintenant pourrait lui faire recommencer ce travail.",
"pt": "O nó está ocupado concluindo uma tarefa longa (como reconstruir as testemunhas de notas). Tente novamente quando terminar; pará-lo agora pode fazê-lo recomeçar esse trabalho.",
"ru": "Узел занят долгой задачей (например, перестройкой свидетелей заметок). Повторите попытку, когда она завершится; если остановить его сейчас, эту работу, возможно, придётся начать заново.",
"zh": "节点正忙于完成一项耗时任务(例如重建票据见证)。请在完成后重试;现在停止它可能导致这项工作从头开始。",
"ja": "ノードは長時間の処理(ノートウィットネスの再構築など)を実行中です。完了してからもう一度お試しください。今停止すると、その処理を最初からやり直すことがあります。",
"ko": "노드가 긴 작업(노트 위트니스 재구성 등)을 마무리하는 중입니다. 끝난 뒤 다시 시도하세요. 지금 중지하면 그 작업을 처음부터 다시 할 수 있습니다.",
},
"appx_node_not_stopped": {
"de": "Der Node wurde nicht rechtzeitig beendet, daher wurde nichts geändert. Versuchen Sie es in ein paar Minuten erneut.",
"es": "El nodo no se detuvo a tiempo, así que no se cambió nada. Vuelve a intentarlo en unos minutos.",
"fr": "Le nœud ne s'est pas arrêté à temps, rien n'a donc été modifié. Réessayez dans quelques minutes.",
"pt": "O nó não parou a tempo, por isso nada foi alterado. Tente novamente em alguns minutos.",
"ru": "Узел не остановился вовремя, поэтому ничего не изменено. Повторите попытку через несколько минут.",
"zh": "节点未能及时停止,因此未做任何更改。请几分钟后重试。",
"ja": "ノードが時間内に停止しなかったため、何も変更されませんでした。数分後にもう一度お試しください。",
"ko": "노드가 제때 중지되지 않아 아무것도 변경되지 않았습니다. 몇 분 후 다시 시도하세요.",
},
# --- Daemon shutdown hardening: stopping the node on quit (never kill unasked) ---
"shutdown_leave_running": {
"de": "Wallet schließen – Node weiterlaufen lassen", "es": "Cerrar la cartera y dejar el nodo en marcha",
"fr": "Fermer le portefeuille — laisser le nœud actif", "pt": "Fechar a carteira e deixar o nó em execução",
"ru": "Закрыть кошелёк — оставить узел работать", "zh": "关闭钱包——保持节点运行",
"ja": "ウォレットを閉じる — ノードは実行したまま", "ko": "지갑 닫기 — 노드는 계속 실행"
},
"shutdown_leave_running_tip": {
"de": "Schließt die Wallet sofort. Der Node läuft allein weiter und beendet, was er gerade tut; nichts geht verloren.",
"es": "Cierra la cartera ahora. El nodo sigue funcionando por su cuenta y termina lo que está haciendo; no se pierde nada.",
"fr": "Ferme le portefeuille maintenant. Le nœud continue de fonctionner seul et termine ce qu'il fait ; rien n'est perdu.",
"pt": "Fecha a carteira agora. O nó continua em execução sozinho e termina o que está fazendo; nada é perdido.",
"ru": "Закрывает кошелёк сейчас. Узел продолжит работать сам и завершит текущую операцию; ничего не теряется.",
"zh": "立即关闭钱包。节点会继续独立运行并完成当前操作,不会丢失任何数据。",
"ja": "ウォレットをすぐに閉じます。ノードは単独で実行を続け、処理中の作業を完了します。何も失われません。",
"ko": "지갑을 지금 닫습니다. 노드는 혼자 계속 실행되며 진행 중인 작업을 마칩니다. 잃는 것은 없습니다."
},
"shutdown_kill_node": {
"de": "Node erzwungen beenden (unsicher)", "es": "Forzar cierre del nodo (inseguro)",
"fr": "Tuer le nœud (risqué)", "pt": "Forçar encerramento do nó (inseguro)",
"ru": "Принудительно завершить узел (небезопасно)", "zh": "强制结束节点(不安全)",
"ja": "ノードを強制終了(危険)", "ko": "노드 강제 종료(위험)"
},
"shutdown_kill_node_tip": {
"de": "Beendet den Node sofort. Alles, was er noch nicht gespeichert hat, geht verloren, und beim nächsten Start spielt er die Blöcke seit dem letzten Speichern erneut ein.",
"es": "Detiene el nodo de inmediato. Se pierde todo lo que no haya guardado, y en el próximo inicio volverá a procesar los bloques desde el último guardado.",
"fr": "Arrête le nœud immédiatement. Tout ce qu'il n'a pas enregistré est perdu, et au prochain démarrage il rejouera les blocs depuis son dernier enregistrement.",
"pt": "Encerra o nó imediatamente. Tudo o que não foi salvo se perde, e no próximo início ele reprocessa os blocos desde o último salvamento.",
"ru": "Немедленно завершает узел. Всё несохранённое будет потеряно, а при следующем запуске он заново обработает блоки с момента последнего сохранения.",
"zh": "立即结束节点。所有尚未保存的数据都会丢失,下次启动时会重新处理自上次保存以来的区块。",
"ja": "ノードを直ちに終了します。保存されていない内容はすべて失われ、次回の起動時に前回の保存以降のブロックを再処理します。",
"ko": "노드를 즉시 종료합니다. 저장되지 않은 내용은 모두 사라지며, 다음 시작 시 마지막 저장 이후의 블록을 다시 처리합니다."
},
"shutdown_kill_confirm_title": {
"de": "Node erzwungen beenden?", "es": "¿Forzar el cierre del nodo?", "fr": "Tuer le nœud ?",
"pt": "Forçar o encerramento do nó?", "ru": "Принудительно завершить узел?", "zh": "强制结束节点?",
"ja": "ノードを強制終了しますか?", "ko": "노드를 강제 종료할까요?"
},
"shutdown_kill_confirm_msg": {
"de": "Der Node zeigt seit Langem keine CPU- oder Festplattenaktivität und hängt möglicherweise.\nWenn Sie ihn erzwungen beenden, geht alles Ungespeicherte verloren; sein nächster Start kann deutlich länger dauern.",
"es": "El nodo lleva mucho tiempo sin actividad de CPU ni de disco y puede estar bloqueado.\nSi fuerzas su cierre se perderá todo lo que no haya guardado; su próximo inicio puede tardar mucho más.",
"fr": "Le nœud ne montre plus d'activité processeur ni disque depuis longtemps et est peut-être bloqué.\nLe tuer fait perdre tout ce qu'il n'a pas enregistré ; son prochain démarrage peut être beaucoup plus long.",
"pt": "O nó não mostra atividade de CPU nem de disco há muito tempo e pode estar travado.\nForçar o encerramento perde tudo o que não foi salvo; o próximo início pode demorar muito mais.",
"ru": "Узел давно не проявляет активности процессора и диска и, возможно, завис.\nПри принудительном завершении всё несохранённое будет потеряно; следующий запуск может занять гораздо больше времени.",
"zh": "节点已长时间没有 CPU 或磁盘活动,可能已卡住。\n强制结束会丢失所有未保存的数据;下次启动可能需要更长时间。",
"ja": "ノードは長時間 CPU やディスクの活動がなく、停止している可能性があります。\n強制終了すると保存されていない内容はすべて失われ、次回の起動に大幅に時間がかかることがあります。",
"ko": "노드가 오랫동안 CPU나 디스크 활동이 없어 멈췄을 수 있습니다.\n강제 종료하면 저장되지 않은 내용이 모두 사라지고, 다음 시작이 훨씬 오래 걸릴 수 있습니다."
},
"shutdown_kill_confirm_yes": {
"de": "Erzwungen beenden", "es": "Forzar cierre", "fr": "Tuer le nœud", "pt": "Forçar encerramento",
"ru": "Завершить узел", "zh": "强制结束", "ja": "強制終了", "ko": "강제 종료"
},
"shutdown_stop_asking": {
"de": "Node wird zum Beenden aufgefordert…", "es": "Pidiendo al nodo que se detenga…",
"fr": "Demande d'arrêt envoyée au nœud…", "pt": "Pedindo ao nó para parar…",
"ru": "Отправка узлу команды остановки…", "zh": "正在请求节点停止…",
"ja": "ノードに停止を要求しています…", "ko": "노드에 중지를 요청하는 중…"
},
"shutdown_stop_queued": {
"de": "Der Node hat die Stopp-Anforderung erhalten, ist aber beschäftigt – er stoppt, sobald er kann",
"es": "El nodo recibió la orden de detenerse, pero está ocupado; se detendrá en cuanto pueda",
"fr": "Le nœud a reçu la demande d'arrêt mais il est occupé — il s'arrêtera dès que possible",
"pt": "O nó recebeu o pedido de parada, mas está ocupado; ele vai parar assim que puder",
"ru": "Узел получил команду остановки, но занят — он остановится, как только сможет",
"zh": "节点已收到停止请求,但正忙——它会尽快停止",
"ja": "ノードは停止要求を受け取りましたが処理中です — 可能になり次第停止します",
"ko": "노드가 중지 요청을 받았지만 작업 중입니다 — 가능해지면 중지됩니다"
},
"shutdown_stop_delivered": {
"de": "Der Node fährt herunter und speichert seinen Zustand", "es": "El nodo se está cerrando y guardando su estado",
"fr": "Le nœud s'arrête et enregistre son état", "pt": "O nó está encerrando e salvando seu estado",
"ru": "Узел завершает работу и сохраняет своё состояние", "zh": "节点正在关闭并保存其状态",
"ja": "ノードを終了し、状態を保存しています", "ko": "노드가 종료 중이며 상태를 저장하고 있습니다"
},
"shutdown_stop_cpu_suffix": {
"de": " · CPU %d%%", "es": " · CPU %d%%", "fr": " · CPU %d%%", "pt": " · CPU %d%%",
"ru": " · ЦП %d%%", "zh": " · CPU %d%%", "ja": " · CPU %d%%", "ko": " · CPU %d%%"
},
"shutdown_stop_idle": {
"de": "Seit %d Min. keine CPU- oder Festplattenaktivität – der Node hängt möglicherweise",
"es": "Sin actividad de CPU ni de disco desde hace %d min; puede que el nodo esté bloqueado",
"fr": "Aucune activité processeur ni disque depuis %d min — le nœud est peut-être bloqué",
"pt": "Sem atividade de CPU ou disco há %d min; o nó pode estar travado",
"ru": "Нет активности процессора и диска уже %d мин — возможно, узел завис",
"zh": "已 %d 分钟没有 CPU 或磁盘活动——节点可能已卡住",
"ja": "%d 分間 CPU やディスクの活動がありません — ノードが停止している可能性があります",
"ko": "%d분 동안 CPU나 디스크 활동이 없습니다 — 노드가 멈췄을 수 있습니다"
},
# --- The node is busy: overlay, banner, status bar ---
"node_busy_rebuild_title": {
"de": "Node beschäftigt: Note-Witnesses werden neu aufgebaut",
"es": "Nodo ocupado: reconstruyendo testigos de notas",
"fr": "Nœud occupé : reconstruction des témoins de notes",
"pt": "Nó ocupado: reconstruindo testemunhas de notas",
"ru": "Узел занят: перестройка свидетелей заметок",
"zh": "节点繁忙:正在重建票据见证",
"ja": "ノード処理中:ノートウィットネスを再構築しています",
"ko": "노드 작업 중: 노트 위트니스 재구성 중",
},
"node_busy_running": {
"de": "Läuft seit %s",
"es": "En curso desde hace %s",
"fr": "En cours depuis %s",
"pt": "Em andamento há %s",
"ru": "Выполняется %s",
"zh": "已运行 %s",
"ja": "経過時間 %s",
"ko": "%s 동안 진행 중",
},
"node_busy_rebuild_desc": {
"de": "Läuft seit %s. Bei großen Wallets kann das etwa eine Stunde dauern, und der Node kann der Wallet erst danach antworten.",
"es": "En curso desde hace %s. En carteras grandes puede tardar alrededor de una hora, y el nodo no puede responder a la cartera hasta que termine.",
"fr": "En cours depuis %s. Pour un gros portefeuille, cela peut prendre environ une heure, et le nœud ne peut pas répondre au portefeuille avant la fin.",
"pt": "Em andamento há %s. Em carteiras grandes pode levar cerca de uma hora, e o nó não consegue responder à carteira até terminar.",
"ru": "Выполняется %s. Для больших кошельков это может занять около часа, и до окончания узел не может отвечать кошельку.",
"zh": "已运行 %s。大型钱包可能需要约一小时,完成前节点无法响应钱包。",
"ja": "経過時間 %s。大きなウォレットでは約 1 時間かかることがあり、完了するまでノードはウォレットに応答できません。",
"ko": "%s 동안 진행 중입니다. 큰 지갑은 약 1시간이 걸릴 수 있으며, 끝날 때까지 노드가 지갑에 응답할 수 없습니다."
},
"node_busy_rebuild_reading": {
"de": "Blöcke werden gelesen: %ld / %ld", "es": "Leyendo bloques: %ld / %ld", "fr": "Lecture des blocs : %ld / %ld",
"pt": "Lendo blocos: %ld / %ld", "ru": "Чтение блоков: %ld / %ld", "zh": "正在读取区块:%ld / %ld",
"ja": "ブロックを読み込み中:%ld / %ld", "ko": "블록 읽는 중: %ld / %ld"
},
"node_busy_rebuild_extending": {
"de": "Witnesses werden erweitert (der Node meldet für diesen Schritt keinen Fortschritt).",
"es": "Ampliando testigos (el nodo no informa del progreso de este paso).",
"fr": "Extension des témoins (le nœud ne signale aucune progression pour cette étape).",
"pt": "Estendendo testemunhas (o nó não informa o progresso desta etapa).",
"ru": "Расширение свидетелей (узел не сообщает о ходе этого этапа).",
"zh": "正在扩展见证(此步骤节点不报告进度)。",
"ja": "ウィットネスを拡張しています(この段階ではノードは進捗を報告しません)。",
"ko": "위트니스 확장 중(이 단계에서는 노드가 진행 상황을 보고하지 않습니다)."
},
"node_busy_replay_title": {
"de": "Node beschäftigt: Blöcke werden nachgeholt", "es": "Nodo ocupado: poniéndose al día con los bloques",
"fr": "Nœud occupé : rattrapage des blocs", "pt": "Nó ocupado: recuperando blocos",
"ru": "Узел занят: догоняет блоки", "zh": "节点繁忙:正在追赶区块",
"ja": "ノード処理中:ブロックを追いかけています", "ko": "노드 작업 중: 블록 따라잡는 중"
},
"node_busy_replay_desc": {
"de": "Läuft seit %s. Der Node wendet Blöcke erneut an, die er vor dem letzten Beenden empfangen hat.",
"es": "En curso desde hace %s. El nodo está volviendo a aplicar bloques que recibió antes de detenerse por última vez.",
"fr": "En cours depuis %s. Le nœud réapplique les blocs reçus avant son dernier arrêt.",
"pt": "Em andamento há %s. O nó está reaplicando blocos que recebeu antes da última parada.",
"ru": "Выполняется %s. Узел повторно применяет блоки, полученные до последней остановки.",
"zh": "已运行 %s。节点正在重新应用上次停止前收到的区块。",
"ja": "経過時間 %s。ノードは前回停止する前に受信したブロックを再適用しています。",
"ko": "%s 동안 진행 중입니다. 노드가 마지막으로 멈추기 전에 받은 블록을 다시 적용하고 있습니다."
},
"node_busy_rpc_title": {
"de": "Node beschäftigt: antwortet noch nicht", "es": "Nodo ocupado: aún no responde",
"fr": "Nœud occupé : ne répond pas encore", "pt": "Nó ocupado: ainda não responde",
"ru": "Узел занят: пока не отвечает", "zh": "节点繁忙:暂未响应",
"ja": "ノード処理中:まだ応答していません", "ko": "노드 작업 중: 아직 응답하지 않음"
},
"node_busy_rpc_desc": {
"de": "Der Node läuft, ist aber seit %s zu beschäftigt, um der Wallet zu antworten – meist wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses.",
"es": "El nodo está en marcha, pero lleva %s demasiado ocupado para responder a la cartera, normalmente por una operación larga como la reconstrucción de testigos.",
"fr": "Le nœud fonctionne mais est trop occupé pour répondre au portefeuille depuis %s, généralement à cause d'une longue opération comme la reconstruction des témoins.",
"pt": "O nó está em execução, mas está ocupado demais para responder à carteira há %s, geralmente por causa de uma operação longa como a reconstrução de testemunhas.",
"ru": "Узел работает, но уже %s слишком занят, чтобы отвечать кошельку, обычно из-за долгой операции, например перестройки свидетелей.",
"zh": "节点正在运行,但已有 %s 忙于其他任务而无法响应钱包,通常是因为见证重建等耗时操作。",
"ja": "ノードは実行中ですが、%s の間ウォレットに応答できないほど処理中です。通常はウィットネスの再構築などの長時間の処理が原因です。",
"ko": "노드는 실행 중이지만 %s 동안 지갑에 응답할 수 없을 만큼 바쁩니다. 보통 위트니스 재구성 같은 긴 작업 때문입니다."
},
"node_starting_title": {
"de": "DragonX-Node wird gestartet…", "es": "Iniciando el nodo DragonX…", "fr": "Démarrage du nœud DragonX…",
"pt": "Iniciando o nó DragonX…", "ru": "Запуск узла DragonX…", "zh": "正在启动 DragonX 节点…",
"ja": "DragonX ノードを起動しています…", "ko": "DragonX 노드 시작 중…"
},
"node_starting_busy_desc": {
"de": "Der Node ist erreichbar, aber beschäftigt und antwortet noch nicht. Das ist nach einem Update oder beim ersten Start normal und kann einige Minuten dauern.",
"es": "El nodo está accesible pero ocupado y aún no responde. Es normal tras una actualización o en el primer inicio; puede tardar unos minutos.",
"fr": "Le nœud est joignable mais occupé et ne répond pas encore. C'est normal après une mise à jour ou au premier lancement ; cela peut prendre quelques minutes.",
"pt": "O nó está acessível, mas ocupado, e ainda não responde. Isso é normal após uma atualização ou na primeira execução; pode levar alguns minutos.",
"ru": "Узел доступен, но занят и пока не отвечает. Это нормально после обновления или при первом запуске; может занять несколько минут.",
"zh": "节点可以访问,但正忙,暂未响应。更新后或首次启动时属正常现象,可能需要几分钟。",
"ja": "ノードには接続できますが処理中のため、まだ応答していません。更新後や初回起動時には正常な状態で、数分かかることがあります。",
"ko": "노드에 연결할 수 있지만 바빠서 아직 응답하지 않습니다. 업데이트 후나 처음 실행할 때는 정상이며, 몇 분 걸릴 수 있습니다."
},
"node_starting_launch_desc": {
"de": "dragonxd wird gestartet, warte auf Verbindung…", "es": "Iniciando dragonxd y esperando a que esté en línea…",
"fr": "Lancement de dragonxd, en attente de sa disponibilité…", "pt": "Iniciando o dragonxd e aguardando ficar online…",
"ru": "Запуск dragonxd и ожидание его готовности…", "zh": "正在启动 dragonxd 并等待其上线…",
"ja": "dragonxd を起動し、オンラインになるのを待っています…", "ko": "dragonxd를 시작하고 온라인 상태가 되기를 기다리는 중…"
},
"sb_node_busy": {
"de": "Node beschäftigt", "es": "Nodo ocupado", "fr": "Nœud occupé", "pt": "Nó ocupado",
"ru": "Узел занят", "zh": "节点繁忙", "ja": "ノード処理中", "ko": "노드 작업 중"
},
# --- Quitting while the node is busy ---
"stopconfirm_rebuild_title": {
"de": "Der Node baut die Note-Witnesses neu auf", "es": "El nodo está reconstruyendo los testigos de notas",
"fr": "Le nœud reconstruit les témoins de notes", "pt": "O nó está reconstruindo as testemunhas de notas",
"ru": "Узел перестраивает свидетелей заметок", "zh": "节点正在重建票据见证",
"ja": "ノードはノートウィットネスを再構築しています", "ko": "노드가 노트 위트니스를 재구성하고 있습니다"
},
"stopconfirm_rebuild_body": {
"de": "Er läuft seit %s, und bei großen Wallets kann das etwa eine Stunde dauern. Wenn Sie den Node jetzt stoppen, wird entweder bis zum Ende des Neuaufbaus gewartet, oder der nächste Start muss den Neuaufbau samt Nachholen wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
"es": "Lleva %s en curso, y en carteras grandes puede tardar alrededor de una hora. Detener el nodo ahora o bien espera a que termine la reconstrucción, o bien hace que el próximo inicio la repita junto con cualquier puesta al día.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
"fr": "Elle est en cours depuis %s, et pour un gros portefeuille elle peut prendre environ une heure. Arrêter le nœud maintenant attend la fin de la reconstruction, ou oblige son prochain démarrage à la refaire, rattrapage compris.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
"pt": "Está em andamento há %s, e em carteiras grandes pode levar cerca de uma hora. Parar o nó agora ou espera a reconstrução terminar, ou faz o próximo início refazê-la junto com qualquer recuperação.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
"ru": "Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
"zh": "已运行 %s,大型钱包可能需要约一小时。现在停止节点,要么会等待重建完成,要么下次启动时需要重新进行重建以及区块追赶。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
"ja": "経過時間は %s で、大きなウォレットでは約 1 時間かかることがあります。今ノードを停止すると、再構築の完了まで待つか、次回の起動時に再構築とブロックの追いつきをやり直すことになります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
"ko": "%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다."
},
"stopconfirm_busy_title": {
"de": "Der Node ist beschäftigt", "es": "El nodo está ocupado", "fr": "Le nœud est occupé",
"pt": "O nó está ocupado", "ru": "Узел занят", "zh": "节点正忙", "ja": "ノードは処理中です",
"ko": "노드가 작업 중입니다"
},
"stopconfirm_busy_body": {
"de": "Er ist seit %s zu beschäftigt, um der Wallet zu antworten – vermutlich wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses. Wenn Sie ihn jetzt stoppen, muss der nächste Start diese Arbeit eventuell wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
"es": "Lleva %s demasiado ocupado para responder a la cartera, probablemente por una operación larga como la reconstrucción de testigos. Detenerlo ahora puede hacer que el próximo inicio repita ese trabajo.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
"fr": "Il est trop occupé pour répondre au portefeuille depuis %s, probablement à cause d'une longue opération comme la reconstruction des témoins. L'arrêter maintenant peut obliger son prochain démarrage à refaire ce travail.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
"pt": "Está ocupado demais para responder à carteira há %s, provavelmente por causa de uma operação longa como a reconstrução de testemunhas. Pará-lo agora pode fazer o próximo início refazer esse trabalho.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
"ru": "Уже %s он слишком занят, чтобы отвечать кошельку, вероятно, из-за долгой операции, например перестройки свидетелей. Если остановить его сейчас, при следующем запуске эту работу, возможно, придётся выполнить заново.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
"zh": "节点已有 %s 忙于其他任务而无法响应钱包,可能是因为见证重建等耗时操作。现在停止它,下次启动时可能需要重做这些工作。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
"ja": "%s の間、ウォレットに応答できないほど処理中です。ウィットネスの再構築などの長時間の処理が原因と考えられます。今停止すると、次回の起動時にその処理をやり直すことがあります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
"ko": "%s 동안 지갑에 응답할 수 없을 만큼 바빴습니다. 위트니스 재구성 같은 긴 작업 때문일 가능성이 큽니다. 지금 중지하면 다음 시작 시 그 작업을 다시 해야 할 수 있습니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다."
},
"stopconfirm_stop_wait": {
"de": "Node stoppen & warten", "es": "Detener el nodo y esperar", "fr": "Arrêter le nœud et attendre",
"pt": "Parar o nó e aguardar", "ru": "Остановить узел и подождать", "zh": "停止节点并等待",
"ja": "ノードを停止して待つ", "ko": "노드 중지 후 대기"
},
"appx_daemon_reinstall_node_busy": {
"de": "Der Node läuft noch, daher wurde der Daemon nicht neu installiert. Versuchen Sie es erneut, sobald er beendet ist.",
"es": "El nodo sigue en marcha, así que no se reinstaló el daemon. Vuelve a intentarlo cuando se haya detenido.",
"fr": "Le nœud est toujours actif, le démon n'a donc pas été réinstallé. Réessayez une fois qu'il sera arrêté.",
"pt": "O nó ainda está em execução, por isso o daemon não foi reinstalado. Tente novamente quando ele tiver parado.",
"ru": "Узел всё ещё работает, поэтому демон не был переустановлен. Повторите попытку, когда он остановится.",
"zh": "节点仍在运行,因此未重新安装守护进程。请在它停止后重试。",
"ja": "ノードがまだ実行中のため、デーモンは再インストールされませんでした。停止してからもう一度お試しください。",
"ko": "노드가 아직 실행 중이라 데몬을 다시 설치하지 않았습니다. 노드가 멈춘 뒤 다시 시도하세요."
},
# --- Balance tab ---
"hide_zero_balances": {
"es": "Ocultar saldos 0", "de": "Nullsalden ausblenden", "fr": "Masquer les soldes à 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
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Cross-compile the test suite (ObsidianDragonTests.exe) and the fake daemon (tools/fake_dragonxd)
# for Windows with mingw-w64, then run the tests: directly under WSL (Windows interop), else with
# wine if installed, else just build.
#
# scripts/test-windows.sh [--no-run] [--jobs N]
#
# Uses its own build/windows-tests tree (full-node config), so build/windows is left alone. Needs
# the mingw toolchain file that ./build.sh --win-release writes to build/windows/. Dependency sources
# already fetched into build/windows/_deps are reused, so nothing is downloaded twice.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bd="$repo/build/windows-tests"
toolchain="$repo/build/windows/mingw-toolchain.cmake"
deps="$repo/build/windows/_deps"
run=true
jobs=8 # each test TU is large; several parallel mingw builds can exhaust RAM
while [[ $# -gt 0 ]]; do
case "$1" in
--no-run) run=false; shift ;;
--jobs) jobs="$2"; shift 2 ;;
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
if [[ ! -f "$toolchain" ]]; then
echo "missing $toolchain; run ./build.sh --win-release once to create the mingw toolchain" >&2
exit 1
fi
src_args=()
for dep in sdl3 json tomlplusplus sqlite3 curl libwebp; do
if [[ -d "$deps/$dep-src" ]]; then
src_args+=("-DFETCHCONTENT_SOURCE_DIR_${dep^^}=$deps/$dep-src")
fi
done
cmake -S "$repo" -B "$bd" \
-DCMAKE_TOOLCHAIN_FILE="$toolchain" \
-DCMAKE_BUILD_TYPE=Release \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DDRAGONX_BUILD_LITE=OFF \
-DDRAGONX_ENABLE_LITE_BACKEND=OFF \
-DBUILD_TESTING=ON \
-DDRAGONX_BUILD_FAKE_DAEMON=ON \
"${src_args[@]}"
cmake --build "$bd" --target ObsidianDragonTests fake_dragonxd -j "$jobs"
exe="$bd/bin/ObsidianDragonTests.exe"
echo "built: $exe"
echo "built: $bd/fake_dragonxd/dragonxd.exe"
$run || exit 0
# The fixture path compiled into the binary is a Linux path; hand the Windows process its own.
# APPDATA/LOCALAPPDATA point at a sandbox so anything resolving them from the environment stays out
# of the real profile. (SHGetFolderPath-based paths ignore the environment; the tests that would
# write through those are compiled out on Windows.)
export DRAGONX_TEST_FIXTURE_DIR="$repo/tests/fixtures"
sandbox="$bd/test-appdata"
mkdir -p "$sandbox"
export APPDATA="$sandbox" LOCALAPPDATA="$sandbox"
# The fake dragonxd.exe plays a "foreign" node (isolated datadir/port) for the never-kill-by-name test.
export DRAGONX_TEST_FAKE_DAEMON="$bd/fake_dragonxd/dragonxd.exe"
if grep -qi microsoft /proc/version 2>/dev/null && [[ -e /proc/sys/fs/binfmt_misc/WSLInterop ]]; then
# /p: translate each to a Windows path for the Windows process
export WSLENV="${WSLENV:+$WSLENV:}DRAGONX_TEST_FIXTURE_DIR/p:APPDATA/p:LOCALAPPDATA/p:DRAGONX_TEST_FAKE_DAEMON/p"
cd "$bd/bin"
exec "$exe"
elif command -v wine >/dev/null 2>&1; then
export DRAGONX_TEST_FIXTURE_DIR="$(winepath -w "$DRAGONX_TEST_FIXTURE_DIR")"
export DRAGONX_TEST_FAKE_DAEMON="$(winepath -w "$DRAGONX_TEST_FAKE_DAEMON")"
cd "$bd/bin"
exec wine "$exe"
else
echo "not on WSL and no wine: copy $exe (and tests/fixtures) to Windows to run it" >&2
fi
+2155 -774
View File
File diff suppressed because it is too large Load Diff
+715 -80
View File
File diff suppressed because it is too large Load Diff
+2731 -684
View File
File diff suppressed because it is too large Load Diff
+459 -140
View File
File diff suppressed because it is too large Load Diff
+260 -30
View File
@@ -19,6 +19,7 @@
#include "ui/notifications.h"
#include "ui/sidebar.h"
#include "ui/windows/send_tab.h"
#include "ui/windows/receive_tab.h"
#include "ui/windows/wallets_dialog.h"
#include "ui/windows/export_transactions_dialog.h"
#include "ui/windows/export_all_keys_dialog.h"
@@ -33,8 +34,15 @@
#include "ui/windows/address_transfer_dialog.h"
#include "ui/windows/key_export_dialog.h"
#include "ui/windows/contacts_tab.h"
#include "ui/windows/chat_tab.h"
#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"
#include "wallet/wallet_capabilities.h"
#include "imgui.h"
@@ -94,28 +102,30 @@ const char* kDemoZAddr =
const char* kDemoTxid = "b647077c471fdd2877d35c9d8b70e3c785547fae618458b4068d913ee3d1dc4f";
// Contacts-view sweep: seed a few demo contacts (Z + T types, some global) so the Cards/List/Table
// modes render with data, and restore the real book afterward. Uses sweepSetEntries (no disk write),
// so the user's persisted address book is never touched even if the sweep is interrupted.
std::vector<data::AddressBookEntry> s_contactsSweepBackup;
// modes render with data, and restore the real book afterward. sweepBegin() snapshots the real book on
// its first call only (this setup re-runs every frame) and latches save()/load(), so the user's
// persisted address book is never touched, even if the sweep is interrupted.
void seedSweepContacts(App& a)
{
s_contactsSweepBackup = a.addressBook().entries();
data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global
e1.avatar = "icon:account_balance"; // icon avatar (verifies the icon-badge render path)
data::AddressBookEntry e2("exchange deposit", "t1DemoTransparentAddressForUiSweep00000", ""); // T
data::AddressBookEntry e2("mining rig", "t1DemoTransparentAddressForUiSweep00000", ""); // T (mining only)
// Scope to the active wallet so it's visible (the tab hides contacts not in the active wallet);
// non-empty hash also keeps it non-global -> no globe badge, so the list shows a global/non-global mix.
e2.scope = a.activeWalletIdentityHash();
data::AddressBookEntry e3("cold savings",
"zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000", "long-term storage"); // Z, global
a.addressBook().sweepSetEntries({ e1, e2, e3 });
a.addressBook().sweepBegin({ e1, e2, e3 });
}
// The contacts view mode is restored by Settings::sweepEnd() at sweep end.
void restoreSweepContacts(App& a)
{
a.addressBook().sweepSetEntries(s_contactsSweepBackup);
s_contactsSweepBackup.clear();
if (a.settings()) a.settings()->setContactsViewMode(0);
a.addressBook().sweepEnd();
}
// The full sweep's sandbox: a fresh owner-only dir under the temp dir. Its demo wallet files live in
// <sandbox>/DRAGONX (and DRAGONX-many, extra-wallets, picker-demo), never in the real datadir or config dir.
const char* kSweepSandboxPrefix = "obsidiandragon-sweep-";
} // namespace
std::string App::screenshotDir() const
@@ -131,6 +141,11 @@ std::string App::screenshotFullDir() const
// ── Demo data (full sweep only): make every data-dependent screen render offline ─────────────
void App::installDemoWalletData()
{
// Persistence latches first: until clearDemoWalletData(), nothing the demo surfaces put into
// Settings or the wallet index (or, via seedSweepContacts, the address book) can reach disk.
if (settings_) settings_->sweepBegin();
wallet_index_.sweepBegin();
// Snapshot the state_ fields we mutate (WalletState isn't copy-assignable — reference aliases).
auto& s = sweep_state_snapshot_;
s.connected = state_.connected; s.warming_up = state_.warming_up;
@@ -145,11 +160,16 @@ void App::installDemoWalletData()
s.transactions = state_.transactions;
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;
@@ -200,6 +220,7 @@ void App::installDemoWalletData()
tx("deadbeef000000000000000000000000000000000000000000000000000000000000", "receive", 0.50000000,
1751290000, 0, "zs1demosecondaryshieldedaddressforuisweep0000000000000000000000000", "pending"),
};
state_.touchTransactions();
// Demo portfolio groups so the Market tab's row styles render with real-looking data. Snapshot
// the user's real portfolio first; setPortfolio* only mutate memory (save() is never called
@@ -227,7 +248,7 @@ void App::installDemoWalletData()
settings_->setPortfolioStyle(0);
}
seedChatDemoData();
sweep_chat_identity_fabricated_ = seedChatDemoData();
}
void App::applyHealthyDemoState()
@@ -245,6 +266,46 @@ 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
{
if (settings_ && (settings_->getSeedMigrationPending() || settings_->getSeedMigrationSweepSubmitted() ||
!settings_->getSeedMigrationSweepTxid().empty() ||
!settings_->getSeedMigrationSweepOpid().empty()))
return "a migrate-to-seed is pending (seed_migration_* settings)";
if (show_seed_migration_ || seed_migration_step_ != SeedMigrationStep::Intro || seed_migration_in_flight_)
return "the migrate-to-seed dialog is open";
if (wallet_switch_phase_.load() != 0 || wallet_switch_dialog_open_.load() ||
!pending_switch_wallet_file_.empty())
return "a wallet switch is in progress";
if (wallet_security_workflow_.inProgress() || wallet_security_workflow_.importActive())
return "an encryption workflow is in progress";
if (encrypt_in_progress_ || pin_in_progress_ || lock_unlock_in_progress_)
return "an encrypt, PIN or lock/unlock operation is in progress";
if (show_seed_backup_ || !seed_backup_phrase_.empty())
return "the seed-phrase backup dialog is open";
if (bootstrap_ || bootstrap_downloading_)
return "a bootstrap is in progress";
if (long_lane_.active() || import_in_progress_ || runtime_rescan_active_)
return "a key import or rescan is in progress";
return nullptr;
}
void App::clearDemoWalletData()
{
auto& s = sweep_state_snapshot_;
@@ -260,12 +321,72 @@ void App::clearDemoWalletData()
state_.totalBalance = s.totalBalance; state_.unconfirmedBalance = s.unconfirmedBalance;
state_.addresses = s.addresses; state_.z_addresses = s.z_addresses; state_.t_addresses = s.t_addresses;
state_.transactions = s.transactions;
state_.touchTransactions();
state_.market.price_usd = s.market_price_usd;
state_.market.change_24h = s.market_change_24h;
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,
// e.g. a file a Windows probe thread still holds).
ui::WalletsDialog::hide();
ui::FolderPicker::close();
ui::WalletsDialog::setSweepDataDir({});
if (!sweep_sandbox_.empty()) {
if (!util::removePrivateTempDir(sweep_sandbox_, kSweepSandboxPrefix))
DEBUG_LOGF("[Sweep] could not fully remove the sandbox %s\n", sweep_sandbox_.c_str());
sweep_sandbox_.clear();
}
// Migrate-to-seed state the mig() surfaces faked. Safe to reset: the preflight refused the sweep
// while a real migration was pending or its dialog was open.
show_seed_migration_ = false;
seed_migration_step_ = SeedMigrationStep::Intro;
if (!seed_migration_seed_.empty())
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
seed_migration_seed_.clear();
seed_migration_dest_.clear();
seed_migration_sweep_txid_.clear();
seed_migration_status_.clear();
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;
seed_migration_sweep_confs_ = 0;
seed_migration_legacy_remaining_ = -1.0;
// Chat: drop the demo conversations (in-memory only). If the sweep had to fabricate the demo
// identity, drop it too and re-arm provisioning, or chat would run on that throwaway key for the rest
// of the session. No real identity existed, so the DB was never unlocked under it.
if (chat::hushChatFeatureEnabledAtBuild()) {
for (const char* cid : {"demo-1", "demo-2", "demo-3"})
chat_service_.store().eraseConversation(cid);
if (sweep_chat_identity_fabricated_) {
chat_service_.clearIdentity();
chat_service_.store().clear();
chat_seen_watermark_.clear();
ui::ResetChatTab();
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_fetch_in_flight_ = false;
chat_identity_unavailable_ = false;
++chat_session_generation_; // discard any identity fetch that was in flight
}
}
sweep_chat_identity_fabricated_ = false;
// Address book and wallet index: back to the real data (no disk write).
addressBook().sweepEnd();
wallet_index_.sweepEnd();
// Restore the user's real portfolio (demo groups were in-memory only).
if (g_pfSnapshotValid && settings_) {
settings_->setPortfolioEntries(g_pfSnapshot);
@@ -273,6 +394,10 @@ void App::clearDemoWalletData()
}
g_pfSnapshot.clear();
g_pfSnapshotValid = false;
// LAST: restore the latched settings and flush once if a save was suppressed. Anything restored
// after this point would be flushed with its demo value.
if (settings_) settings_->sweepEnd();
}
// ── Catalog ──────────────────────────────────────────────────────────────────────────────────
@@ -370,6 +495,13 @@ void App::buildSweepCatalog()
add("modal-validate-address", ui::NavPage::Receive,
[](App&) { ui::ValidateAddressDialog::show(); },
[](App&) { ui::ValidateAddressDialog::hide(); });
add("modal-validate-mining", ui::NavPage::Receive,
[](App&) { ui::ValidateAddressDialog::sweepShowMiningResult("t1DemoTransparentAddressForUiSweep00000"); },
[](App&) { ui::ValidateAddressDialog::hide(); });
// A mining (transparent) address selected on Receive: no payment request, no QR — the explainer instead.
add("receive-mining-address", ui::NavPage::Receive,
[](App& a) { ui::SweepSelectReceiveAddress(&a, "t1DemoTransparentAddressForUiSweep00000"); },
[](App& a) { ui::SweepSelectReceiveAddress(&a, nullptr); });
add("modal-address-label", ui::NavPage::Overview,
[](App& a) { ui::AddressLabelDialog::show(&a, kDemoZAddr, true); },
[](App&) { ui::AddressLabelDialog::hide(); });
@@ -393,6 +525,10 @@ void App::buildSweepCatalog()
add("modal-merge", ui::NavPage::Send,
[](App&) { ui::ShieldDialog::showMerge(); },
[](App&) { ui::ShieldDialog::hide(); });
// Consolidate (wallet-size preset): keeps each address's funds in that address by default.
add("modal-consolidate", ui::NavPage::Send,
[](App&) { ui::ShieldDialog::showConsolidate(); },
[](App&) { ui::ShieldDialog::hide(); });
// z->t transfer so the (converted) deshielding DialogWarningHeader renders.
add("modal-transfer", ui::NavPage::Overview,
[](App& a) {
@@ -434,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,
@@ -443,7 +577,13 @@ void App::buildSweepCatalog()
a.show_seed_backup_ = true; a.seed_backup_fetch_started_ = true;
a.seed_backup_loading_ = false; a.seed_backup_no_mnemonic_ = false;
a.seed_backup_status_.clear();
if (a.seed_backup_phrase_.empty()) a.seed_backup_phrase_ = kDemoMnemonic;
// Always the demo phrase: anything else here could be a real mnemonic, and it would be
// written into this surface's screenshots for every skin.
if (a.seed_backup_phrase_ != kDemoMnemonic) {
if (!a.seed_backup_phrase_.empty())
sodium_memzero(&a.seed_backup_phrase_[0], a.seed_backup_phrase_.size());
a.seed_backup_phrase_ = kDemoMnemonic;
}
},
[](App& a) {
a.show_seed_backup_ = false; a.seed_backup_fetch_started_ = false;
@@ -484,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,
@@ -497,12 +637,13 @@ void App::buildSweepCatalog()
mig("modal-migrate-error", SeedMigrationStep::Error,
[](App& a) { a.seed_migration_status_ = "Example error: the daemon did not respond."; });
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the (throwaway)
// datadir + cache their metadata so the list renders populated.
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the sandbox's
// datadir + cache their metadata (latched, never saved) so the list renders populated.
add("modal-wallets", ui::NavPage::Settings,
[](App& a) {
if (a.sweep_sandbox_.empty()) return;
std::error_code ec;
const std::string dd = util::Platform::getDragonXDataDir();
const std::string dd = a.sweep_sandbox_ + "/DRAGONX";
fs::create_directories(dd, ec);
if (!fs::exists(dd + "/wallet.dat", ec))
std::ofstream(dd + "/wallet.dat", std::ios::binary) << std::string(122880, '\0');
@@ -517,26 +658,26 @@ void App::buildSweepCatalog()
a.walletIndex().upsert(e1);
a.walletIndex().upsert(e2);
// An out-of-datadir wallet (in an extra folder) so the Import action shows too.
const std::string extra = util::Platform::getConfigDir() + "extra-wallets";
const std::string extra = a.sweep_sandbox_ + "/extra-wallets";
fs::create_directories(extra, ec);
if (!fs::exists(extra + "/my-wallet.dat", ec))
std::ofstream(extra + "/my-wallet.dat", std::ios::binary) << std::string(80000, '\0');
a.walletIndex().addExtraFolder(extra);
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
ui::WalletsDialog::setSweepDataDir(dd);
ui::WalletsDialog::show(&a);
},
[](App&) { ui::WalletsDialog::hide(); });
// Many wallets — exercises the viewport-cap path: the card can't fit all rows, so the list
// must scroll internally while the create/scan/footer controls stay pinned (esp. at 150%).
// Teardown removes the extra files it dropped so the plain modal-wallets surface stays a
// clean 3-wallet reference (both surfaces share the one throwaway datadir).
static const char* kManyExtra[] = {"wallet-cold.dat", "wallet-trading.dat", "wallet-mining.dat",
"wallet-donations.dat", "wallet-2023.dat", "wallet-payroll.dat"};
// Its own sandbox datadir keeps the plain modal-wallets surface a clean 3-wallet reference;
// nothing is ever deleted here (the whole sandbox goes at sweep end).
add("modal-wallets-many", ui::NavPage::Settings,
[](App& a) {
if (a.sweep_sandbox_.empty()) return;
std::error_code ec;
const std::string dd = util::Platform::getDragonXDataDir();
const std::string dd = a.sweep_sandbox_ + "/DRAGONX-many";
fs::create_directories(dd, ec);
const char* names[] = {"wallet.dat", "wallet-savings.dat", "wallet-cold.dat",
"wallet-trading.dat", "wallet-mining.dat", "wallet-donations.dat",
@@ -550,14 +691,10 @@ void App::buildSweepCatalog()
a.walletIndex().upsert(e);
}
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
ui::WalletsDialog::setSweepDataDir(dd);
ui::WalletsDialog::show(&a);
},
[](App& a) {
ui::WalletsDialog::hide();
std::error_code ec;
const std::string dd = util::Platform::getDragonXDataDir();
for (const char* n : kManyExtra) fs::remove(dd + "/" + n, ec);
});
[](App&) { ui::WalletsDialog::hide(); });
}
// First-run wizard — one per meaningful phase (full-node only; blocks all other UI while shown).
@@ -588,6 +725,64 @@ 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"); },
[](App& a) { ui::SweepSetSendRecipient(&a, nullptr); });
// Send-confirm popup (state lives in send_tab statics → driven via a debug hook).
add("popup-send-confirm", ui::NavPage::Send,
[](App& a) { ui::SweepShowSendConfirm(&a, true); },
@@ -683,13 +878,15 @@ void App::buildSweepCatalog()
// list shows sub-folders + wallet files, then open the wallets dialog + the picker over it.
add("modal-folder-picker", ui::NavPage::Settings,
[](App& a) {
if (a.sweep_sandbox_.empty()) return;
std::error_code ec;
const std::string demo = util::Platform::getConfigDir() + "picker-demo";
const std::string demo = a.sweep_sandbox_ + "/picker-demo";
for (const char* sub : {"Documents", "Downloads", "Backups", "wallet-archive"})
fs::create_directories(demo + "/" + sub, ec);
for (const char* f : {"wallet-cold.dat", "wallet-2023.dat"})
if (!fs::exists(demo + "/" + f, ec))
std::ofstream(demo + "/" + f, std::ios::binary) << std::string(66000, '\0');
ui::WalletsDialog::setSweepDataDir(a.sweep_sandbox_ + "/DRAGONX");
ui::WalletsDialog::show(&a);
ui::FolderPicker::open(demo, [](const std::string&) {});
},
@@ -699,7 +896,24 @@ void App::buildSweepCatalog()
// ── State machine ───────────────────────────────────────────────────────────────────────────
void App::startSweepImpl(bool full)
{
#if !DRAGONX_ENABLE_UI_SWEEP
// Dev tool only (CMake DRAGONX_ENABLE_UI_SWEEP, OFF in every release); an undefined macro counts as 0.
(void)full;
DEBUG_LOGF("[Sweep] disabled in this build\n");
return;
#else
if (screenshot_sweep_active_) return;
// Sentinel: a release binary must not contain this literal (strings <bin> | grep -c ... == 0).
DEBUG_LOGF("[Sweep] DRAGONX_UI_SWEEP_COMPILED_IN\n");
// The full sweep fakes wallet, migration, switch and security state: never over a real workflow.
if (full) {
if (const char* why = fullSweepBlockedReason()) {
DEBUG_LOGF("[Sweep] full sweep refused: %s\n", why);
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
return;
}
}
sweep_skins_.clear();
for (const auto& sk : ui::schema::SkinManager::instance().available())
@@ -717,6 +931,21 @@ void App::startSweepImpl(bool full)
std::string sweepOnlyStr = sweepOnly ? sweepOnly : "";
if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark"));
if (full) {
// A fresh private sandbox for the demo wallet files: never the real datadir or config dir.
std::error_code tec;
const fs::path tmp = fs::temp_directory_path(tec);
sweep_sandbox_ = tec ? std::string() : util::makePrivateTempDir(tmp, kSweepSandboxPrefix);
if (sweep_sandbox_.empty()) {
DEBUG_LOGF("[Sweep] full sweep refused: could not create a private sandbox under %s\n",
tmp.string().c_str());
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
return;
}
ui::WalletsDialog::setSweepDataDir(sweep_sandbox_ + "/DRAGONX");
DEBUG_LOGF("[Sweep] sandbox %s\n", sweep_sandbox_.c_str());
}
sweep_full_ = full;
if (full) { capture_mode_ = true; installDemoWalletData(); }
buildSweepCatalog();
@@ -742,6 +971,7 @@ void App::startSweepImpl(bool full)
ui::Notifications::instance().info(full ? "Full UI sweep running…" : "Screenshot sweep running…");
DEBUG_LOGF("[Sweep] %s -> %s (%d skins x %d surfaces)\n", full ? "FULL" : "tabs",
sweep_dir_.c_str(), (int)sweep_skins_.size(), (int)sweep_targets_.size());
#endif
}
void App::startScreenshotSweep() { startSweepImpl(false); }
+86 -43
View File
@@ -73,10 +73,11 @@ 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;
}
if (refuseIfNodeBusy()) return; // the wizard stops the node
DEBUG_LOGF("[App] Restarting setup wizard — stopping daemon...\n");
// Reset crash counter for fresh wizard attempt
@@ -86,6 +87,7 @@ void App::restartWizard()
// Disconnect RPC
if (rpc_ && rpc_->isConnected()) {
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
rpc_->disconnect();
}
onDisconnected("Wizard restart");
@@ -356,13 +358,9 @@ void App::renderFirstRunWizard() {
if (lang.first == wiz_cur_lang) { wiz_language_index = idx; break; }
idx++;
}
// Apply loaded settings to runtime so visuals match slider values
ui::effects::setLowSpecMode(wiz_low_spec);
ui::effects::ImGuiAcrylic::ApplyBlurAmount(wiz_blur_amount);
ui::effects::ImGuiAcrylic::SetUIOpacity(wiz_ui_opacity);
ui::effects::ThemeEffects::instance().setEnabled(wiz_theme_effects);
ui::effects::ThemeEffects::instance().setReducedTransparency(!wiz_theme_effects);
ui::ConsoleTab::s_scanline_enabled = wiz_scanline;
// Apply the loaded settings to the runtime (same low-spec-aware path as startup) so the
// visuals match the slider values
applyVisualSettings();
wiz_appearance_init = true;
}
@@ -537,6 +535,9 @@ void App::renderFirstRunWizard() {
settings_->setScanlineEnabled(wiz_scanline);
settings_->setLowSpecMode(wiz_low_spec);
settings_->save();
// Make the runtime match what was just saved, both ways: unticking a low-spec that was
// loaded rather than ticked here has no snapshot, so the branch above pushes nothing.
applyVisualSettings();
}
ImGui::PopStyleVar();
ImGui::SameLine();
@@ -799,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;
}
@@ -825,9 +827,12 @@ void App::renderFirstRunWizard() {
float cancelBX = rightX + (colW - cancelW) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(cancelBX, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
// Disabled while the old chain data is being replaced (not cancellable).
ImGui::BeginDisabled(!prog.cancellable);
if (ui::material::TactileButton(TR("cancel"), ImVec2(cancelW, cancelH))) {
bootstrap_->cancel();
bootstrap_->requestCancel(); // a no-op once the old chain data is being replaced
}
ImGui::EndDisabled();
ImGui::PopStyleVar();
cy += cancelH;
@@ -858,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));
@@ -874,19 +884,38 @@ void App::renderFirstRunWizard() {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2))) {
if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2)) && !refuseIfNodeBusy()) {
// Stop embedded daemon before bootstrap to avoid chain data corruption
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;
@@ -974,9 +1003,12 @@ void App::renderFirstRunWizard() {
}
}
wizard_stop_status_ = TR("wiz_daemon_waiting_stop");
for (int i = 0; i < 60 && !token.cancelled(); i++) {
// Stopped means the PROCESS is gone, not just its RPC port: dragonxd unbinds RPC
// early in its shutdown and holds the datadir lock until it exits.
for (int i = 0; i < 120 && !token.cancelled(); i++) {
std::this_thread::sleep_for(std::chrono::seconds(1));
if (!daemon::EmbeddedDaemon::isRpcPortInUse()) {
if (!daemon::EmbeddedDaemon::isRpcPortInUse() &&
!daemon::EmbeddedDaemon::isDaemonProcessRunning()) {
wizard_stop_status_ = TR("wiz_daemon_stopped_ok");
wizard_stopping_external_ = false;
return;
@@ -1007,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;
@@ -1024,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;
}
@@ -1058,12 +1098,13 @@ void App::renderFirstRunWizard() {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2))) {
if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2)) && !refuseIfNodeBusy()) {
// Stop embedded daemon before bootstrap to avoid chain data corruption
stopDaemonForBootstrap();
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();
@@ -1077,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))) {
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);
@@ -1394,7 +1436,7 @@ void App::renderFirstRunWizard() {
(pinEntered && pinOk) ? pinStr : std::string());
// Persist that encryption was requested (never the passphrase) so a quit/crash or
// failed daemon connect before it applies isn't silent — reconciled on the next
// connect in refreshWalletEncryptionState (W2-2). Saved with the wizard state below.
// connect in App::reconcileEncryptionPending (W2-2). Saved with the wizard state below.
settings_->setEncryptionPending(true);
// Clear sensitive buffers
@@ -1414,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();
+154 -11
View File
@@ -89,6 +89,7 @@ bool ChatDatabase::unlockWithSecret(const std::string& secret)
lock();
return false;
}
loadTombstones();
return true;
}
@@ -97,16 +98,28 @@ void ChatDatabase::lock()
sodium_memzero(key_.data(), key_.size());
key_ready_ = false;
wallet_tag_.clear();
tombstones_.clear();
}
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
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(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;
@@ -129,14 +142,71 @@ bool ChatDatabase::append(const ChatMessage& message)
return sqlite3_changes(db_) > 0;
}
int ChatDatabase::appendBatch(const std::vector<ChatMessage>& messages)
{
if (!key_ready_ || !ensureOpen()) return 0;
if (messages.empty()) return 0;
struct Row { std::string dedup; std::vector<unsigned char> nonce, cipher; };
std::vector<Row> rows;
rows.reserve(messages.size());
for (const auto& m : messages) {
if (isTombstoned(m)) continue; // locally deleted — don't re-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);
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) });
} catch (const std::exception&) {
continue; // skip only this message; never open a txn with bad data
}
}
if (rows.empty()) return 0;
if (!exec("BEGIN")) return 0; // match deleteMessages() in-file style
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT OR IGNORE INTO chat_messages (wallet_tag, dedup_hash, nonce, payload) "
"VALUES (?, ?, ?, ?)",
-1, &stmt, nullptr) != SQLITE_OK) {
exec("ROLLBACK");
return 0;
}
int inserted = 0;
bool ok = true;
for (const auto& r : rows) {
sqlite3_reset(stmt);
sqlite3_clear_bindings(stmt);
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, r.dedup.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_blob(stmt, 3, r.nonce.data(), static_cast<int>(r.nonce.size()), SQLITE_TRANSIENT);
sqlite3_bind_blob(stmt, 4, r.cipher.data(), static_cast<int>(r.cipher.size()), SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE) { ok = false; break; }
if (sqlite3_changes(db_) > 0) ++inserted;
}
sqlite3_finalize(stmt);
if (!exec(ok ? "COMMIT" : "ROLLBACK")) return 0;
return ok ? inserted : 0;
}
bool ChatDatabase::upsert(const ChatMessage& message)
{
if (!key_ready_ || !ensureOpen()) return false;
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(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;
@@ -193,13 +263,79 @@ std::vector<ChatMessage> ChatDatabase::load()
void ChatDatabase::clearWallet()
{
if (wallet_tag_.empty() || !ensureOpen()) return;
for (const char* sql : {"DELETE FROM chat_messages WHERE wallet_tag = ?",
"DELETE FROM chat_deleted WHERE wallet_tag = ?"}) {
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) continue;
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
tombstones_.clear();
}
bool ChatDatabase::deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone)
{
if (!key_ready_ || !ensureOpen()) return false;
if (messages.empty()) return true;
if (!exec("BEGIN")) return false;
bool ok = true;
for (const auto& m : messages) {
const std::string dedup = dedupHash(m.txid, m.payload_position);
sqlite3_stmt* del = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ? AND dedup_hash = ?",
-1, &del, nullptr) == SQLITE_OK) {
sqlite3_bind_text(del, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(del, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(del) != SQLITE_DONE) ok = false;
sqlite3_finalize(del);
} else {
ok = false;
}
if (tombstone) {
sqlite3_stmt* ins = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT OR IGNORE INTO chat_deleted (wallet_tag, dedup_hash) VALUES (?, ?)",
-1, &ins, nullptr) == SQLITE_OK) {
sqlite3_bind_text(ins, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ins, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(ins) != SQLITE_DONE) ok = false;
sqlite3_finalize(ins);
} else {
ok = false;
}
}
}
if (!exec(ok ? "COMMIT" : "ROLLBACK")) ok = false;
// Only reflect the tombstones in the in-memory cache once they are durably committed.
if (ok && tombstone)
for (const auto& m : messages) tombstones_.insert(dedupHash(m.txid, m.payload_position));
return ok;
}
bool ChatDatabase::isTombstoned(const ChatMessage& message) const
{
if (!key_ready_ || tombstones_.empty()) return false;
return tombstones_.count(dedupHash(message.txid, message.payload_position)) > 0;
}
void ChatDatabase::loadTombstones()
{
tombstones_.clear();
if (!key_ready_ || !ensureOpen()) return;
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
!= SQLITE_OK) {
if (sqlite3_prepare_v2(db_, "SELECT dedup_hash FROM chat_deleted WHERE wallet_tag = ?",
-1, &stmt, nullptr) != SQLITE_OK) {
return;
}
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
while (sqlite3_step(stmt) == SQLITE_ROW) {
const auto* h = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (h) tombstones_.insert(h);
}
sqlite3_finalize(stmt);
}
@@ -260,11 +396,18 @@ bool ChatDatabase::exec(const char* sql)
bool ChatDatabase::createSchema()
{
return exec("CREATE TABLE IF NOT EXISTS chat_messages ("
if (!exec("CREATE TABLE IF NOT EXISTS chat_messages ("
"wallet_tag TEXT NOT NULL, "
"dedup_hash TEXT NOT NULL, "
"nonce BLOB NOT NULL, "
"payload BLOB NOT NULL, "
"PRIMARY KEY (wallet_tag, dedup_hash))"))
return false;
// Tombstones for locally-deleted messages (dedup_hash only — the same keyed, non-revealing hash the
// message rows use). A chain re-scan checks this so a deleted message never re-imports.
return exec("CREATE TABLE IF NOT EXISTS chat_deleted ("
"wallet_tag TEXT NOT NULL, "
"dedup_hash TEXT NOT NULL, "
"nonce BLOB NOT NULL, "
"payload BLOB NOT NULL, "
"PRIMARY KEY (wallet_tag, dedup_hash))");
}
@@ -291,7 +434,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
+20
View File
@@ -16,6 +16,7 @@
#include <array>
#include <cstddef>
#include <string>
#include <unordered_set>
#include <vector>
struct sqlite3;
@@ -43,6 +44,12 @@ public:
// Returns true if newly inserted; false on duplicate or while locked.
bool append(const ChatMessage& message);
// Persist many messages in ONE transaction (INSERT OR IGNORE, same dedup key as append()).
// Serializes+encrypts every message BEFORE opening the transaction, so a non-UTF-8 body is
// skipped without leaving a transaction open. Preserves input order (load() relies on rowid).
// Returns the number of rows actually inserted. No-op (returns 0) while locked or on empty input.
int appendBatch(const std::vector<ChatMessage>& messages);
// Persist-or-overwrite one message by its (txid+position) dedup key. Unlike append(), this
// updates an existing row's payload — used for outgoing echoes whose delivery status changes
// (Sending → Sent/Failed). Returns true on success; false while locked / on error.
@@ -54,8 +61,20 @@ public:
void clearWallet(); // delete the unlocked wallet's rows
// Per-conversation local delete. Removes the given messages' rows; when `tombstone` is true it also
// records their (txid,position) dedup keys so a chain re-scan never re-imports them — this backs the
// "delete, but a NEW message revives the thread" path. With `tombstone` false the rows are simply
// removed (used by "delete & block", where a settings-level cid block suppresses re-import until the
// user unblocks, at which point the history re-imports from chain). Atomic; no-op while locked.
bool deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone);
// True if this message's (txid,position) was locally deleted with a tombstone. Checked against an
// in-memory cache loaded on unlock — O(1), no SQL. False while locked.
bool isTombstoned(const ChatMessage& message) const;
private:
bool ensureOpen();
void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet
bool exec(const char* sql);
bool createSchema();
std::string dedupHash(const std::string& txid, std::size_t position) const;
@@ -74,6 +93,7 @@ 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::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
};
} // namespace dragonx::chat
+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
+56 -6
View File
@@ -16,11 +16,13 @@ ChatService::~ChatService() {
void ChatService::setIdentity(const ChatKeyPair& keys) {
identity_ = keys;
has_identity_ = true;
undecryptable_.clear(); // another identity may decrypt them
}
void ChatService::clearIdentity() {
wipeChatKeyPair(identity_);
has_identity_ = false;
undecryptable_.clear();
}
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
@@ -28,11 +30,19 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
std::int64_t fallbackTimestamp,
std::vector<std::string>* newIncomingCids) {
if (!has_identity_) return 0;
// Persistence is attached but not unlocked (e.g. the DB failed to open with the seed): we can't
// consult tombstones, so ingesting now would resurface locally-deleted messages into the store.
// Skip until the DB is usable — chat is degraded anyway without its store.
if (db_ && !db_->hasKey()) return 0;
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
int added = 0;
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
for (const auto& meta : metadata) {
// 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
@@ -62,16 +72,35 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
}
message.payload_position = meta.payload_position;
// Suppress locally-removed conversations before the (relatively costly) decrypt. A blocked cid
// 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 (meta.type == HushChatHeaderType::ContactRequest) {
message.kind = ChatMessageKind::ContactRequest;
message.body = meta.payload_memo; // plaintext request text
} else {
message.kind = ChatMessageKind::Message;
// Keyed by the decrypt's inputs, not only (txid, position): another scan path that pairs a
// 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;
++decrypt_attempts_;
std::string plaintext;
const ChatCryptoStatus status = decryptIncoming(
identity_, meta.sender_public_key_hex, meta.secretstream_header_hex,
meta.payload_memo, plaintext);
if (status != ChatCryptoStatus::Ok) continue; // drop undecryptable silently
if (status != ChatCryptoStatus::Ok) { // drop undecryptable silently, and don't try it again
if (status != ChatCryptoStatus::SodiumInitFailed) { // not a property of the message
if (undecryptable_.size() >= 4096) undecryptable_.clear();
undecryptable_.insert(failKey);
}
continue;
}
message.body = std::move(plaintext);
}
@@ -79,23 +108,44 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
// session loadFromDatabase() repopulates the store, so re-scanning the chain re-ingests but
// the store dedup prevents a duplicate write.
if (store_.append(message)) {
if (db_) db_->append(message);
fresh.push_back(message);
++added;
// Every ingested message is incoming — report its cid so the caller can notify without
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
}
}
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
return added;
}
void ChatService::loadFromDatabase() {
if (!db_) return;
for (const auto& message : db_->load()) {
store_.append(message);
auto rows = db_->load();
store_.reserve(store_.size() + rows.size());
for (auto& message : rows) {
// Never surface a blocked conversation, even if a prior "delete & block" failed to remove its
// rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path.
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; // evaluate before the move
store_.append(std::move(message));
}
}
bool ChatService::deleteConversation(const std::string& conversationId, bool block) {
// Delete the persisted rows FIRST and only mutate the in-memory view if that succeeds. Doing it the
// other way round means a failed DB write (disk full / locked) would empty the store while the rows
// survive — and on the next reload the conversation silently reappears with no tombstone.
// Revive-on-new-message => tombstone the removed rows so a re-scan won't re-import them.
// Block => remove the rows without a tombstone; the caller's blocked predicate suppresses re-import
// until unblocked, at which point the conversation re-imports from chain.
if (db_) {
std::vector<ChatMessage> msgs = store_.conversation(conversationId); // snapshot (copy)
if (!db_->deleteMessages(msgs, /*tombstone=*/!block)) return false;
}
store_.eraseConversation(conversationId);
return true;
}
std::string ChatService::identityPublicKeyHex() const {
if (!has_identity_) return {};
return chatIdentityPublicKeyHex(identity_);
@@ -139,8 +189,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
}
+23 -1
View File
@@ -11,8 +11,10 @@
#include "chat_store.h"
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace dragonx::chat {
@@ -55,6 +57,19 @@ public:
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
void loadFromDatabase();
// Set a predicate returning true for a BLOCKED conversation id. ingest() drops those messages
// entirely (they are never stored or persisted) until the predicate stops returning true — this is
// how "delete & block" suppresses old and future messages. Typically wired to Settings::isChatBlocked.
void setBlockedPredicate(std::function<bool(const std::string&)> pred) { blocked_pred_ = std::move(pred); }
// Locally delete a conversation: remove its messages from the store and the database. When `block`
// is false, the removed messages are tombstoned so a chain re-scan won't re-import them, but a
// genuinely NEW message (new txid) revives the thread. When `block` is true, the rows are removed
// WITHOUT a tombstone (the caller also records the cid as blocked via the predicate above); unblocking
// later lets the conversation re-import from chain. Returns false (leaving the store untouched) if the
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
bool deleteConversation(const std::string& conversationId, bool block);
// --- Outgoing (compose) ---
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
std::string identityPublicKeyHex() const;
@@ -80,16 +95,23 @@ 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_; }
std::uint64_t decryptAttempts() const { return decrypt_attempts_; } // tests / diagnostics
private:
ChatKeyPair identity_{};
bool has_identity_ = false;
// Decrypt failures, keyed by what the decrypt reads (bounded). A rescan re-sends every message ever
// received, so without it each poll re-ran the crypto for every undecryptable memo.
std::unordered_set<std::string> undecryptable_;
std::uint64_t decrypt_attempts_ = 0;
ChatStore store_;
ChatDatabase* db_ = nullptr; // optional; not owned
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
};
} // namespace dragonx::chat
+67 -2
View File
@@ -3,19 +3,47 @@
#include "chat_store.h"
#include <algorithm>
#include <sodium.h>
namespace dragonx::chat {
std::string ChatStore::messageKey(const std::string& txid, std::size_t payloadPosition) {
return txid + ":" + std::to_string(payloadPosition);
}
std::string ChatStore::dedupKey(const ChatMessage& message) {
return message.txid + ":" + std::to_string(message.payload_position);
return messageKey(message.txid, message.payload_position);
}
bool ChatStore::append(const ChatMessage& message) {
if (!seen_.insert(dedupKey(message)).second) return false;
messages_.push_back(message);
++revision_;
return true;
}
bool ChatStore::append(ChatMessage&& message) {
if (!seen_.insert(dedupKey(message)).second) return false; // reads txid/payload_position before the move
messages_.push_back(std::move(message));
++revision_;
return true;
}
void ChatStore::reserve(std::size_t n) {
messages_.reserve(n);
seen_.reserve(n);
}
std::unordered_map<std::string, std::int64_t> ChatStore::latestTimestampByConversation() const {
std::unordered_map<std::string, std::int64_t> out;
for (const auto& message : messages_) {
auto it = out.find(message.conversation_id);
if (it == out.end()) out.emplace(message.conversation_id, static_cast<std::int64_t>(message.timestamp));
else if (static_cast<std::int64_t>(message.timestamp) > it->second) it->second = static_cast<std::int64_t>(message.timestamp);
}
return out;
}
std::vector<ChatMessage> ChatStore::conversation(const std::string& conversationId) const {
std::vector<ChatMessage> out;
for (const auto& message : messages_) {
@@ -34,16 +62,30 @@ 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;
}
}
return nullptr;
}
int ChatStore::countUnread(const std::function<bool(const std::string&)>& excluded,
const std::function<std::int64_t(const std::string&)>& seenFor) const {
int unread = 0;
for (const auto& m : messages_) {
if (m.direction != ChatDirection::Incoming) continue;
if (excluded && excluded(m.conversation_id)) continue;
if (m.timestamp > seenFor(m.conversation_id)) ++unread;
}
return unread;
}
std::vector<std::string> ChatStore::conversationIds() const {
std::vector<std::string> ids;
std::unordered_set<std::string> seenIds;
@@ -53,9 +95,32 @@ std::vector<std::string> ChatStore::conversationIds() const {
return ids;
}
std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& conversationId) {
std::vector<ChatMessage> removed;
std::vector<ChatMessage> kept;
kept.reserve(messages_.size());
for (auto& m : messages_) {
if (m.conversation_id == conversationId) {
seen_.erase(dedupKey(m));
removed.push_back(std::move(m));
} else {
kept.push_back(std::move(m));
}
}
messages_.swap(kept);
if (!removed.empty()) ++revision_;
return removed;
}
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)
++revision_;
}
} // namespace dragonx::chat
+58 -2
View File
@@ -5,7 +5,10 @@
#include "chat_message.h"
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
@@ -17,19 +20,34 @@ class ChatStore {
public:
// Returns true if newly inserted, false if a duplicate was ignored.
bool append(const ChatMessage& message);
// Same, but moves the message into the store (dedup key is computed before the move).
bool append(ChatMessage&& message);
// Pre-size the message vector + dedup set for a bulk load of n additional messages.
void reserve(std::size_t n);
// Max timestamp per conversation in ONE pass over messages_. Semantically equal to
// conversation(cid).back().timestamp (conversation() sorts ascending) without the per-cid copy+sort.
std::unordered_map<std::string, std::int64_t> latestTimestampByConversation() const;
// The dedup key of a message: "<txid>:<payload_position>".
static std::string messageKey(const std::string& txid, std::size_t payloadPosition);
// A message with this (txid, position) is in the store (append would refuse it).
bool contains(const std::string& txid, std::size_t payloadPosition) const {
return seen_.count(messageKey(txid, payloadPosition)) != 0;
}
// Messages in a conversation, in insertion order.
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
// 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;
// The set of on-chain txids that carried a chat message (sent or received, messages + contact
// requests) — used by the History tab to badge / filter chat transactions. O(messages), no copies.
// requests). Builds a fresh COPY every call (O(messages) allocations) — prefer hasChatTxid() on hot paths.
std::unordered_set<std::string> chatTxids() const {
std::unordered_set<std::string> out;
out.reserve(messages_.size());
@@ -38,15 +56,53 @@ public:
return out;
}
// True if an on-chain txid carried a chat message (History tab "Message" badge / Chat filter). Backed by a
// txid index rebuilt lazily when revision() moved (once per store mutation, O(messages)); O(1) on an empty
// store and per lookup. One txid may back several messages; a from-scratch rebuild handles erase correctly.
bool hasChatTxid(const std::string& txid) const {
if (messages_.empty() || txid.empty()) return false;
if (txid_index_rev_ != revision_) rebuildTxidIndex();
return txid_index_.count(txid) != 0;
}
// Remove every message in a conversation from the in-memory view and return the removed messages
// (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest
// guard (blocked-cid predicate / DB tombstone), not here.
std::vector<ChatMessage> eraseConversation(const std::string& conversationId);
std::size_t size() const { return messages_.size(); }
bool empty() const { return messages_.empty(); }
void clear();
// Monotonic counter bumped on every mutation (append / updateDelivery change / eraseConversation /
// clear). Callers memoize expensive per-frame reads (conversation-list build, unread count) against it
// so they only rebuild when the store actually changed.
std::uint64_t revision() const { return revision_; }
// Count unread incoming messages in a SINGLE pass over the store: an incoming message counts when its
// cid is not `excluded` and its timestamp is newer than `seenFor(cid)`. Avoids the per-conversation
// copy+sort that conversation() does — the count doesn't need ordering.
int countUnread(const std::function<bool(const std::string&)>& excluded,
const std::function<std::int64_t(const std::string&)>& seenFor) const;
private:
static std::string dedupKey(const ChatMessage& message);
void rebuildTxidIndex() const {
txid_index_.clear();
txid_index_.reserve(messages_.size());
for (const auto& m : messages_)
if (!m.txid.empty()) txid_index_.insert(m.txid);
txid_index_rev_ = revision_;
}
std::vector<ChatMessage> messages_;
std::unordered_set<std::string> seen_;
std::uint64_t revision_ = 0;
// Lazy memo for hasChatTxid(): valid iff txid_index_rev_ == revision_. revision_ is monotonic and never reset,
// so append/updateDelivery/eraseConversation/clear all invalidate it (~0 = never built). Main thread only.
mutable std::unordered_set<std::string> txid_index_;
mutable std::uint64_t txid_index_rev_ = ~std::uint64_t{0};
};
} // namespace dragonx::chat
+108 -7
View File
@@ -15,6 +15,7 @@
#include <algorithm>
#include <type_traits>
#include "../util/coalescing_writer.h"
#include "../util/logger.h"
#include "../util/platform.h"
@@ -108,6 +109,20 @@ void loadClamped(const json& j, const char* key, T& field, T lo, T hi)
} // namespace
std::vector<Settings::LiteServerPreference> Settings::defaultLiteServers()
{
return {
{"https://lite.dragonx.is", "DragonX Lite", true},
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
{"https://lite5.dragonx.is", "DragonX Lite 5", true},
{"https://lite6.dragonx.is", "DragonX Lite 6", true},
{"https://lite7.dragonx.is", "DragonX Lite 7", true}
};
}
std::string Settings::getDefaultPath()
{
// Single per-platform, per-variant config dir (util::Platform::getConfigDir handles the
@@ -157,6 +172,14 @@ bool Settings::load(const std::string& path)
for (const auto& c : j["hidden_chat_cids"])
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
}
if (j.contains("blocked_chat_convs") && j["blocked_chat_convs"].is_array()) {
blocked_chat_convs_.clear();
for (const auto& c : j["blocked_chat_convs"])
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
blocked_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_);
@@ -172,13 +195,17 @@ bool Settings::load(const std::string& path)
loadScalar(j, "blur_multiplier", blur_multiplier_);
loadScalar(j, "noise_opacity", noise_opacity_);
loadScalar(j, "gradient_background", gradient_background_);
// Migrate legacy reduced_transparency bool -> ui_opacity float
if (j.contains("ui_opacity")) {
ui_opacity_ = j["ui_opacity"].get<float>();
} else if (j.contains("reduced_transparency") && j["reduced_transparency"].get<bool>()) {
// Migrate legacy reduced_transparency bool -> ui_opacity float (type-tolerant: a hand-edited or
// inf->null re-saved value must not quarantine the whole settings file).
if (j.contains("ui_opacity") && j["ui_opacity"].is_number()) {
ui_opacity_ = std::clamp(j["ui_opacity"].get<float>(), 0.3f, 1.0f); // matches setUIOpacity()
} else if (j.contains("reduced_transparency") && j["reduced_transparency"].is_boolean()
&& j["reduced_transparency"].get<bool>()) {
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>();
@@ -215,6 +242,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()) {
@@ -232,6 +271,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();
@@ -246,6 +289,7 @@ bool Settings::load(const std::string& path)
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
loadScalar(j, "seed_migration_sweep_submitted", seed_migration_sweep_submitted_);
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
loadScalar(j, "unlock_duration", unlock_duration_);
loadScalar(j, "pin_enabled", pin_enabled_);
@@ -300,6 +344,16 @@ bool Settings::load(const std::string& path)
}
lite_servers_.push_back(preference);
}
// Surface newly-shipped default servers (e.g. lite6/lite7) on an existing
// install whose saved list predates them. Servers are hidden (see
// lite_hidden_servers_), never deleted, so appending a missing default won't
// resurrect one the user intentionally removed.
for (const auto& def : defaultLiteServers()) {
bool present = false;
for (const auto& s : lite_servers_)
if (s.url == def.url) { present = true; break; }
if (!present) lite_servers_.push_back(def);
}
}
if (lite.contains("rollout_override") && lite["rollout_override"].is_string()) {
const auto v = lite["rollout_override"].get<std::string>();
@@ -388,7 +442,7 @@ bool Settings::load(const std::string& path)
entry.showSparkline = e["show_sparkline"].get<bool>();
if (e.contains("sparkline_interval") && e["sparkline_interval"].is_number_integer())
entry.sparklineInterval = e["sparkline_interval"].get<int>();
entry.scope = e.value("scope", "");
if (e.contains("scope") && e["scope"].is_string()) entry.scope = e["scope"].get<std::string>();
if (e.contains("grid_col") && e["grid_col"].is_number_integer())
entry.gridCol = e["grid_col"].get<int>();
if (e.contains("grid_row") && e["grid_row"].is_number_integer())
@@ -431,6 +485,26 @@ bool Settings::load(const std::string& path)
}
}
int Settings::reattachLegacyPortfolioScopes(const std::string& scopeId, const std::string& identityHash,
const std::set<std::string>& walletAddresses, bool singleWallet)
{
if (scopeId.empty()) return 0;
int moved = 0;
for (auto& e : portfolio_entries_) {
if (!e.isLegacyScope()) continue; // global stays global; "w:" ids are already stable
bool mine = singleWallet || (!identityHash.empty() && e.scope == identityHash);
if (!mine) {
for (const auto& a : e.addresses) {
if (walletAddresses.count(a)) { mine = true; break; }
}
}
if (!mine) continue; // ambiguous: leave legacy (fail-open visible)
e.scope = scopeId;
++moved;
}
return moved;
}
bool Settings::save()
{
if (settings_path_.empty()) {
@@ -441,6 +515,8 @@ bool Settings::save()
bool Settings::save(const std::string& path)
{
// UI sweep: nothing is written until sweepEnd(), which restores the real values and saves once.
if (sweep_active_) { sweep_suppressed_save_ = true; return true; }
json j;
j["theme"] = theme_;
@@ -461,6 +537,13 @@ bool Settings::save(const std::string& path)
j["hidden_chat_cids"] = json::array();
for (const auto& c : hidden_chat_cids_)
j["hidden_chat_cids"].push_back(c);
j["blocked_chat_convs"] = json::array();
for (const auto& b : blocked_chat_convs_) {
json o;
o["cid"] = b.cid;
o["name"] = b.name;
j["blocked_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_;
@@ -477,6 +560,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_;
@@ -494,6 +578,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_) {
@@ -509,6 +600,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_)
@@ -521,6 +616,7 @@ bool Settings::save(const std::string& path)
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
j["seed_migration_sweep_submitted"] = seed_migration_sweep_submitted_;
j["auto_lock_timeout"] = auto_lock_timeout_;
j["unlock_duration"] = unlock_duration_;
j["pin_enabled"] = pin_enabled_;
@@ -617,8 +713,13 @@ bool Settings::save(const std::string& path)
// Atomic + durable: write to a temp file, fsync, then rename over the real file.
// A crash mid-write can no longer truncate settings.json (which would silently
// reset every preference on the next launch). Owner-only (0600) — it carries the
// lite-server list and address metadata.
return util::Platform::writeFileAtomically(path, j.dump(4), /*restrictPermissions=*/true);
// lite-server list and address metadata. With a writer attached the same atomic write
// runs on its background thread (queued, latest-wins); after a critical setter
// (durable_next_save_) this waits until the file is on disk, as the old save did.
const bool ok = util::persistFileAtomically(writer_, durable_next_save_, path, j.dump(4),
/*restrictPermissions=*/true);
if (ok) durable_next_save_ = false;
return ok;
} catch (const std::exception& e) {
DEBUG_LOGF("Failed to save settings: %s\n", e.what());
return false;
+160 -21
View File
@@ -12,6 +12,7 @@
#include <vector>
namespace dragonx {
namespace util { class CoalescingWriter; }
namespace config {
/**
@@ -39,10 +40,16 @@ public:
/**
* @brief Save settings to default location
* @return true if saved successfully
* With a file writer attached (the App's), the JSON snapshot is taken here, on the caller's thread,
* and the atomic write runs on the writer's thread: returns true once queued. After a fund/security-
* critical setter (see durable_next_save_) it waits until the file is on disk, like the old save().
* @return true if saved (or queued) successfully
*/
bool save();
/** @brief Route saves through a background writer (not owned; must outlive this). Null = synchronous. */
void setFileWriter(util::CoalescingWriter* writer) { writer_ = writer; }
/**
* @brief Save settings to specific path
* @param path Path to settings file
@@ -93,10 +100,21 @@ public:
bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
int sparklineInterval = 4; // 0=minute 1=hour 2=day 3=week 4=month (default month: a real curve
// from the daily series, vs the young in-session minute buffer)
// Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
// hash (shown only when that wallet is active). New entries are tagged with the current
// wallet so a portfolio built for wallet A doesn't clutter wallet B.
// Per-wallet visibility: "" (shown in every wallet - legacy/global) or the active wallet's STABLE scope
// id ("w:"+hex, App::activeWalletScopeId(), persisted in wallets.json, never derived from the address
// set). Older builds stored the address-set identity hash here, which drifted whenever an address was
// added; such a legacy scope fails open (stays visible) until reattachLegacyPortfolioScopes() moves it
// to the stable id. New entries are tagged with the current wallet so a portfolio built for wallet A
// doesn't clutter wallet B.
std::string scope;
bool isGlobal() const { return scope.empty(); }
bool isLegacyScope() const { return !scope.empty() && scope.rfind("w:", 0) != 0; }
bool visibleInWallet(const std::string& scopeId) const {
if (isGlobal()) return true;
if (scopeId.empty()) return false; // wallet not resolved yet: never another wallet's groups
if (isLegacyScope()) return true; // fail open until migrated
return scope == scopeId;
}
// Legacy dashboard-grid placement (deprecated by the row layout; kept for back-compat so an
// older build's saved positions aren't dropped, but no longer used by the renderer).
int gridCol = -1;
@@ -144,6 +162,26 @@ public:
hidden_chat_cids_.end());
}
// Blocked chat conversations (by cid). Unlike hide (reversible, keeps messages), block DELETES the
// local history AND suppresses every message for the cid — old and future — until you unblock, at
// which point the conversation re-imports from the chain. The last-known peer name is kept so the
// "Blocked" list can label the entry (its messages are gone from the local store).
struct BlockedChatConv { std::string cid; std::string name; };
bool isChatBlocked(const std::string& cid) const {
for (const auto& b : blocked_chat_convs_) if (b.cid == cid) return true;
return false;
}
void setChatBlocked(const std::string& cid, const std::string& name, bool blocked) {
const bool already = isChatBlocked(cid);
if (blocked && !already) blocked_chat_convs_.push_back({cid, name});
else if (!blocked && already)
blocked_chat_convs_.erase(
std::remove_if(blocked_chat_convs_.begin(), blocked_chat_convs_.end(),
[&](const BlockedChatConv& b) { return b.cid == cid; }),
blocked_chat_convs_.end());
}
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_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; }
@@ -174,6 +212,10 @@ public:
bool getUseTor() const { return use_tor_; }
void setUseTor(bool tor) { use_tor_ = tor; }
// "Use Tor" was never implemented: no build routed any traffic through Tor. Clears a stored opt-in
// and reports whether there was one, so App::init can tell that user once (the cleared key is the
// "already told" flag).
bool consumeLegacyUseTor() { const bool was = use_tor_; use_tor_ = false; return was; }
// Fees
bool getAllowCustomFees() const { return allow_custom_fees_; }
@@ -219,6 +261,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; }
@@ -274,6 +321,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;
@@ -324,11 +387,22 @@ public:
// First-run wizard
bool getWizardCompleted() const { return wizard_completed_; }
void setWizardCompleted(bool v) { wizard_completed_ = v; }
void setWizardCompleted(bool v) { wizard_completed_ = v; durable_next_save_ = true; }
// 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_; }
@@ -346,7 +420,7 @@ public:
// observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
// and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
bool getEncryptionPending() const { return encryption_pending_; }
void setEncryptionPending(bool v) { encryption_pending_ = v; }
void setEncryptionPending(bool v) { encryption_pending_ = v; durable_next_save_ = true; }
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
@@ -357,25 +431,31 @@ public:
// the datadir; defaults to the daemon's own default. Used at launch to know which wallet we're
// on before connect + to scope per-wallet data.
std::string getActiveWalletFile() const { return active_wallet_file_; }
void setActiveWalletFile(const std::string& v) { active_wallet_file_ = v; }
void setActiveWalletFile(const std::string& v) { active_wallet_file_ = v; durable_next_save_ = true; }
// Pending "migrate to a seed wallet" state (Phase 1 created the wallet; a later sweep/adopt
// step consumes it). dest = the new wallet's sweep-target z-address; tempDir = its datadir.
bool getSeedMigrationPending() const { return seed_migration_pending_; }
void setSeedMigrationPending(bool v) { seed_migration_pending_ = v; }
void setSeedMigrationPending(bool v) { seed_migration_pending_ = v; durable_next_save_ = true; }
std::string getSeedMigrationDest() const { return seed_migration_dest_; }
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; }
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; durable_next_save_ = true; }
std::string getSeedMigrationTempDir() const { return seed_migration_temp_dir_; }
void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; }
void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; durable_next_save_ = true; }
// The sweep transaction id, persisted once the sweep is submitted — non-empty means the
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; durable_next_save_ = true; }
// W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
// to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; durable_next_save_ = true; }
// Set TRUE synchronously when a sweep is broadcast; survives the stale-opid resume fallback so the
// Sweep step knows a ~0 legacy balance may be an UNCONFIRMED in-flight sweep (funds not yet safe)
// and must NOT short-circuit to adopt/swap wallet.dat. Cleared on confirmed-adopt / reset / a
// failed submit. See [[decideSeedMigrationResume]] and App::renderSeedMigrationDialog (fund-safety).
bool getSeedMigrationSweepSubmitted() const { return seed_migration_sweep_submitted_; }
void setSeedMigrationSweepSubmitted(bool v) { seed_migration_sweep_submitted_ = v; durable_next_save_ = true; }
// Security — auto-lock timeout (seconds; 0 = disabled)
int getAutoLockTimeout() const { return auto_lock_timeout_; }
@@ -387,7 +467,7 @@ public:
// Security — PIN unlock enabled
bool getPinEnabled() const { return pin_enabled_; }
void setPinEnabled(bool v) { pin_enabled_ = v; }
void setPinEnabled(bool v) { pin_enabled_ = v; durable_next_save_ = true; }
// Daemon — keep running in background when closing the app
bool getKeepDaemonRunning() const { return keep_daemon_running_; }
@@ -419,10 +499,21 @@ public:
void setLitePersistSelectedServer(bool persist) { lite_persist_selected_server_ = persist; }
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
// Servers shipped as defaults. Also merged into an existing install's saved list on load
// (see Settings::load), so upgrades surface newly-added servers (e.g. lite6/lite7).
static std::vector<LiteServerPreference> defaultLiteServers();
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
void setPortfolioEntries(const std::vector<PortfolioEntry>& entries) { portfolio_entries_ = entries; }
// Re-attach groups stuck on a legacy (address-set-hash) scope to the stable wallet id `scopeId`.
// A legacy group is claimed when ANY of: its scope == identityHash (the current address-set hash, exact
// match); at least one of its own addresses is in `walletAddresses` (groups carry their addresses, so
// this attributes correctly in multi-wallet installs); or `singleWallet` (only one known wallet, so
// attribution is unambiguous - same rule contacts use). Anything else stays legacy (fail-open visible).
// Idempotent. Does NOT save: the caller saves once if the return value is > 0. Returns #entries moved.
int reattachLegacyPortfolioScopes(const std::string& scopeId, const std::string& identityHash,
const std::set<std::string>& walletAddresses, bool singleWallet);
// Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle).
const std::set<std::string>& getLiteHiddenServers() const { return lite_hidden_servers_; }
@@ -550,8 +641,51 @@ public:
bool needsUpgradeSave() const { return needs_upgrade_save_; }
void clearUpgradeSave() { needs_upgrade_save_ = false; }
// UI-sweep ONLY. sweepBegin() snapshots the values the full sweep's demo surfaces change: the active
// wallet file, the contacts view mode, and the pool URL/worker (the Mining tab defaults an empty
// worker to the first DEMO z-address). Until sweepEnd(), save() returns true without writing, so
// neither those nor the demo portfolio can reach disk, even through the shutdown save if the app
// quits mid-sweep. sweepEnd() restores the snapshot and, only if a save was suppressed, saves once
// so a real change made meanwhile still lands. The caller restores everything else it changed
// (the portfolio) BEFORE sweepEnd(). Both are idempotent.
void sweepBegin() {
if (sweep_active_) return;
sweep_active_wallet_file_ = active_wallet_file_;
sweep_contacts_view_mode_ = contacts_view_mode_;
sweep_pool_url_ = pool_url_;
sweep_pool_worker_ = pool_worker_;
sweep_suppressed_save_ = false;
sweep_active_ = true;
}
void sweepEnd() {
if (!sweep_active_) return;
sweep_active_ = false;
active_wallet_file_ = sweep_active_wallet_file_;
contacts_view_mode_ = sweep_contacts_view_mode_;
pool_url_ = sweep_pool_url_;
pool_worker_ = sweep_pool_worker_;
if (sweep_suppressed_save_) {
sweep_suppressed_save_ = false;
save();
}
}
bool sweepActive() const { return sweep_active_; }
private:
// UI-sweep latch (see sweepBegin()).
bool sweep_active_ = false;
bool sweep_suppressed_save_ = false;
std::string sweep_active_wallet_file_;
int sweep_contacts_view_mode_ = 0;
std::string sweep_pool_url_, sweep_pool_worker_;
std::string settings_path_;
util::CoalescingWriter* writer_ = nullptr; // null -> synchronous saves (tests, tools, early startup)
// Armed by the fund/security-critical setters (seed-migration stage, encryption_pending, pin_enabled,
// active_wallet_file, wizard_completed): the next save() waits until the file is on disk before it
// returns, so e.g. the sweep-submitted marker is durable before the sweep is broadcast. Cleared by a
// successful save. A NEW setter for crash-critical state must arm it too.
bool durable_next_save_ = false;
// Settings values
std::string theme_ = "dragonx";
@@ -559,6 +693,7 @@ private:
std::string chat_reply_zaddr_;
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)
// 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)
@@ -582,6 +717,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)
@@ -603,9 +739,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;
@@ -616,6 +761,7 @@ private:
std::string seed_migration_temp_dir_;
std::string seed_migration_sweep_txid_;
std::string seed_migration_sweep_opid_;
bool seed_migration_sweep_submitted_ = false;
int auto_lock_timeout_ = 900; // 15 minutes
int unlock_duration_ = 600; // 10 minutes
bool pin_enabled_ = false;
@@ -634,14 +780,7 @@ private:
bool lite_persist_selected_server_ = true;
std::string lite_rollout_override_ = "auto"; // auto|force_on|force_off
std::string lite_install_id_; // random local-only id; rollout-bucket source
std::vector<LiteServerPreference> lite_servers_ = {
{"https://lite.dragonx.is", "DragonX Lite", true},
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
{"https://lite5.dragonx.is", "DragonX Lite 5", true}
};
std::vector<LiteServerPreference> lite_servers_ = defaultLiteServers();
std::set<std::string> lite_hidden_servers_; // server URLs hidden from the Network tab
std::vector<PortfolioEntry> portfolio_entries_; // Market tab custom portfolio groups
+207
View File
@@ -0,0 +1,207 @@
#pragma once
// Is the node busy with something that stopping it would throw away? Tracks dragonxd's own log markers,
// in order, for two independent states:
// - rebuilding: a witness-cache rebuild (BuildWitnessCache). It holds cs_main for up to an hour on a
// big wallet, its second phase logs nothing and ignores shutdown, and a node killed mid-rebuild redoes
// it (after replaying everything since its last flush) on the next start;
// - replaying: the startup replay ("Activating best chain..."). Stopping there is safe — the daemon
// checks for shutdown between steps and flushes — it just takes a moment.
// A rebuild can start in the middle of a replay, so its end must not clear `replaying`, and the state is
// order-based: it never compares timestamps (POSIX nodes log to stdout without them). Pure and
// header-only, so the test target can feed it lines.
#include <cstdint>
#include <cstdio>
#include <string>
namespace dragonx {
namespace daemon {
/**
* "YYYY-MM-DD HH:MM:SS ..." (dragonxd's debug.log prefix, which is UTC) -> seconds since the epoch, or 0
* when the line has no such prefix. Days-from-civil arithmetic, not mktime(): mktime reads local time.
*/
inline std::int64_t parseDaemonLogUtc(const std::string& line)
{
int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0;
if (line.size() < 19 || std::sscanf(line.c_str(), "%4d-%2d-%2d %2d:%2d:%2d", &y, &mo, &d, &h, &mi, &s) != 6)
return 0;
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 60) return 0;
// Howard Hinnant's days_from_civil.
const int yy = y - (mo <= 2 ? 1 : 0);
const int era = (yy >= 0 ? yy : yy - 399) / 400;
const unsigned yoe = static_cast<unsigned>(yy - era * 400);
const unsigned doy = (153u * static_cast<unsigned>(mo + (mo > 2 ? -3 : 9)) + 2u) / 5u + static_cast<unsigned>(d) - 1u;
const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
const std::int64_t days = static_cast<std::int64_t>(era) * 146097 + static_cast<std::int64_t>(doe) - 719468;
return days * 86400 + h * 3600 + mi * 60 + s;
}
class DaemonBusyTracker {
public:
// Feed raw output: any chunking, a line split across two chunks is reassembled.
void feed(const std::string& chunk)
{
for (char c : chunk) {
if (c == '\n') {
feedLine(carry_);
carry_.clear();
} else if (c != '\r') {
carry_.push_back(c);
if (carry_.size() > 64 * 1024) carry_.clear(); // not a log line; don't grow without bound
}
}
}
void feedLine(const std::string& line)
{
// A new daemon process, or the end of the last one: forget everything.
if (contains(line, "DragonX version ") || contains(line, "Hush version ") || contains(line, "Shutdown: done")) {
reset();
return;
}
if (contains(line, "init message: Activating best chain")) {
replaying_ = true;
replayStartUtc_ = parseDaemonLogUtc(line);
lastMarker_ = line;
return;
}
if (contains(line, "init message: Full Node Done Loading") || contains(line, "init message: Done loading")) {
replaying_ = false;
lastMarker_ = line;
return;
}
const size_t read = line.find("Reading blocks for witness rebuild: ");
if (read != std::string::npos || contains(line, "Building Witnesses for block ")) {
if (!rebuilding_) rebuildStartUtc_ = parseDaemonLogUtc(line);
rebuilding_ = true;
if (read != std::string::npos) {
long done = 0, total = 0;
if (std::sscanf(line.c_str() + read, "Reading blocks for witness rebuild: %ld / %ld", &done, &total) == 2) {
readDone_ = done;
readTotal_ = total;
}
}
lastMarker_ = line;
return;
}
// The rebuild's end. UpdateTip is logged under cs_main, so one after a rebuild marker means the
// rebuild has released it (exceptions and non-bulk paths print no end line).
if (rebuilding_ && (contains(line, "note witness cache(s) to height") ||
contains(line, "aborting witness rebuild") ||
contains(line, "aborted during witness rebuild") ||
contains(line, "aborting building witnesses") ||
contains(line, "UpdateTip: "))) {
rebuilding_ = false;
readDone_ = readTotal_ = 0;
rebuildStartUtc_ = 0;
lastMarker_ = line;
}
}
void reset()
{
rebuilding_ = replaying_ = false;
readDone_ = readTotal_ = 0;
rebuildStartUtc_ = replayStartUtc_ = 0;
lastMarker_.clear();
}
bool rebuilding() const { return rebuilding_; }
bool replaying() const { return replaying_; }
bool busy() const { return rebuilding_ || replaying_; }
long readDone() const { return readDone_; } // the last Phase-1 "reading blocks" progress, 0 if none
long readTotal() const { return readTotal_; }
std::int64_t rebuildStartUtc() const { return rebuildStartUtc_; } // 0: unknown (no timestamps)
std::int64_t replayStartUtc() const { return replayStartUtc_; }
const std::string& lastMarker() const { return lastMarker_; }
private:
static bool contains(const std::string& s, const char* needle) { return s.find(needle) != std::string::npos; }
std::string carry_;
bool rebuilding_ = false;
bool replaying_ = false;
long readDone_ = 0;
long readTotal_ = 0;
std::int64_t rebuildStartUtc_ = 0;
std::int64_t replayStartUtc_ = 0;
std::string lastMarker_;
};
/**
* The "keep the node running?" guard on quit. Prompt only when this quit would stop the node and it is
* rebuilding witnesses, or has been alive but too busy to answer RPC for a while (a rebuild that logged
* no marker). A replay alone never prompts: stopping it is safe. No daemon version suppresses the prompt
* (v1.3.0 does not checkpoint a witness rebuild; nothing survives a kill).
*/
inline bool shouldConfirmStop(bool stopsDaemon, const DaemonBusyTracker& tracker, bool rpcBusyWhileAlive)
{
return stopsDaemon && (tracker.rebuilding() || rpcBusyWhileAlive);
}
// Retry schedule after failing to find a pid's banner in debug.log: re-scan fast during the fresh-node grace
// window, then back off 4 s doubling to a 60 s cap. `backoff` is the caller's running state (0 until grace ends).
inline double nextExternalLogRetryDelay(double ageSinceMiss, double& backoff,
double graceWindow = 15.0, double fast = 2.0,
double base = 4.0, double cap = 60.0)
{
if (ageSinceMiss < graceWindow) return fast;
backoff = (backoff < base) ? base : (backoff * 2.0 < cap ? backoff * 2.0 : cap);
return backoff;
}
/**
* Where to start feeding a DaemonBusyTracker from the last `window` of the debug.log of a node someone else
* started at `processStartUtc` (UTC seconds). Only lines that process wrote count: a node started with
* -printtoconsole leaves an old debug.log behind, whose last run may have died mid-rebuild. Returns the
* offset of that process's startup banner in the window; 0 when its banner is older than the window
* (`windowStartsMidFile`) and the window's first stamped line postdates the process start; npos when the
* log isn't that process's.
*/
inline size_t externalLogFeedOffset(const std::string& window, bool windowStartsMidFile, std::int64_t processStartUtc)
{
size_t lastBanner = std::string::npos;
std::int64_t lastBannerAt = 0, firstStamp = 0;
size_t pos = 0;
while (pos < window.size()) {
size_t end = window.find('\n', pos);
if (end == std::string::npos) end = window.size();
const std::string line = window.substr(pos, end - pos);
const std::int64_t at = parseDaemonLogUtc(line);
if (at > 0 && firstStamp == 0) firstStamp = at;
if (line.find("DragonX version ") != std::string::npos || line.find("Hush version ") != std::string::npos) {
lastBanner = pos;
lastBannerAt = at;
}
pos = end + 1;
}
if (processStartUtc <= 0) return std::string::npos;
const std::int64_t since = processStartUtc - 5; // the log's whole seconds vs the process's start time
if (lastBanner != std::string::npos) return (lastBannerAt > 0 && lastBannerAt >= since) ? lastBanner : std::string::npos;
if (windowStartsMidFile && firstStamp > 0 && firstStamp >= since) return 0;
return std::string::npos;
}
// What the "node is starting / not answering" overlay should say, most specific first.
enum class DaemonStatusKind {
Rebuilding, // its own log says it's rebuilding note witnesses
Replaying, // its own log says it's catching up on blocks it saved before (startup replay)
InitPhase, // a recognisable init phase line (loading the block index, verifying, ...)
Busy, // it answers the connection but not the request (reply timeout / queue full)
Launching, // nothing bound to the RPC port yet
};
inline DaemonStatusKind chooseDaemonStatus(bool hasInitPhaseLine, bool reachableButBusy,
const DaemonBusyTracker& tracker)
{
if (tracker.rebuilding()) return DaemonStatusKind::Rebuilding;
if (tracker.replaying()) return DaemonStatusKind::Replaying;
if (hasInitPhaseLine) return DaemonStatusKind::InitPhase;
if (reachableButBusy) return DaemonStatusKind::Busy;
return DaemonStatusKind::Launching;
}
} // namespace daemon
} // namespace dragonx
+14 -2
View File
@@ -41,10 +41,10 @@ void DaemonController::syncSettings(const config::Settings* settings)
daemon_->setWalletFile(walletFile);
}
bool DaemonController::start(const config::Settings* settings)
bool DaemonController::start(const config::Settings* settings, int lockWaitPolls)
{
syncSettings(settings);
return daemon_->start();
return daemon_->start("", lockWaitPolls);
}
void DaemonController::stop(int waitMs)
@@ -52,6 +52,11 @@ void DaemonController::stop(int waitMs)
daemon_->stop(waitMs);
}
void DaemonController::markStopRequested()
{
daemon_->markStopRequested();
}
bool DaemonController::isRunning() const
{
return daemon_->isRunning();
@@ -94,11 +99,18 @@ double DaemonController::memoryUsageMB() const
return daemon_ ? daemon_->getMemoryUsageMB() : 0.0;
}
std::function<double()> DaemonController::memoryProbe() const
{
return daemon_ ? daemon_->memoryProbe() : std::function<double()>{};
}
std::vector<std::string> DaemonController::recentLines(std::size_t count) const
{
return daemon_ ? daemon_->getRecentLines(count) : std::vector<std::string>{};
}
std::size_t DaemonController::outputEnd() const { return daemon_ ? daemon_->getOutputEnd() : 0; }
std::string DaemonController::outputSince(std::size_t& offset) const
{
return daemon_ ? daemon_->getOutputSince(offset) : std::string{};
+239 -10
View File
@@ -1,9 +1,12 @@
#pragma once
#include "embedded_daemon.h"
#include "stop_delivery.h"
#include <algorithm>
#include <cstddef>
#include <deque>
#include <functional>
#include <memory>
#include <string>
#include <vector>
@@ -61,17 +64,21 @@ public:
class LifecycleRuntime {
public:
virtual ~LifecycleRuntime() = default;
virtual void stopDaemonWithPolicy() = 0;
// Stop the node and wait (bounded, never killing it) for it to exit. False: it is still running
// (or we stopped waiting), and nothing that needs it gone may run.
virtual bool stopDaemonWithPolicy() = 0;
virtual bool startDaemon() = 0;
virtual int deleteBlockchainData() = 0;
virtual void resetOutputOffset() = 0;
virtual void requestRpcStopAndDisconnect(const char* context, const char* reason) = 0;
// Disconnect and stop the node off this thread; whoever needs it gone waits for the process to exit.
virtual void beginBackgroundStop(const char* reason) = 0;
};
struct LifecycleExecutionResult {
bool completed = false;
bool cancelled = false;
bool stopped = false;
bool stopFailed = false; // the node didn't exit: the operation was cancelled before changing anything
bool started = false;
int deletedItems = 0;
};
@@ -88,8 +95,9 @@ public:
void setStateCallback(StateCallback callback);
void syncSettings(const config::Settings* settings);
bool start(const config::Settings* settings);
bool start(const config::Settings* settings, int lockWaitPolls = 3);
void stop(int waitMs);
void markStopRequested();
bool isRunning() const;
bool externalDaemonDetected() const;
@@ -99,8 +107,10 @@ public:
int crashCount() const;
int lastBlockHeight() const;
double memoryUsageMB() const;
std::function<double()> memoryProbe() const; // EmbeddedDaemon::memoryProbe(); empty without a daemon
std::vector<std::string> recentLines(std::size_t count) const;
std::string outputSince(std::size_t& offset) const;
std::size_t outputEnd() const;
void resetCrashCount();
void setRescanOnNextStart(bool enabled);
@@ -191,22 +201,24 @@ public:
switch (decision.operation) {
case LifecycleOperation::BootstrapStop:
// The download takes minutes; the bootstrap worker waits for the process to exit before it
// touches chain data.
if (decision.wasRunning) {
runtime.requestRpcStopAndDisconnect("Bootstrap daemon stop", "Bootstrap");
runtime.beginBackgroundStop("Bootstrap");
result.stopped = true;
}
result.completed = true;
return result;
case LifecycleOperation::ManualRestart:
if (decision.wasRunning) {
runtime.stopDaemonWithPolicy();
result.stopped = true;
}
break;
case LifecycleOperation::Rescan:
case LifecycleOperation::RepairWallet:
case LifecycleOperation::DeleteBlockchainData:
runtime.stopDaemonWithPolicy();
if (decision.operation == LifecycleOperation::ManualRestart && !decision.wasRunning) break;
// Never start a second node, or delete chain data, under one that hasn't exited.
if (!runtime.stopDaemonWithPolicy()) {
result.stopFailed = true;
return result;
}
result.stopped = true;
break;
}
@@ -233,6 +245,223 @@ public:
ShutdownDecision shutdownDecision(bool keepDaemonRunning,
bool stopExternalDaemon) const;
// ------------------------------------------------------------------------------------------------
// Stopping the node: deliver "stop" until it lands, then wait for the process to exit. Never kill it
// on our own: a node that is busy (a witness rebuild can hold it for an hour) or flushing would lose
// everything since its last save and replay it on the next start. Only an explicit user "Kill",
// offered once the node has gone idle, ends it by force; "Leave running" lets the wallet close while
// the node finishes on its own.
// ------------------------------------------------------------------------------------------------
using StopUserChoice = daemon::StopUserChoice;
using StopOutcome = daemon::StopOutcome;
struct StopPolicy {
bool owned = true; // we launched it: watch the process, signal it, offer Kill
bool gracefulSignal = false; // POSIX: a group SIGTERM is a stop channel (dragonxd treats it as stop)
double maxWaitSec = 0.0; // stop waiting (never kill) after this long; 0 = no limit
bool userChoices = false; // the shutdown screen offers "Leave running" / "Kill"
bool abandonOnShutdown = false; // stop waiting once the app starts shutting down (background callers)
double idleOfferSec = 600.0; // delivered + no CPU/I-O progress for this long: offer Kill
};
// A bounded stop that has no way to reach the node (a node we don't own refused our credentials, or our
// own did with every source and there is no signal) gives up after this long rather than its full budget.
static constexpr double kUndeliverableGraceSec = 5.0;
// What the stop loop reports for display.
struct StopStatus {
bool delivered = false; // stop accepted (or the node is shutting down, or was signalled)
bool heard = false; // the node has the request (possibly still queued)
bool signalled = false; // a graceful signal (POSIX SIGTERM) went out
double elapsedSec = 0.0;
double cpuPercent = -1.0; // < 0: unknown
double idleSec = 0.0;
bool killOffered = false;
bool rebuildActive = false;
StopSendResult lastSend = StopSendResult::Other;
int attempts = 0;
};
class StopRuntime {
public:
virtual ~StopRuntime() = default;
virtual double nowSec() = 0;
virtual bool processAlive() = 0;
virtual StopSendResult trySendStop() = 0;
virtual bool nextCredentials() = 0; // switch to the next stop credentials; false when none left
virtual bool sendGracefulSignal() = 0; // POSIX group SIGTERM; false where there is none (Windows)
virtual double cpuSeconds() = 0; // cumulative CPU of the node; < 0 if unknown
virtual double ioBytes() = 0; // cumulative I/O bytes of the node; < 0 if unknown
virtual bool rebuildActive() = 0; // a witness rebuild is running: never offer Kill
virtual StopUserChoice userChoice() = 0;
virtual void offerForceKill(bool offered) = 0;
virtual void forceKill() = 0;
virtual void setStatus(const StopStatus& status) = 0;
};
/**
* "Making progress" = >= 0.05 cores of CPU or >= 1 MB of I/O over the last >= 60s. A flush that
* fsyncs a big wallet is mostly I/O at low CPU; a rebuild is mostly CPU. Idle time counts from the
* first sample. With no readings at all (unknown), it never reports idle, so Kill is never offered.
*/
class StopProgressTracker {
public:
static constexpr double kWindowSec = 60.0;
static constexpr double kMinCores = 0.05;
static constexpr double kMinIoBytes = 1024.0 * 1024.0;
void add(double t, double cpuSec, double ioBytes)
{
if (cpuSec >= 0 || ioBytes >= 0) measurable_ = true;
if (samples_.empty()) lastProgressAt_ = t;
samples_.push_back({t, cpuSec, ioBytes});
// Keep the newest sample that is at least a full window old as the comparison base.
while (samples_.size() > 2 && samples_[1].t <= t - kWindowSec) samples_.pop_front();
const Sample& base = samples_.front();
const double span = t - base.t;
if (span >= kWindowSec) {
const bool cpuMoved = cpuSec >= 0 && base.cpu >= 0 && (cpuSec - base.cpu) / span >= kMinCores;
const bool ioMoved = ioBytes >= 0 && base.io >= 0 && (ioBytes - base.io) >= kMinIoBytes;
if (cpuMoved || ioMoved) lastProgressAt_ = t;
}
}
double idleSec(double t) const { return measurable_ ? std::max(0.0, t - lastProgressAt_) : 0.0; }
// CPU over the most recent sample interval (100 = one core), for display; < 0 unknown.
double cpuPercent() const
{
if (samples_.size() < 2) return -1.0;
const Sample& a = samples_[samples_.size() - 2];
const Sample& b = samples_.back();
if (a.cpu < 0 || b.cpu < 0 || b.t <= a.t) return -1.0;
return std::max(0.0, (b.cpu - a.cpu) / (b.t - a.t) * 100.0);
}
private:
struct Sample { double t, cpu, io; };
std::deque<Sample> samples_;
double lastProgressAt_ = 0.0;
bool measurable_ = false;
};
enum class StopWaitDecision { Done, Wait, GiveUp, Detach, OfferForceKill, ForceKill };
struct StopWaitSample {
bool processAlive = true;
bool delivered = false;
double elapsedSec = 0.0;
double idleSec = 0.0;
bool rebuildActive = false;
StopUserChoice choice = StopUserChoice::None;
bool killOffered = false; // the Kill offer is on screen right now
bool shuttingDown = false; // the app began shutting down / the task was cancelled
bool undeliverable = false; // no stop delivered, none will be sent, and no signal to send
};
static inline StopWaitDecision evaluateStopWait(const StopPolicy& p, const StopWaitSample& s)
{
if (!s.processAlive) return StopWaitDecision::Done;
if (p.userChoices && s.choice == StopUserChoice::Detach) return StopWaitDecision::Detach;
if (p.userChoices && p.owned && s.choice == StopUserChoice::Kill && s.killOffered)
return StopWaitDecision::ForceKill;
if (p.abandonOnShutdown && s.shuttingDown) return StopWaitDecision::GiveUp;
if (p.maxWaitSec > 0 && s.elapsedSec >= p.maxWaitSec) return StopWaitDecision::GiveUp;
if (p.maxWaitSec > 0 && s.undeliverable && s.elapsedSec >= kUndeliverableGraceSec)
return StopWaitDecision::GiveUp;
if (p.userChoices && p.owned && s.delivered && !s.rebuildActive && s.idleSec >= p.idleOfferSec)
return StopWaitDecision::OfferForceKill;
return StopWaitDecision::Wait;
}
static constexpr int kStopTickMs = 250;
static inline StopOutcome executeDaemonStop(const StopPolicy& policy, StopRuntime& rt,
LifecycleTaskContext& task)
{
if (!rt.processAlive()) return StopOutcome::Exited; // already gone: not a single RPC call
const double t0 = rt.nowSec();
StopDeliveryPolicy delivery(policy.owned);
StopProgressTracker progress;
bool signalled = false;
bool offered = false;
auto withdrawOffer = [&] {
if (offered) { offered = false; rt.offerForceKill(false); }
};
for (;;) {
double now = rt.nowSec() - t0;
if (delivery.shouldSend(now)) {
const StopSendResult r = delivery.record(rt.trySendStop(), now);
if (r == StopSendResult::Auth && policy.owned) {
// Our own node refusing our credentials means they're stale (e.g. a regenerated
// .cookie): try the next source; out of sources, the graceful signal below remains.
if (rt.nextCredentials()) delivery.retryNow(rt.nowSec() - t0);
else delivery.giveUp();
}
// POSIX: a group SIGTERM bypasses the RPC queue altogether (dragonxd's handler sets the same
// flag as stop). Send it as soon as an attempt shows stop didn't get through.
if (policy.gracefulSignal && !signalled && !delivery.delivered() &&
r != StopSendResult::Queued && rt.sendGracefulSignal()) {
signalled = true;
delivery.markDelivered();
}
}
if (!rt.processAlive()) {
withdrawOffer();
return StopOutcome::Exited;
}
now = rt.nowSec() - t0;
progress.add(now, rt.cpuSeconds(), rt.ioBytes());
StopWaitSample s;
s.delivered = delivery.delivered();
s.elapsedSec = now;
s.idleSec = progress.idleSec(now);
s.rebuildActive = rt.rebuildActive();
s.choice = rt.userChoice();
s.killOffered = offered;
s.shuttingDown = task.cancelled() || task.shuttingDown();
s.undeliverable = delivery.finished() && !delivery.delivered() && !signalled &&
!(policy.gracefulSignal && policy.owned);
switch (evaluateStopWait(policy, s)) {
case StopWaitDecision::Done:
withdrawOffer();
return StopOutcome::Exited;
case StopWaitDecision::Detach:
withdrawOffer();
return StopOutcome::Detached;
case StopWaitDecision::ForceKill:
withdrawOffer();
rt.forceKill();
return StopOutcome::Killed;
case StopWaitDecision::GiveUp:
withdrawOffer();
return StopOutcome::StillRunning;
case StopWaitDecision::OfferForceKill:
if (!offered) { offered = true; rt.offerForceKill(true); }
break;
case StopWaitDecision::Wait:
withdrawOffer(); // progress resumed (or a rebuild started): the node isn't stuck after all
break;
}
StopStatus st;
st.delivered = delivery.delivered();
st.heard = delivery.heard();
st.signalled = signalled;
st.elapsedSec = now;
st.cpuPercent = progress.cpuPercent();
st.idleSec = s.idleSec;
st.killOffered = offered;
st.rebuildActive = s.rebuildActive;
st.lastSend = delivery.last();
st.attempts = delivery.attempts();
rt.setStatus(st);
task.sleepForMs(kStopTickMs);
}
}
private:
std::unique_ptr<EmbeddedDaemon> daemon_;
};
File diff suppressed because it is too large Load Diff
+251 -19
View File
@@ -4,15 +4,23 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <functional>
#include <memory>
#include <atomic>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono>
#include <set>
#include <vector>
#include "output_window.h"
#ifdef _WIN32
#include <windows.h>
#else
@@ -50,16 +58,72 @@ public:
/**
* @brief Start the embedded dragonxd daemon
* @param binary_path Path to dragonxd binary (auto-detect if empty)
* @return true if process started successfully
* @return true if process started successfully (or is already running); false while a stop
* session (beginStop() .. endStop()) is still in progress
*/
bool start(const std::string& binary_path = "");
static constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
/** @param lockWaitPolls max 100ms datadir-lock waits (0 = single check, never sleeps; for the UI thread) */
bool start(const std::string& binary_path = "", int lockWaitPolls = kDatadirLockWaitMaxPolls);
/**
* @brief Stop the running daemon
* @brief Stop the daemon process this object launched (never any other dragonxd)
* @param wait_ms Maximum milliseconds to wait for graceful shutdown
* @return true if it exited on its own (or there was nothing to stop), false if it had to be
* force-killed — then its wallet/chain files may not have been closed cleanly
*/
void stop(int wait_ms = 5000);
bool stop(int wait_ms = 5000);
/**
* @brief Record that we are about to ask the daemon to stop (e.g. over RPC)
*
* Call it before sending the stop. If the daemon then exits before stop() runs, the monitor
* records a clean stop instead of a crash (crash_count_ drives auto-restart). stop() sets it
* too; start() clears it.
*/
void markStopRequested() { stop_requested_ = true; }
// --- A stop session, driven by DaemonController::executeDaemonStop through the app's StopRuntime ---
/** Marks the stop as requested and stops the monitor, so the session is the only reaper.
* @return true if a process this object launched is still alive; then finish with endStop(). */
bool beginStop();
/** Polls our own process (reaping it on POSIX) and drains its output; false once it has exited. */
bool pollOwnedProcess();
/** POSIX: SIGTERM our process group (dragonxd treats it like RPC stop). Windows has no such channel. */
bool signalGracefulStop();
/** Ends our own process by force (SIGKILL / TerminateProcess) and reaps it. Explicit user choice only. */
void forceKillOwnedProcess();
struct ResourceUsage {
double cpuSeconds = -1.0; // cumulative; < 0 if unknown
double ioBytes = -1.0; // cumulative storage I/O; < 0 if unknown
};
/** CPU and I/O used so far by our process (its whole process group on Linux). */
ResourceUsage ownedResourceUsage() const;
/** Finishes a stop session. The process is gone: release everything. Still running: keepWatching
* resumes the monitor (its exit is still seen, and booked as a clean stop); otherwise let it go —
* the user chose to leave it running. */
void endStop(bool keepWatching);
/**
* @brief Parse a /proc/<pid>/stat line: process group (field 5) and utime+stime ticks (fields 14+15)
*
* comm (field 2) is parenthesised and may itself contain spaces and ')', so fields are read from
* after the LAST ')'.
*/
static bool parseProcStatLine(const std::string& line, long& pgrp, unsigned long long& cpuTicks)
{
const size_t close = line.rfind(')');
if (close == std::string::npos) return false;
unsigned long long utime = 0, stime = 0;
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime
if (std::sscanf(line.c_str() + close + 1, " %*c %*d %ld %*d %*d %*d %*u %*u %*u %*u %*u %llu %llu",
&pgrp, &utime, &stime) != 3)
return false;
cpuTicks = utime + stime;
return true;
}
/**
* @brief Check if daemon process is running
*/
@@ -73,8 +137,18 @@ public:
/**
* @brief Get daemon process memory usage (RSS) in MB
* @return Memory usage in MB, or 0 if unavailable
*
* Synchronous (Linux scans /proc, macOS forks `ps`): UI code uses memoryProbe() through a
* util::BackgroundSampler instead.
*/
double getMemoryUsageMB() const;
/**
* @brief A self-contained RSS probe (MB) for another thread. Snapshots what it needs on the calling
* thread (the pid; on Windows the value itself: one cheap call on the owned handle); the callable
* touches no EmbeddedDaemon state. Empty when there is no process.
*/
std::function<double()> memoryProbe() const;
/**
* @brief Get last error message
@@ -93,17 +167,25 @@ public:
/**
* @brief Get new output since a given offset (thread-safe).
* Returns the new text and updates offset to the current size.
* Returns the new text and advances offset. Offsets are absolute positions in everything ever
* appended (start from 0), so trimming old output never shifts them: a reader that falls behind a
* trim only loses the trimmed part, not output that is still buffered.
*/
std::string getOutputSince(size_t& offset) const {
std::lock_guard<std::mutex> lk(output_mutex_);
if (offset >= process_output_.size()) {
offset = process_output_.size();
return {};
}
std::string result = process_output_.substr(offset);
offset = process_output_.size();
return result;
return outputSince(process_output_, output_total_, offset);
}
/** getOutputSince() on a buffer holding the LAST buffer.size() of `total` bytes ever appended. */
static std::string outputSince(const std::string& buffer, size_t total, size_t& offset)
{
return outputWindowSince(buffer, total, offset);
}
/** Absolute end of everything ever appended (a valid getOutputSince() offset). */
size_t getOutputEnd() const {
std::lock_guard<std::mutex> lk(output_mutex_);
return output_total_;
}
/**
@@ -245,6 +327,121 @@ public:
*/
static bool isDaemonProcessRunning();
/**
* @brief Does a /proc entry (its comm + its exe link's basename) belong to a dragonxd? (Linux)
*
* comm alone misses a node in its final shutdown phase: Shutdown() renames the main thread to
* "hush-shutoff" while the process still holds the datadir lock. The exe link keeps the binary
* name, with " (deleted)" appended once the binary was replaced underneath it (reinstall/update).
*/
static bool isDaemonProcessIdentity(const std::string& comm, std::string exeName)
{
static const std::string kDeleted = " (deleted)";
if (exeName.size() > kDeleted.size() &&
exeName.compare(exeName.size() - kDeleted.size(), kDeleted.size(), kDeleted) == 0)
exeName.erase(exeName.size() - kDeleted.size());
return comm == "dragonxd" || exeName == "dragonxd";
}
// ---- Ownership record --------------------------------------------------------------------------
// <datadir>/obsidiandragon-dragonxd.pid names the node this wallet launched, so a later session that
// finds it still running (after "Leave running") knows it is ours: it may stop it gracefully and wait
// for it, never kill it. The start token (when that process started) defeats PID reuse and makes a
// record from before a reboot stale.
struct OwnershipRecord {
long pid = 0;
std::string startToken;
std::string exePath;
};
static std::string formatOwnershipRecord(const OwnershipRecord& r)
{
return "pid=" + std::to_string(r.pid) + "\nstart=" + r.startToken + "\nexe=" + r.exePath + "\n";
}
static bool parseOwnershipRecord(const std::string& text, OwnershipRecord& out)
{
OwnershipRecord r;
size_t pos = 0;
while (pos < text.size()) {
size_t end = text.find('\n', pos);
if (end == std::string::npos) end = text.size();
std::string line = text.substr(pos, end - pos);
pos = end + 1;
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.rfind("pid=", 0) == 0) r.pid = std::strtol(line.c_str() + 4, nullptr, 10);
else if (line.rfind("start=", 0) == 0) r.startToken = line.substr(6);
else if (line.rfind("exe=", 0) == 0) r.exePath = line.substr(4);
}
if (r.pid <= 0 || r.startToken.empty()) return false;
out = r;
return true;
}
/** Linux /proc/<pid>/stat: the process state and its start time (clock ticks after boot, field 22). */
static bool parseProcStatStart(const std::string& line, char& state, unsigned long long& startTicks)
{
const size_t close = line.rfind(')');
if (close == std::string::npos) return false;
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime
// priority nice num_threads itrealvalue starttime
return std::sscanf(line.c_str() + close + 1,
" %c %*d %*d %*d %*d %*d %*u %*u %*u %*u %*u %*u %*u %*d %*d %*d %*d %*d %*d %llu",
&state, &startTicks) == 2;
}
/**
* dragonxd shrinks a large debug.log at startup (it keeps the tail), so the file can become smaller
* than where we were reading. Resume at THIS run's startup banner — the first banner line stamped at or
* after `launchUtc` (UTC) — not at 0, which would replay the previous run's lines (old rebuild markers,
* old corruption errors) as if they were new. No such banner yet: skip to the end (it follows).
*/
static size_t shrinkResumeOffset(const std::string& content, std::int64_t launchUtc);
/** When process `pid` started, as an opaque token; "" if it isn't running (or is a zombie). */
static std::string processStartToken(long pid);
/** When process `pid` started, in UTC seconds; 0 if unknown. */
static std::int64_t processStartUtc(long pid);
/** Is process `pid` a dragonxd (by name / executable)? */
static bool processIsDaemon(long pid);
static std::string ownershipRecordPath(); // <datadir>/obsidiandragon-dragonxd.pid
static void writeOwnershipRecord(const std::string& path, const OwnershipRecord& r);
/** The record at `path`, if its process is still running; a stale or unreadable record is deleted. */
static bool liveOwnershipRecord(const std::string& path, OwnershipRecord* out = nullptr);
/**
* Does this DRAGONX.conf text set `key` on an active line ("key=value", spaces allowed around '=';
* '#' starts a comment)? Used before adding a flag of our own: a command-line flag overrides the conf,
* so the user's own setting must win.
*/
static bool confSetsKey(const std::string& confText, const std::string& key)
{
size_t pos = 0;
while (pos < confText.size()) {
size_t end = confText.find('\n', pos);
if (end == std::string::npos) end = confText.size();
std::string line = confText.substr(pos, end - pos);
pos = end + 1;
const size_t hash = line.find('#');
if (hash != std::string::npos) line.erase(hash);
const size_t eq = line.find('=');
if (eq == std::string::npos) continue;
size_t b = line.find_first_not_of(" \t\r");
size_t e = line.find_last_not_of(" \t\r", eq == 0 ? 0 : eq - 1);
if (b == std::string::npos || b >= eq || e == std::string::npos || e < b) continue;
if (line.compare(b, e - b + 1, key) == 0) return true;
}
return false;
}
/**
* Drop the wallet's own -dbcache= pin from `args` when the user's DRAGONX.conf sets dbcache (a
* command-line flag overrides the conf, so the user's own value must win). Pure; no logging.
*/
static void dropPinnedDbcacheIfConfSets(std::vector<std::string>& args, const std::string& conf)
{
if (!confSetsKey(conf, "dbcache")) return;
args.erase(std::remove_if(args.begin(), args.end(),
[](const std::string& a) { return a.rfind("-dbcache=", 0) == 0; }),
args.end());
}
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
struct StartLockGateDecision {
bool proceed = true; // false => bail before spawning
@@ -278,10 +475,27 @@ public:
int getCrashCount() const { return crash_count_.load(); }
/** Reset crash counter (call on successful connection or manual restart) */
void resetCrashCount() { crash_count_ = 0; }
/** Unexpected exits since this object was created; never reset (unlike the crash count), so a
* reader can tell a new crash from an error it has already reported. */
std::uint64_t getUnexpectedExitCount() const { return unexpected_exits_.load(); }
/** Monitor poll interval: stay `fast` for the first 30 s after the thread starts and for 3 s after any
* output; otherwise double per idle tick up to `cap`. Any output resets to `fast`. Pure. */
static std::chrono::milliseconds nextMonitorInterval(std::chrono::milliseconds cur, bool bytesRead,
std::chrono::milliseconds sinceStart, std::chrono::milliseconds sinceLastData,
std::chrono::milliseconds fast, std::chrono::milliseconds cap)
{
using ms = std::chrono::milliseconds;
if (bytesRead || sinceStart < ms(30000) || sinceLastData < ms(3000)) return fast;
const ms next = cur * 2;
return next > cap ? cap : (next < fast ? fast : next);
}
private:
void setState(State s, const std::string& message = "");
void monitorProcess();
void requestMonitorStop(); // set should_stop_ under monitor_mutex_ and wake the monitor
size_t outputTotalSnapshot() const; // output_total_ under output_mutex_ (monitor idle detection)
void drainOutput(); // read any pending stdout into process_output_
void appendOutput(const char* data, size_t len); // append + trim (caller holds output_mutex_)
bool startProcess(const std::string& binary_path, const std::vector<std::string>& args);
@@ -290,32 +504,50 @@ 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_)
StateCallback state_callback_;
// 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 debug_log_path_; // path to daemon's debug.log for output tailing
size_t debug_log_offset_ = 0; // read offset into 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;
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
// after monitor_thread_ is joined, so that stop() is the sole reaper.
bool reapWithin(pid_t pid, int timeout_ms, int& status);
#endif
// The node's output is its <datadir>/debug.log (timestamped; it outlives us if the node is left running).
std::string debug_log_path_;
size_t debug_log_offset_ = 0; // read offset into debug.log
std::int64_t launch_utc_ = 0; // when we launched it (to find this run's banner after a log shrink)
std::thread monitor_thread_;
std::atomic<bool> should_stop_{false};
std::mutex monitor_mutex_; // pairs with monitor_cv_ so stop wakes the monitor at once
std::condition_variable monitor_cv_;
std::atomic<bool> stop_requested_{false}; // a stop was asked for: the next exit is not a crash
// Serializes start/beginStop/stop/endStop over monitor_thread_, should_stop_ and stop_requested_ (two
// threads joining one std::thread is undefined). Never taken by the monitor thread or the state callback.
std::mutex lifecycle_mutex_;
std::set<std::string> debug_categories_;
int max_connections_ = 0; // 0 = daemon default
bool stratum_enabled_ = false; // -stratum: host a RandomX pool (v1.3.0+; older daemons ignore it)
std::string stratum_allowip_; // -stratumallowip subnet (empty = daemon default: loopback only)
std::string wallet_file_; // -wallet=<name> for the active wallet; empty/"wallet.dat" = default
std::atomic<int> crash_count_{0}; // consecutive crash counter
std::atomic<std::uint64_t> unexpected_exits_{0}; // all unexpected exits (see getUnexpectedExitCount)
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
+41
View File
@@ -0,0 +1,41 @@
// DragonX Wallet - ImGui Edition
// Shared absolute-offset output ring helpers (daemon debug.log feed, xmrig console feed).
#pragma once
#include <cstddef>
#include <string>
namespace dragonx {
namespace daemon {
// getOutputSince() over a buffer holding the LAST buffer.size() of `total` bytes ever appended.
inline std::string outputWindowSince(const std::string& buffer, std::size_t total, std::size_t& offset)
{
const std::size_t base = total - buffer.size(); // absolute position of buffer[0]
if (offset < base) offset = base; // trimmed before read: resume at what's left
if (offset >= total) {
offset = total;
return {};
}
std::string result = buffer.substr(offset - base);
offset = total;
return result;
}
// Append `n` bytes, advance absolute `total`, and trim to ~cap at a newline once size exceeds 1.25x cap.
// The `nl < size-1` guard keeps a lone trailing newline from erasing the whole buffer.
inline void appendBounded(std::string& buffer, std::size_t& total, const char* data, std::size_t n, std::size_t cap)
{
buffer.append(data, n);
total += n;
if (buffer.size() > cap + cap / 4) {
const std::size_t trim_to = buffer.size() - cap;
const std::size_t nl = buffer.find('\n', trim_to);
if (nl != std::string::npos && nl < buffer.size() - 1) buffer.erase(0, nl + 1);
else buffer.erase(0, trim_to);
}
}
} // namespace daemon
} // namespace dragonx
+28 -7
View File
@@ -17,6 +17,21 @@ namespace daemon {
namespace {
// Asks the isolated node to stop over its own RPC (port + throwaway credentials, never the main node's)
// and waits for it to exit. Without the RPC stop it was only ever force-killed, holding the wallet.dat
// it had just created. Returns false if it still had to be force-killed.
bool stopIsolatedNode(EmbeddedDaemon& node, rpc::RPCClient& cli, int port, const std::string& user,
const std::string& pass, int waitMs)
{
node.markStopRequested();
// connect() also succeeds against a node still in warmup, which accepts stop too.
if (cli.isConnected() || cli.connect("127.0.0.1", std::to_string(port), user, pass, /*useTls=*/false)) {
try { cli.call("stop", nlohmann::json::array(), 10L); } catch (const std::exception&) {}
}
cli.disconnect();
return node.stop(waitMs);
}
// Random alphanumeric token for the isolated node's throwaway RPC credentials (libsodium CSPRNG;
// sodium_init() has already run at app startup for the chat crypto).
std::string randomToken(int n)
@@ -113,7 +128,7 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
}
if (!ready) {
r.error = "The isolated node did not become ready in time.";
temp.stop(20000);
stopIsolatedNode(temp, cli, port, user, pass, 20000); // datadir is thrown away: a forced stop is harmless
fs::remove_all(base, ec);
return r;
}
@@ -146,18 +161,24 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
}
}
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly). Adopt later copies
// this wallet.dat, so it must have been closed cleanly: a node that had to be force-killed
// fails the creation (nothing has been swept into it yet, so nothing is lost by retrying).
if (!stopIsolatedNode(temp, cli, port, user, pass, 60000) && r.ok) {
r.ok = false;
r.error = "The isolated node had to be force-stopped, so its new wallet can't be trusted. "
"Please try again.";
}
// W1-2: never hand back a live seed on a failure path. If the mnemonic was exported but a
// later step failed (empty address, or z_getnewaddress threw), the caller discards this
// result without wiping it, which would leave the seed resident. Success keeps it deliberately.
// later step failed (empty address, z_getnewaddress threw, or an unclean stop), the caller
// discards this result without wiping it, which would leave the seed resident. Success keeps
// it deliberately.
if (!r.ok && !r.seedPhrase.empty()) {
sodium_memzero(&r.seedPhrase[0], r.seedPhrase.size());
r.seedPhrase.clear();
}
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
cli.disconnect();
temp.stop(20000);
// 8. Keep the temp wallet for a later sweep/adopt step, or scrub it. tempDatadir is the
// migration root `base`; the new wallet.dat lives in <base>/DRAGONX.
r.tempDatadir = base;
+155
View File
@@ -0,0 +1,155 @@
#pragma once
// Delivering "stop" to dragonxd: what one attempt achieved, and when to send another. Pure and
// header-only so the test target can drive it without the RPC client.
//
// Why retrying matters: dragonxd's HTTP server has 8 workers and a 16-deep queue. While a long
// operation holds cs_main, the GUI's own abandoned polls can fill every slot, and each new request is
// refused at once with "Work queue depth exceeded" until a slot frees. The old code made one or two
// attempts, gated on a getinfo probe that needs cs_main, so stop was never delivered and the node was
// then killed.
#include "rpc/rpc_failure.h"
namespace dragonx {
namespace daemon {
// What the user picked on the shutdown screen while the node was stopping.
enum class StopUserChoice { None, Detach, Kill };
// How a stop session ended. The wallet never kills the node unless the user chose Kill.
enum class StopOutcome {
Exited, // the process is gone
StillRunning, // we stopped waiting (time budget, app shutdown, cancellation); never killed
Detached, // the user chose to leave it running
Killed, // the user chose Kill
};
inline const char* stopOutcomeName(StopOutcome o)
{
switch (o) {
case StopOutcome::Exited: return "Exited";
case StopOutcome::StillRunning: return "StillRunning";
case StopOutcome::Detached: return "Detached";
case StopOutcome::Killed: return "Killed";
}
return "Exited";
}
enum class StopSendResult {
Accepted, // HTTP 200: the daemon ran stop
Queued, // reply timeout: it reached the daemon and waits for a worker; it will run
QueueFull, // "Work queue depth exceeded": refused before queueing (holds no slot)
NotListening, // refused / connect timeout: RPC isn't bound (not up yet, or already unbound)
ShuttingDown, // 503 / "Unhandled request": the daemon is already shutting down
Auth, // 401: our credentials are wrong or stale
Other,
};
inline const char* stopSendResultName(StopSendResult r)
{
switch (r) {
case StopSendResult::Accepted: return "Accepted";
case StopSendResult::Queued: return "Queued";
case StopSendResult::QueueFull: return "QueueFull";
case StopSendResult::NotListening: return "NotListening";
case StopSendResult::ShuttingDown: return "ShuttingDown";
case StopSendResult::Auth: return "Auth";
case StopSendResult::Other: return "Other";
}
return "Other";
}
// What a stop call's failure kind (RpcFailureKind::None = HTTP 200) means for delivery.
inline StopSendResult stopSendResultFromKind(rpc::RpcFailureKind kind)
{
using K = rpc::RpcFailureKind;
switch (kind) {
case K::None: return StopSendResult::Accepted;
case K::ReplyTimeout: return StopSendResult::Queued;
case K::QueueFull: return StopSendResult::QueueFull;
case K::NotListening:
case K::ConnectTimeout: return StopSendResult::NotListening;
case K::ShuttingDown:
case K::Unhandled: return StopSendResult::ShuttingDown;
case K::Auth: return StopSendResult::Auth;
default: return StopSendResult::Other;
}
}
/**
* When to (re)send stop. Times are seconds on the caller's clock.
*
* - Accepted / ShuttingDown: delivered, never resend.
* - Queued: the daemon has it. Resend at most every 30s: each resend takes another queue slot, and
* concurrent stops race on the miner threads (stop's GenerateBitcoins(false)).
* - QueueFull / NotListening / Other: not delivered, cheap to retry: again after 1.5s.
* - NotListening AFTER the daemon had heard us means it unbound its listener to shut down: delivered.
* - Auth: give up for a node we don't own. For our own node the caller may switch credentials and
* retry at once (retryNow), or give up (giveUp).
*/
class StopDeliveryPolicy {
public:
static constexpr double kRetrySec = 1.5;
static constexpr double kQueuedResendSec = 30.0;
explicit StopDeliveryPolicy(bool owned) : owned_(owned) {}
bool shouldSend(double now) const { return !finished_ && now >= nextSendAt_; }
// Records one attempt; returns it as interpreted (a refusal after we were heard reads as ShuttingDown).
StopSendResult record(StopSendResult r, double now)
{
++attempts_;
if (r == StopSendResult::NotListening && heard_) r = StopSendResult::ShuttingDown;
switch (r) {
case StopSendResult::Accepted:
heard_ = true;
markDelivered();
break;
case StopSendResult::ShuttingDown:
markDelivered();
break;
case StopSendResult::Queued:
heard_ = true;
nextSendAt_ = now + kQueuedResendSec;
break;
case StopSendResult::Auth:
authRejected_ = true;
if (!owned_) finished_ = true;
nextSendAt_ = now + kRetrySec;
break;
case StopSendResult::QueueFull:
case StopSendResult::NotListening:
case StopSendResult::Other:
nextSendAt_ = now + kRetrySec;
break;
}
last_ = r;
return r;
}
void markDelivered() { delivered_ = finished_ = true; } // also for a graceful signal (POSIX SIGTERM)
void retryNow(double now) { nextSendAt_ = now; }
void giveUp() { finished_ = true; }
bool delivered() const { return delivered_; } // the daemon is stopping (or was signalled)
bool heard() const { return heard_; } // it has (or ran) our request
bool finished() const { return finished_; } // no more attempts
bool authRejected() const { return authRejected_; }
int attempts() const { return attempts_; }
StopSendResult last() const { return last_; }
private:
bool owned_ = true;
bool delivered_ = false;
bool heard_ = false;
bool finished_ = false;
bool authRejected_ = false;
int attempts_ = 0;
double nextSendAt_ = 0.0;
StopSendResult last_ = StopSendResult::Other;
};
} // namespace daemon
} // namespace dragonx
+37 -29
View File
@@ -173,9 +173,10 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
int hw = (int)std::thread::hardware_concurrency();
if (hw < 1) hw = 1;
// Use explicit thread count (not just a hint)
threads_ = (cfg.threads > 0) ? cfg.threads : std::max(1, hw / 2);
if (threads_ > hw) threads_ = hw;
// Use explicit thread count (not just a hint). One store: the UI thread reads it (getRequestedThreads).
int t = (cfg.threads > 0) ? cfg.threads : std::max(1, hw / 2);
if (t > hw) t = hw;
threads_ = t;
json j;
j["autosave"] = false;
@@ -324,7 +325,7 @@ bool XmrigManager::start(const Config& cfg) {
return false;
}
DEBUG_LOGF("[INFO] XmrigManager: config written to %s (API port %d, threads %d)\n",
cfgPath.c_str(), api_port_, threads_);
cfgPath.c_str(), api_port_, threads_.load());
// Spawn process
if (!startProcess(binary, cfgPath, threads_)) {
@@ -355,12 +356,6 @@ void XmrigManager::stop(int wait_ms) {
// Try graceful termination first
TerminateProcess(process_handle_, 0);
WaitForSingleObject(process_handle_, wait_ms);
CloseHandle(process_handle_);
process_handle_ = nullptr;
}
if (stdout_read_) {
CloseHandle(stdout_read_);
stdout_read_ = nullptr;
}
#else
if (process_pid_ > 0) {
@@ -385,15 +380,29 @@ void XmrigManager::stop(int wait_ms) {
}
process_pid_ = 0;
}
#endif
if (monitor_thread_.joinable())
monitor_thread_.join();
// Close the pipe only after the monitor thread has finished with it (it
// drains stdout); closing earlier let read() hit a closed/reused fd.
#ifdef _WIN32
if (process_handle_) {
CloseHandle(process_handle_);
process_handle_ = nullptr;
}
if (stdout_read_) {
CloseHandle(stdout_read_);
stdout_read_ = nullptr;
}
#else
if (stdout_fd_ >= 0) {
close(stdout_fd_);
stdout_fd_ = -1;
}
#endif
if (monitor_thread_.joinable())
monitor_thread_.join();
state_ = State::Stopped;
DEBUG_LOGF("[INFO] XmrigManager: stopped\n");
}
@@ -474,6 +483,7 @@ double XmrigManager::getMemoryUsageMB() const {
}
void XmrigManager::drainOutput() {
std::lock_guard<std::mutex> io_lk(io_mutex_);
if (!stdout_read_) return;
char buf[4096];
DWORD avail = 0;
@@ -564,7 +574,7 @@ double XmrigManager::getMemoryUsageMB() const {
#ifdef __APPLE__
// macOS: use ps to read RSS for xmrig process
char cmd[128];
snprintf(cmd, sizeof(cmd), "ps -o rss= -p %d 2>/dev/null", process_pid_);
snprintf(cmd, sizeof(cmd), "ps -o rss= -p %d 2>/dev/null", (int)process_pid_.load(std::memory_order_relaxed));
FILE* fp = popen(cmd, "r");
if (!fp) return 0.0;
char line[64];
@@ -577,7 +587,7 @@ double XmrigManager::getMemoryUsageMB() const {
return mb;
#else
char path[64];
snprintf(path, sizeof(path), "/proc/%d/statm", process_pid_);
snprintf(path, sizeof(path), "/proc/%d/statm", (int)process_pid_.load(std::memory_order_relaxed));
FILE* fp = fopen(path, "r");
if (!fp) return 0.0;
long dummy = 0, pages = 0;
@@ -591,6 +601,7 @@ double XmrigManager::getMemoryUsageMB() const {
}
void XmrigManager::drainOutput() {
std::lock_guard<std::mutex> io_lk(io_mutex_);
if (stdout_fd_ < 0) return;
char buf[4096];
while (true) {
@@ -612,17 +623,7 @@ void XmrigManager::drainOutput() {
void XmrigManager::appendOutput(const char* data, size_t len) {
// Caller must hold output_mutex_
static constexpr size_t MAX_OUTPUT = 1024 * 1024; // 1 MB cap
process_output_.append(data, len);
if (process_output_.size() > MAX_OUTPUT) {
// Trim from the front at a newline boundary
size_t cut = process_output_.size() - MAX_OUTPUT;
auto pos = process_output_.find('\n', cut);
if (pos != std::string::npos)
process_output_.erase(0, pos + 1);
else
process_output_.erase(0, cut);
}
appendBounded(process_output_, output_total_, data, len, 1024 * 1024); // 1 MB cap (1.25x hysteresis)
}
std::vector<std::string> XmrigManager::getRecentLines(int maxLines) const {
@@ -711,9 +712,8 @@ void XmrigManager::monitorProcess() {
// ============================================================================
void XmrigManager::pollStats() {
// No-op on UI thread — stats are fetched by the monitor thread.
// Just drain stdout so log lines stay fresh.
drainOutput();
// No-op on UI thread — stats and the stdout drain both run on the monitor
// thread (a second reader here raced stop() closing the fd).
}
void XmrigManager::fetchStatsHttp() {
@@ -739,18 +739,23 @@ void XmrigManager::fetchStatsHttp() {
headers = curl_slist_append(headers, authHeader.c_str());
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_NOPROXY, "*"); // 127.0.0.1 miner API: never via an env proxy
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseData);
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 2000L); // 2s timeout — generous for loaded system
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 1000L); // 1s connect timeout
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); // Avoid signal issues in threads
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, &XmrigManager::abortCb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
if (should_stop_.load()) return; // aborted by stop(): not a failure
static int s_fail_count = 0;
if (++s_fail_count <= 5 || s_fail_count % 30 == 0) {
DEBUG_LOGF("[WARN] XmrigManager::pollStats: curl failed (%d): %s url=%s\n",
@@ -845,6 +850,9 @@ void XmrigManager::fetchPoolApiStats() {
// pool.dragonx.cc sits behind Cloudflare and 403s odd User-Agents.
curl_easy_setopt(curl, CURLOPT_USERAGENT, "Mozilla/5.0 (compatible; ObsidianDragon)");
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, &XmrigManager::abortCb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
CURLcode res = curl_easy_perform(curl);
curl_easy_cleanup(curl);
+13 -12
View File
@@ -11,6 +11,8 @@
#include <vector>
#include <cstdint>
#include "output_window.h"
#ifdef _WIN32
#include <winsock2.h>
#include <windows.h>
@@ -106,13 +108,7 @@ public:
*/
std::string getOutputSince(size_t& offset) const {
std::lock_guard<std::mutex> lk(output_mutex_);
if (offset >= process_output_.size()) {
offset = process_output_.size();
return {};
}
std::string result = process_output_.substr(offset);
offset = process_output_.size();
return result;
return outputWindowSince(process_output_, output_total_, offset);
}
/**
@@ -161,6 +157,9 @@ private:
// Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread).
void setLastError(std::string e) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(e); }
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
// curl xfer-info callback: abort an in-flight stats fetch once stop() is requested.
static int abortCb(void* p, int64_t, int64_t, int64_t, int64_t)
{ return static_cast<const XmrigManager*>(p)->should_stop_.load() ? 1 : 0; }
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
std::atomic<State> state_{State::Stopped};
@@ -169,23 +168,25 @@ private:
mutable std::mutex output_mutex_;
std::string process_output_;
std::size_t output_total_ = 0; // bytes ever appended (absolute end; guarded by output_mutex_, kept across restarts)
// xmrig HTTP API credentials (random per session)
int api_port_ = 0;
std::string api_token_;
int threads_ = 0; // Thread count for mining
std::atomic<int> threads_{0}; // Thread count for mining (written on the mining-control thread, read by the UI)
std::string pool_host_; // Pool hostname for stats API
PoolStats stats_;
mutable std::mutex stats_mutex_;
// Process handles
#ifdef _WIN32
HANDLE process_handle_ = nullptr;
HANDLE stdout_read_ = nullptr;
std::atomic<HANDLE> process_handle_{nullptr};
std::atomic<HANDLE> stdout_read_{nullptr};
#else
pid_t process_pid_ = 0;
int stdout_fd_ = -1;
std::atomic<pid_t> process_pid_{0};
std::atomic<int> stdout_fd_{-1};
#endif
std::mutex io_mutex_; // serializes drainOutput(); lock order io_mutex_ -> output_mutex_
std::thread monitor_thread_;
std::atomic<bool> should_stop_{false};
+8 -2
View File
@@ -31,6 +31,7 @@ std::string AddressBook::getDefaultPath()
bool AddressBook::load()
{
if (sweep_active_) return true; // UI sweep: keep the demo list and the real-book snapshot
file_path_ = getDefaultPath();
std::ifstream file(file_path_);
@@ -68,8 +69,9 @@ bool AddressBook::load()
}
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
++revision_;
return true;
} catch (const std::exception& e) {
DEBUG_LOGF("Error loading address book: %s\n", e.what());
return false;
@@ -78,6 +80,7 @@ bool AddressBook::load()
bool AddressBook::save()
{
if (sweep_active_) return true; // UI sweep: demo contacts never reach disk
if (file_path_.empty()) {
file_path_ = getDefaultPath();
}
@@ -121,6 +124,7 @@ bool AddressBook::addEntry(const AddressBookEntry& entry)
}
entries_.push_back(entry);
++revision_;
return save();
}
@@ -136,6 +140,7 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
}
entries_[index] = entry;
++revision_;
return save();
}
@@ -146,6 +151,7 @@ bool AddressBook::removeEntry(size_t index)
}
entries_.erase(entries_.begin() + index);
++revision_;
return save();
}
@@ -159,7 +165,7 @@ int AddressBook::reattachLegacyScopes(const std::string& scopeId)
e.scope = scopeId;
++rescoped;
}
if (rescoped > 0) save();
if (rescoped > 0) { ++revision_; save(); }
return rescoped;
}
+29 -3
View File
@@ -4,6 +4,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
@@ -122,10 +123,32 @@ public:
size_t size() const { return entries_.size(); }
/**
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
* @brief Monotonic counter bumped on every content change (add/update/remove/load/sweep). Consumers
* (e.g. the chat conversation-list memo) key their caches off this so an IN-PLACE edit — a rename or
* address change that keeps size() constant — still invalidates them.
*/
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); }
std::uint64_t revision() const { return revision_; }
/**
* @brief UI-sweep ONLY: show `demo` instead of the real book. The first call snapshots the real
* entries; later calls (the sweep re-runs its setup every frame) only swap the demo list. While
* active, save() and load() return true without touching disk or the snapshot, so nothing (an
* add/update/remove, reattachLegacyScopes) can persist demo contacts. Do not use outside the sweep.
*/
void sweepBegin(std::vector<AddressBookEntry> demo) {
if (!sweep_active_) { sweep_backup_ = std::move(entries_); sweep_active_ = true; }
entries_ = std::move(demo);
++revision_;
}
/** @brief UI-sweep ONLY: put the real book back (no disk write). Idempotent. */
void sweepEnd() {
if (!sweep_active_) return;
entries_ = std::move(sweep_backup_);
sweep_backup_.clear();
sweep_active_ = false;
++revision_;
}
bool sweepActive() const { return sweep_active_; }
/**
* @brief Check if empty
@@ -134,7 +157,10 @@ public:
private:
std::vector<AddressBookEntry> entries_;
std::uint64_t revision_ = 0;
std::string file_path_;
std::vector<AddressBookEntry> sweep_backup_; // the real book while a UI sweep shows demo contacts
bool sweep_active_ = false;
};
} // namespace data
+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
@@ -4,6 +4,7 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
@@ -38,6 +39,25 @@ struct ExchangeInfo {
*/
const std::vector<ExchangeInfo>& getExchangeRegistry();
/**
* @brief Index of the selected exchange/pair in `reg`, resolved by saved NAME.
* Matches the exchange name (any exchange when selEx is empty) THEN the pair display name, like the chart
* fetch always has. Not found => (0,0), found=false (the registry's first pair - the app's default).
* Single source of truth for the Market tab chips and App::refreshExchangeChart so they cannot diverge.
*/
struct ExchangeSelection { int exchangeIdx = 0; int pairIdx = 0; bool found = false; };
inline ExchangeSelection resolveExchangeSelection(const std::vector<ExchangeInfo>& reg,
const std::string& selEx, const std::string& selPair)
{
for (std::size_t ei = 0; ei < reg.size(); ++ei) {
if (!selEx.empty() && reg[ei].name != selEx) continue;
for (std::size_t pi = 0; pi < reg[ei].pairs.size(); ++pi)
if (reg[ei].pairs[pi].displayName == selPair)
return {static_cast<int>(ei), static_cast<int>(pi), true};
}
return {};
}
/**
* @brief Parse a CoinGecko /coins/{id}/tickers JSON body into the exchange registry.
*
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <string>
// Pure decisions for the PIN quick-unlock vault (C87), kept free of App/UI/RPC state so they are
// unit-testable. The vault seals the wallet passphrase under the PIN. None of these paths deletes
// it: a vault whose passphrase the wallet no longer accepts is only marked out of date for the
// session (no lockout penalty), and the user removes it and sets the PIN again. The vault may hold
// the only copy of a passphrase that still opens an older wallet backup.
namespace dragonx {
namespace data {
// What a completed walletpassphrasechange means for the active wallet's PIN vault.
enum class VaultAfterChange {
Keep, // no vault, the change failed or kept the same passphrase, or the vault is another wallet's
MarkStale, // the vault seals the OLD passphrase: stop offering the PIN this session and warn (keep the file)
};
// Decided on the main thread when the change completes, not when it is sent, so a vault created
// while the change was in flight is covered too. `pathAtDispatch` is the vault path captured when
// the change was sent and `pathNow` the active vault path now: a wallet switch in between makes
// them differ, and the other wallet's vault is left alone.
inline VaultAfterChange decideVaultAfterPassphraseChange(bool changeSucceeded,
bool vaultExistsNow,
bool samePassphrase,
const std::string& pathAtDispatch,
const std::string& pathNow) {
if (!changeSucceeded || !vaultExistsNow || samePassphrase) return VaultAfterChange::Keep;
if (pathAtDispatch.empty() || pathAtDispatch != pathNow) return VaultAfterChange::Keep;
return VaultAfterChange::MarkStale;
}
// dragonxd's RPC_WALLET_PASSPHRASE_INCORRECT ("The wallet passphrase entered was incorrect.").
constexpr int kRpcWalletPassphraseIncorrect = -14;
// A PIN unlock whose vault opened (so the PIN was right) but whose walletpassphrase call the daemon
// refused with -14: the vault holds a passphrase the wallet no longer accepts (it was changed after
// the PIN was set, e.g. with dragonx-cli walletpassphrasechange). Not a wrong-PIN attempt, so no
// lockout penalty; the PIN is marked out of date and the vault is kept.
inline bool isStaleVaultUnlockFailure(bool vaultDecrypted, int rpcCode) {
return vaultDecrypted && rpcCode == kRpcWalletPassphraseIncorrect;
}
} // namespace data
} // namespace dragonx
+28
View File
@@ -34,4 +34,32 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
return MigrationResume::SweepGate;
}
// At the Sweep step with a ~0 legacy balance, may we offer the direct "no funds -> adopt" short-cut
// (swap wallet.dat immediately)? Only when NO sweep has been submitted — a genuinely empty wallet.
// If a sweep WAS submitted, a ~0 balance is ambiguous: the sweep tx may be unconfirmed in the mempool
// and could revert on a drop/reorg, so adopting now can strand the funds in the moved-aside backup.
// In that case adopt must go through the confirmation-gated path (>=1 conf) instead — never swap
// 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
+69
View File
@@ -0,0 +1,69 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "transaction_csv.h"
#include <cstdio>
#include <ctime>
namespace dragonx {
namespace data {
std::string escapeCsvField(const std::string& field)
{
// Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
// tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
// memo/address execute on open. Prefix such fields with a single quote so they render as text.
std::string safe = field;
if (!safe.empty()) {
const char c0 = safe.front();
if (c0 == '=' || c0 == '+' || c0 == '-' || c0 == '@' || c0 == '\t' || c0 == '\r')
safe.insert(safe.begin(), '\'');
}
if (safe.find(',') != std::string::npos ||
safe.find('"') != std::string::npos ||
safe.find('\n') != std::string::npos) {
std::string escaped;
escaped.reserve(safe.size() + 4);
escaped += '"';
for (char c : safe) {
if (c == '"') escaped += "\"\"";
else escaped += c;
}
escaped += '"';
return escaped;
}
return safe;
}
std::string formatTransactionsCsv(const std::vector<TransactionInfo>& txs)
{
std::string out;
out.reserve(64 + txs.size() * 200);
out += "Date,Type,Amount,Address,TXID,Confirmations,Memo\n";
for (const auto& tx : txs) {
std::time_t t = static_cast<std::time_t>(tx.timestamp);
std::tm tmv{};
#ifdef _WIN32
const bool okTm = localtime_s(&tmv, &t) == 0;
#else
const bool okTm = localtime_r(&t, &tmv) != nullptr;
#endif
char datebuf[32] = "";
if (okTm) std::strftime(datebuf, sizeof(datebuf), "%Y-%m-%d %H:%M:%S", &tmv);
char amt[48];
std::snprintf(amt, sizeof(amt), "%.8f", tx.amount);
out += datebuf; out += ',';
out += escapeCsvField(tx.type); out += ',';
out += amt; out += ',';
out += escapeCsvField(tx.address); out += ',';
out += escapeCsvField(tx.txid); out += ',';
out += std::to_string(tx.confirmations); out += ',';
out += escapeCsvField(tx.memo); out += '\n';
}
return out;
}
} // namespace data
} // namespace dragonx
+23
View File
@@ -0,0 +1,23 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include "wallet_state.h"
#include <string>
#include <vector>
namespace dragonx {
namespace data {
// CSV-escape one field: neutralises spreadsheet formula injection (leading = + - @ \t \r gets a
// ' prefix) and quotes fields containing , " or \n.
std::string escapeCsvField(const std::string& field);
// "Date,Type,Amount,Address,TXID,Confirmations,Memo\n" + one line per transaction
// (local time, 8-decimals amount).
std::string formatTransactionsCsv(const std::vector<TransactionInfo>& txs);
} // namespace data
} // namespace dragonx
+219 -30
View File
@@ -9,6 +9,7 @@
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <utility>
@@ -110,8 +111,25 @@ std::string associatedDataForWallet(const std::string& walletHash)
return std::string("obsidian-dragon-tx-history-v1:") + walletHash;
}
// Non-reversible 64-bit tag of a derived key (BLAKE2b keyed by the key, over a fixed label). Only used to
// notice that the key changed; nothing about the key can be recovered from it, and it never leaves memory.
std::uint64_t computeKeyId(const std::array<unsigned char, 32>& key)
{
static const unsigned char kLabel[] = "obsidian-dragon-tx-history-keyid";
unsigned char out[crypto_generichash_BYTES_MIN]; // 16, libsodium's minimum; the first 8 are used
crypto_generichash(out, sizeof(out), kLabel, sizeof(kLabel) - 1, key.data(), key.size());
std::uint64_t id = 0;
std::memcpy(&id, out, sizeof(id));
return id;
}
} // namespace
TransactionHistoryCache::SnapshotJob::~SnapshotJob()
{
sodium_memzero(key.data(), key.size());
}
TransactionHistoryCache::TransactionHistoryCache()
: TransactionHistoryCache(defaultDatabasePath())
{
@@ -170,6 +188,7 @@ std::string TransactionHistoryCache::walletIdentityHash(const std::string& walle
bool TransactionHistoryCache::ensureOpen()
{
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (db_) return true;
try {
@@ -212,29 +231,91 @@ bool TransactionHistoryCache::ensureOpen()
bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity,
const std::string& passphrase)
{
if (walletIdentity.empty() || passphrase.empty() || !ensureOpen()) return false;
if (walletIdentity.empty() || passphrase.empty()) return false;
// Held across the KDF on purpose: "this wallet's salt row exists" and "this wallet is the unlocked one"
// must be atomic with respect to a writer job's prune (writeSnapshot keeps the unlocked wallet), or a job
// for the previous wallet could delete the salt this key is being derived from. The UI thread only waits
// here for a write already in flight, never for the queue.
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return false;
std::string walletHash = walletIdentityHash(walletIdentity);
std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
if (salt.empty()) return false;
if (!deriveKey(passphrase, salt)) return false;
unlocked_wallet_hash_ = std::move(walletHash);
key_ready_ = true;
std::array<unsigned char, kKeyBytes> derived{};
if (!deriveKey(passphrase, salt, derived)) return false;
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
key_ = derived;
key_id_ = computeKeyId(derived);
unlocked_wallet_hash_ = std::move(walletHash);
key_ready_ = true;
}
sodium_memzero(derived.data(), derived.size());
return true;
}
bool TransactionHistoryCache::unlockUnencrypted(const std::string& walletIdentity)
{
if (walletIdentity.empty()) return false;
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return false;
std::string walletHash = walletIdentityHash(walletIdentity);
std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
if (salt.empty()) return false;
std::array<unsigned char, kKeyBytes> derived{};
if (!deriveKeyFast(walletHash, salt, derived)) return false;
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
key_ = derived;
key_id_ = computeKeyId(derived);
unlocked_wallet_hash_ = std::move(walletHash);
key_ready_ = true;
}
sodium_memzero(derived.data(), derived.size());
return true;
}
void TransactionHistoryCache::lockKey()
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
if (key_ready_) sodium_memzero(key_.data(), key_.size());
key_ready_ = false;
unlocked_wallet_hash_.clear();
// key_id_ is kept on purpose: re-unlocking with the same passphrase yields the same id.
}
bool TransactionHistoryCache::hasKey() const
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
return key_ready_;
}
bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const
{
return key_ready_ && !walletIdentity.empty() &&
unlocked_wallet_hash_ == walletIdentityHash(walletIdentity);
if (walletIdentity.empty()) return false;
const std::string hash = walletIdentityHash(walletIdentity); // outside the lock
std::lock_guard<std::mutex> keyLock(key_mutex_);
return key_ready_ && unlocked_wallet_hash_ == hash;
}
bool TransactionHistoryCache::captureKey(const std::string& walletIdentity,
std::array<unsigned char, 32>& keyOut) const
{
if (walletIdentity.empty()) return false;
const std::string hash = walletIdentityHash(walletIdentity);
std::lock_guard<std::mutex> keyLock(key_mutex_);
if (!key_ready_ || unlocked_wallet_hash_ != hash) return false;
keyOut = key_;
return true;
}
std::uint64_t TransactionHistoryCache::keyId() const
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
return key_id_;
}
TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
@@ -243,6 +324,7 @@ TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
const std::string& currentTipHash)
{
LoadResult result;
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_); // serialised against the writer's INSERT/prune
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result;
std::string walletHash = walletIdentityHash(walletIdentity);
@@ -284,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;
@@ -301,35 +406,81 @@ 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)
{
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return false;
// The synchronous form (tests, tools): the same write the background writer does.
SnapshotJob job;
if (!captureKey(walletIdentity, job.key)) return false;
job.walletIdentity = walletIdentity;
job.tipHeight = tipHeight;
job.tipHash = tipHash;
job.transactions = transactions;
job.updatedAt = updatedAt;
job.shieldedScanHeights = shieldedScanHeights;
job.sentSweep = sentSweep;
return writeSnapshot(std::move(job));
}
std::string walletHash = walletIdentityHash(walletIdentity);
bool TransactionHistoryCache::writeSnapshot(SnapshotJob&& job)
{
struct WipeKey {
std::array<unsigned char, 32>& key;
~WipeKey() { sodium_memzero(key.data(), key.size()); }
} wipe{job.key};
// A job without a key would replace the good snapshot with one nobody can read.
if (job.walletIdentity.empty() || job.key == std::array<unsigned char, 32>{}) return false;
const std::string walletHash = walletIdentityHash(job.walletIdentity);
// ---- the expensive part (JSON DOM, dump, AEAD), with no lock held ----
json payload;
payload["schema_version"] = kSchemaVersion;
payload["wallet_hash"] = walletHash;
payload["tip_height"] = tipHeight;
payload["tip_hash"] = tipHash;
payload["updated_at"] = static_cast<std::int64_t>(updatedAt);
payload["tip_height"] = job.tipHeight;
payload["tip_hash"] = job.tipHash;
payload["updated_at"] = static_cast<std::int64_t>(job.updatedAt);
payload["transactions"] = json::array();
for (const auto& transaction : transactions) {
for (const auto& transaction : job.transactions) {
payload["transactions"].push_back(transactionToJson(transaction));
}
payload["shielded_scan_heights"] = json::object();
for (const auto& [address, height] : shieldedScanHeights) {
for (const auto& [address, height] : job.shieldedScanHeights) {
if (!address.empty() && height >= 0) {
payload["shielded_scan_heights"][address] = height;
}
}
// 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;
bool encrypted = encryptPayload(walletHash, plainText, nonce, cipherText);
const bool encrypted = encryptPayload(job.key, walletHash, plainText, nonce, cipherText);
sodium_memzero(plainText.data(), plainText.size());
if (!encrypted) return false;
// ---- one autocommit INSERT OR REPLACE (WAL: a crash leaves the previous snapshot), then the prune ----
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return false;
Statement statement(db_,
"INSERT OR REPLACE INTO transaction_history_snapshots "
"(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) "
@@ -338,14 +489,22 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
if (!bindText(statement.handle, 1, walletHash)) return false;
sqlite3_bind_int(statement.handle, 2, kSchemaVersion);
sqlite3_bind_int(statement.handle, 3, std::max(0, tipHeight));
if (!bindText(statement.handle, 4, tipHash)) return false;
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(updatedAt));
sqlite3_bind_int(statement.handle, 3, std::max(0, job.tipHeight));
if (!bindText(statement.handle, 4, job.tipHash)) return false;
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(job.updatedAt));
if (!bindBlob(statement.handle, 6, nonce)) return false;
if (!bindBlob(statement.handle, 7, cipherText)) return false;
if (sqlite3_step(statement.handle) != SQLITE_DONE) return false;
pruneOtherWallets(walletHash); // bound DB growth — drop stale-hash snapshots/salts
if (sqlite3_step(statement.handle) != SQLITE_DONE) {
DEBUG_LOGF("Transaction history cache write failed: %s\n", sqlite3_errmsg(db_));
return false;
}
std::string liveHash;
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
liveHash = unlocked_wallet_hash_;
}
pruneOtherWallets(walletHash, liveHash); // bound DB growth — drop stale-hash snapshots/salts
return true;
}
@@ -357,6 +516,7 @@ void TransactionHistoryCache::clearWallet(const std::string& walletIdentity)
int TransactionHistoryCache::snapshotCount()
{
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return 0;
Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots");
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
@@ -417,8 +577,23 @@ std::vector<unsigned char> TransactionHistoryCache::getOrCreateSalt(const std::s
return salt;
}
bool TransactionHistoryCache::deriveKeyFast(const std::string& walletHash,
const std::vector<unsigned char>& salt,
std::array<unsigned char, 32>& keyOut)
{
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
static const unsigned char kLabel[] = "obsidian-dragon-tx-history-unenc-v1";
// keyed BLAKE2b over label||walletHash, keyed by the per-wallet salt -> 32-byte key.
std::string msg(reinterpret_cast<const char*>(kLabel), sizeof(kLabel) - 1);
msg += walletHash;
return crypto_generichash(keyOut.data(), keyOut.size(),
reinterpret_cast<const unsigned char*>(msg.data()), msg.size(),
salt.data(), salt.size()) == 0;
}
bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
const std::vector<unsigned char>& salt)
const std::vector<unsigned char>& salt,
std::array<unsigned char, 32>& keyOut)
{
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
unsigned char derived[kKeyBytes];
@@ -429,17 +604,17 @@ bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
crypto_pwhash_MEMLIMIT_INTERACTIVE,
crypto_pwhash_ALG_ARGON2ID13);
if (result != 0) return false;
std::copy(derived, derived + sizeof(derived), key_.begin());
std::copy(derived, derived + sizeof(derived), keyOut.begin());
sodium_memzero(derived, sizeof(derived));
return true;
}
bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
bool TransactionHistoryCache::encryptPayload(const std::array<unsigned char, 32>& key,
const std::string& walletHash,
const std::string& plainText,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipherText) const
std::vector<unsigned char>& cipherText)
{
if (!key_ready_) return false;
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
randombytes_buf(nonce.data(), nonce.size());
@@ -450,7 +625,7 @@ bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
cipherText.data(), &cipherLength,
reinterpret_cast<const unsigned char*>(plainText.data()), plainText.size(),
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
nullptr, nonce.data(), key_.data());
nullptr, nonce.data(), key.data());
if (result != 0) return false;
cipherText.resize(static_cast<std::size_t>(cipherLength));
return true;
@@ -461,10 +636,16 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
const std::vector<unsigned char>& cipherText,
std::string& plainText) const
{
if (!key_ready_ || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) {
return false;
}
std::array<unsigned char, kKeyBytes> key{};
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
if (!key_ready_) return false;
key = key_;
}
std::string associatedData = associatedDataForWallet(walletHash);
std::vector<unsigned char> plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES);
@@ -473,7 +654,8 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
plain.data(), &plainLength, nullptr,
cipherText.data(), cipherText.size(),
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
nonce.data(), key_.data());
nonce.data(), key.data());
sodium_memzero(key.data(), key.size());
if (result != 0) return false;
plainText.assign(reinterpret_cast<const char*>(plain.data()), static_cast<std::size_t>(plainLength));
@@ -506,6 +688,7 @@ bool TransactionHistoryCache::readSnapshot(const std::string& walletHash,
void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
{
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return;
Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?");
if (!statement.handle) return;
@@ -513,22 +696,28 @@ void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
sqlite3_step(statement.handle);
}
void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash)
void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash,
const std::string& alsoKeepWalletHash)
{
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen() || keepWalletHash.empty()) return;
const std::string& alsoKeep = alsoKeepWalletHash.empty() ? keepWalletHash : alsoKeepWalletHash;
// Table names are hardcoded literals (no injection surface). Prune both the snapshot
// blobs and the now-orphaned salt rows so a stale salt can't outlive its ciphertext.
for (const char* table : {"transaction_history_snapshots", "transaction_history_keys"}) {
const std::string sql = std::string("DELETE FROM ") + table + " WHERE wallet_hash <> ?";
const std::string sql = std::string("DELETE FROM ") + table +
" WHERE wallet_hash <> ? AND wallet_hash <> ?";
Statement statement(db_, sql.c_str());
if (!statement.handle) continue;
if (!bindText(statement.handle, 1, keepWalletHash)) continue;
if (!bindText(statement.handle, 2, alsoKeep)) continue;
sqlite3_step(statement.handle);
}
}
void TransactionHistoryCache::close()
{
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!db_) return;
sqlite3_close(db_);
db_ = nullptr;
+76 -9
View File
@@ -5,6 +5,7 @@
#include <array>
#include <cstdint>
#include <ctime>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
@@ -14,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 {
@@ -24,6 +45,28 @@ 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
// state it came from has moved on (and after lockKey() zeroed the live key). Move-only; the private key
// copy is wiped by writeSnapshot() and, for a job that never ran (superseded), by the destructor.
struct SnapshotJob {
std::string walletIdentity;
std::array<unsigned char, 32> key{};
int tipHeight = 0;
std::string tipHash;
std::vector<TransactionInfo> transactions;
std::time_t updatedAt = 0;
std::unordered_map<std::string, int> shieldedScanHeights;
SentSweepState sentSweep;
SnapshotJob() = default;
SnapshotJob(const SnapshotJob&) = delete;
SnapshotJob& operator=(const SnapshotJob&) = delete;
SnapshotJob(SnapshotJob&&) = default;
SnapshotJob& operator=(SnapshotJob&&) = default;
~SnapshotJob();
};
TransactionHistoryCache();
@@ -40,9 +83,17 @@ public:
bool ensureOpen();
bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase);
// Unlock an UNENCRYPTED wallet's cache without Argon2id: the "secret" is derivable from the DB, so a
// cheap domain-separated BLAKE2b KDF is security-equivalent and avoids a ~50-100 ms UI-thread stall.
bool unlockUnencrypted(const std::string& walletIdentity);
void lockKey();
bool hasKey() const { return key_ready_; }
bool hasKey() const;
bool isUnlockedFor(const std::string& walletIdentity) const;
// Copy of the live key if it is unlocked for `walletIdentity` (keyOut untouched otherwise). Caller wipes it.
bool captureKey(const std::string& walletIdentity, std::array<unsigned char, 32>& keyOut) const;
// Non-reversible id of the key last installed. Survives lockKey(), so re-unlocking with the same
// passphrase gives the same id and a passphrase change gives another. 0 = never unlocked.
std::uint64_t keyId() const;
LoadResult load(const std::string& walletIdentity,
int currentTipHeight,
@@ -52,7 +103,11 @@ 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);
void clearWallet(const std::string& walletIdentity);
int snapshotCount();
@@ -60,12 +115,17 @@ private:
bool exec(const char* sql);
bool createSchema();
std::vector<unsigned char> getOrCreateSalt(const std::string& walletHash);
bool deriveKey(const std::string& passphrase,
const std::vector<unsigned char>& salt);
bool encryptPayload(const std::string& walletHash,
const std::string& plainText,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipherText) const;
static bool deriveKeyFast(const std::string& walletHash,
const std::vector<unsigned char>& salt,
std::array<unsigned char, 32>& keyOut); // BLAKE2b, label-separated
static bool deriveKey(const std::string& passphrase,
const std::vector<unsigned char>& salt,
std::array<unsigned char, 32>& keyOut);
static bool encryptPayload(const std::array<unsigned char, 32>& key,
const std::string& walletHash,
const std::string& plainText,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipherText);
bool decryptPayload(const std::string& walletHash,
const std::vector<unsigned char>& nonce,
const std::vector<unsigned char>& cipherText,
@@ -79,14 +139,21 @@ private:
void clearWalletByHash(const std::string& walletHash);
// Delete snapshot + salt rows for every wallet hash except the live one, bounding the
// DB so generating a new address (which changes the hash) doesn't orphan history forever.
void pruneOtherWallets(const std::string& keepWalletHash);
// Also keeps `alsoKeepWalletHash` (the wallet unlocked now, if any): a delayed writer job for the previous
// wallet must not delete the salt of the wallet just switched to.
void pruneOtherWallets(const std::string& keepWalletHash, const std::string& alsoKeepWalletHash);
void close();
// Lock order: db_mutex_ then key_mutex_. key_mutex_ is never held across I/O or the KDF. Only the UI
// thread changes the key fields; the writer thread reads unlocked_wallet_hash_ (prune) under both locks.
mutable std::recursive_mutex db_mutex_; // db_ and every SQL statement
mutable std::mutex key_mutex_; // key_, key_ready_, unlocked_wallet_hash_, key_id_
sqlite3* db_ = nullptr;
std::string database_path_;
std::array<unsigned char, 32> key_{};
bool key_ready_ = false;
std::string unlocked_wallet_hash_;
std::uint64_t key_id_ = 0;
};
} // namespace data
@@ -0,0 +1,145 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "transaction_history_store_policy.h"
#include <cstring>
namespace dragonx {
namespace data {
namespace {
constexpr std::uint64_t kOffset = 0xcbf29ce484222325ULL;
constexpr std::uint64_t kMul = 0x9E3779B97F4A7C15ULL; // odd
static_assert(sizeof(double) == sizeof(std::uint64_t), "amount bit pattern is hashed as 64 bits");
// 8 bytes per step: a 5k-row wallet (~1.5 MB of row text) hashes in about a millisecond. Endianness-dependent
// on purpose: the value never leaves the process.
inline void mixBytes(std::uint64_t& h, const void* data, std::size_t n)
{
const auto* b = static_cast<const unsigned char*>(data);
while (n >= 8) {
std::uint64_t w;
std::memcpy(&w, b, 8);
h = (h ^ w) * kMul;
h ^= h >> 32;
b += 8;
n -= 8;
}
if (n) {
std::uint64_t w = 0;
std::memcpy(&w, b, n);
h = (h ^ w ^ (static_cast<std::uint64_t>(n) << 56)) * kMul;
h ^= h >> 32;
}
}
inline void mixU64(std::uint64_t& h, std::uint64_t v) { mixBytes(h, &v, sizeof(v)); }
inline void mixStr(std::uint64_t& h, const std::string& s)
{
mixU64(h, s.size()); // length first: ("ab","c") != ("a","bc")
mixBytes(h, s.data(), s.size());
}
inline std::uint64_t finalize(std::uint64_t x) // splitmix64 finalizer
{
x += 0x9e3779b97f4a7c15ULL;
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
return x ^ (x >> 31);
}
// The thresholds a stored row is read at: transactions_tab.cpp:788-793 / transaction_details_dialog.cpp:80-85
// (0 pending, < 10 counted, >= 100 mature for mined), mining_earnings.cpp:102 (>= 100), the confirmed-tx cache
// (>= 10, app_network.cpp:1915) and conflicted (< 0, network_refresh_service.cpp:1470).
inline std::uint64_t confirmationBucket(int c)
{
return c < 0 ? 0 : c == 0 ? 1 : c < 10 ? 2 : c < 100 ? 3 : 4;
}
} // namespace
HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>& transactions,
const std::unordered_set<std::string>& skipTxids,
const std::string& walletHash,
std::uint64_t keyId,
const std::unordered_map<std::string, int>& shieldedScanHeights,
int tipHeight,
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
std::uint64_t exactSum = 0;
for (const auto& tx : transactions) {
if (!skipTxids.empty() && skipTxids.count(tx.txid)) continue;
std::uint64_t h = kOffset;
mixStr(h, tx.txid);
mixStr(h, tx.type);
std::uint64_t amountBits = 0;
std::memcpy(&amountBits, &tx.amount, sizeof(amountBits));
mixU64(h, amountBits);
mixU64(h, static_cast<std::uint64_t>(tx.timestamp));
mixStr(h, tx.address);
mixStr(h, tx.from_address);
mixStr(h, tx.memo);
std::uint64_t e = h;
mixU64(h, confirmationBucket(tx.confirmations));
mixU64(e, static_cast<std::uint64_t>(static_cast<std::int64_t>(tx.confirmations)));
rowSum += finalize(h);
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;
for (const auto& [address, height] : shieldedScanHeights) {
if (address.empty() || height < 0) continue; // exactly what replace() persists
std::uint64_t h = kOffset;
mixStr(h, address);
mixU64(h, static_cast<std::uint64_t>(height));
heightSum += finalize(h);
}
std::uint64_t s = kOffset;
mixU64(s, heightSum);
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;
}
HistoryStoreAction decideHistoryStore(const HistoryStoreInput& in)
{
const bool contentDirty = !in.haveStored || in.stored.content != in.current.content;
const bool slowDirty = in.haveStored && in.stored.slow != in.current.slow;
if (in.mode == HistoryStoreMode::Now)
return (contentDirty || slowDirty) ? HistoryStoreAction::Store : HistoryStoreAction::Skip;
if (contentDirty)
return in.sinceLastSubmitSec >= kHistoryMinStoreIntervalSec ? HistoryStoreAction::Store
: HistoryStoreAction::Defer;
if (slowDirty && in.sinceLastSubmitSec >= kHistorySlowStoreIntervalSec) return HistoryStoreAction::Store;
return HistoryStoreAction::Skip;
}
} // namespace data
} // namespace dragonx
@@ -0,0 +1,72 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Pure decision logic for persisting the tx-history snapshot: what counts as "changed", and when a write is
// due. No I/O, no clock, no sodium: unit-tested directly.
#pragma once
#include "wallet_state.h"
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace dragonx {
namespace data {
// 64-bit fingerprints of what a snapshot would contain.
struct HistorySnapshotSig {
std::uint64_t content = 0; // rows (confirmations by UI bucket) + wallet hash + key id
std::uint64_t slow = 0; // shielded scan heights + raw confirmations + tip: drift every block
std::size_t rows = 0; // rows that would be persisted (pending sends excluded)
};
// Order-independent over rows. `content` covers the persisted row fields (txid, type, amount bit pattern,
// timestamp, address, from_address, memo) with confirmations bucketed at the thresholds the UI and the refresh
// read off a stored row (< 0 conflicted, 0 pending, 1-9 counted, >= 10 confirmed, >= 100 mature), plus the
// wallet hash and `keyId` (a passphrase change must rewrite: the old ciphertext is unreadable with the new key).
// `slow` covers what changes every block: the per-address scan heights (entries replace() would persist), each
// row's raw confirmations, and the tip.
HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>& transactions,
const std::unordered_set<std::string>& skipTxids,
const std::string& walletHash,
std::uint64_t keyId,
const std::unordered_map<std::string, int>& shieldedScanHeights,
int tipHeight,
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
// kHistorySlowStoreIntervalSec
Now // user action / disconnect / lock / exit: store now if anything differs from the last submit
};
enum class HistoryStoreAction { Skip, Defer, Store };
constexpr double kHistoryMinStoreIntervalSec = 20.0;
constexpr double kHistorySlowStoreIntervalSec = 600.0;
struct HistoryStoreInput {
HistoryStoreMode mode = HistoryStoreMode::Coalesce;
bool haveStored = false; // a snapshot of this session (submitted, or the loaded one) is on disk
HistorySnapshotSig stored; // its signature
HistorySnapshotSig current;
double sinceLastSubmitSec = 1e9; // 1e9 = never submitted this session
};
HistoryStoreAction decideHistoryStore(const HistoryStoreInput& input);
} // namespace data
} // namespace dragonx
+4 -1
View File
@@ -9,6 +9,7 @@
#include <filesystem>
#include <fstream>
#include "../util/coalescing_writer.h"
#include "../util/logger.h"
#include "../util/platform.h"
@@ -88,6 +89,7 @@ bool WalletIndex::load()
bool WalletIndex::save()
{
if (sweep_active_) return true; // UI sweep: demo entries never reach disk
if (file_path_.empty()) file_path_ = getDefaultPath();
try {
@@ -109,7 +111,8 @@ bool WalletIndex::save()
j["extraFolders"] = extra_folders_;
// No secrets here (file names + coarse metadata) but keep it owner-only for consistency.
if (!util::Platform::writeFileAtomically(file_path_, j.dump(2), /*restrictPermissions=*/true)) {
if (!util::persistFileAtomically(writer_, /*waitForDisk=*/false, file_path_, j.dump(2),
/*restrictPermissions=*/true)) {
DEBUG_LOGF("Could not write wallet index: %s\n", file_path_.c_str());
return false;
}
+32 -1
View File
@@ -5,9 +5,11 @@
#pragma once
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
namespace util { class CoalescingWriter; }
namespace data {
/**
@@ -41,9 +43,12 @@ struct WalletIndexEntry {
class WalletIndex {
public:
bool load();
bool save();
bool save(); // queued on the writer when one is attached (returns true once queued)
static std::string getDefaultPath();
/** @brief Route save() through a background writer (not owned; must outlive this). Null = synchronous. */
void setFileWriter(util::CoalescingWriter* writer) { writer_ = writer; }
const std::vector<WalletIndexEntry>& entries() const { return entries_; }
const std::vector<std::string>& extraFolders() const { return extra_folders_; }
@@ -60,10 +65,36 @@ public:
bool addExtraFolder(const std::string& dir);
bool removeExtraFolder(const std::string& dir);
/**
* @brief UI-sweep ONLY: snapshot the entries and extra folders; until sweepEnd(), save() returns
* true without writing or queueing. The sweep's demo upserts drop the real entry's scopeId, and
* activeWalletScopeId() would then mint and save a new one, hiding the user's "w:"-scoped contacts.
*/
void sweepBegin() {
if (sweep_active_) return;
sweep_entries_ = entries_;
sweep_extra_folders_ = extra_folders_;
sweep_active_ = true;
}
/** @brief UI-sweep ONLY: restore the snapshot (no disk write). Idempotent. */
void sweepEnd() {
if (!sweep_active_) return;
entries_ = std::move(sweep_entries_);
extra_folders_ = std::move(sweep_extra_folders_);
sweep_entries_.clear();
sweep_extra_folders_.clear();
sweep_active_ = false;
}
bool sweepActive() const { return sweep_active_; }
private:
std::vector<WalletIndexEntry> entries_;
std::vector<std::string> extra_folders_;
std::string file_path_;
util::CoalescingWriter* writer_ = nullptr; // null -> synchronous writes
std::vector<WalletIndexEntry> sweep_entries_; // the real index while a UI sweep runs
std::vector<std::string> sweep_extra_folders_;
bool sweep_active_ = false;
};
} // namespace data
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
namespace dragonx {
namespace data {
enum class EncryptionPendingAction { None, Clear, Warn };
// What to do with the persisted "encryption requested in the wizard" flag, given ONE parsed getwalletinfo
// result. rpcOk must come from the parsed reply (a -28 warm-up failure or a throw is rpcOk=false => None);
// never derive it from WalletState (a 15 s timeout forces encrypted=false there).
inline EncryptionPendingAction decideEncryptionPending(bool flagSet, bool rpcOk, bool walletEncrypted,
bool deferredPending, bool encryptInProgress,
bool alreadyWarned)
{
if (!flagSet || !rpcOk) return EncryptionPendingAction::None;
if (walletEncrypted) return EncryptionPendingAction::Clear;
if (deferredPending || encryptInProgress || alreadyWarned) return EncryptionPendingAction::None;
return EncryptionPendingAction::Warn;
}
// ---- C90 ----
constexpr int kSetPinProbeSecs = 5; // the verification unlock used when the wallet is locked
struct SetPinVerifyPlan {
int timeoutSecs; // walletpassphrase timeout for the passphrase check
bool relockAfter; // call walletlock afterwards (only if the wallet was locked before)
};
// Set PIN verifies the passphrase with walletpassphrase. If the wallet is unlocked now, re-unlock with the time
// it has left (replaces the daemon's timer with the same deadline) and DO NOT relock; otherwise a 5 s probe + relock.
inline SetPinVerifyPlan planSetPinVerify(bool guiUnlocked, std::int64_t unlockedUntil, std::int64_t now)
{
const std::int64_t left = unlockedUntil - now;
if (guiUnlocked && left > kSetPinProbeSecs) {
const std::int64_t capped = left > 86400 * 7 ? 86400 * 7 : left;
return {static_cast<int>(capped), false};
}
return {kSetPinProbeSecs, true};
}
// The daemon's absolute unlock window has run out but the GUI still says "unlocked". Wall clock, because the
// deadline is an absolute unix time. unlockedUntil == 0 means "no deadline known" => never expired here.
inline bool unlockWindowExpired(bool encrypted, bool locked, std::int64_t unlockedUntil, std::int64_t now)
{
return encrypted && !locked && unlockedUntil > 0 && now >= unlockedUntil;
}
} // namespace data
} // namespace dragonx
+26
View File
@@ -43,6 +43,32 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
return bestIndex;
}
int addressIndexByString(const std::vector<AddressInfo>& addresses, const std::string& address)
{
if (address.empty()) return -1;
for (size_t i = 0; i < addresses.size(); ++i) {
if (addresses[i].address == address) return static_cast<int>(i);
}
return -1;
}
SourceSelection resolveSourceSelection(const std::vector<AddressInfo>& addresses, const std::string& current,
bool autoLatched, bool connected)
{
SourceSelection r;
r.address = current;
r.autoLatched = autoLatched;
r.index = addressIndexByString(addresses, current);
if (!autoLatched && connected && !addresses.empty()) {
if (current.empty()) { // only ever fill an empty source
const int best = bestSpendableAddressIndex(addresses);
if (best >= 0) { r.address = addresses[best].address; r.index = best; }
}
r.autoLatched = true; // one shot, whether or not it picked
}
return r;
}
std::string TransactionInfo::getTimeString() const
{
if (timestamp == 0) return "Unknown";
+101 -3
View File
@@ -9,6 +9,7 @@
#include <cstdint>
#include <chrono>
#include <ctime>
#include <cstring>
#include <utility>
#include "exchange_info.h"
@@ -41,6 +42,20 @@ struct AddressInfo {
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
bool requirePositiveBalance = true);
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses);
// Index of the address whose string equals `address` (exact match), or -1 (empty string / not listed).
int addressIndexByString(const std::vector<AddressInfo>& addresses, const std::string& address);
// The Send tab's source-address resolution, pure. `current` (s_from_address) is the single source of truth:
// the one-shot auto-pick (highest confirmed balance) only fills an EMPTY source, and never replaces one the
// user - or another tab's "Send from this address" - already set. A source that is not in the list (the list
// can be transiently short) is kept as is with index -1; it must NOT trigger a re-pick.
struct SourceSelection {
std::string address; // what s_from_address must hold afterwards
int index = -1; // its row in `addresses`, -1 if not listed
bool autoLatched = false; // new value of the one-shot latch
};
SourceSelection resolveSourceSelection(const std::vector<AddressInfo>& addresses, const std::string& current,
bool autoLatched, bool connected);
/**
* @brief Represents a wallet transaction
@@ -60,8 +75,40 @@ 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
// BIT-exactly (like the old History fingerprint): -0.0 and +0.0 render differently ("+-0.0" vs "+0.0").
// Must list EVERY TransactionInfo field (tests mutate each one).
inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo& b) {
return a.timestamp == b.timestamp && a.confirmations == b.confirmations &&
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;
}
// 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)
if (!sameTransactionInfo(a[i], b[i])) return false;
return true;
}
/**
* @brief Represents a connected peer
*/
@@ -115,7 +162,8 @@ struct MiningInfo {
// History for chart
std::vector<double> hashrate_history; // Last N samples
static constexpr int MAX_HISTORY = 300; // 5 minutes at 1s intervals
std::vector<std::time_t> hashrate_history_t; // wall-clock stamp per sample (lockstep with hashrate_history)
static constexpr int MAX_HISTORY = 300; // 5 minutes at ~1s intervals
// Recent daemon log lines for the mining log panel
std::vector<std::string> log_lines;
@@ -130,6 +178,7 @@ struct SyncInfo {
double verification_progress = 0.0;
bool syncing = false;
std::string best_blockhash;
std::string lite_error; // lite: last sync failure text, "" = none
// Rescan state (detected from daemon output)
bool rescanning = false;
@@ -149,7 +198,18 @@ struct SyncInfo {
float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase
int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1)
bool isSynced() const { return !syncing && blocks > 0 && blocks >= headers - 2; }
// True only when the daemon reports a positive peer-derived tip (longestchain > 0). A node with
// no peers reports longestchain == 0, so `blocks` is then only the LOCAL best height and cannot be
// trusted as "at the network tip". isSynced() gates on this so a peerless / cold-started node is
// never reported synced (which would enable Send against a stale balance). (audit: peerless-synced)
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); }
};
/**
@@ -215,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;
@@ -225,7 +287,8 @@ struct PoolMiningState {
// Hashrate history for chart (mirrors MiningInfo::hashrate_history)
std::vector<double> hashrate_history;
static constexpr int MAX_HISTORY = 60; // 5 minutes at ~5s intervals
std::vector<std::time_t> hashrate_history_t; // wall-clock stamp per sample (lockstep with hashrate_history)
static constexpr int MAX_HISTORY = 60; // ~2 min at the ~2s pool poll
// Recent log lines for the log panel
std::vector<std::string> log_lines;
@@ -312,8 +375,39 @@ struct WalletState {
// Timestamps for refresh logic
int64_t last_balance_update = 0;
// A Core poll that skipped the balance confirmed it is still current (nothing it depends on changed):
// the staleness badge counts from the later of the two.
int64_t balance_verified_at = 0;
// The last balance call that was sent failed (or timed out): the staleness badge then shows at its usual
// thresholds even while the scan budget is holding the next one.
bool balance_last_attempt_failed = false;
int wallet_tx_count = -1; // getwalletinfo's txcount; -1 = unknown
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
int64_t last_tx_update = 0;
// Change counter for `transactions` (main thread only). Starts at 1 so a UI cache's 0 sentinel never matches;
// only ever increases (clear() bumps, never resets) so a wallet switch can't alias an old cache key. EVERY
// write to `transactions` (replace, insert/erase, in-place field edit) must use the helpers below or call
// 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;
// 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;
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;
int64_t last_mining_update = 0;
@@ -345,6 +439,7 @@ struct WalletState {
z_addresses.clear();
t_addresses.clear();
transactions.clear();
touchTransactions(); // never reset: a post-switch revision must differ from every cached key
peers.clear();
bannedPeers.clear();
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
@@ -356,6 +451,9 @@ struct WalletState {
// badge (and any "updated X ago" reader) briefly reports it as current until the first refresh
// re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return "").
last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0;
balance_verified_at = 0;
balance_last_attempt_failed = false;
wallet_tx_count = -1;
}
// Rebuild combined addresses list from z/t lists
+4
View File
@@ -0,0 +1,4 @@
// DragonX Wallet - INCBIN text-resource embedding (CMake-generated). Do NOT edit the generated copy.
#include "incbin.h"
INCBIN(ui_toml, "@CMAKE_SOURCE_DIR@/res/themes/ui.toml");
INCBIN(default_banlist, "@CMAKE_SOURCE_DIR@/res/default_banlist.txt");
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <cstdint>
// Defined in generated/embedded_text.cpp via INCBIN (.incbin). Data is NOT NUL-terminated;
// every consumer passes an explicit size. Size is `unsigned int`.
extern "C" {
extern const unsigned char g_ui_toml_data[];
extern const unsigned int g_ui_toml_size;
extern const unsigned char g_default_banlist_data[];
extern const unsigned int g_default_banlist_size;
}
+388 -105
View File
@@ -23,6 +23,7 @@
#include "imgui_internal.h"
#include "imgui_impl_sdl3.h"
#include "ui/effects/imgui_acrylic.h"
#include "ui/effects/capture_extent.h"
#include "ui/effects/theme_effects.h"
#include "util/texture_loader.h"
#include "util/platform.h"
@@ -48,6 +49,7 @@
#ifdef _WIN32
#include "platform/windows_backdrop.h"
#include <windows.h>
#include <io.h>
#include <dwmapi.h>
#include <shlobj.h>
#include <propkey.h>
@@ -71,11 +73,13 @@ extern "C" HRESULT __stdcall SHGetPropertyStoreForWindow(HWND hwnd, REFIID riid,
#endif
#endif
#include <algorithm>
#include <cstdio>
#include <cstdarg>
#include <cstdlib>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
@@ -330,6 +334,11 @@ static LRESULT CALLBACK BorderlessWndProc(HWND hwnd, UINT msg,
return CallWindowProcW(g_sdlWndProc, hwnd, msg, wParam, lParam);
}
// Caption-button window ops are deferred out of the open ImGui frame and run
// after the frame closes (SDL pushes resize events synchronously). (C146)
enum class CaptionOp { None, Minimize, Maximize, Restore };
static CaptionOp g_pendingCaptionOp = CaptionOp::None;
// Draw custom window-control buttons (minimize / maximize-restore / close)
// on the foreground draw list, and handle title-bar dragging.
static void renderBorderlessControls(SDL_Window* window, HWND /*hwnd*/,
@@ -376,7 +385,7 @@ static void renderBorderlessControls(SDL_Window* window, HWND /*hwnd*/,
float cy = oy + btnH * 0.5f, lx = minX + btnW * 0.5f - iconHalf;
fg->AddLine(ImVec2(lx, cy), ImVec2(lx + iconHalf * 2.0f, cy),
wcIcon, lineThk);
if (hov && click) SDL_MinimizeWindow(window);
if (hov && click) g_pendingCaptionOp = CaptionOp::Minimize;
}
// --- Maximize / Restore button ---
@@ -398,10 +407,7 @@ static void renderBorderlessControls(SDL_Window* window, HWND /*hwnd*/,
ImVec2(cx + iconHalf, cy + iconHalf - dp),
wcIcon, 0, 0, lineThk);
}
if (hov && click) {
if (maximized) SDL_RestoreWindow(window);
else SDL_MaximizeWindow(window);
}
if (hov && click) g_pendingCaptionOp = maximized ? CaptionOp::Restore : CaptionOp::Maximize;
}
// --- Close button ---
@@ -550,7 +556,7 @@ static void drawWindowBackdrop(dragonx::App& app, ImDrawList* bgDL, ImVec2 p0, I
}
// ── Window-dimension diagnostic tracer ──────────────────────────────────────────────────────
// Always-on (writes to stderr → dragonx-debug.log on Windows) so it's visible in release builds,
// Always-on (writes to stderr → dragonx-stdout.log on Windows) so it's visible in release builds,
// unlike DRAGONX_DEBUG-gated DEBUG_LOGF. Grep the log for "[WINLOG]" to trace how the window size
// changes when dragging between monitors of different DPI. Low volume — only fires on window
// resize / move / DPI-scale events. TODO: remove once the multi-monitor sizing bug is resolved.
@@ -778,6 +784,102 @@ static void installPosixCrashHandler(const std::string& crashLogPath)
}
#endif // !_WIN32
// ============================================================================
// Frame pacing (P6)
// ============================================================================
// The main loop renders a frame only when something needs one:
// - full rate: the user is interacting (and for kRecentInputMs after the last input event), smooth
// scrolling, the screenshot sweep, and a few settle frames after a window-state change or a window
// appearing;
// - ambient: continuous motion that isn't tied to input (theme-effect animation, the translucent
// backdrop, animated avatars, toast fades) at kAmbientFrameMs while the app has focus and
// kAmbientFrameUnfocusedMs while it doesn't;
// - progress: things that only need to keep visibly moving (spinners and progress bars for work on
// other threads, countdowns, a focused text field's caret, the send/refresh/switch/shutdown
// states) at kProgressFrameMs / kProgressFrameUnfocusedMs;
// - idle: no frames until input, RPC results, or the periodic housekeeping wake.
// Hidden or occluded windows get neither ambient nor progress frames.
// frameBudgetNs() caps every rate at the display refresh where swap/present doesn't block on vsync
// (WSLg, VMs, vsync forced off in the driver), which otherwise spins a core at hundreds of fps.
namespace {
constexpr Uint32 kAmbientFrameMs = 33; // ~30 fps while the app has focus
constexpr Uint32 kAmbientFrameUnfocusedMs = 100; // ~10 fps while another app has focus
constexpr Uint32 kProgressFrameMs = 100; // ~10 fps: spinners, progress, caret blink
constexpr Uint32 kProgressFrameUnfocusedMs = 250; // ~4 fps
constexpr Uint64 kRecentInputMs = 750; // stay at full rate this long after input stops
constexpr int kSettleFrames = 3; // full-rate frames after a window-state change
// Minimum spacing between frame starts: 90% of the display's refresh interval, so a working vsync
// never waits here and a non-blocking swap can't outrun the display.
Uint64 frameBudgetNs(SDL_Window* window)
{
float hz = 60.0f;
if (const SDL_DisplayMode* dm = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(window))) {
if (dm->refresh_rate > 1.0f) hz = dm->refresh_rate;
}
hz = std::clamp(hz, 30.0f, 240.0f);
return static_cast<Uint64>(0.9e9 / hz);
}
bool isInputEvent(Uint32 type)
{
switch (type) {
case SDL_EVENT_KEY_DOWN:
case SDL_EVENT_KEY_UP:
case SDL_EVENT_TEXT_INPUT:
case SDL_EVENT_TEXT_EDITING:
case SDL_EVENT_TEXT_EDITING_CANDIDATES:
case SDL_EVENT_MOUSE_MOTION:
case SDL_EVENT_MOUSE_BUTTON_DOWN:
case SDL_EVENT_MOUSE_BUTTON_UP:
case SDL_EVENT_MOUSE_WHEEL:
case SDL_EVENT_FINGER_DOWN:
case SDL_EVENT_FINGER_UP:
case SDL_EVENT_FINGER_MOTION:
case SDL_EVENT_PEN_DOWN:
case SDL_EVENT_PEN_UP:
case SDL_EVENT_PEN_MOTION:
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
case SDL_EVENT_GAMEPAD_BUTTON_UP:
return true;
default:
return false;
}
}
// Window-state changes after which the compositor or the acrylic capture may need a few fresh frames.
bool isWindowStateEvent(Uint32 type)
{
switch (type) {
case SDL_EVENT_WINDOW_SHOWN:
case SDL_EVENT_WINDOW_EXPOSED:
case SDL_EVENT_WINDOW_RESTORED:
case SDL_EVENT_WINDOW_MAXIMIZED:
case SDL_EVENT_WINDOW_RESIZED:
case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
case SDL_EVENT_WINDOW_DISPLAY_CHANGED:
case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED:
case SDL_EVENT_WINDOW_FOCUS_GAINED:
case SDL_EVENT_WINDOW_FOCUS_LOST:
case SDL_EVENT_WINDOW_OCCLUDED:
return true;
default:
return false;
}
}
// A held keyboard key keeps ImGui's own key repeat running even where the OS sends no repeat events.
// Keyboard only: gamepad keys come from polled analog state, so a drifting stick or a trigger resting
// past ImGui's 0.1 threshold would read as held forever (gamepad presses still count as input events).
bool anyKeyDown()
{
for (int k = ImGuiKey_Keyboard_BEGIN; k < ImGuiKey_Keyboard_END; ++k) {
if (ImGui::IsKeyDown(static_cast<ImGuiKey>(k))) return true;
}
return false;
}
} // namespace
int main(int argc, char* argv[])
{
// Ensure ObsidianDragon config directory exists early (before any file I/O)
@@ -820,8 +922,10 @@ int main(int argc, char* argv[])
// above so the two writers don't interleave/contend on one file.
{
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
freopen(logPath.c_str(), "w", stdout);
freopen(logPath.c_str(), "a", stderr);
freopen(logPath.c_str(), "w", stdout); // per-run truncation
freopen("NUL", "w", stderr); // GUI subsystem may have no valid fd 2 — give it one first
_dup2(_fileno(stdout), _fileno(stderr)); // stderr now shares stdout's handle + file offset
setvbuf(stderr, nullptr, _IONBF, 0); // keep stderr unbuffered (crash diagnostics)
}
// Install crash handler for diagnostics
SetUnhandledExceptionFilter(CrashHandler);
@@ -880,9 +984,16 @@ int main(int argc, char* argv[])
// Load saved window size early (before window creation, before App exists).
// Settings are loaded again later in app.init(), this is just for the size.
int savedWinW = 1200, savedWinH = 720;
float earlyFontScale = 1.0f; // consumed below, before the first (and only) font-atlas build
bool earlyEmojiColor = false;
bool earlyOpaque = false; // opaque start: skip DWM blur/acrylic backdrop (Windows)
{
dragonx::config::Settings earlySettings;
if (earlySettings.load()) {
const bool earlyLoaded = earlySettings.load();
earlyFontScale = earlySettings.getFontScale(); // defaults when load failed, same as App::init sees
earlyEmojiColor = earlySettings.getChatEmojiColor();
earlyOpaque = earlySettings.getLowSpecMode() || earlySettings.getWindowOpacity() >= 0.99f;
if (earlyLoaded) {
int sw = earlySettings.getWindowWidth();
int sh = earlySettings.getWindowHeight();
if (sw >= 1024 && sh >= 720) {
@@ -896,6 +1007,12 @@ int main(int argc, char* argv[])
}
}
// Everything the first Typography::load() reads must be final BEFORE InitImGui (DX11 and GL paths below):
// ui.toml sizes, the saved font scale (non-flagging setter so the first preFrame does not reload), emoji style.
dragonx::App::loadBaseUiSchema();
dragonx::ui::Layout::setUserFontScaleVisual(earlyFontScale);
dragonx::ui::material::Typography::instance().setColorEmoji(earlyEmojiColor);
// ---------------------------------------------------------------
// Platform-specific window creation and graphics context
// ---------------------------------------------------------------
@@ -1048,14 +1165,17 @@ int main(int argc, char* argv[])
if (hwnd_blur) {
// Override SDL's zero-area blur region with a full-window blur.
// This gives DWM the signal to blur everything behind our window.
HRGN blurRgn = CreateRectRgn(0, 0, -1, -1); // full window
DWM_BLURBEHIND bb = {};
bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION;
bb.fEnable = TRUE;
bb.hRgnBlur = blurRgn;
HRESULT hr_blur = DwmEnableBlurBehindWindow(hwnd_blur, &bb);
DeleteObject(blurRgn);
DEBUG_LOGF("DwmEnableBlurBehindWindow (full region): 0x%08lx\n", hr_blur);
// Skipped when the window starts opaque (SDL's zero-area region stays).
if (!earlyOpaque) {
HRGN blurRgn = CreateRectRgn(0, 0, -1, -1); // full window
DWM_BLURBEHIND bb = {};
bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION;
bb.fEnable = TRUE;
bb.hRgnBlur = blurRgn;
HRESULT hr_blur = DwmEnableBlurBehindWindow(hwnd_blur, &bb);
DeleteObject(blurRgn);
DEBUG_LOGF("DwmEnableBlurBehindWindow (full region): 0x%08lx\n", hr_blur);
}
// On Windows 11 22H2+, also enable Acrylic for nicer modern blur
auto ver = dragonx::platform::getWindowsVersion();
@@ -1073,7 +1193,7 @@ int main(int argc, char* argv[])
// Acrylic = blurred, semi-transparent desktop view
// (NOT Mica which is opaque)
DWORD backdrop = 3; // DWMSBT_TRANSIENTWINDOW = Acrylic
DWORD backdrop = earlyOpaque ? 1 /*DWMSBT_NONE*/ : 3 /*DWMSBT_TRANSIENTWINDOW = Acrylic*/;
HRESULT hr_acr = DwmSetWindowAttribute(hwnd_blur, 38,
&backdrop, sizeof(backdrop));
DEBUG_LOGF("DWM Acrylic (DWMSBT_TRANSIENTWINDOW): 0x%08lx\n", hr_acr);
@@ -1124,8 +1244,9 @@ int main(int argc, char* argv[])
// Create window with graphics context
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
// Nothing in the app uses depth/stencil (ImGui + acrylic are color-only): request none.
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 0);
SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8);
SDL_WindowFlags window_flags = (SDL_WindowFlags)(SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_TRANSPARENT);
@@ -1159,6 +1280,11 @@ int main(int argc, char* argv[])
}
SDL_GL_MakeCurrent(window, gl_context);
{ int d = -1, s = -1;
const bool okd = SDL_GL_GetAttribute(SDL_GL_DEPTH_SIZE, &d);
const bool oks = SDL_GL_GetAttribute(SDL_GL_STENCIL_SIZE, &s);
DEBUG_LOGF("GL default framebuffer: depth=%d%s stencil=%d%s\n",
d, okd ? "" : " (no attachment)", s, oks ? "" : " (no attachment)"); }
SDL_GL_SetSwapInterval(1); // Enable vsync
bool backdrop_active = false;
@@ -1319,6 +1445,11 @@ int main(int argc, char* argv[])
// Create application instance
dragonx::App app;
// A quit deferred for the "keep the node running?" prompt brings the window back so it can be seen.
app.setRaiseWindowCallback([window]() {
SDL_RestoreWindow(window);
SDL_RaiseWindow(window);
});
if (!app.init()) {
fprintf(stderr, "Failed to initialize application!\n");
#ifdef _WIN32
@@ -1345,8 +1476,10 @@ int main(int argc, char* argv[])
dragonx::ui::schema::UISchema::instance().reapplyColorsToImGui();
}
// Initialize performance profiler — writes to <ObsidianDragonDir>/perf.log
{
// Performance profiler — writes to <ObsidianDragonDir>/perf.log. Opt-in (DRAGONX_PERFLOG=1): always
// on, it appended a block every 300 frames and was never rotated (tens of MB a day for any wallet
// left open).
if (const char* perfEnv = std::getenv("DRAGONX_PERFLOG"); perfEnv && *perfEnv && std::strcmp(perfEnv, "0") != 0) {
std::string perfPath = (std::filesystem::path(
dragonx::util::Platform::getObsidianDragonDir()) / "perf.log").string();
dragonx::util::PerfLog::instance().init(perfPath);
@@ -1401,6 +1534,20 @@ int main(int argc, char* argv[])
if (event->window.windowID != SDL_GetWindowID(ctx->window))
return true;
// Never nest NewFrame() inside an already-open frame — Windows min/max/restore buttons
// (renderBorderlessControls) push SDL window events synchronously from inside app.render(). (C146)
ImGuiContext* gctx = ImGui::GetCurrentContext();
if (!gctx || gctx->WithinFrameScope) return true;
// Dedupe the RESIZED + PIXEL_SIZE_CHANGED pair: both report the same final pixel size, so render
// once. SDL_GetWindowSizeInPixels is a consistent key across both event types (their data1/data2
// differ — points vs pixels — and cannot be compared directly).
int pw = 0, ph = 0;
SDL_GetWindowSizeInPixels(ctx->window, &pw, &ph);
static int lastW = 0, lastH = 0;
if (pw == lastW && ph == lastH) return true;
lastW = pw; lastH = ph;
// Avoid re-entrancy
static bool in_resize_render = false;
if (in_resize_render) return true;
@@ -1416,6 +1563,8 @@ int main(int argc, char* argv[])
SDL_GL_MakeCurrent(ctx->window, ctx->gl_context);
ImGui_ImplOpenGL3_NewFrame();
#endif
// Free/rebuild acrylic targets at the frame boundary (after MakeCurrent on GL: FBOs are per-context).
dragonx::ui::effects::ImGuiAcrylic::ReleaseIdleBuffers();
ImGui_ImplSDL3_NewFrame();
ImGui::NewFrame();
@@ -1433,7 +1582,9 @@ int main(int argc, char* argv[])
ImGuiIO& io = ImGui::GetIO();
dragonx::ui::effects::ImGuiAcrylic::BeginFrame(
(int)io.DisplaySize.x, (int)io.DisplaySize.y);
(int)io.DisplaySize.x, (int)io.DisplaySize.y,
dragonx::ui::effects::framebufferExtent(io.DisplaySize.x, io.DisplayFramebufferScale.x),
dragonx::ui::effects::framebufferExtent(io.DisplaySize.y, io.DisplayFramebufferScale.y));
ctx->app->update();
ctx->app->render();
@@ -1475,6 +1626,50 @@ int main(int argc, char* argv[])
bool needsRedraw = true; // P6: Start with a full redraw
auto lastPeriodicFrame = std::chrono::steady_clock::now();
// Frame pacing state (P6; see kAmbientFrameMs and friends above main()).
Uint64 lastInputMs = 0; // SDL_GetTicks() of the last input-class event
Uint64 nextAmbientFrameMs = 0; // when the next ambient frame is due; 0 = none scheduled
Uint64 lastFrameStartNs = 0; // software frame cap bookkeeping (SDL_GetTicksNS)
int settleFrames = kSettleFrames;
auto noteEvent = [&](const SDL_Event& e) {
if (isInputEvent(e.type)) {
lastInputMs = SDL_GetTicks();
app.noteUserInput(); // auto-lock idle clock (C21): real input only, never widget state
}
else if (isWindowStateEvent(e.type)) settleFrames = kSettleFrames;
};
// Deferred font-scale commit: smooth visual update during Alt+scroll,
// atlas rebuild after scrolling stops (~200ms idle).
Uint64 fontScaleCommitTick = 0; // 0 = nothing pending
// Alt + scroll wheel -> font scale. Returns true if the event was consumed.
auto handleAltWheel = [&](const SDL_Event& e) -> bool {
if (e.type != SDL_EVENT_MOUSE_WHEEL) return false;
if (!(SDL_GetModState() & SDL_KMOD_ALT)) return false;
float step = 0.05f * e.wheel.y;
float cur = dragonx::ui::Layout::userFontScale();
float next = std::max(1.0f, std::min(1.5f, cur + step));
if (next != cur) {
// Smooth preview — no atlas rebuild yet
dragonx::ui::Layout::setUserFontScaleVisual(next);
// Schedule atlas rebuild after scrolling stops
fontScaleCommitTick = SDL_GetTicks() + 200;
}
return true;
};
// Headless verification hook (DRAGONX_FULL_SWEEP=1, handled after present), hoisted so the pacing
// decision can keep its warm-up frames coming.
#if DRAGONX_ENABLE_UI_SWEEP
const bool autoSweep = std::getenv("DRAGONX_FULL_SWEEP") != nullptr;
#else
// The env hook is dead in shipped builds. It must be: with startSweepImpl a no-op, an enabled
// autoSweep would quit the app as soon as the warm-up frames ran out.
const bool autoSweep = false;
#endif
int autoSweepFrames = 0;
bool autoSweepStarted = false;
// Per-scale saved window sizes for DPI round-trips.
// Each entry maps scale-percentage (100, 125, 150, 200, …) to the
// physical pixel size the user had at that scale. When transitioning
@@ -1501,6 +1696,7 @@ int main(int argc, char* argv[])
int dpiSettleFrames = 0;
SDL_DisplayID lastLoggedDisplay = 0; // [WINLOG] throttle: log MOVED only when the display changes
Uint64 minimizedLastTickMs = 0; // real-clock tick for the minimized "keep syncing" update
Uint64 backgroundSinceMs = 0; // when the window started to look backgrounded (0 = it doesn't)
{
float s = dragonx::ui::material::Typography::instance().getDpiScale();
int w = 0, h = 0;
@@ -1538,12 +1734,19 @@ int main(int argc, char* argv[])
// above forces redraws while active, so this drains promptly even if the app is otherwise idle.
if (dpiSettleFrames > 0) --dpiSettleFrames;
// P6: Idle rendering — sleep if nothing needs redrawing
// P6: Idle rendering — sleep until input, the next ambient frame, or a 200 ms housekeeping tick
if (!needsRedraw) {
// Wait up to 200ms for user input or wake event
int waitMs = 200;
if (nextAmbientFrameMs != 0) {
const Uint64 nowMs = SDL_GetTicks();
waitMs = nextAmbientFrameMs > nowMs
? static_cast<int>(std::min<Uint64>(nextAmbientFrameMs - nowMs, 200)) : 0;
}
SDL_Event waitEvent;
if (SDL_WaitEventTimeout(&waitEvent, 200)) {
ImGui_ImplSDL3_ProcessEvent(&waitEvent);
if (waitMs > 0 && SDL_WaitEventTimeout(&waitEvent, waitMs)) {
noteEvent(waitEvent);
if (handleAltWheel(waitEvent)) needsRedraw = true;
else ImGui_ImplSDL3_ProcessEvent(&waitEvent);
if (waitEvent.type == SDL_EVENT_QUIT) {
app.beginShutdown();
}
@@ -1581,11 +1784,12 @@ int main(int argc, char* argv[])
lastKnownH = waitEvent.window.data2;
}
}
// Window restored from minimized — trigger immediate data refresh
// Window restored. No forced refresh: update() keeps running while minimized, so the data
// is current, and refreshNow() wiped the shielded-scan progress and re-scanned every
// z-address (SDL also sends RESTORED on un-maximize, so a title-bar double-click did it).
if (waitEvent.type == SDL_EVENT_WINDOW_RESTORED &&
waitEvent.window.windowID == SDL_GetWindowID(window)) {
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
app.refreshNow();
}
// Handle DPI change that arrived while idle (same logic as poll loop)
if (waitEvent.type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED &&
@@ -1608,17 +1812,22 @@ int main(int argc, char* argv[])
auto sinceLast = std::chrono::duration_cast<std::chrono::milliseconds>(
now - lastPeriodicFrame).count();
// Immediate triggers: async RPC results or visible notifications
bool hasImmediateWork = app.hasPendingRPCResults()
// Immediate triggers: background results, visible notifications, work in progress
// (also what keeps a hidden or occluded window responsive: ~5 fps while it lasts)
bool hasImmediateWork = app.hasPendingBackgroundResults()
|| app.hasTransactionSendProgress()
|| app.isTransactionRefreshInProgress()
|| app.isShuttingDown()
|| dragonx::ui::Notifications::instance().hasActive();
// Ambient frame due (scheduled by the pacing decision at the end of the last frame)
const bool ambientDue = nextAmbientFrameMs != 0 && SDL_GetTicks() >= nextAmbientFrameMs;
// Periodic maintenance: fire refresh timers in app.update()
// Low-spec: every 5s; normal: every 2s
int periodMs = dragonx::ui::effects::isLowSpecMode() ? 5000 : 2000;
if (hasImmediateWork || sinceLast >= periodMs) {
if (ambientDue || hasImmediateWork || sinceLast >= periodMs) {
needsRedraw = true;
} else {
continue; // Nothing to do — go back to sleep
@@ -1626,30 +1835,23 @@ int main(int argc, char* argv[])
}
}
// Software frame cap: never start frames faster than the display refreshes. A no-op where
// swap/present blocks on vsync; elsewhere it stops the loop spinning a core. The screenshot
// sweep switches vsync off on purpose and is exempt. Before the event poll, so the frame still
// renders with the freshest input.
if (!app.isScreenshotSweeping() && lastFrameStartNs != 0) {
const Uint64 budgetNs = frameBudgetNs(window);
const Uint64 sinceStartNs = SDL_GetTicksNS() - lastFrameStartNs;
if (sinceStartNs < budgetNs) SDL_DelayNS(budgetNs - sinceStartNs);
}
lastFrameStartNs = SDL_GetTicksNS();
// Poll remaining events
SDL_Event event;
// Deferred font-scale commit: smooth visual update during Alt+scroll,
// atlas rebuild after scrolling stops (~200ms idle).
static Uint64 fontScaleCommitTick = 0; // 0 = nothing pending
while (SDL_PollEvent(&event)) {
// Alt + scroll wheel: smooth font scale adjustment
if (event.type == SDL_EVENT_MOUSE_WHEEL) {
SDL_Keymod mods = SDL_GetModState();
if (mods & SDL_KMOD_ALT) {
float step = 0.05f * event.wheel.y;
float cur = dragonx::ui::Layout::userFontScale();
float next = std::max(1.0f, std::min(1.5f, cur + step));
if (next != cur) {
// Smooth preview — no atlas rebuild yet
dragonx::ui::Layout::setUserFontScaleVisual(next);
// Schedule atlas rebuild after scrolling stops
fontScaleCommitTick = SDL_GetTicks() + 200;
}
// Don't pass Alt+scroll to ImGui (would scroll windows)
continue;
}
}
noteEvent(event);
// Alt + scroll wheel: smooth font scale adjustment (don't pass to ImGui: would scroll windows)
if (handleAltWheel(event)) continue;
ImGui_ImplSDL3_ProcessEvent(&event);
if (event.type == SDL_EVENT_QUIT) {
@@ -1711,11 +1913,10 @@ int main(int argc, char* argv[])
}
}
}
// Window restored from minimized — trigger immediate data refresh
// Window restored: no forced refresh (see the wait branch above).
if (event.type == SDL_EVENT_WINDOW_RESTORED &&
event.window.windowID == SDL_GetWindowID(window)) {
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
app.refreshNow();
}
// Handle DPI/display scale changes (e.g. window dragged to a
// different-DPI monitor, or user changes Windows scaling)
@@ -1733,6 +1934,22 @@ int main(int argc, char* argv[])
}
}
// C134: the window is in the background when it is minimized, hidden, or covered while none of our
// windows has focus, for a few seconds with no input meanwhile. Focus loss alone never counts. The
// app then polls only the chain state; coming back is immediate.
{
const Uint64 nowMs = SDL_GetTicks();
const SDL_WindowFlags wf = SDL_GetWindowFlags(window);
const bool flagged = dragonx::services::windowLooksBackground(
(wf & SDL_WINDOW_MINIMIZED) != 0, (wf & SDL_WINDOW_HIDDEN) != 0,
(wf & SDL_WINDOW_OCCLUDED) != 0, SDL_GetKeyboardFocus() != nullptr);
if (!flagged) backgroundSinceMs = 0;
else if (backgroundSinceMs == 0) backgroundSinceMs = nowMs;
app.setWindowBackground(dragonx::services::isBackgroundWindow(
flagged, backgroundSinceMs != 0 ? (nowMs - backgroundSinceMs) / 1000.0 : 0.0,
lastInputMs != 0 ? (nowMs - lastInputMs) / 1000.0 : 1.0e9));
}
// Check if window is minimized
if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) {
// Keep the wallet syncing while minimized: run the logic update (drains RPC results, ticks the
@@ -1773,6 +1990,8 @@ int main(int argc, char* argv[])
// Pre-frame: font atlas rebuilds and schema hot-reload must
// happen BEFORE NewFrame() because NewFrame() caches font ptrs.
app.preFrame();
// Acrylic targets are freed/rebuilt here, between frames: nothing recorded can reference them.
dragonx::ui::effects::ImGuiAcrylic::ReleaseIdleBuffers();
// Smooth font-scale: compensate visual font size via FontScaleMain
// when the atlas hasn't been rebuilt yet (during slider drag).
@@ -1858,10 +2077,14 @@ int main(int argc, char* argv[])
ImGuiIO& io = ImGui::GetIO();
if (!dragonx::ui::effects::isLowSpecMode()) {
dragonx::ui::effects::ImGuiAcrylic::BeginFrame(
(int)io.DisplaySize.x, (int)io.DisplaySize.y);
(int)io.DisplaySize.x, (int)io.DisplaySize.y,
dragonx::ui::effects::framebufferExtent(io.DisplaySize.x, io.DisplayFramebufferScale.x),
dragonx::ui::effects::framebufferExtent(io.DisplaySize.y, io.DisplayFramebufferScale.y));
}
// Update theme visual effects timing
// Update theme visual effects timing (held still under Reduce Motion)
dragonx::ui::effects::ThemeEffects::instance().setMotionFrozen(
app.settings() && app.settings()->getReduceMotion());
dragonx::ui::effects::ThemeEffects::instance().beginFrame();
// Propagate window opacity to theme effects so they fade with the
// backdrop while UI elements (cards, text, buttons) stay opaque.
@@ -2020,7 +2243,9 @@ int main(int argc, char* argv[])
// Debug screenshot sweep — grab the finished frame before swap.
if (app.wantsScreenshotThisFrame()) {
captureFrameToPng(app.screenshotSweepPath(), (int)io.DisplaySize.x, (int)io.DisplaySize.y);
captureFrameToPng(app.screenshotSweepPath(),
dragonx::ui::effects::framebufferExtent(io.DisplaySize.x, io.DisplayFramebufferScale.x),
dragonx::ui::effects::framebufferExtent(io.DisplaySize.y, io.DisplayFramebufferScale.y));
app.onScreenshotCaptured();
}
@@ -2053,16 +2278,11 @@ int main(int argc, char* argv[])
// Headless verification hook: DRAGONX_FULL_SWEEP=1 runs the full UI sweep once the app has
// settled, then quits when it finishes. Used to run the sweep without clicking the button.
{
static const bool autoSweep = std::getenv("DRAGONX_FULL_SWEEP") != nullptr;
static int autoFrames = 0;
static bool autoStarted = false;
if (autoSweep) {
if (!autoStarted) {
if (++autoFrames >= 90) { app.startFullUiSweep(); autoStarted = true; }
} else if (!app.isScreenshotSweeping()) {
running = false; // sweep complete
}
if (autoSweep) {
if (!autoSweepStarted) {
if (++autoSweepFrames >= 90) { app.startFullUiSweep(); autoSweepStarted = true; }
} else if (!app.isScreenshotSweeping()) {
running = false; // sweep complete
}
}
@@ -2085,41 +2305,99 @@ int main(int argc, char* argv[])
// Track last render for idle periodic timer
lastPeriodicFrame = std::chrono::steady_clock::now();
// P6: Determine if next frame needs redraw or can idle
// P6: Decide when the next frame is needed: right away (full rate), at the ambient rate, or not
// until the next event / housekeeping wake (idle). See the frame-pacing notes above main().
{
ImGuiContext& g = *GImGui;
const Uint64 nowMs = SDL_GetTicks();
// Detect actual user interaction — NOT WantCaptureMouse/Keyboard
// which are just "ImGui has windows that accept input" (always true).
bool mouseMoving = g.IO.MouseDelta.x != 0.0f || g.IO.MouseDelta.y != 0.0f;
bool mouseClicked = false;
// Hidden or occluded windows get no ambient frames (SDL reports occlusion on macOS and
// Wayland; elsewhere a covered window usually also lacks focus). Focus is per application:
// any of our windows, multi-viewport platform windows included, not just the main one.
const SDL_WindowFlags wflags = SDL_GetWindowFlags(window);
const bool visible =
(wflags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_OCCLUDED | SDL_WINDOW_MINIMIZED)) == 0;
const bool focused = SDL_GetKeyboardFocus() != nullptr;
// Interaction — not WantCaptureMouse/Keyboard, which only mean "ImGui has windows that
// accept input" (always true). A focused text field by itself (only the caret moves: the
// lock screen's passphrase box, the console input) is ambient below; it used to hold the
// loop at the display rate for as long as the field kept focus.
const bool textEditing = g.ActiveId != 0 && g.ActiveId == g.InputTextState.ID;
bool mouseDown = false;
for (int i = 0; i < IM_ARRAYSIZE(g.IO.MouseDown); i++)
mouseClicked |= g.IO.MouseDown[i];
bool scrolling = g.IO.MouseWheel != 0.0f || g.IO.MouseWheelH != 0.0f;
bool typing = g.IO.InputQueueCharacters.Size > 0;
bool widgetActive = g.ActiveId != 0 || g.MovingWindow != nullptr;
mouseDown |= g.IO.MouseDown[i];
const bool uiActive = g.IO.MouseDelta.x != 0.0f || g.IO.MouseDelta.y != 0.0f
|| mouseDown
|| g.IO.MouseWheel != 0.0f || g.IO.MouseWheelH != 0.0f
|| (g.ActiveId != 0 && !textEditing) || g.MovingWindow != nullptr
|| g.InputEventsQueue.Size > 0 // input still trickling in
|| (focused && anyKeyDown()) // ImGui key repeat
|| (lastInputMs != 0 && nowMs - lastInputMs < kRecentInputMs);
bool uiActive = mouseMoving || mouseClicked || scrolling
|| typing || widgetActive;
// A window that appeared this frame (a popup hidden for its auto-fit frame, an overlay
// dialog's scrim while its card measures and captures its blur) needs a few more frames
// to show, even when it opened from an RPC result or a timer rather than input.
for (ImGuiWindow* w : g.Windows) {
if (w->Active && w->Appearing) { settleFrames = kSettleFrames; break; }
}
// When the background is fully opaque, backdrop doesn't
// need continuous frames — let the app idle and save power.
float wo2 = app.settings() ? app.settings()->getWindowOpacity() : 1.0f;
bool opaqueBackground = dragonx::ui::effects::isLowSpecMode() || wo2 >= 0.99f;
bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
&& !opaqueBackground;
bool animating = app.isShuttingDown()
|| app.isWalletSwitchInProgress()
|| backdropNeedsFrames
|| app.hasTransactionSendProgress()
|| app.isTransactionRefreshInProgress()
|| dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|| dragonx::ui::Notifications::instance().hasActive()
|| dragonx::ui::material::SmoothScrollAnimating()
|| dragonx::ui::ConsumeContactsAvatarAnimation();
// If nothing is happening, allow the next iteration to idle
needsRedraw = uiActive || animating;
const bool fullRate = uiActive
|| settleFrames > 0
|| app.isScreenshotSweeping()
|| (autoSweep && !autoSweepStarted)
|| dragonx::ui::material::SmoothScrollAnimating();
if (settleFrames > 0) --settleFrames;
// Ambient: continuous motion without input. Theme effects stop requesting frames under
// Reduce Motion (their clock is frozen above). The backdrop itself is static; the
// translucent window stays on this tier as the conservative choice for compositors.
const bool avatarAnimating = dragonx::ui::ConsumeContactsAvatarAnimation(); // consume every frame
const bool progressRequested = dragonx::ui::material::ConsumeProgressFrameRequest(); // ditto
const float wo2 = app.settings() ? app.settings()->getWindowOpacity() : 1.0f;
const bool opaqueBackground = dragonx::ui::effects::isLowSpecMode() || wo2 >= 0.99f;
const bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
&& !opaqueBackground;
const bool reduceMotion = app.settings() && app.settings()->getReduceMotion();
// C128: suppress backdrop theme-effect frame requests while a blur overlay (a modal dialog or
// the lock screen) covers them -- they aren't visible, and the overlay's own moving elements
// request frames via RequestProgressFrame(), so this never freezes anything on screen.
const bool effectsAnimating = !reduceMotion
&& dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
&& !dragonx::ui::material::AnyBlurOverlayActiveLastFrame();
const bool ambient = effectsAnimating
|| backdropNeedsFrames
|| avatarAnimating
|| dragonx::ui::Notifications::instance().hasActive();
// Progress: only needs to keep visibly moving (a spinner, a percentage, dots, a caret).
const bool progress = progressRequested
|| (textEditing && focused)
|| app.isShuttingDown()
|| app.isWalletSwitchInProgress()
|| app.hasTransactionSendProgress()
|| app.isTransactionRefreshInProgress();
needsRedraw = fullRate;
nextAmbientFrameMs = 0;
if (!fullRate && visible && (ambient || progress)) {
const Uint32 interval = ambient ? (focused ? kAmbientFrameMs : kAmbientFrameUnfocusedMs)
: (focused ? kProgressFrameMs : kProgressFrameUnfocusedMs);
nextAmbientFrameMs = lastFrameStartNs / 1000000 + interval;
}
}
#ifdef DRAGONX_USE_DX11
if (g_pendingCaptionOp != CaptionOp::None) {
switch (g_pendingCaptionOp) {
case CaptionOp::Minimize: SDL_MinimizeWindow(window); break;
case CaptionOp::Maximize: SDL_MaximizeWindow(window); break;
case CaptionOp::Restore: SDL_RestoreWindow(window); break;
default: break;
}
g_pendingCaptionOp = CaptionOp::None;
needsRedraw = true; // after the pacing block, so it is not overwritten
}
#endif
}
// Remove resize event watcher
@@ -2147,10 +2425,9 @@ int main(int argc, char* argv[])
// Watchdog: if cleanup takes too long the process lingers without a
// window, showing up as a "Background Service" in Task Manager.
// Force-exit after timeout — all critical state (settings, daemon
// stop) was handled in beginShutdown().
// Allow enough time for the daemon to shut down gracefully (up to
// 10s in stop()) plus RPC disconnect overhead.
// Force-exit after timeout. The daemon stop is already over by the time the render loop exits: the
// node has exited, or the user chose to leave it running (it carries on as its own process), so this
// only bounds the remaining thread joins and RPC disconnects — it never cuts a node's shutdown short.
std::thread([]() {
std::this_thread::sleep_for(std::chrono::seconds(15));
fflush(stdout);
@@ -2158,8 +2435,14 @@ int main(int argc, char* argv[])
_Exit(0);
}).detach();
// Land the queued settings/wallets.json writes (incl. the window size saved above) before the joins
// in shutdown(); bounded (3 s) so a wedged disk can't eat the watchdog's budget.
app.flushPendingWrites();
// Final cleanup (daemon already stopped by beginShutdown)
app.shutdown();
// shutdown()'s fallback branch saves settings again; land that too, BEFORE the single-instance lock
// is released below so a relaunch can't read a stale settings.json. _Exit() skips ~App().
app.flushPendingWrites();
#ifdef DRAGONX_USE_DX11
Shutdown(window, dx);
#else
@@ -2186,7 +2469,11 @@ int main(int argc, char* argv[])
static bool InitSDL()
{
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) {
// SDL3 otherwise holds timeBeginPeriod(1) for the process lifetime on Windows. Nothing here needs
// sub-15 ms waits (vsync pacing, the 200 ms idle wait, >=50 ms thread sleeps), so let the system
// timer relax. Ignored on non-Windows platforms; a false return (env override) is harmless.
SDL_SetHint(SDL_HINT_TIMER_RESOLUTION, "0");
if (!SDL_Init(SDL_INIT_VIDEO)) {
fprintf(stderr, "Error: SDL_Init(): %s\n", SDL_GetError());
return false;
}
@@ -2211,8 +2498,6 @@ static bool InitImGui(SDL_Window* window, dragonx::platform::DX11Context& dx)
// Enable keyboard navigation
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
// Enable gamepad navigation
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
// Enable multi-viewport (windows can float outside main window)
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
@@ -2273,8 +2558,6 @@ static bool InitImGui(SDL_Window* window, SDL_GLContext gl_context)
// Enable keyboard navigation
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
// Enable gamepad navigation
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
// Enable multi-viewport (windows can float outside main window)
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
+16 -11
View File
@@ -252,16 +252,6 @@ void DX11Context::resize(int width, int height)
(void)height;
if (!swapChain_ || !deviceContext_) return;
cleanupRenderTarget();
// Unbind the render target from the pipeline — ResizeBuffers requires
// ALL outstanding references to back-buffer resources to be released.
// Without this, ResizeBuffers fails with DXGI_ERROR_INVALID_CALL when
// growing the window (shrinking appears fine because the old larger
// buffer simply gets cropped by DWM).
deviceContext_->OMSetRenderTargets(0, nullptr, nullptr);
deviceContext_->Flush();
// For composition swap chains (CreateSwapChainForComposition),
// passing 0,0 means "keep current size" — NOT auto-detect from HWND.
// We must pass the actual pixel dimensions from GetClientRect.
@@ -272,7 +262,22 @@ void DX11Context::resize(int width, int height)
w = static_cast<UINT>(rc.right - rc.left);
h = static_cast<UINT>(rc.bottom - rc.top);
}
if (w == 0 || h == 0) return;
if (w == 0 || h == 0) return; // minimized / zero-area: keep the current RTV, do nothing
// Already this size with a live RTV: nothing to do (skips the destroy/recreate churn).
DXGI_SWAP_CHAIN_DESC1 d;
if (SUCCEEDED(swapChain_->GetDesc1(&d)) && d.Width == w && d.Height == h && renderTargetView_)
return;
cleanupRenderTarget();
// Unbind the render target from the pipeline — ResizeBuffers requires
// ALL outstanding references to back-buffer resources to be released.
// Without this, ResizeBuffers fails with DXGI_ERROR_INVALID_CALL when
// growing the window (shrinking appears fine because the old larger
// buffer simply gets cropped by DWM).
deviceContext_->OMSetRenderTargets(0, nullptr, nullptr);
deviceContext_->Flush();
HRESULT hr = swapChain_->ResizeBuffers(0, w, h, DXGI_FORMAT_UNKNOWN, 0);
if (FAILED(hr)) {
+512 -283
View File
@@ -1,10 +1,15 @@
#include "embedded_resources.h"
#include "../util/platform.h"
#include "../util/logger.h"
#include <algorithm>
#include <cerrno>
#include <fstream>
#include <filesystem>
#include <functional>
#include <mutex>
#include <vector>
#include <cstdio>
#include <cstring>
#include <cctype>
#include <chrono>
@@ -12,6 +17,7 @@
#include <windows.h>
#include <shlobj.h>
#else
#include <fcntl.h>
#include <unistd.h>
#include <pwd.h>
#include <sys/types.h>
@@ -83,8 +89,8 @@ bool hasEmbeddedResources()
const EmbeddedResource* getEmbeddedResource(const std::string& name)
{
// Search static resource table (params, daemon binaries, etc.)
for (const auto* res = s_resources; res->data != nullptr; ++res) {
if (name == res->filename) {
for (const auto* res = s_resources; res->filename != nullptr; ++res) {
if (res->data && res->size && name == res->filename) {
return res;
}
}
@@ -116,6 +122,27 @@ const EmbeddedTheme* getEmbeddedThemes()
return themes.data();
}
int writeFileIfDifferent(const std::string& path, const uint8_t* data, size_t size)
{
{
std::ifstream cur(path, std::ios::binary | std::ios::ate);
if (cur.is_open()) {
const std::streamoff len = cur.tellg();
if (len >= 0 && static_cast<size_t>(len) == size) {
std::string buf(size, '\0');
cur.seekg(0);
if (size == 0 || (cur.read(&buf[0], static_cast<std::streamsize>(size)) &&
std::memcmp(buf.data(), data, size) == 0))
return 0;
}
}
} // reader closed before the writer opens (Windows sharing)
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f.is_open()) return -1;
f.write(reinterpret_cast<const char*>(data), static_cast<std::streamsize>(size));
return f.good() ? 1 : -1;
}
int extractBundledThemes(const std::string& destDir)
{
namespace fs = std::filesystem;
@@ -134,14 +161,12 @@ int extractBundledThemes(const std::string& destDir)
for (const auto* t = themes; t->data != nullptr; ++t) {
fs::path dest = fs::path(destDir) / t->filename;
// Always overwrite — bundled themes should match the binary version
std::ofstream f(dest, std::ios::binary);
if (f.is_open()) {
f.write(reinterpret_cast<const char*>(t->data), t->size);
f.close();
DEBUG_LOGF("[INFO] EmbeddedResources: Extracted theme: %s (%zu bytes)\n",
t->filename, t->size);
count++;
// Bundled themes must match the binary version: rewrite only when the bytes differ (not size-only:
// a same-length palette edit must still update).
const int w = writeFileIfDifferent(dest.string(), t->data, t->size);
if (w >= 0) {
if (w == 1) DEBUG_LOGF("[INFO] EmbeddedResources: Extracted theme: %s (%zu bytes)\n", t->filename, t->size);
count++; // present and current (caller only checks > 0)
} else {
DEBUG_LOGF("[ERROR] EmbeddedResources: Failed to write theme: %s\n", dest.string().c_str());
}
@@ -205,272 +230,461 @@ std::string getParamsDirectory()
#endif
}
// Forward declaration — defined below extractResource()
static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath);
// ---------------------------------------------------------------------------------------------------
// Writing into the daemon directory: one lock, atomic (temp + flush + rename) files.
// ---------------------------------------------------------------------------------------------------
namespace {
bool needsParamsExtraction()
// Every writer of the daemon directory (startup job, getDaemonPath(), getXmrigPath(), forceExtractXmrig(),
// reextractBundledDaemon(), ensureWalletRebuildHelperExtracted(), copyBundledFiles()) holds this for its whole
// write; whoever comes second finds the files complete and does nothing. Never taken re-entrantly. Lock order:
// provisionMutex -> Logger mutex (DEBUG_LOGF) only. The in-app updaters do not take it (see residual risks).
std::mutex& provisionMutex() { static std::mutex m; return m; }
struct ProvisionEntry {
const char* name;
bool refreshOnSizeMismatch; // re-extract when the bundled size differs (params, asmap)
bool executable;
bool fullNodeOnly; // not written by Lite builds (the lite backend bakes in its own params)
bool startupTrigger; // makes needsParamsExtraction() true
};
// Params and asmap follow the bundled size; the node binaries, the rebuild helper and the miner are placed only
// when MISSING (user-updatable via the updaters: the user may run a specific dragonxd or an "Update miner..."
// build; replacing the node is the explicit Settings action). asmap/cli/tx/rebuild are written along, never a trigger.
const ProvisionEntry kProvisionEntries[] = {
{ RESOURCE_SAPLING_SPEND, true, false, true, true },
{ RESOURCE_SAPLING_OUTPUT, true, false, true, true },
{ RESOURCE_ASMAP, true, false, true, false },
#ifdef HAS_EMBEDDED_DAEMON
{ RESOURCE_DRAGONXD, false, true, true, true },
{ RESOURCE_DRAGONX_CLI, false, true, true, false },
{ RESOURCE_DRAGONX_TX, false, true, true, false },
#endif
#ifdef HAS_EMBEDDED_XMRIG
{ RESOURCE_XMRIG, kXmrigRefreshOnSizeMismatch, true, false, true },
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
{ RESOURCE_DRAGONX_WALLET_REBUILD, false, true, true, false },
#endif
};
bool entryActive(const ProvisionEntry& e)
{
if (!hasEmbeddedResources()) {
#if defined(DRAGONX_LITE_BUILD) && DRAGONX_LITE_BUILD
return !e.fullNodeOnly;
#else
(void)e;
return true;
#endif
}
std::string joinDir(const std::string& dir, const char* name)
{
#ifdef _WIN32
return dir + "\\" + name;
#else
return dir + "/" + name;
#endif
}
constexpr std::size_t kCopyChunk = 4u * 1024u * 1024u;
bool prepareParentDir(const std::string& destPath)
{
const std::filesystem::path dest(destPath);
if (!dest.has_parent_path()) return true;
std::error_code ec;
std::filesystem::create_directories(dest.parent_path(), ec);
if (ec) {
DEBUG_LOGF("[ERROR] Failed to create directory %s: %s\n", dest.parent_path().string().c_str(),
ec.message().c_str());
return false;
}
// Check daemon directory (dragonx/) — the only extraction target
std::string daemonDir = getDaemonDirectory();
return true;
}
// Temp-file sink for writeFileAtomic / copyFileAtomic. Windows: ONE CreateFileA handle with no SECURITY_ATTRIBUTES,
// i.e. NOT inheritable: std::ofstream's CRT handle is, and a child created with bInheritHandles=TRUE meanwhile
// (xmrig, runHiddenCapture) would hold "<name>.partial" without FILE_SHARE_DELETE, so the MoveFileEx in
// replaceFromTemp fails while it runs (same fix as Platform::writeFileAtomically). commit() flushes on the same
// handle (FlushFileBuffers / fsync) before close, so the bytes are on disk before the rename. POSIX: O_CLOEXEC.
class TempSink {
public:
explicit TempSink(const std::string& path)
{
#ifdef _WIN32
const char pathSep = '\\';
h_ = CreateFileA(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
#else
const char pathSep = '/';
#endif
std::string spendPath = daemonDir + pathSep + RESOURCE_SAPLING_SPEND;
std::string outputPath = daemonDir + pathSep + RESOURCE_SAPLING_OUTPUT;
// Check if params are missing or stale (size mismatch → updated in newer build)
const auto* spendRes = getEmbeddedResource(RESOURCE_SAPLING_SPEND);
const auto* outputRes = getEmbeddedResource(RESOURCE_SAPLING_OUTPUT);
if (spendRes && resourceNeedsUpdate(spendRes, spendPath)) return true;
if (outputRes && resourceNeedsUpdate(outputRes, outputPath)) return true;
// Daemon binaries are only auto-placed when MISSING (never auto-overwritten on a size
// mismatch) — the user may be running a specific dragonxd. Replacing the bundled daemon is
// an explicit action via Settings → daemon binary. So only trigger extraction if it's absent.
#ifdef HAS_EMBEDDED_DAEMON
const auto* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
std::string daemonPath = daemonDir + pathSep + RESOURCE_DRAGONXD;
if (daemonRes && !std::filesystem::exists(daemonPath)) return true;
fd_ = ::open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
#endif
}
~TempSink() { (void)close(); }
TempSink(const TempSink&) = delete;
TempSink& operator=(const TempSink&) = delete;
#ifdef HAS_EMBEDDED_XMRIG
const auto* xmrigRes = getEmbeddedResource(RESOURCE_XMRIG);
std::string xmrigPath = daemonDir + pathSep + RESOURCE_XMRIG;
if (xmrigRes && resourceNeedsUpdate(xmrigRes, xmrigPath)) return true;
bool ok() const
{
#ifdef _WIN32
return h_ != INVALID_HANDLE_VALUE;
#else
return fd_ >= 0;
#endif
}
bool write(const void* data, std::size_t n)
{
const char* p = static_cast<const char*>(data);
while (n > 0) {
#ifdef _WIN32
const DWORD chunk = static_cast<DWORD>(n > (1u << 30) ? (1u << 30) : n);
DWORD w = 0;
if (!WriteFile(h_, p, chunk, &w, nullptr) || w == 0) return false;
#else
const ssize_t w = ::write(fd_, p, n);
if (w < 0) { if (errno == EINTR) continue; return false; }
if (w == 0) return false;
#endif
p += w;
n -= static_cast<std::size_t>(w);
}
return true;
}
// Flush to stable storage, then close. False on any error.
bool commit()
{
bool good = ok();
#ifdef _WIN32
if (good && !FlushFileBuffers(h_)) good = false;
#else
if (good && ::fsync(fd_) != 0) good = false;
#endif
if (!close()) good = false;
return good;
}
private:
bool close()
{
#ifdef _WIN32
if (h_ == INVALID_HANDLE_VALUE) return true;
const bool r = CloseHandle(h_) != 0;
h_ = INVALID_HANDLE_VALUE;
return r;
#else
if (fd_ < 0) return true;
const bool r = ::close(fd_) == 0;
fd_ = -1;
return r;
#endif
}
#ifdef _WIN32
HANDLE h_ = INVALID_HANDLE_VALUE;
#else
int fd_ = -1;
#endif
};
// Move `tmp` over `dest`. POSIX: rename() replaces atomically (even a running binary) + best-effort dir fsync.
// Windows (same primitive as Platform::writeFileAtomically): MoveFileEx REPLACE_EXISTING|WRITE_THROUGH. A
// running .exe cannot be replaced but can be renamed, so it is moved aside to ".old" once (the running process
// keeps it; cleaned on a later run). A just-closed file is often held for a moment by antivirus (sharing
// violation), so retry for up to ~2 s, but stop at once if the temp vanished (quarantined). On failure the
// temp is removed and a moved-aside original is put back.
bool replaceFromTemp(const std::string& tmp, const std::string& dest)
{
#ifdef _WIN32
const std::string aside = dest + ".old";
bool movedAside = false;
DWORD err = 0;
for (int attempt = 0; attempt < 20; ++attempt) {
if (MoveFileExA(tmp.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
return true;
err = GetLastError();
if (GetFileAttributesA(tmp.c_str()) == INVALID_FILE_ATTRIBUTES) break; // temp gone (AV quarantine)
if (!movedAside && GetFileAttributesA(dest.c_str()) != INVALID_FILE_ATTRIBUTES) {
DeleteFileA(aside.c_str());
if (MoveFileExA(dest.c_str(), aside.c_str(), MOVEFILE_REPLACE_EXISTING)) {
movedAside = true;
DEBUG_LOGF("[INFO] %s is in use - moved the old copy to .old\n", dest.c_str());
continue;
}
}
Sleep(100);
}
DEBUG_LOGF("[ERROR] Could not move %s into place (Win32 error %lu)\n", dest.c_str(), (unsigned long)err);
DeleteFileA(tmp.c_str());
if (movedAside) MoveFileExA(aside.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING);
return false;
#else
if (::rename(tmp.c_str(), dest.c_str()) != 0) {
DEBUG_LOGF("[ERROR] Could not move %s into place: %s\n", dest.c_str(), std::strerror(errno));
::remove(tmp.c_str());
return false;
}
const std::string dir = std::filesystem::path(dest).parent_path().string();
const int dfd = ::open(dir.empty() ? "." : dir.c_str(), O_RDONLY | O_CLOEXEC);
if (dfd >= 0) { ::fsync(dfd); ::close(dfd); }
return true;
#endif
}
} // namespace
bool resourceNeedsWrite(std::uint64_t embeddedSize, bool refreshOnSizeMismatch, const std::string& destPath)
{
if (embeddedSize == 0) return false;
std::error_code ec;
if (!std::filesystem::exists(destPath, ec)) return true;
if (!refreshOnSizeMismatch) return false;
const auto diskSize = std::filesystem::file_size(destPath, ec);
if (ec) return true; // can't stat -> rewrite
return static_cast<std::uint64_t>(diskSize) != embeddedSize;
}
// Check if an on-disk file is missing or differs in size from the embedded resource.
// A size mismatch means the binary was updated in a newer wallet build.
static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath)
[[maybe_unused]] static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath)
{
if (!res || !res->data || res->size == 0) return false;
if (!std::filesystem::exists(destPath)) return true;
std::error_code ec;
auto diskSize = std::filesystem::file_size(destPath, ec);
if (ec) return true; // can't stat → re-extract
return diskSize != static_cast<std::uintmax_t>(res->size);
return resourceNeedsWrite(res->size, /*refreshOnSizeMismatch=*/true, destPath);
}
static bool extractResource(const EmbeddedResource* res, const std::string& destPath)
bool needsParamsExtraction()
{
if (!res || !res->data || res->size == 0) {
return false;
if (!hasEmbeddedResources()) return false;
const std::string dir = getDaemonDirectory(); // the only extraction target
for (const auto& e : kProvisionEntries) {
if (!entryActive(e) || !e.startupTrigger) continue;
const EmbeddedResource* res = getEmbeddedResource(e.name);
if (res && resourceNeedsWrite(res->size, e.refreshOnSizeMismatch, joinDir(dir, e.name))) return true;
}
// Create parent directories
std::filesystem::path path(destPath);
if (path.has_parent_path()) {
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
DEBUG_LOGF("[ERROR] Failed to create directory %s: %s\n",
path.parent_path().string().c_str(), ec.message().c_str());
return false;
}
}
// Write file
std::ofstream file(destPath, std::ios::binary);
if (!file) {
// The destination may be locked because the previous daemon is still using the binary:
// Windows locks a running .exe against truncation, Linux returns ETXTBSY. Both platforms
// DO allow renaming/moving such a file — the running process keeps the moved copy — so move
// the stale binary aside and write a fresh one at the original path.
std::error_code ec;
if (std::filesystem::exists(destPath)) {
std::string sidelined = destPath + ".old";
std::filesystem::remove(sidelined, ec); // clear any leftover from a prior swap
ec.clear();
std::filesystem::rename(destPath, sidelined, ec);
if (!ec) {
file.clear();
file.open(destPath, std::ios::binary);
if (file)
DEBUG_LOGF("[INFO] Replaced in-use %s (old copy moved to .old)\n", destPath.c_str());
} else {
DEBUG_LOGF("[WARN] Could not move stale %s aside: %s\n",
destPath.c_str(), ec.message().c_str());
return false;
}
bool writeFileAtomic(const std::string& destPath, const std::uint8_t* data, std::size_t size, bool executable,
ProvisionProgress* progress, const CancelFn& cancelled)
{
if (!data || size == 0) return false;
if (!prepareParentDir(destPath)) return false;
const std::string tmp = destPath + kPartialFileSuffix;
std::error_code ec;
bool good = true;
{
TempSink f(tmp);
if (!f.ok()) { DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", tmp.c_str()); return false; }
for (std::size_t off = 0; off < size; off += kCopyChunk) {
if (cancelled && cancelled()) { good = false; break; }
const std::size_t n = std::min(kCopyChunk, size - off);
if (!f.write(data + off, n)) {
DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", n, tmp.c_str());
good = false; break;
}
if (progress) progress->bytesDone.fetch_add(n, std::memory_order_relaxed);
}
if (!file) {
DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", destPath.c_str());
return false;
}
}
file.write(reinterpret_cast<const char*>(res->data), res->size);
if (!file) {
DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", res->size, destPath.c_str());
return false;
}
DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), res->size);
// Flush on the same handle BEFORE the rename: a power cut can't leave a truncated file under the final name.
if (good && !f.commit()) { DEBUG_LOGF("[ERROR] Failed to finish writing %s\n", tmp.c_str()); good = false; }
} // closed here: Windows can't delete a file that is still open without FILE_SHARE_DELETE
if (!good) { std::filesystem::remove(tmp, ec); return false; }
#ifndef _WIN32
if (executable) ::chmod(tmp.c_str(), 0755); // before it becomes visible under the final name
#else
(void)executable;
#endif
if (!replaceFromTemp(tmp, destPath)) return false;
DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), size);
return true;
}
bool extractEmbeddedResources()
bool copyFileAtomic(const std::string& srcPath, const std::string& destPath,
ProvisionProgress* progress, const CancelFn& cancelled)
{
namespace fs = std::filesystem;
if (!prepareParentDir(destPath)) return false;
const std::string tmp = destPath + kPartialFileSuffix;
std::error_code ec;
bool good = true;
{
std::ifstream in(srcPath, std::ios::binary); // a read handle on the source is harmless if inherited
if (!in) { DEBUG_LOGF("[ERROR] Failed to open %s\n", srcPath.c_str()); return false; }
TempSink out(tmp);
if (!out.ok()) { DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", tmp.c_str()); return false; }
std::vector<char> buf(kCopyChunk);
for (;;) {
if (cancelled && cancelled()) { good = false; break; }
in.read(buf.data(), static_cast<std::streamsize>(buf.size()));
const std::streamsize got = in.gcount();
if (got > 0) {
if (!out.write(buf.data(), static_cast<std::size_t>(got))) {
DEBUG_LOGF("[ERROR] Failed to write %s\n", tmp.c_str());
good = false; break;
}
if (progress) progress->bytesDone.fetch_add(static_cast<std::uint64_t>(got), std::memory_order_relaxed);
}
if (!in) break; // EOF (eof|fail) or a read error (bad, checked below)
}
if (good && in.bad()) {
DEBUG_LOGF("[ERROR] Failed to read %s\n", srcPath.c_str());
good = false;
}
// Flush on the same handle BEFORE the rename: a power cut can't leave a truncated file under the final name.
if (good && !out.commit()) { DEBUG_LOGF("[ERROR] Failed to finish writing %s\n", tmp.c_str()); good = false; }
} // closed here: Windows can't delete a file that is still open without FILE_SHARE_DELETE
if (!good) { fs::remove(tmp, ec); return false; }
const auto st = fs::status(srcPath, ec);
if (!ec) fs::permissions(tmp, st.permissions(), ec); // keeps dragonxd/cli/tx executable (as copy_file did)
return replaceFromTemp(tmp, destPath);
}
// Requires provisionMutex() held.
static bool extractResourceLocked(const EmbeddedResource* res, const std::string& destPath, bool executable,
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {})
{
if (!res || !res->data || res->size == 0) return false;
return writeFileAtomic(destPath, res->data, res->size, executable, progress, cancelled);
}
bool extractEmbeddedResources(ProvisionProgress* progress, const CancelFn& cancelled)
{
if (!hasEmbeddedResources()) {
DEBUG_LOGF("[ERROR] No embedded resources available\n");
return false;
}
bool success = true;
#ifdef _WIN32
const char pathSep = '\\';
#else
const char pathSep = '/';
#endif
// All files go to <ObsidianDragonDir>/dragonx/
std::string daemonDir = getDaemonDirectory();
std::lock_guard<std::mutex> lock(provisionMutex());
// Extract Sapling params to daemon directory alongside dragonxd
const EmbeddedResource* spendRes = getEmbeddedResource(RESOURCE_SAPLING_SPEND);
if (spendRes) {
std::string dest = daemonDir + pathSep + RESOURCE_SAPLING_SPEND;
if (resourceNeedsUpdate(spendRes, dest)) {
DEBUG_LOGF("[INFO] Extracting sapling-spend.params (%zu MB)...\n", spendRes->size / (1024*1024));
if (!extractResource(spendRes, dest)) {
success = false;
}
}
}
const EmbeddedResource* outputRes = getEmbeddedResource(RESOURCE_SAPLING_OUTPUT);
if (outputRes) {
std::string dest = daemonDir + pathSep + RESOURCE_SAPLING_OUTPUT;
if (resourceNeedsUpdate(outputRes, dest)) {
DEBUG_LOGF("[INFO] Extracting sapling-output.params (%zu MB)...\n", outputRes->size / (1024*1024));
if (!extractResource(outputRes, dest)) {
success = false;
}
}
}
// Extract asmap.dat to daemon directory
const EmbeddedResource* asmapRes = getEmbeddedResource(RESOURCE_ASMAP);
if (asmapRes) {
std::string dest = daemonDir + pathSep + RESOURCE_ASMAP;
if (resourceNeedsUpdate(asmapRes, dest)) {
DEBUG_LOGF("[INFO] Extracting asmap.dat...\n");
if (!extractResource(asmapRes, dest)) {
success = false;
}
}
}
// Extract daemon binaries — NOT the data directory.
// Running dragonxd.exe from inside the data directory (where it writes blockchain
// data, debug.log, lock files, etc.) causes crashes on some Windows machines.
#ifdef HAS_EMBEDDED_DAEMON
// All files go to <ObsidianDragonDir>/dragonx/ — NOT the data directory (running dragonxd.exe from inside
// the data directory, where it writes chain data / debug.log / lock files, crashes on some Windows machines).
const std::string daemonDir = getDaemonDirectory();
DEBUG_LOGF("[INFO] Daemon extraction directory: %s\n", daemonDir.c_str());
// Daemon binaries are placed ONLY when missing — never auto-overwritten on a size mismatch
// (the user may run a specific dragonxd; replacing it is an explicit Settings action).
const EmbeddedResource* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
if (daemonRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONXD;
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonxd (%zu MB)...\n", daemonRes->size / (1024*1024));
if (!extractResource(daemonRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
std::error_code ec;
std::filesystem::create_directories(daemonDir, ec);
ec.clear();
// Temp files left by an interrupted run (killed process, power cut). Only OUR suffix.
for (const auto& e : kProvisionEntries) {
std::filesystem::remove(joinDir(daemonDir, e.name) + kPartialFileSuffix, ec);
ec.clear();
}
struct Todo { const ProvisionEntry* entry; const EmbeddedResource* res; std::string dest; };
std::vector<Todo> todo;
std::uint64_t total = 0;
for (const auto& e : kProvisionEntries) {
if (!entryActive(e)) continue;
const EmbeddedResource* res = getEmbeddedResource(e.name);
if (!res) continue;
std::string dest = joinDir(daemonDir, e.name);
if (!resourceNeedsWrite(res->size, e.refreshOnSizeMismatch, dest)) continue;
total += res->size;
todo.push_back({ &e, res, std::move(dest) });
}
if (progress) {
progress->bytesDone.store(0, std::memory_order_relaxed);
progress->bytesTotal.store(total, std::memory_order_relaxed);
progress->setPhase(ProvisionPhase::Extracting);
}
bool success = true;
for (const auto& t : todo) {
if (cancelled && cancelled()) return false;
DEBUG_LOGF("[INFO] Extracting %s (%zu MB)...\n", t.entry->name, t.res->size / (1024 * 1024));
if (!extractResourceLocked(t.res, t.dest, t.entry->executable, progress, cancelled)) {
if (cancelled && cancelled()) return false;
success = false;
}
}
const EmbeddedResource* cliRes = getEmbeddedResource(RESOURCE_DRAGONX_CLI);
if (cliRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_CLI;
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-cli (%zu MB)...\n", cliRes->size / (1024*1024));
if (!extractResource(cliRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
// Best-effort cleanup of ".old" copies left by an in-use replacement. Once the old process has exited the
// file is no longer locked and removes cleanly; if it still runs, the remove fails and we retry next run.
for (const auto& e : kProvisionEntries) {
std::filesystem::remove(joinDir(daemonDir, e.name) + std::string(".old"), ec);
ec.clear();
}
const EmbeddedResource* txRes = getEmbeddedResource(RESOURCE_DRAGONX_TX);
if (txRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_TX;
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-tx (%zu MB)...\n", txRes->size / (1024*1024));
if (!extractResource(txRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
}
#endif
#ifdef HAS_EMBEDDED_XMRIG
const EmbeddedResource* xmrigRes = getEmbeddedResource(RESOURCE_XMRIG);
if (xmrigRes) {
std::string dest = daemonDir + pathSep + RESOURCE_XMRIG;
if (resourceNeedsUpdate(xmrigRes, dest)) {
if (std::filesystem::exists(dest))
DEBUG_LOGF("[INFO] Updating stale xmrig (size mismatch)...\n");
DEBUG_LOGF("[INFO] Extracting xmrig (%zu MB)...\n", xmrigRes->size / (1024*1024));
if (!extractResource(xmrigRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
}
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
if (rebuildRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
if (!extractResource(rebuildRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
}
#endif
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
// if it's still running, the remove fails harmlessly and we retry on the next startup.
{
std::error_code ec;
for (const char* name : { RESOURCE_DRAGONXD, RESOURCE_XMRIG }) {
std::filesystem::remove(daemonDir + pathSep + name + std::string(".old"), ec);
ec.clear();
}
}
return success;
}
std::vector<std::string> bundleSearchDirs()
{
std::vector<std::string> dirs;
const std::string exeDir = util::Platform::getExecutableDirectory();
if (exeDir.empty()) return dirs;
#ifdef __APPLE__
// Inside a .app the params live in Contents/Resources while the executable is in Contents/MacOS.
std::error_code ec;
const std::filesystem::path res = std::filesystem::path(exeDir).parent_path() / "Resources";
if (std::filesystem::is_directory(res, ec)) dirs.push_back(res.string());
#endif
dirs.push_back(exeDir);
return dirs;
}
const std::vector<std::string>& saplingParamFileNames()
{
static const std::vector<std::string> v = { "sapling-spend.params", "sapling-output.params" };
return v;
}
const std::vector<std::string>& nodeBundleFileNames()
{
static const std::vector<std::string> v = { "sapling-spend.params", "sapling-output.params", "asmap.dat",
"dragonxd", "dragonx-cli", "dragonx-tx" };
return v;
}
std::vector<BundleCopyItem> planBundleCopy(const std::vector<std::string>& searchDirs, const std::string& destDir,
const std::vector<std::string>& names, bool replaceExisting)
{
namespace fs = std::filesystem;
std::vector<BundleCopyItem> plan;
std::error_code ec;
for (const auto& name : names) {
const fs::path dst = fs::path(destDir) / name;
if (!replaceExisting && fs::exists(dst, ec)) continue;
for (const auto& dir : searchDirs) {
const fs::path src = fs::path(dir) / name;
if (fs::exists(src, ec)) { plan.push_back({ name, src.string(), dst.string() }); break; }
}
}
return plan;
}
BundleCopyResult copyBundledFiles(const std::vector<std::string>& searchDirs, const std::string& destDir,
const std::vector<std::string>& names, bool replaceExisting,
ProvisionProgress* progress, const CancelFn& cancelled)
{
BundleCopyResult r;
// Stat-only check first: the steady-state call (every daemon start) never touches the lock.
if (planBundleCopy(searchDirs, destDir, names, replaceExisting).empty()) return r;
std::lock_guard<std::mutex> lock(provisionMutex());
const auto plan = planBundleCopy(searchDirs, destDir, names, replaceExisting); // re-check under the lock
std::uint64_t total = 0;
std::error_code ec;
for (const auto& it : plan) {
std::filesystem::remove(it.dst + kPartialFileSuffix, ec); // stale temp from an interrupted copy
ec.clear();
const auto sz = std::filesystem::file_size(it.src, ec);
if (!ec) total += sz;
ec.clear();
}
if (progress) {
progress->bytesDone.store(0, std::memory_order_relaxed);
progress->bytesTotal.store(total, std::memory_order_relaxed);
progress->setPhase(ProvisionPhase::Copying);
}
for (const auto& it : plan) {
if (cancelled && cancelled()) { r.ok = false; return r; }
DEBUG_LOGF("Copying bundled %s to %s\n", it.name.c_str(), destDir.c_str());
if (copyFileAtomic(it.src, it.dst, progress, cancelled)) {
++r.copied;
} else {
if (cancelled && cancelled()) { r.ok = false; return r; }
DEBUG_LOGF("[ERROR] Failed to copy %s\n", it.name.c_str());
r.ok = false;
if (r.firstFailure.empty()) r.firstFailure = it.name;
}
}
return r;
}
std::string ensureWalletRebuildHelperExtracted()
{
#ifdef HAS_EMBEDDED_WALLET_REBUILD
@@ -484,12 +698,11 @@ std::string ensureWalletRebuildHelperExtracted()
const std::string dir = getDaemonDirectory();
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
std::error_code ec;
if (std::filesystem::exists(dest, ec)) return dest; // already extracted
if (std::filesystem::exists(dest, ec)) return dest; // already extracted (atomic write: exists == complete)
std::lock_guard<std::mutex> lock(provisionMutex()); // serialise with the startup job / other writers
if (std::filesystem::exists(dest, ec)) return dest; // written meanwhile by the startup job
std::filesystem::create_directories(dir, ec);
if (!extractResource(res, dest)) return {};
#ifndef _WIN32
chmod(dest.c_str(), 0755);
#endif
if (!extractResourceLocked(res, dest, /*executable=*/true)) return {};
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
return dest;
#else
@@ -577,22 +790,6 @@ std::string getDaemonPath()
return "";
}
bool needsXmrigExtraction()
{
#ifdef HAS_EMBEDDED_XMRIG
std::string daemonDir = getDaemonDirectory();
#ifdef _WIN32
std::string xmrigPath = daemonDir + "\\xmrig.exe";
#else
std::string xmrigPath = daemonDir + "/xmrig";
#endif
const auto* res = getEmbeddedResource(RESOURCE_XMRIG);
return resourceNeedsUpdate(res, xmrigPath);
#else
return false;
#endif
}
bool hasXmrigAvailable()
{
#ifdef HAS_EMBEDDED_XMRIG
@@ -623,14 +820,11 @@ bool forceExtractXmrig()
}
DEBUG_LOGF("[INFO] forceExtractXmrig: extracting xmrig (%zu MB) to %s\n",
xmrigRes->size / (1024*1024), dest.c_str());
if (!extractResource(xmrigRes, dest)) {
std::lock_guard<std::mutex> lock(provisionMutex());
if (!extractResourceLocked(xmrigRes, dest, /*executable=*/true)) {
DEBUG_LOGF("[ERROR] forceExtractXmrig: extraction failed\n");
return false;
}
#ifndef _WIN32
// Set executable permission on Linux
chmod(dest.c_str(), 0755);
#endif
return true;
#else
return false;
@@ -639,12 +833,13 @@ bool forceExtractXmrig()
// Scan a binary blob for the daemon's version stamp: 'v' <maj>.<min>.<rev> optionally followed by
// '-' <commit hash (>=6 hex)>, e.g. "v1.0.2-ddd851dc1". Returns the first match, or "" if none.
static std::string scanBinaryVersion(const uint8_t* data, std::size_t size)
static std::string scanBinaryVersion(const uint8_t* data, std::size_t size,
std::size_t lastStart = static_cast<std::size_t>(-1))
{
if (!data || size < 6) return "";
auto isdig = [](uint8_t c) { return std::isdigit(static_cast<unsigned char>(c)) != 0; };
auto isxd = [](uint8_t c) { return std::isxdigit(static_cast<unsigned char>(c)) != 0; };
for (std::size_t i = 0; i + 5 < size; ++i) {
for (std::size_t i = 0; i + 5 < size && i <= lastStart; ++i) {
if (data[i] != 'v') continue;
std::size_t k = i + 1, s;
s = k; while (k < size && isdig(data[k])) ++k; if (k == s) continue; // major
@@ -663,14 +858,38 @@ static std::string scanBinaryVersion(const uint8_t* data, std::size_t size)
return "";
}
DaemonBinaryInfo getInstalledDaemonInfo()
// Stream the file in 4 MiB chunks (bounded memory, stops at the first stamp). A non-final chunk only starts
// matches where a whole stamp (<= kStampMax bytes) still fits, and the last kStampMax-1 bytes are carried into the
// next chunk, so a stamp straddling a boundary is neither cut short nor missed.
static std::string scanFileVersion(const std::string& path)
{
std::ifstream f(path, std::ios::binary);
if (!f) return "";
constexpr std::size_t kChunk = 4u << 20, kStampMax = 64;
std::vector<uint8_t> buf(kChunk + kStampMax);
std::size_t carry = 0;
for (;;) {
f.read(reinterpret_cast<char*>(buf.data() + carry), static_cast<std::streamsize>(kChunk));
const std::size_t got = static_cast<std::size_t>(f.gcount());
const std::size_t total = carry + got;
const bool last = got < kChunk; // short read => EOF
const std::size_t lastStart = (last || total < kStampMax) ? static_cast<std::size_t>(-1) : total - kStampMax;
std::string v = scanBinaryVersion(buf.data(), total, lastStart);
if (!v.empty()) return v;
if (last) return "";
carry = kStampMax - 1;
std::memmove(buf.data(), buf.data() + total - carry, carry);
}
}
DaemonBinaryInfo getInstalledDaemonInfo(bool scanVersion)
{
DaemonBinaryInfo info;
std::string daemonDir = getDaemonDirectory();
#ifdef _WIN32
info.path = daemonDir + "\\" + RESOURCE_DRAGONXD;
info.path = daemonDir + "\\" + DAEMON_BINARY_NAME;
#else
info.path = daemonDir + "/" + RESOURCE_DRAGONXD;
info.path = daemonDir + "/" + DAEMON_BINARY_NAME; // was RESOURCE_DRAGONXD ("dragonxd.exe"): never existed on POSIX (C123)
#endif
std::error_code ec;
if (!std::filesystem::exists(info.path, ec)) return info; // exists stays false
@@ -687,17 +906,24 @@ DaemonBinaryInfo getInstalledDaemonInfo()
static_cast<std::int64_t>(std::chrono::system_clock::to_time_t(sysTime));
}
// Read the binary and scan for its version stamp (one-off; caller caches the result).
std::ifstream f(info.path, std::ios::binary);
if (f) {
f.seekg(0, std::ios::end);
std::streamoff len = f.tellg();
f.seekg(0, std::ios::beg);
if (len > 0) {
std::vector<uint8_t> buf(static_cast<std::size_t>(len));
f.read(reinterpret_cast<char*>(buf.data()), len);
info.version = scanBinaryVersion(buf.data(), static_cast<std::size_t>(f.gcount()));
// Scan for the version stamp (streamed; call off the UI thread). Cached by (path, size, mtime): Refresh and the
// post-install reload only rescan when the file actually changed.
if (!scanVersion) return info;
{
static std::mutex cacheMutex;
static std::string cPath, cVersion;
static std::uintmax_t cSize = 0;
static std::int64_t cMtime = 0;
{
std::lock_guard<std::mutex> lock(cacheMutex);
if (cPath == info.path && cSize == info.size && cMtime == info.modifiedEpoch && !cVersion.empty()) {
info.version = cVersion;
return info;
}
}
info.version = scanFileVersion(info.path);
std::lock_guard<std::mutex> lock(cacheMutex);
cPath = info.path; cSize = info.size; cMtime = info.modifiedEpoch; cVersion = info.version;
}
return info;
}
@@ -721,6 +947,7 @@ BundledDaemonInfo getBundledDaemonInfo()
bool reextractBundledDaemon()
{
#ifdef HAS_EMBEDDED_DAEMON
std::lock_guard<std::mutex> lock(provisionMutex());
std::string daemonDir = getDaemonDirectory();
#ifdef _WIN32
const char pathSep = '\\';
@@ -735,15 +962,12 @@ bool reextractBundledDaemon()
if (!res) continue;
std::string dest = daemonDir + pathSep + name;
DEBUG_LOGF("[INFO] reextractBundledDaemon: writing %s (%zu MB)\n", name, res->size / (1024*1024));
if (!extractResource(res, dest)) {
if (!extractResourceLocked(res, dest, /*executable=*/true)) {
DEBUG_LOGF("[ERROR] reextractBundledDaemon: failed to write %s\n", name);
ok = false;
continue;
}
wroteAny = true;
#ifndef _WIN32
chmod(dest.c_str(), 0755);
#endif
}
return ok && wroteAny;
#else
@@ -769,8 +993,13 @@ std::string getXmrigPath()
std::string embeddedPath = daemonDir + pathSep + xmrigName;
DEBUG_LOGF("[DEBUG] getXmrigPath: checking embedded path %s\n", embeddedPath.c_str());
if (!std::filesystem::exists(embeddedPath)) {
DEBUG_LOGF("[DEBUG] getXmrigPath: not found, force re-extracting xmrig\n");
forceExtractXmrig();
// Serialise with the startup job: if it just wrote xmrig, the re-check finds it complete.
std::lock_guard<std::mutex> lock(provisionMutex());
if (!std::filesystem::exists(embeddedPath)) {
DEBUG_LOGF("[DEBUG] getXmrigPath: not found, force re-extracting xmrig\n");
const EmbeddedResource* xr = getEmbeddedResource(RESOURCE_XMRIG);
if (xr) extractResourceLocked(xr, embeddedPath, /*executable=*/true);
}
}
if (std::filesystem::exists(embeddedPath)) {
DEBUG_LOGF("[INFO] getXmrigPath: found at %s\n", embeddedPath.c_str());
+63 -9
View File
@@ -1,7 +1,12 @@
#pragma once
#include <string>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
#include "provision_state.h"
namespace dragonx {
namespace resources {
@@ -20,9 +25,50 @@ bool hasEmbeddedResources();
// Returns nullptr if not found or not embedded
const EmbeddedResource* getEmbeddedResource(const std::string& name);
// Extract all embedded resources to appropriate locations
// Returns true if all resources extracted successfully
bool extractEmbeddedResources();
using CancelFn = std::function<bool()>; // polled between 4 MB chunks; true = stop now
// Suffix of our own temp files in the daemon dir ("<name>.partial"). NOT ".tmp": the in-app miner/node
// updaters stage "<name>.tmp" in the same directory and must never have it deleted under them.
inline constexpr const char* kPartialFileSuffix = ".partial";
// Extract all embedded resources to the daemon directory. Serialised with every other writer of that
// directory by an internal mutex; atomic per file (temp + flush + rename), so a destination is either the old
// complete file or the new one. `progress` (optional) receives bytes written; `cancelled` (optional) aborts
// between chunks leaving no partial destination. Returns true if every needed file was written (false also on
// cancel). Safe on any thread.
bool extractEmbeddedResources(ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
// Does `destPath` need (re)writing? Missing -> yes. Present: only when `refreshOnSizeMismatch` and the size
// differs. embeddedSize == 0 -> never. (Pure apart from the stat; exposed for tests.)
bool resourceNeedsWrite(std::uint64_t embeddedSize, bool refreshOnSizeMismatch, const std::string& destPath);
// The bundled miner is placed only when MISSING: a present xmrig is never replaced by the bundled copy, even when
// its size differs, so an "Update miner..." install survives a wallet restart or upgrade.
inline constexpr bool kXmrigRefreshOnSizeMismatch = false; // the in-app updater owns the miner once placed
// Write `data` to `destPath` via `destPath + kPartialFileSuffix`, flush it to disk, then move it into place
// (POSIX rename; Windows MoveFileEx with retry and move-aside of a running .exe to ".old"). POSIX: chmod 0755
// first when `executable`. Returns false (temp removed, original kept) on any failure or on cancel.
// Takes no lock itself (extractEmbeddedResources / copyBundledFiles / the on-demand extractors do).
bool writeFileAtomic(const std::string& destPath, const std::uint8_t* data, std::size_t size, bool executable,
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
// Same contract, streaming from a source file in 4 MB chunks; keeps the source's permissions.
bool copyFileAtomic(const std::string& srcPath, const std::string& destPath,
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
// Node files shipped NEXT TO the executable (zip / AppImage / .app; no embedded_data.h).
struct BundleCopyItem { std::string name, src, dst; };
std::vector<std::string> bundleSearchDirs(); // macOS: .app Contents/Resources first; then exe dir
const std::vector<std::string>& saplingParamFileNames(); // sapling-spend.params, sapling-output.params
const std::vector<std::string>& nodeBundleFileNames(); // params + asmap.dat + dragonxd + dragonx-cli + dragonx-tx
// First search dir holding a name wins; names with no source are skipped (optional); names whose destination
// exists are skipped unless replaceExisting. Stat-only.
std::vector<BundleCopyItem> planBundleCopy(const std::vector<std::string>& searchDirs, const std::string& destDir,
const std::vector<std::string>& names, bool replaceExisting);
struct BundleCopyResult { bool ok = true; int copied = 0; std::string firstFailure; };
// Executes planBundleCopy under the provisioning lock (checks the plan lock-free first: the common
// "nothing to copy" call never touches the lock).
BundleCopyResult copyBundledFiles(const std::vector<std::string>& searchDirs, const std::string& destDir,
const std::vector<std::string>& names, bool replaceExisting,
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
// Check if params need to be extracted (not already present)
bool needsParamsExtraction();
@@ -48,7 +94,8 @@ struct BundledDaemonInfo {
};
// Read + scan the installed dragonxd (reads the file; call off the UI thread or cache the result).
DaemonBinaryInfo getInstalledDaemonInfo();
// scanVersion=false: stat only (exists / size / modifiedEpoch), no 16 MB read.
DaemonBinaryInfo getInstalledDaemonInfo(bool scanVersion = true);
// Info about the bundled daemon (scans the embedded bytes once, cached).
BundledDaemonInfo getBundledDaemonInfo();
// Force-overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones. The
@@ -65,7 +112,13 @@ std::string ensureWalletRebuildHelperExtracted();
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
constexpr const char* RESOURCE_ASMAP = "asmap.dat";
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe"; // embedded-resource table key (Windows builds only)
// The daemon binary's file name on THIS platform. Use it to build paths; RESOURCE_DRAGONXD is only the table key.
#ifdef _WIN32
constexpr const char* DAEMON_BINARY_NAME = "dragonxd.exe";
#else
constexpr const char* DAEMON_BINARY_NAME = "dragonxd";
#endif
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
@@ -87,6 +140,10 @@ const EmbeddedTheme* getEmbeddedThemes();
// Returns number of themes extracted
int extractBundledThemes(const std::string& destDir);
// Write `size` bytes to `path` unless the file already holds exactly those bytes.
// Returns 1 = written, 0 = already identical (untouched), -1 = write failed.
int writeFileIfDifferent(const std::string& path, const uint8_t* data, size_t size);
// Update stale bundled theme files in the given directory.
// Compares on-disk file size to embedded size; overwrites on mismatch.
// Returns number of themes updated.
@@ -104,9 +161,6 @@ bool hasDaemonAvailable();
// Get daemon executable path (extracts if needed)
std::string getDaemonPath();
// Check if xmrig needs to be extracted
bool needsXmrigExtraction();
// Check if xmrig is available (embedded or in app directory)
bool hasXmrigAvailable();
+118
View File
@@ -0,0 +1,118 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// provision_state.h - state of the startup "node files" job (App::startStartupFilesProvision): on Windows it
// extracts the bundled Sapling params / asmap / node / miner from the .exe, on Linux/macOS it copies them
// from the executable's directory, and on full-node builds it then verifies the Sapling params. The job runs
// on a worker thread; the UI thread only polls this. No app dependencies, so tests include it directly.
#pragma once
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <mutex>
namespace dragonx {
namespace resources {
enum class ProvisionPhase : int { Idle = 0, Extracting, Copying, Verifying };
// Lock-free progress: written by the worker, read by the UI every frame.
struct ProvisionProgress {
std::atomic<std::uint64_t> bytesDone{0};
std::atomic<std::uint64_t> bytesTotal{0};
std::atomic<int> phase{static_cast<int>(ProvisionPhase::Idle)};
void setPhase(ProvisionPhase p) { phase.store(static_cast<int>(p), std::memory_order_relaxed); }
ProvisionPhase getPhase() const { return static_cast<ProvisionPhase>(phase.load(std::memory_order_relaxed)); }
};
// 0..100; 0 while the total is unknown.
inline int provisionPercent(std::uint64_t done, std::uint64_t total)
{
if (total == 0) return 0;
if (done >= total) return 100;
return static_cast<int>((done * 100u) / total);
}
// The chip stays hidden for a short grace period so a job that ends in a few ms never flashes.
inline bool provisionChipVisible(bool running, double elapsedSeconds, double delaySeconds)
{
return running && elapsedSeconds >= delaySeconds;
}
// What App::startEmbeddedDaemon() does while the job may still be writing the node files: never launch the
// node over half-written files, never block the UI thread (defer + replay), workers simply wait.
enum class DaemonStartGate { Proceed, Defer, Wait };
inline DaemonStartGate daemonStartGate(bool jobRunning, bool onUiThread)
{
if (!jobRunning) return DaemonStartGate::Proceed;
return onUiThread ? DaemonStartGate::Defer : DaemonStartGate::Wait;
}
class StartupProvision {
public:
struct Result {
bool extractOk = true; // every needed file written/copied (false: disk full / permissions / AV)
bool verifyOk = true; // Sapling SHA-256 check (full node only)
bool cancelled = false; // shutdown stopped it; nothing is wrong
};
enum class State : int { Idle, Running, Finished, Reaped };
ProvisionProgress progress;
// The Sapling param repair copy (replace the daemon-dir params from the bundle, ~51.5 MB) ran this session:
// set by the job and by App::startEmbeddedDaemon(), so a still-failing verify never repeats it on every
// connect retry (the UI thread).
std::atomic<bool> paramRepairTried{false};
void begin() {
std::lock_guard<std::mutex> lk(m_);
state_ = State::Running;
startedAt_ = std::chrono::steady_clock::now();
}
// Nothing needed doing (the steady-state launch): no thread was started.
void markNothingToDo() { std::lock_guard<std::mutex> lk(m_); state_ = State::Reaped; }
void finish(const Result& r) {
{ std::lock_guard<std::mutex> lk(m_); result_ = r; state_ = State::Finished; }
cv_.notify_all();
}
bool isRunning() const { std::lock_guard<std::mutex> lk(m_); return state_ == State::Running; }
// Non-reaping peek: the job has ended and shutdown cancelled it (synchronized with finish() through m_).
bool endedCancelled() const {
std::lock_guard<std::mutex> lk(m_);
return state_ != State::Running && result_.cancelled;
}
// App::hasPendingBackgroundResults(): finished and not yet reaped (the reap needs one update() tick).
// NOT while Running: nothing is reapable then, and the chip already requests progress frames when visible.
bool hasCompletion() const { std::lock_guard<std::mutex> lk(m_); return state_ == State::Finished; }
// UI thread: true exactly once, after the job finished.
bool takeCompletion(Result* out) {
std::lock_guard<std::mutex> lk(m_);
if (state_ != State::Finished) return false;
if (out) *out = result_;
state_ = State::Reaped;
return true;
}
// Worker threads: true when the job is no longer running.
bool waitUntilDone(int timeoutMs) {
std::unique_lock<std::mutex> lk(m_);
cv_.wait_for(lk, std::chrono::milliseconds(timeoutMs), [&] { return state_ != State::Running; });
return state_ != State::Running;
}
double elapsedSeconds() const {
std::lock_guard<std::mutex> lk(m_);
if (state_ != State::Running) return 0.0;
return std::chrono::duration<double>(std::chrono::steady_clock::now() - startedAt_).count();
}
private:
mutable std::mutex m_;
std::condition_variable cv_;
State state_ = State::Idle;
Result result_;
std::chrono::steady_clock::time_point startedAt_{};
};
} // namespace resources
} // namespace dragonx
+33 -18
View File
@@ -241,6 +241,30 @@ bool Connection::verifySaplingParams()
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
}
Connection::SaplingParamState Connection::saplingParamStateIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests)
{
if (dir.empty() || digests.empty()) return SaplingParamState::Missing;
std::vector<std::string> current;
current.reserve(digests.size());
for (const auto& d : digests) {
const std::string p = joinParamPath(dir, d.first);
std::error_code ec;
if (!fs::exists(p, ec)) return SaplingParamState::Missing;
current.push_back(paramStatLine(p));
}
return saplingMarkerMatches(joinParamPath(dir, ".sapling_verified"), current)
? SaplingParamState::Verified : SaplingParamState::NeedsHash;
}
Connection::SaplingParamState Connection::saplingParamState()
{
std::vector<std::pair<std::string, std::string>> digests;
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
return saplingParamStateIn(getSaplingParamsDir(), digests);
}
ConnectionConfig Connection::parseConfFile(const std::string& path)
{
ConnectionConfig config;
@@ -436,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";
@@ -461,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());
+8
View File
@@ -83,6 +83,14 @@ public:
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests);
// Stat-only (never reads or hashes the params): Missing = a param is absent, Verified = the
// .sapling_verified marker matches every param's size:mtime, NeedsHash = present but unverified.
enum class SaplingParamState { Missing, NeedsHash, Verified };
static SaplingParamState saplingParamState();
static SaplingParamState saplingParamStateIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests);
/**
* @brief Get the Sapling params directory
*/
+113
View File
@@ -0,0 +1,113 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Long wallet RPCs (C61). A rescan or a key import holds the daemon's cs_main for as long as the wallet scan
// takes: minutes to hours on a big wallet. On the shared clients they used to hit the 30 s curl timeout, report
// a failure that wasn't one and flip the app into "node busy" (a reply timeout is recorded by the busy gate).
// They now run one at a time on a dedicated, ungated client with no client timeout, abortable from any thread.
#include "connection.h" // ConnectionConfig
#include "rpc_client.h"
#include <atomic>
#include <mutex>
#include <string>
namespace dragonx {
namespace rpc {
// The wallet RPCs that scan the chain inside the call: they run on the long-call lane, never on the shared
// clients (rpc_ / the console's fast lane), whose 30 s timeout they outlast.
inline bool isLongWalletMethod(const std::string& method)
{
return method == "rescan" || method == "importprivkey" || method == "importaddress" ||
method == "importwallet" || method == "z_importkey" || method == "z_importviewingkey" ||
method == "z_importwallet";
}
// How a long wallet call that threw an RpcTransportError ended, which picks the wording shown to the user.
enum class LongCallCutOff {
None, // the request never reached the node: its error is the whole story
Cancelled, // our own abort cut the transfer; the node may still be scanning
Interrupted, // the request was sent but no answer came back (a dropped connection, or a proxy's 502/504
// while the node still runs it): the key may already be in the wallet
Refused, // the node's HTTP layer turned it away before the method ran (401, 503, queue full,
// unhandled), so nothing was imported
};
inline LongCallCutOff classifyLongCallCutOff(RpcFailureKind kind, bool requestSent)
{
switch (kind) {
case RpcFailureKind::Aborted:
return LongCallCutOff::Cancelled;
case RpcFailureKind::Auth:
case RpcFailureKind::ShuttingDown:
case RpcFailureKind::QueueFull:
case RpcFailureKind::Unhandled:
return LongCallCutOff::Refused;
default:
// Any other HTTP status (a proxy's 502/504) or a lost reply proves nothing about the node's side.
return requestSent ? LongCallCutOff::Interrupted : LongCallCutOff::None;
}
}
// One long wallet RPC at a time on its own ungated client, with no client timeout, abortable from any thread.
class LongCallLane {
public:
// Main thread: claim the lane. false when a long call is already running. A new call starts un-aborted.
bool tryBegin()
{
bool idle = false;
if (!active_.compare_exchange_strong(idle, true)) return false;
abort_ = false;
return true;
}
// The call and its continuation are done: the lane is free again.
void end() { active_ = false; }
bool active() const { return active_.load(); }
bool aborted() const { return abort_.load(); }
// Any thread. Sticky until the next tryBegin(): a call that has not opened its client yet is aborted as soon
// as it does, and an in-flight transfer is cut through the client's progress callback (about a second).
void abort()
{
std::lock_guard<std::mutex> lk(mx_);
abort_ = true;
if (client_) client_->requestAbort();
}
// Lane thread. open() FIRST (it clears the client's abort flag), THEN publish the client, THEN re-check the
// sticky flag under the same mutex, so an abort that landed before or during open() is never erased.
// An open() failure is not handled here: the body's first call then throws "Not connected".
template <class Body>
void run(RPCClient& c, const ConnectionConfig& cfg, Body&& body)
{
c.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, cfg.use_tls);
{
std::lock_guard<std::mutex> lk(mx_);
client_ = &c;
if (abort_) c.requestAbort();
}
struct Unpublish {
LongCallLane& lane;
~Unpublish()
{
std::lock_guard<std::mutex> lk(lane.mx_);
lane.client_ = nullptr;
}
} unpublish{*this};
body(c);
}
private:
std::mutex mx_;
RPCClient* client_ = nullptr; // guarded by mx_; set only while run() executes the body
std::atomic<bool> abort_{false};
std::atomic<bool> active_{false};
};
} // namespace rpc
} // namespace dragonx
+169
View File
@@ -0,0 +1,169 @@
#pragma once
// Keeping the GUI from flooding a busy node (S6). dragonxd's HTTP server has 8 workers and a 16-deep
// queue. While a long operation holds cs_main (a witness rebuild can take an hour), every request that
// needs cs_main waits, and a client that gives up does NOT free its slot. The GUI's polls used to time
// out and be re-sent until all 24 slots were its own abandoned requests, after which even "stop" was
// refused. The gate stops that: after the first sign the node is busy (a reply timeout, or a queue-full
// refusal), the GUI's clients send nothing that needs cs_main until a single "canary" request (which
// waits as long as it takes) gets through. Pure and header-only; shared by the GUI's RPC clients.
#include "rpc_failure.h"
#include <chrono>
#include <mutex>
#include <string>
namespace dragonx {
namespace rpc {
// Kinds that mean "reached a node that is too busy to answer", as opposed to "no node there".
inline bool rpcKindMeansBusy(RpcFailureKind kind)
{
return kind == RpcFailureKind::ReplyTimeout || kind == RpcFailureKind::QueueFull ||
kind == RpcFailureKind::Deferred;
}
// Kinds that mean the connection itself is in doubt (counted toward a reconnect). Busy kinds, a
// warmup reply, an application error and our own abort are not: the node is there.
inline bool rpcKindMeansLost(RpcFailureKind kind)
{
switch (kind) {
case RpcFailureKind::None:
case RpcFailureKind::ReplyTimeout:
case RpcFailureKind::QueueFull:
case RpcFailureKind::Deferred:
case RpcFailureKind::Warmup:
case RpcFailureKind::AppError:
case RpcFailureKind::Aborted:
return false;
default:
return true;
}
}
class RpcBusyGate {
public:
// Methods dragonxd runs without cs_main: always sent, and their results say nothing about cs_main.
static bool isLockFree(const std::string& method)
{
// getmempoolinfo takes only mempool.cs (rpc/blockchain.cpp), and the dispatcher no global lock.
return method == "stop" || method == "help" || method == "getrescaninfo" || method == "getmempoolinfo";
}
// May this request be sent now? Lock-free methods always; the rest only while the node isn't busy.
bool admit(const std::string& method) const
{
if (isLockFree(method)) return true;
std::lock_guard<std::mutex> lk(mx_);
return !busy_;
}
// How a sent request went. Any real answer (even an error) means the node is serving cs_main
// requests again; a reply timeout or a queue-full refusal means it isn't. Other failures (nothing
// listening, auth, our own abort) say nothing about busyness and leave the state alone.
void record(const std::string& method, RpcFailureKind kind)
{
if (isLockFree(method)) return;
std::lock_guard<std::mutex> lk(mx_);
if (kind == RpcFailureKind::None || kind == RpcFailureKind::AppError || kind == RpcFailureKind::Warmup) {
clearLocked();
} else if (kind == RpcFailureKind::ReplyTimeout || kind == RpcFailureKind::QueueFull) {
if (!busy_) {
busy_ = true;
since_ = std::chrono::steady_clock::now();
++busyPeriods_;
}
if (kind == RpcFailureKind::ReplyTimeout) ++abandoned_; // it still holds a daemon slot
}
}
// The canary got through (or the node is gone): poll normally again.
void clear()
{
std::lock_guard<std::mutex> lk(mx_);
clearLocked();
}
bool busy() const
{
std::lock_guard<std::mutex> lk(mx_);
return busy_;
}
// Requests we gave up on that the node may still be holding, since the gate last cleared.
int abandoned() const
{
std::lock_guard<std::mutex> lk(mx_);
return abandoned_;
}
// Increments each time the gate turns busy: lets the owner arm exactly one canary per busy period.
int busyPeriods() const
{
std::lock_guard<std::mutex> lk(mx_);
return busyPeriods_;
}
double busySeconds() const
{
std::lock_guard<std::mutex> lk(mx_);
if (!busy_) return 0.0;
return std::chrono::duration<double>(std::chrono::steady_clock::now() - since_).count();
}
private:
void clearLocked()
{
busy_ = false;
abandoned_ = 0;
}
mutable std::mutex mx_;
bool busy_ = false;
int abandoned_ = 0;
int busyPeriods_ = 0;
std::chrono::steady_clock::time_point since_{};
};
/**
* How long to wait before the next connection attempt after a failure of this kind. Each repeat of the
* same kind waits longer, up to its cap; a success or a different kind starts over.
* nothing listening / connect timeout: 1, 2, 5s (the node is starting, or gone)
* queue full: 2, 4, 8, 16, 30s (the queue is someone's; hammering it only adds to it)
* busy (reply timeout / deferred): 30s; the canary brings the connection back as soon as it can
* anything else: 4s
*/
class ConnectRetryBackoff {
public:
float next(RpcFailureKind kind)
{
streak_ = (kind == last_) ? streak_ + 1 : 0;
last_ = kind;
switch (kind) {
case RpcFailureKind::NotListening:
case RpcFailureKind::ConnectTimeout: {
static const float steps[] = {1.0f, 2.0f, 5.0f};
return steps[streak_ < 2 ? streak_ : 2];
}
case RpcFailureKind::QueueFull: {
static const float steps[] = {2.0f, 4.0f, 8.0f, 16.0f, 30.0f};
return steps[streak_ < 4 ? streak_ : 4];
}
case RpcFailureKind::ReplyTimeout:
case RpcFailureKind::Deferred:
return 30.0f;
default:
return 4.0f;
}
}
void reset()
{
streak_ = 0;
last_ = RpcFailureKind::None;
}
private:
RpcFailureKind last_ = RpcFailureKind::None;
int streak_ = 0;
};
} // namespace rpc
} // namespace dragonx
+154 -99
View File
@@ -163,6 +163,17 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
bool useTls)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
if (!open(host, port, user, password, useTls)) return false;
return probeConnection(host);
}
bool RPCClient::open(const std::string& host, const std::string& port,
const std::string& user, const std::string& password,
bool useTls)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
connected_ = false; // only a successful probe (connect()) sets these
warming_up_ = false;
host_ = host;
port_ = port;
last_connect_info_ = json();
@@ -205,7 +216,8 @@ bool RPCClient::connect(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);
@@ -214,17 +226,26 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
// Loopback RPC carries basic-auth creds + wallet data: never route it through an env proxy
// (http_proxy / all_proxy). Remote daemons keep proxy support. NOPROXY also overrides no_proxy.
if (Connection::isLocalHost(host))
curl_easy_setopt(impl_->curl, CURLOPT_NOPROXY, "*");
// Enforce TLS certificate + hostname verification explicitly rather than relying on libcurl's
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
return true;
}
bool RPCClient::probeConnection(const std::string& host)
{
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
// hang means a wedged/loading occupant — no point blocking the full 30s before we retry and
// update the UI. (call(timeoutSec) restores the persistent 30s afterwards, so normal RPC calls
// that legitimately take longer are unaffected.) Remote/TLS daemons keep the full budget.
const long probeTimeout = Connection::isLocalHost(host) ? 8L : 30L;
last_connect_kind_ = RpcFailureKind::Other; // until the probe says otherwise
try {
json result = call("getinfo", json::array(), probeTimeout);
if (result.contains("version")) {
@@ -232,36 +253,32 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
warming_up_ = false;
warmup_status_.clear();
last_connect_error_.clear();
last_connect_kind_ = RpcFailureKind::None;
last_connect_info_ = result;
if (connect_error_repeats_ > 0)
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
logged_connect_error_.clear();
connect_error_repeats_ = 0;
DEBUG_LOGF("Connected to dragonxd v%d\n", result["version"].get<int>());
return true;
}
} catch (const std::exception& e) {
last_connect_error_ = e.what();
// Daemon warmup messages (Loading block index, Verifying blocks, etc.)
// are normal startup progress — the daemon is reachable and auth works,
// it just hasn't finished initializing yet. Mark as connected+warmup
// so the wallet can show the UI instead of a blocking overlay.
last_connect_kind_ = rpcFailureKindOf(e);
// Daemon warmup (Loading block index, Verifying blocks, etc.) is normal startup progress — the
// daemon is reachable and auth works, it just hasn't finished initializing yet. Mark as
// connected+warmup so the wallet can show the UI instead of a blocking overlay. Only the
// JSON-RPC code (-28, RPC_IN_WARMUP) counts: matching the message text would also match a
// plain-text HTTP body that happens to say "Loading".
std::string msg = e.what();
// Warmup is JSON-RPC error code -28 (RPC_IN_WARMUP) — the robust signal. Fall back
// to message substrings for any path that didn't carry the numeric code.
int code = 0;
if (const auto* re = dynamic_cast<const RpcError*>(&e)) code = re->code;
bool isWarmup = (code == -28) ||
(msg.find("Loading") != std::string::npos ||
msg.find("Verifying") != std::string::npos ||
msg.find("Activating") != std::string::npos ||
msg.find("Rewinding") != std::string::npos ||
msg.find("Rescanning") != std::string::npos ||
msg.find("Pruning") != std::string::npos);
if (isWarmup) {
if (last_connect_kind_ == RpcFailureKind::Warmup) {
connected_ = true;
warming_up_ = true;
warmup_status_ = msg;
DEBUG_LOGF("Daemon warming up: %s\n", msg.c_str());
return true;
} else {
DEBUG_LOGF("Connection failed: %s\n", msg.c_str());
logConnectFailure(msg);
}
}
@@ -272,6 +289,34 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
return false;
}
// A busy or absent daemon makes every connect() fail the same way, and logging each one flooded the
// log (60k lines in one incident). Log a new error at once; a repeat only every 100th time.
void RPCClient::logConnectFailure(const std::string& msg)
{
const std::string line = std::string("[") + rpcFailureKindName(last_connect_kind_) + "] " + msg;
if (line != logged_connect_error_) {
if (connect_error_repeats_ > 0)
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
logged_connect_error_ = line;
connect_error_repeats_ = 0;
DEBUG_LOGF("Connection failed %s\n", line.c_str());
return;
}
if (++connect_error_repeats_ % 100 == 0)
DEBUG_LOGF("Connection failed %s (repeated %u times)\n", line.c_str(), connect_error_repeats_);
}
RpcFailureKind RPCClient::trySendStop(long timeoutSec)
{
try {
TraceScope trace("Daemon lifecycle / Stop command");
call("stop", json::array(), timeoutSec);
return RpcFailureKind::None;
} catch (const std::exception& e) {
return rpcFailureKindOf(e);
}
}
json RPCClient::getLastConnectInfo() const
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
@@ -320,10 +365,26 @@ json RPCClient::makePayload(const std::string& method, const json& params)
std::string RPCClient::performCall(const std::string& method, const json& params, long& httpCodeOut)
{
if (busy_gate_ && !busy_gate_->admit(method))
throw RpcTransportError(RpcFailureKind::Deferred, "RPC deferred: the node is busy", CURLE_OK, 0, false);
emitRpcTrace(method);
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
@@ -335,38 +396,60 @@ std::string RPCClient::performCall(const std::string& method, const json& params
CURLcode res = curl_easy_perform(impl_->curl);
if (res != CURLE_OK) {
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
// libcurl reports connect- and reply-phase timeouts with the same code. The request size
// (reset for every transfer) tells them apart: 0 means it never reached the daemon.
long requestSize = 0;
curl_easy_getinfo(impl_->curl, CURLINFO_REQUEST_SIZE, &requestSize);
const bool sent = requestSize > 0;
const RpcFailureKind kind = classifyRpcFailure(res, sent, 0, 0, std::string());
if (busy_gate_) busy_gate_->record(method, kind);
throw RpcTransportError(kind, "RPC request failed: " + std::string(curl_easy_strerror(res)), res, 0, sent);
}
// Check HTTP response code
httpCodeOut = 0;
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &httpCodeOut);
if (busy_gate_) {
// Any HTTP answer means a daemon worker ran our request, except the queue-full refusal.
const bool queueFull = httpCodeOut == 500 &&
response_data.find("Work queue depth exceeded") != std::string::npos;
busy_gate_->record(method, queueFull ? RpcFailureKind::QueueFull : RpcFailureKind::None);
}
return response_data;
}
void RPCClient::throwHttpFailure(long httpCode, const std::string& body, bool hideBody)
{
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors (insufficient funds, bad
// params, etc.), and 400/404 for a bad request / unknown method, with a JSON body carrying the
// real message. Parse the body first to extract it.
bool isJson = false;
try {
json response = json::parse(body);
isJson = true;
if (response.contains("error") && response["error"].is_object()) {
int errCode = 0;
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
errCode = response["error"]["code"].get<int>();
if (response["error"].contains("message") && response["error"]["message"].is_string())
throw RpcError(errCode, response["error"]["message"].get<std::string>(), httpCode);
// message missing/non-string — keep the detail instead of a bare HTTP code
throw RpcError(errCode, "RPC error: " + response["error"].dump(), httpCode);
}
} catch (const json::exception&) {
// Not JSON: the daemon's HTTP layer refused the request itself ("Work queue depth exceeded",
// "Unhandled request") or sent a 401/503 page.
}
const std::string plain = isJson ? std::string() : body;
throw RpcTransportError(classifyRpcFailure(CURLE_OK, true, httpCode, 0, plain),
formatHttpError(httpCode, hideBody ? std::string() : plain),
CURLE_OK, httpCode, true);
}
json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scrubSource)
{
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors
// (insufficient funds, bad params, etc.) with a valid JSON body.
// Parse the body first to extract the real error message.
if (httpCode != 200) {
int errCode = 0;
try {
json response = json::parse(body);
if (response.contains("error") && response["error"].is_object()) {
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
errCode = response["error"]["code"].get<int>();
if (response["error"].contains("message") && response["error"]["message"].is_string())
throw RpcError(errCode, response["error"]["message"].get<std::string>());
// message missing/non-string — keep the detail instead of a bare HTTP code
throw RpcError(errCode, "RPC error: " + response["error"].dump());
}
} catch (const json::exception&) {
// Body wasn't valid JSON — fall through to generic HTTP error
}
throw RpcError(errCode, "RPC error: HTTP " + std::to_string(httpCode));
}
if (httpCode != 200) throwHttpFailure(httpCode, body, /*hideBody=*/scrubSource);
json response = json::parse(body);
@@ -380,11 +463,11 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scru
err_msg = response["error"]["message"].get<std::string>();
}
if (err_msg.empty()) err_msg = response["error"].dump();
throw RpcError(errCode, "RPC error: " + err_msg);
throw RpcError(errCode, "RPC error: " + err_msg, httpCode);
}
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
if (scrubSource) scrubJsonSecrets(response); // zero the discarded tree's secret before it frees (B7)
json result = std::move(response["result"]); // move the subtree out: the secret now lives only here
if (scrubSource) scrubJsonSecrets(response); // response["result"] is now moved-from (null); harmless (B7)
return result;
}
@@ -481,34 +564,9 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
throw std::runtime_error("Not connected");
}
emitRpcTrace(method);
json payload = makePayload(method, params);
std::string body = payload.dump();
std::string response_data;
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDSIZE, (long)body.size());
curl_easy_setopt(impl_->curl, CURLOPT_WRITEDATA, &response_data);
CURLcode res = curl_easy_perform(impl_->curl);
if (res != CURLE_OK) {
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
}
long http_code = 0;
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &http_code);
if (http_code != 200) {
try {
json response = json::parse(response_data);
if (response.contains("error") && !response["error"].is_null()) {
std::string err_msg = response["error"]["message"].get<std::string>();
throw std::runtime_error(err_msg);
}
} catch (const json::exception&) {}
throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code));
}
std::string response_data = performCall(method, params, http_code);
if (http_code != 200) throwHttpFailure(http_code, response_data, /*hideBody=*/false);
// Parse with ordered_json to preserve the daemon's original key order
nlohmann::ordered_json oj = nlohmann::ordered_json::parse(response_data);
@@ -517,11 +575,14 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
// error (missing/non-string message) must yield a clean RPC error, not a json type-exception.
const auto& err = oj["error"];
std::string err_msg;
int errCode = 0;
if (err.is_object() && err.contains("code") && err["code"].is_number_integer())
errCode = err["code"].get<int>();
if (err.is_object() && err.contains("message") && err["message"].is_string())
err_msg = err["message"].get<std::string>();
else
err_msg = err.dump();
throw std::runtime_error("RPC error: " + err_msg);
throw RpcError(errCode, "RPC error: " + err_msg, http_code);
}
auto& result = oj["result"];
@@ -543,6 +604,28 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
return out;
}
std::string RPCClient::callRaw(const std::string& method, const json& params, long timeoutSec)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
if (!impl_->curl) {
throw std::runtime_error("Not connected");
}
// Temporarily override the timeout, as call(method, params, timeoutSec) does
long prevTimeout = 30L;
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec);
try {
std::string out = callRaw(method, params);
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
return out;
} catch (...) {
// Ensure timeout is always restored
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
throw;
}
}
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)
{
try {
@@ -616,7 +699,7 @@ void RPCClient::z_getNewAddress(Callback cb, ErrorCallback err)
void RPCClient::listTransactions(int count, Callback cb, ErrorCallback err)
{
doRPC("listtransactions", {"", count}, cb, err);
doRPC("listtransactions", {"*", count}, cb, err);
}
void RPCClient::z_viewTransaction(const std::string& txid, Callback cb, ErrorCallback err)
@@ -790,24 +873,6 @@ void RPCClient::z_mergeToAddress(const std::vector<std::string>& fromAddrs, cons
doRPC("z_mergetoaddress", {addrs, toAddr, fee, 0, limit}, cb, err);
}
void RPCClient::z_getOperationStatus(const std::vector<std::string>& opids, Callback cb, ErrorCallback err)
{
json ids = json::array();
for (const auto& id : opids) {
ids.push_back(id);
}
doRPC("z_getoperationstatus", {ids}, cb, err);
}
void RPCClient::z_getOperationResult(const std::vector<std::string>& opids, Callback cb, ErrorCallback err)
{
json ids = json::array();
for (const auto& id : opids) {
ids.push_back(id);
}
doRPC("z_getoperationresult", {ids}, cb, err);
}
void RPCClient::z_listReceivedByAddress(const std::string& address, int minconf, Callback cb, ErrorCallback err)
{
doRPC("z_listreceivedbyaddress", {address, minconf}, cb, err);
@@ -860,16 +925,6 @@ void RPCClient::z_mergeToAddress(const std::vector<std::string>& fromAddrs, cons
doRPC("z_mergetoaddress", {addrs, toAddr, fee, 0, limit}, handlers.first, handlers.second);
}
void RPCClient::z_getOperationStatus(const std::vector<std::string>& opids, UnifiedCallback cb)
{
json ids = json::array();
for (const auto& id : opids) {
ids.push_back(id);
}
auto handlers = splitUnified(std::move(cb));
doRPC("z_getoperationstatus", {ids}, handlers.first, handlers.second);
}
void RPCClient::getBlock(int height, UnifiedCallback cb)
{
// First get block hash, then get block
+49 -25
View File
@@ -5,6 +5,8 @@
#pragma once
#include "types.h"
#include "rpc_failure.h" // RpcError, RpcTransportError, RpcFailureKind
#include "rpc_busy_gate.h"
#include <atomic>
#include <string>
#include <functional>
@@ -20,21 +22,6 @@ using json = nlohmann::json;
using Callback = std::function<void(const json&)>;
using ErrorCallback = std::function<void(const std::string&)>;
/**
* @brief A JSON-RPC error carrying the daemon's numeric error code.
*
* what() preserves the exact human-readable message (so existing string matching
* still works); `code` exposes the JSON-RPC error code — notably -28 (RPC_IN_WARMUP)
* for a daemon still starting up. Derives from std::runtime_error, so every existing
* `catch (const std::exception&)` continues to handle it unchanged.
*/
class RpcError : public std::runtime_error {
public:
RpcError(int errorCode, const std::string& message)
: std::runtime_error(message), code(errorCode) {}
int code = 0;
};
/**
* @brief JSON-RPC client for dragonxd
*
@@ -78,6 +65,31 @@ public:
const std::string& user, const std::string& password,
bool useTls);
/**
* @brief Set up the connection without probing it (connect() = open() + a getinfo probe)
*
* For sending "stop": the getinfo probe needs the daemon's cs_main, so against a busy node it
* times out (holding one of the daemon's few RPC slots) or is refused, and stop never went out.
* isConnected() stays false; call() works once this returns true.
*/
bool open(const std::string& host, const std::string& port,
const std::string& user, const std::string& password, bool useTls);
/**
* @brief Send "stop" once, with its own timeout; returns how it went (None = the daemon ran it)
*
* A ReplyTimeout here is NOT a failure: the request reached the daemon and waits for a worker.
*/
RpcFailureKind trySendStop(long timeoutSec = 2);
/**
* @brief Share a busy gate with the app's other polling clients (nullptr: none)
*
* While the gate is busy, calls that need the daemon's cs_main fail at once with a Deferred
* RpcTransportError instead of being sent; every sent call reports how it went to the gate.
*/
void setBusyGate(std::shared_ptr<RpcBusyGate> gate) { busy_gate_ = std::move(gate); }
/**
* @brief Disconnect from dragonxd
*/
@@ -117,6 +129,8 @@ public:
* @brief Get the error message from the last failed connect() attempt.
*/
const std::string& getLastConnectError() const { return last_connect_error_; }
/** @brief How the last connect() probe went: None on success, Warmup for a warming-up node, else the failure. */
RpcFailureKind getLastConnectFailureKind() const { return last_connect_kind_; }
json getLastConnectInfo() const;
static void setTraceCallback(TraceCallback callback);
@@ -170,6 +184,12 @@ public:
*/
std::string callRaw(const std::string& method, const json& params = json::array());
/**
* @brief callRaw() with a custom timeout, restored to the default afterwards (like the 3-arg call())
* @param timeoutSec Timeout in seconds (0 = no timeout)
*/
std::string callRaw(const std::string& method, const json& params, long timeoutSec);
// High-level API methods - mirror the Qt version
// Info methods
@@ -243,10 +263,6 @@ public:
void z_mergeToAddress(const std::vector<std::string>& fromAddrs, const std::string& toAddr,
double fee, int limit, Callback cb, ErrorCallback err = nullptr);
// Operation status monitoring
void z_getOperationStatus(const std::vector<std::string>& opids, Callback cb, ErrorCallback err = nullptr);
void z_getOperationResult(const std::vector<std::string>& opids, Callback cb, ErrorCallback err = nullptr);
// Received transactions
void z_listReceivedByAddress(const std::string& address, int minconf, Callback cb, ErrorCallback err = nullptr);
@@ -258,7 +274,6 @@ public:
double fee, int limit, UnifiedCallback cb);
void z_mergeToAddress(const std::vector<std::string>& fromAddrs, const std::string& toAddr,
double fee, int limit, UnifiedCallback cb);
void z_getOperationStatus(const std::vector<std::string>& opids, UnifiedCallback cb);
void getBlock(int height, UnifiedCallback cb);
private:
@@ -270,10 +285,15 @@ private:
// hold curl_mutex_ and have verified impl_->curl.
std::string performCall(const std::string& method, const json& params, long& httpCodeOut);
// Centralizes the HTTP-code check and JSON error->RpcError extraction, returning
// response["result"] on success. When scrubSource is true (secret-bearing calls), the intermediate
// parse tree's string values are zeroed before it is discarded — parseRpcResult returns a COPY of
// the result node, so its own tree would otherwise free the secret un-wiped (B7).
// response["result"] on success. The result subtree is moved out of the parse tree, so a secret lives
// only in the returned node (which callSecret/callSecretString callers wipe). When scrubSource is true
// (secret-bearing calls), any remaining parse-tree string values are zeroed before it is discarded (B7).
static json parseRpcResult(long httpCode, const std::string& body, bool scrubSource = false);
// A non-200 reply: RpcError when the body carries a JSON-RPC error, else RpcTransportError classified
// from the status and plain-text body. hideBody keeps the body out of the message (secret calls).
[[noreturn]] static void throwHttpFailure(long httpCode, const std::string& body, bool hideBody);
bool probeConnection(const std::string& host); // connect()'s getinfo probe, after open()
void logConnectFailure(const std::string& msg);
// Splits a UnifiedCallback into the (Callback, ErrorCallback) pair used by doRPC.
static std::pair<Callback, ErrorCallback> splitUnified(UnifiedCallback cb);
@@ -281,11 +301,15 @@ private:
std::string host_;
std::string port_;
std::string auth_; // Base64 encoded "user:password"
bool connected_ = false;
std::atomic<bool> connected_{false};
std::atomic<bool> abort_{false}; // set cross-thread to abort an in-flight transfer
bool warming_up_ = false;
std::atomic<bool> warming_up_{false};
std::string warmup_status_;
std::string last_connect_error_;
RpcFailureKind last_connect_kind_ = RpcFailureKind::None;
std::shared_ptr<RpcBusyGate> busy_gate_;
std::string logged_connect_error_; // last "Connection failed" text logged (rate limit)
unsigned connect_error_repeats_ = 0; // identical failures since it was logged
json last_connect_info_;
mutable std::recursive_mutex curl_mutex_; // serializes all curl handle access
+230
View File
@@ -0,0 +1,230 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Classifying a failed RPC call from structured data (curl code, request-sent bit, HTTP status,
// JSON-RPC error code, non-JSON body) instead of substrings of what(). Header-only and pure, so the
// test target can use it without compiling rpc_client.cpp.
#include <curl/curl.h>
#include <cctype>
#include <stdexcept>
#include <string>
namespace dragonx {
namespace rpc {
enum class RpcFailureKind {
None, // the call succeeded
NotListening, // connection refused: nothing bound to the RPC port (not started, or already unbound)
ConnectTimeout, // TCP connect didn't complete in time (Windows after the daemon unbinds; filtered port)
ReplyTimeout, // request sent, no reply in time: the daemon holds it (busy, e.g. blocked on cs_main)
QueueFull, // HTTP 500 "Work queue depth exceeded": rejected before any handler ran; holds no slot
ShuttingDown, // HTTP 503: the daemon's HTTP server was interrupted for shutdown
Unhandled, // HTTP 500 "Unhandled request": queued, then dropped by a shutting-down daemon
Auth, // HTTP 401: wrong rpcuser/rpcpassword (or a stale .cookie)
Warmup, // JSON-RPC -28 (RPC_IN_WARMUP): the node is still loading
AppError, // the daemon ran the method and answered with a JSON-RPC error
Aborted, // our own requestAbort() cut the transfer
Deferred, // not sent: the node is busy (RpcBusyGate), so we held back rather than queue more
Other,
};
inline const char* rpcFailureKindName(RpcFailureKind kind)
{
switch (kind) {
case RpcFailureKind::None: return "None";
case RpcFailureKind::NotListening: return "NotListening";
case RpcFailureKind::ConnectTimeout: return "ConnectTimeout";
case RpcFailureKind::ReplyTimeout: return "ReplyTimeout";
case RpcFailureKind::QueueFull: return "QueueFull";
case RpcFailureKind::ShuttingDown: return "ShuttingDown";
case RpcFailureKind::Unhandled: return "Unhandled";
case RpcFailureKind::Auth: return "Auth";
case RpcFailureKind::Warmup: return "Warmup";
case RpcFailureKind::AppError: return "AppError";
case RpcFailureKind::Aborted: return "Aborted";
case RpcFailureKind::Deferred: return "Deferred";
case RpcFailureKind::Other: return "Other";
}
return "Other";
}
// True for the two curl timeouts: what the old `what().find("Timeout")` checks meant.
inline bool isRpcTimeout(RpcFailureKind kind)
{
return kind == RpcFailureKind::ConnectTimeout || kind == RpcFailureKind::ReplyTimeout;
}
/**
* @param curlCode CURLcode of the transfer (CURLE_OK when a response arrived)
* @param requestSent the request reached the daemon (CURLINFO_REQUEST_SIZE > 0). libcurl reports
* connect- and reply-phase timeouts with the same code; this tells them apart.
* @param httpStatus HTTP status, 0 when there was no response
* @param rpcCode JSON-RPC error.code from a JSON error body, 0 when there was none
* @param plainBody the response body when it was NOT JSON (dragonxd's HTTP-layer replies), else ""
*/
inline RpcFailureKind classifyRpcFailure(int curlCode, bool requestSent, long httpStatus, int rpcCode,
const std::string& plainBody)
{
if (curlCode != CURLE_OK) {
switch (curlCode) {
case CURLE_ABORTED_BY_CALLBACK: return RpcFailureKind::Aborted;
case CURLE_COULDNT_CONNECT: return RpcFailureKind::NotListening;
case CURLE_OPERATION_TIMEDOUT:
return requestSent ? RpcFailureKind::ReplyTimeout : RpcFailureKind::ConnectTimeout;
default: return RpcFailureKind::Other;
}
}
if (rpcCode == -28) return RpcFailureKind::Warmup; // takes precedence over the HTTP status
if (rpcCode != 0) return RpcFailureKind::AppError; // 500, or 400/404 (invalid request / no method)
if (httpStatus == 200) return RpcFailureKind::None;
if (httpStatus == 401) return RpcFailureKind::Auth;
if (httpStatus == 503) return RpcFailureKind::ShuttingDown;
if (httpStatus == 500) {
if (plainBody.find("Work queue depth exceeded") != std::string::npos) return RpcFailureKind::QueueFull;
if (plainBody.find("Unhandled request") != std::string::npos) return RpcFailureKind::Unhandled;
}
return RpcFailureKind::Other;
}
/**
* "RPC error: HTTP <code>", plus the body when the daemon sent a plain-text one (e.g. ": Work queue
* depth exceeded"), trimmed and limited to 120 printable ASCII characters so an HTML page or binary
* junk can't flood a log line. Exactly "RPC error: HTTP <code>" when nothing printable remains.
*/
inline std::string formatHttpError(long httpStatus, const std::string& plainBody)
{
std::string text;
bool pendingSpace = false;
for (unsigned char c : plainBody) {
if (text.size() >= 120) break;
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
pendingSpace = !text.empty();
} else if (c >= 0x21 && c <= 0x7e) {
if (pendingSpace && text.size() < 119) text.push_back(' ');
pendingSpace = false;
text.push_back(static_cast<char>(c));
}
}
std::string out = "RPC error: HTTP " + std::to_string(httpStatus);
if (!text.empty()) out += ": " + text;
return out;
}
/**
* The daemon ran the method and answered with a JSON-RPC error. what() is the daemon's message;
* `code` is its JSON-RPC error code, notably -28 (RPC_IN_WARMUP). Callers catch this type to mean
* "the daemon said no" (e.g. no mnemonic -> fall back), as opposed to a transport failure.
*/
class RpcError : public std::runtime_error {
public:
RpcError(int errorCode, const std::string& message, long status = 0)
: std::runtime_error(message), code(errorCode), httpStatus(status) {}
RpcFailureKind kind() const { return code == -28 ? RpcFailureKind::Warmup : RpcFailureKind::AppError; }
int code = 0;
long httpStatus = 0;
};
/**
* The call produced no JSON-RPC answer: curl failed (refused, timed out, aborted), or the daemon's
* HTTP layer turned the request away (401, 503, queue full, unhandled). what() keeps the historic
* text ("RPC request failed: <curl error>" / "RPC error: HTTP <code>[: body]").
*/
class RpcTransportError : public std::runtime_error {
public:
RpcTransportError(RpcFailureKind failureKind, const std::string& message, int curl = CURLE_OK,
long status = 0, bool sent = false)
: std::runtime_error(message), kind(failureKind), curlCode(curl), httpStatus(status), requestSent(sent) {}
RpcFailureKind kind = RpcFailureKind::Other;
int curlCode = CURLE_OK;
long httpStatus = 0;
bool requestSent = false;
};
// The kind of any exception an RPC call threw (Other for anything that isn't an RPC error).
inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
{
if (const auto* t = dynamic_cast<const RpcTransportError*>(&e)) return t->kind;
if (const auto* r = dynamic_cast<const RpcError*>(&e)) return r->kind();
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 };
inline UnlockFailure classifyUnlockFailure(const std::exception& e)
{
if (const auto* r = dynamic_cast<const RpcError*>(&e)) {
if (r->code == -14) return UnlockFailure::WrongPassphrase; // RPC_WALLET_PASSPHRASE_INCORRECT
if (r->code == -15) return UnlockFailure::NotEncrypted; // RPC_WALLET_WRONG_ENC_STATE
if (r->code == -28) return UnlockFailure::NodeBusy; // RPC_IN_WARMUP
return UnlockFailure::Other;
}
switch (rpcFailureKindOf(e)) {
case RpcFailureKind::ReplyTimeout:
case RpcFailureKind::QueueFull:
case RpcFailureKind::Deferred:
case RpcFailureKind::Warmup:
case RpcFailureKind::ConnectTimeout:
case RpcFailureKind::NotListening:
return UnlockFailure::NodeBusy;
default:
return UnlockFailure::Other;
}
}
} // namespace rpc
} // namespace dragonx
+4
View File
@@ -142,6 +142,10 @@ void RPCWorker::run()
}
} catch (const std::exception& e) {
DEBUG_LOGF("[RPCWorker] Task threw: %s\n", e.what());
} catch (...) {
// A raw post() job that throws still loses its MainCb (no worse than the
// std::exception path); this only keeps the worker thread alive.
DEBUG_LOGF("[RPCWorker] Task threw unknown exception\n");
}
}
}
+172
View File
@@ -0,0 +1,172 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// When the HushChat scans are worth sending. Both are wallet-wide (they hold dragonxd's cs_main): the inbox
// scan (z_listreceivedbyaddress of the chat address, every note ever) and the note-buffer scan
// (z_listunspent). They used to run on a timer whether or not anything had changed. Here they run when
// something they depend on can have changed, and the WalletScanGovernor decides when (the scan budget).
//
// Pure, header-only.
#include "rpc/rpc_failure.h"
#include "services/wallet_scan_governor.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdint>
#include <string>
#include <unordered_map>
namespace dragonx {
namespace services {
constexpr double kChatTipSettleSec = 3.0; // the wallet notifier stores a block's txs ~1 s after it connects
constexpr double kChatNoteScanFloorSec = 4.0; // spacing between note scans (a failing need can't spin), not a duty rule
constexpr double kChatProbeMaxDuty = 0.02;
constexpr double kChatProbeMaxSpacingSec = 60.0; // the probe is the inbox safety net's only path
// getwalletinfo's txcount (mapWallet.size(), read under cs_wallet): it changes only after the daemon's wallet
// notifier has stored a tx and its notes. A mempool or tip change can run ahead of that (the notifier runs
// about once a second), so the inbox is gated on this instead. -1 when absent, malformed or negative.
inline std::int64_t parseWalletTxCount(const nlohmann::json& reply)
{
if (!reply.is_object() || !reply.contains("txcount") || !reply["txcount"].is_number_integer()) return -1;
const auto v = reply["txcount"].get<std::int64_t>();
return v < 0 ? -1 : v;
}
// The inbox probe (getwalletinfo: three passes over the wallet under cs_main) isn't a governed scan: at most 2%
// of wall-clock, and at least once a minute (it is the only way the inbox safety net is checked).
inline bool chatProbeDue(double lastProbeMs, double lastProbeAtSec, double nowSec)
{
return nowSec - lastProbeAtSec >= std::min((std::max(0.0, lastProbeMs) / 1000.0) / kChatProbeMaxDuty,
kChatProbeMaxSpacingSec);
}
// Safety nets when no change signal fired: a minute, stretched with the scan's own cost (the inbox is gated on
// txcount, so its net is only a backstop; the note fallback refreshes the buffer status and the payer view).
inline double chatInboxSafetyNetSec(double emaCostMs) { return std::max(60.0, 50.0 * emaCostMs / 1000.0); }
inline double chatNoteFallbackSec(double emaCostMs) { return std::max(60.0, 20.0 * emaCostMs / 1000.0); }
struct ChatInboxScanInputs {
std::int64_t basisTxCount = -1; // the count the last successful scan is current for (-1: none)
std::int64_t txCount = -1; // the probe's read
double nowSec = 0.0;
double lastScanAtSec = 0.0;
double safetyNetSec = 60.0;
};
// The inbox needs a scan: the count is unknown (no basis yet, or a daemon without txcount: as the budget
// allows), the wallet gained or lost a transaction, or the safety net is due.
inline bool chatInboxScanNeeded(const ChatInboxScanInputs& in)
{
if (in.txCount < 0 || in.basisTxCount < 0) return true;
if (in.txCount != in.basisTxCount) return true;
return in.nowSec - in.lastScanAtSec >= in.safetyNetSec;
}
struct ChatNoteScanInputs {
bool onChatPage = false; // the Chat page is shown (and the window isn't in the background)
bool queueWaiting = false; // a chat send waits for a verified note
bool splitOutstanding = false; // a self-split is confirming
bool rescanRequested = false; // a chat operation finished
bool modelSeen = false; // a scan (or the address scan's feed) succeeded this session
int verifiedBudget = 0;
int pipelineNotes = 0;
std::int64_t tipHeight = 0;
std::int64_t lastScanTip = -1;
double nowSec = 0.0;
double lastScanAtSec = 0.0;
double tipChangedAtSec = 0.0;
double fallbackSec = 60.0;
double floorSec = kChatNoteScanFloorSec;
double tipSettleSec = kChatTipSettleSec;
};
// The note view is worth refreshing: something can use it (the Chat page, a waiting send, a split) and it
// can have changed (first look, a chat op finished, a block that matters once the notifier caught up, or the
// fallback). How often it actually runs is the scan budget's call.
inline bool shouldScanChatNotes(const ChatNoteScanInputs& in)
{
if (!in.onChatPage && !in.queueWaiting && !in.splitOutstanding) return false; // nothing can use it
const bool ever = in.lastScanAtSec > 0.0;
if (ever && in.nowSec - in.lastScanAtSec < in.floorSec) return false;
if (!in.modelSeen || in.rescanRequested) return true;
const bool tipMoved = in.tipHeight != in.lastScanTip && in.nowSec - in.tipChangedAtSec >= in.tipSettleSec;
const bool starved = in.queueWaiting && in.verifiedBudget < 1;
const bool maturing = in.onChatPage && in.verifiedBudget < in.pipelineNotes; // notes still confirming
if (tipMoved && (starved || in.splitOutstanding || maturing)) return true;
return !ever || in.nowSec - in.lastScanAtSec >= in.fallbackSec;
}
// Verified notes (at least a fee, unlocked, with the confirmations a chat send needs) by z-address.
// z_sendmany pays from ONE address and spends its largest notes first.
struct ChatPayerFunds {
std::uint64_t zat = 0;
int notes = 0;
};
using ChatPayerMap = std::unordered_map<std::string, ChatPayerFunds>;
// `preferred` if it has a verified note and covers needZat, else the richest that does (ties: the smallest
// address). "" if none does.
inline std::string chooseChatPayer(const ChatPayerMap& funds, const std::string& preferred, std::uint64_t needZat)
{
auto eligible = [&](const ChatPayerFunds& f) { return f.notes > 0 && f.zat >= needZat; };
if (!preferred.empty()) {
const auto it = funds.find(preferred);
if (it != funds.end() && eligible(it->second)) return preferred;
}
std::string best;
std::uint64_t bestZat = 0;
for (const auto& [address, f] : funds) {
if (address.empty() || !eligible(f)) continue;
if (best.empty() || f.zat > bestZat || (f.zat == bestZat && address < best)) {
best = address;
bestZat = f.zat;
}
}
return best;
}
inline std::uint64_t richestChatPayerZat(const ChatPayerMap& funds)
{
std::uint64_t best = 0;
for (const auto& [address, f] : funds) {
if (!address.empty() && f.notes > 0) best = std::max(best, f.zat);
}
return best;
}
enum class ChatPayerSpend { OneNote, All };
// Until the next scan restores the truth: a chat send spends one whole note (its change is unverified for a
// block); a split may spend all of the payer's verified notes.
inline void debitChatPayer(ChatPayerMap& funds, const std::string& payer, ChatPayerSpend spend)
{
const auto it = funds.find(payer);
if (it == funds.end()) return;
auto& f = it->second;
if (spend == ChatPayerSpend::All || f.notes <= 1) {
f = {};
return;
}
f.zat -= f.zat / static_cast<std::uint64_t>(f.notes);
--f.notes;
}
// How a chat scan's result is charged to the scan budget.
inline ScanOutcome chatScanOutcome(bool ok, rpc::RpcFailureKind kind)
{
if (ok) return ScanOutcome::Ok;
if (kind == rpc::RpcFailureKind::ReplyTimeout) return ScanOutcome::TimedOut; // the daemon still runs it
if (kind == rpc::RpcFailureKind::Deferred || kind == rpc::RpcFailureKind::QueueFull)
return ScanOutcome::Skipped; // never ran
return ScanOutcome::Failed;
}
} // namespace services
} // namespace dragonx
+63
View File
@@ -0,0 +1,63 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Holds the wallet-wide scans (the balance, the address scan, the history) for a moment after the node
// starts answering: until the lock state is known and either the node knows the chain tip or the wallet is
// small. Right after warmup a node that is behind doesn't know it yet (longestchain stays 0 until its first
// UpdateTip), so the first Core poll would read "synced" and a large wallet's scans would start, holding
// cs_main ahead of the catch-up. Opens after kGraceSeconds whatever happens.
#include <chrono>
namespace dragonx {
namespace services {
class ConnectScanGate {
public:
using Clock = std::chrono::steady_clock;
static constexpr int kGraceSeconds = 20;
static constexpr int kMinWalletTxs = 500; // below this the scans are cheap: don't hold them for the tip
void arm(Clock::time_point now, bool tipKnown)
{
armed_ = true;
edgePending_ = true;
tipKnown_ = tipKnown;
armedAt_ = now;
}
void noteTipKnown() { tipKnown_ = true; }
void disarm()
{
armed_ = false;
edgePending_ = false;
}
bool armed() const { return armed_; }
// walletTxCount: getwalletinfo's txcount, -1 when unknown.
bool open(Clock::time_point now, bool lockStateKnown, int walletTxCount) const
{
if (!armed_) return true;
const bool smallWallet = walletTxCount >= 0 && walletTxCount < kMinWalletTxs;
if (lockStateKnown && (tipKnown_ || smallWallet)) return true;
return now - armedAt_ >= std::chrono::seconds(kGraceSeconds);
}
// True once per arm(), the first time open() is: what the gate held is owed now.
bool consumeOpenEdge(Clock::time_point now, bool lockStateKnown, int walletTxCount)
{
if (!armed_ || !edgePending_ || !open(now, lockStateKnown, walletTxCount)) return false;
edgePending_ = false;
return true;
}
private:
bool armed_ = false;
bool edgePending_ = false;
bool tipKnown_ = false;
Clock::time_point armedAt_{};
};
} // namespace services
} // namespace dragonx
+242
View File
@@ -0,0 +1,242 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "history_scan_policy.h"
#include <algorithm>
#include <cmath>
namespace dragonx {
namespace services {
int historyStaleAfterBlocks(std::size_t addressCount, double perAddressScanMs)
{
if (addressCount == 0 || !(perAddressScanMs > 0.0)) return kHistoryMinStaleBlocks;
const double blocks = std::ceil((static_cast<double>(addressCount) * perAddressScanMs / 1000.0) /
(kHistoryBlockSeconds * kHistoryScanDuty));
if (!(blocks < kHistoryMaxStaleBlocks)) return kHistoryMaxStaleBlocks; // also NaN / inf
return std::max(kHistoryMinStaleBlocks, static_cast<int>(blocks));
}
double updateScanCostEstimateMs(double previousMs, double sampleMs)
{
if (!(sampleMs > 0.0)) return previousMs;
if (!(previousMs > 0.0) || sampleMs > previousMs) return sampleMs;
return 0.75 * previousMs + 0.25 * sampleMs;
}
int transparentPageSize(std::size_t previousRowCount)
{
const std::size_t want = previousRowCount + std::max<std::size_t>(200, previousRowCount / 10);
return static_cast<int>(std::clamp<std::size_t>(want, kTransparentPageMin, kTransparentPageMax));
}
bool sentSweepIsFull(int tip, int fromHeight)
{
return tip <= 0 || fromHeight < 0 || fromHeight > tip;
}
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) + 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;
if (consecutiveFailures == 1) return 600;
if (consecutiveFailures == 2) return 3600;
return 21600;
}
bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind)
{
using K = rpc::RpcFailureKind;
return kind == K::ReplyTimeout || kind == K::AppError || kind == K::Other;
}
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
const std::unordered_map<std::string, int>& heights)
{
std::size_t best = 0;
int bestHeight = 0;
bool haveBest = false;
for (std::size_t i = 0; i < addresses.size(); ++i) {
const auto it = heights.find(addresses[i]);
if (it == heights.end()) return i; // never scanned: first
if (!haveBest || it->second < bestHeight) {
best = i;
bestHeight = it->second;
haveBest = true;
}
}
return best;
}
std::string ProbeRotation::next(const std::vector<std::string>& addresses,
const std::unordered_map<std::string, int>& heights)
{
std::unordered_set<std::string> listed(addresses.begin(), addresses.end());
for (auto it = left_.begin(); it != left_.end();) {
if (listed.count(*it)) ++it;
else it = left_.erase(it);
}
if (left_.empty()) left_ = std::move(listed);
std::vector<std::string> left;
for (const auto& address : addresses) {
if (left_.count(address)) left.push_back(address);
}
if (left.empty()) return {};
return left[oldestScannedAddressIndex(left, heights)];
}
int ShieldedHistoryPass::floorAt(int tip) const
{
return tip > 0 ? std::min(startHeight_, tip) : startHeight_;
}
ShieldedHistoryPass::Work ShieldedHistoryPass::evaluate(int tip, const std::vector<std::string>& addresses,
const std::unordered_map<std::string, int>& heights,
int staleAfter, bool initialComplete) const
{
Work work;
work.total = addresses.size();
const int floor = active_ ? floorAt(tip) : -1;
std::size_t below = 0;
bool staleDue = false;
for (const auto& address : addresses) {
const auto it = heights.find(address);
if (it == heights.end()) {
++work.missing;
continue;
}
if (active_) {
if (it->second < floor) ++below;
} else if (tip > 0 && it->second < tip - staleAfter) {
staleDue = true;
}
}
work.pending = work.missing + (active_ ? below : (staleDue ? work.total - work.missing : 0));
work.explicitWork = !stalled() &&
(work.missing > 0 || (active_ ? (explicit_ && below > 0) : (staleDue && !initialComplete)));
work.routineWork = !work.explicitWork && work.pending > 0;
return work;
}
void ShieldedHistoryPass::begin(int tip, const std::vector<std::string>& addresses,
const std::unordered_map<std::string, int>& heights, int staleAfter,
bool initialComplete, bool routineAllowed)
{
if (active_) {
if (tip > 0 && startHeight_ > tip) startHeight_ = tip; // the chain reorganised below the floor
return;
}
if (tip <= 0 || !(routineAllowed || !initialComplete)) return;
bool staleDue = false;
for (const auto& address : addresses) {
const auto it = heights.find(address);
if (it != heights.end() && it->second < tip - staleAfter) {
staleDue = true;
break;
}
}
if (!staleDue) return;
active_ = true;
explicit_ = !initialComplete;
startHeight_ = tip;
}
int ShieldedHistoryPass::scanFloor(int tip, bool routineAllowed) const
{
return (active_ && (explicit_ || routineAllowed)) ? floorAt(tip) : -1;
}
void ShieldedHistoryPass::finish(int tip, const std::vector<std::string>& addresses,
const std::unordered_map<std::string, int>& heights, std::size_t attempted,
std::size_t succeeded)
{
if (attempted > 0) stalledCycles_ = succeeded > 0 ? 0 : stalledCycles_ + 1;
// An empty list proves nothing (a failed enumeration blanks it); a real wallet change resets the pass.
if (!active_ || addresses.empty()) return;
const int floor = floorAt(tip);
for (const auto& address : addresses) {
const auto it = heights.find(address);
if (it == heights.end() || it->second < floor) return; // still work left in this pass
}
active_ = false;
explicit_ = false;
}
void ShieldedHistoryPass::reset()
{
*this = ShieldedHistoryPass{};
}
TxCycle decideTxCycle(const TxCycleInputs& in)
{
const bool wanted = in.neverFetched || in.newTip || in.dirty || in.explicitScanWork || in.routineScanWork;
const bool scansAllowed = !in.syncing && in.heightKnown;
if (wanted) {
if (scansAllowed && (in.explicitScanWork || in.gateOpen)) return TxCycle::Full;
// Rows now (a mutation, a tracked send at a new tip); wallet-wide scans wait for the throttle.
if (in.neverFetched || in.dirty || (in.sendTracking && in.newTip)) return TxCycle::Light;
}
if (in.recentDue) return (scansAllowed && in.gateOpen) ? TxCycle::RecentWithProbe : TxCycle::Recent;
return TxCycle::None;
}
TxCycleInputs shapeTxCycleForMiningPage(TxCycleInputs in)
{
if (!in.sendTracking) { // tracked sends need their Light cycle at a new tip, wherever the user is
in.newTip = false;
in.routineScanWork = false;
}
return in;
}
bool miningTxPollDue(const MiningTxPollInputs& in)
{
return in.historyLoaded && in.tip > 0 && in.tip != in.lastPolledTip && in.txAgeDue && !in.dirty &&
!in.addressesDirty;
}
} // namespace services
} // namespace dragonx
+200
View File
@@ -0,0 +1,200 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// When the transaction history refresh may scan what. The history comes from three wallet-wide RPCs, each
// holding the daemon's cs_main for its whole run (seconds per call on a large wallet): listtransactions,
// z_listreceivedbyaddress (once per z-address) and z_listsentbyaddress. This decides, per refresh cycle,
// which of them run, so the cost stays bounded and every pass over the z-addresses finishes. Pure and
// main-thread only; App::refreshTransactionData() feeds it and the test target drives it directly.
#include "rpc/rpc_failure.h"
#include <nlohmann/json.hpp>
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace dragonx {
namespace services {
constexpr int kHistoryBlockSeconds = 36; // -ac_blocktime=36
constexpr double kHistoryScanDuty = 0.10; // the same 10% as the other scan throttles
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;
// Blocks after which an address's last receive scan counts as stale and a new routine pass is due: the
// pass then costs about kHistoryScanDuty of wall-clock (one z_listreceivedbyaddress of perAddressScanMs per
// address, spread over that many blocks), within [kHistoryMinStaleBlocks, kHistoryMaxStaleBlocks].
int historyStaleAfterBlocks(std::size_t addressCount, double perAddressScanMs);
// Running cost estimate: a higher sample replaces it at once, a lower one pulls it down 25%. A sample of 0
// or less (nothing measured) leaves it as it is.
double updateScanCostEstimateMs(double previousMs, double sampleMs);
// listtransactions rows to ask for in the first page: last time's count plus headroom, so most wallets
// get every row in one call instead of re-paging the whole history in 1000-row pages.
int transparentPageSize(std::size_t previousRowCount);
// z_listsentbyaddress: the whole wallet when there's no usable watermark (none yet, or it is above the
// 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). `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,
// an error answer, anything unclassified), not when the call never ran (held back, aborted, not listening).
bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind);
// The address scanned longest ago (never-scanned first), for the between-blocks probe.
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
const std::unordered_map<std::string, int>& heights);
// The between-blocks probe scans one address per run. After what it watches changed (the mempool, a wallet
// mutation) it isn't done until it has probed every z-address once: the change may be any of them. Tracks
// which are left, by address (heights can't tell: a rotation at one tip leaves them all equal).
class ProbeRotation {
public:
// The next address to probe: the oldest-scanned of those left. Drops addresses no longer listed, and
// starts a new rotation when none are left. Empty when there are no addresses.
std::string next(const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights);
void probed(const std::string& address) { left_.erase(address); }
bool complete() const { return left_.empty(); }
void reset() { left_.clear(); }
private:
std::unordered_set<std::string> left_;
};
// One pass of receive scans over every z-address. An address is done once it was scanned at or above the
// pass's start height (its floor), so a pass ends after ceil(A / budget) cycles however the tip moves. An
// explicit pass (the first load of a wallet, or after an invalidation) may run back to back; a routine
// re-pass (addresses gone stale) runs only as the scan throttle allows.
class ShieldedHistoryPass {
public:
struct Work {
std::size_t total = 0; // z-addresses
std::size_t missing = 0; // never scanned
std::size_t pending = 0; // still to scan in this pass (or in the pass that is due)
bool explicitWork = false; // may skip the scan throttle
bool routineWork = false; // waits for the scan throttle
};
Work evaluate(int tip, const std::vector<std::string>& addresses,
const std::unordered_map<std::string, int>& heights, int staleAfter, bool initialComplete) const;
// Start a pass if one is due and allowed (routine passes need routineAllowed, unless the first load
// isn't complete yet). Clamps an active pass's floor after a reorg below it.
void begin(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
int staleAfter, bool initialComplete, bool routineAllowed);
// The floor a cycle scans up to: -1 means only never-scanned (and priority) addresses, which is all an
// explicit-only cycle may do while a routine pass is active.
int scanFloor(int tip, bool routineAllowed) const;
// After a cycle's heights are merged: count stalls (scans attempted, none succeeded), end the pass once
// no address is missing or below its floor.
void finish(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
std::size_t attempted, std::size_t succeeded);
void reset();
bool active() const { return active_; }
bool explicitPass() const { return explicit_; }
int startHeight() const { return startHeight_; }
// Scans keep failing: explicit work is treated as routine (gated) until one succeeds again.
bool stalled() const { return stalledCycles_ >= kStallCyclesBeforeGating; }
private:
int floorAt(int tip) const;
bool active_ = false;
bool explicit_ = false;
int startHeight_ = -1;
int stalledCycles_ = 0;
};
enum class TxCycle {
None,
Full, // listtransactions, receive scans, the sent sweep, enrichment
Light, // listtransactions and enrichment only: no wallet-wide z-scans
Recent, // the between-blocks poll: recent listtransactions rows
RecentWithProbe, // ...plus one receive scan of the address scanned longest ago
};
struct TxCycleInputs {
bool neverFetched = false; // no history loaded yet this session
bool newTip = false; // the tip moved since the last history apply
bool dirty = false; // something changed the wallet (a send, an import, ...)
bool sendTracking = false; // tracked sends still waiting for their view
bool explicitScanWork = false; // ShieldedHistoryPass::Work::explicitWork
bool routineScanWork = false; // ShieldedHistoryPass::Work::routineWork
bool gateOpen = false; // the scan throttle allows a wallet-wide scan now
bool syncing = false; // the node is catching up (or settling after it)
bool heightKnown = false; // the tip height is known
bool recentDue = false; // the between-blocks poll is due on a wallet page
};
// Only explicit work skips the throttle with scans; sends and other mutations get rows now through Light
// cycles; nothing scans while syncing or before the tip is known. Tracking alone needs a new tip for a
// Light cycle, so it can't loop them at one tip.
TxCycle decideTxCycle(const TxCycleInputs& in);
// The Mining tab shows history-derived earnings but is not a wallet-data page: it gets the cheap between-blocks
// poll (recent listtransactions rows) and nothing routine. Explicit work (first load, dirty, new addresses) and
// tracked sends still go through the normal cycles on any page.
TxCycleInputs shapeTxCycleForMiningPage(TxCycleInputs in);
struct MiningTxPollInputs {
int tip = 0; // current chain height
int lastPolledTip = -1; // the tip the last Mining poll was dispatched at (-1 = none)
bool txAgeDue = false; // RefreshTimer::TxAge due (15 s since the last Recent poll / full cycle)
bool historyLoaded = false; // last_tx_block_height_ >= 0
bool dirty = false; // transactions_dirty_
bool addressesDirty = false; // addresses_dirty_
};
// One poll per new block (confirmation counts and new coinbase rows only change with a block), after the history
// has loaded, never while a dirty cycle is already owed. Unlike the wallet pages, an EMPTY history may poll (a fresh
// miner's first block must appear).
bool miningTxPollDue(const MiningTxPollInputs& in);
} // namespace services
} // namespace dragonx
+90
View File
@@ -0,0 +1,90 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Pure search for the lowest block height whose data the node can still read (a node restored from a
// bootstrap snapshot lacks everything below the snapshot base). No I/O: the caller supplies the probe.
// Header-only so the test target needs no new source.
#pragma once
#include "../rpc/rpc_failure.h"
#include <nlohmann/json.hpp>
#include <exception>
#include <functional>
#include <string>
namespace dragonx {
namespace services {
enum class BlockProbe { Present, Missing, Unknown };
struct LowestBlockResult {
bool ok = false; // false: could not tell (never read as "missing")
int lowest = 0; // lowest readable height (valid when ok)
bool fullHistory = false; // block 1 readable: not a bootstrapped / pruned node
};
// What a failed getblockhash / getblock means. Only "the daemon says it cannot read the block data"
// (RPC_INTERNAL_ERROR -32603: "Can't read block from disk" / "Block not available (pruned data)",
// external/dragonx/src/rpc/blockchain.cpp) is Missing. Every transport problem (timeout, busy gate
// "RPC deferred", queue full, abort, not listening), warmup (-28), out of range (-8), not found (-5) and
// anything unrecognised is Unknown, which stops the search.
inline BlockProbe classifyBlockProbeError(const std::exception& e)
{
if (const auto* r = dynamic_cast<const rpc::RpcError*>(&e)) {
if (r->code == -32603) return BlockProbe::Missing;
const std::string m = r->what();
if (m.find("from disk") != std::string::npos || m.find("pruned") != std::string::npos)
return BlockProbe::Missing;
return BlockProbe::Unknown;
}
return BlockProbe::Unknown; // RpcTransportError, runtime_error("Not connected"), json errors
}
// call(method, params) -> json, throwing like RPCClient::call. Present = both calls answered.
template <typename Call>
BlockProbe probeBlockPresence(Call&& call, int height)
{
try {
nlohmann::json h = call("getblockhash", nlohmann::json::array({height}));
if (!h.is_string()) return BlockProbe::Unknown;
nlohmann::json b = call("getblock", nlohmann::json::array({h.get<std::string>()}));
return b.is_null() ? BlockProbe::Unknown : BlockProbe::Present;
} catch (const std::exception& e) {
return classifyBlockProbeError(e);
}
}
// Assumes block data is present from some base height upward (as the old search did). tip = a height known
// to exist (the caller's synced tip). Probe order: 1 (a normal full-history node answers in ONE probe), then
// the tip (the search's premise), then a binary search over (1, tip]. Any Unknown ends it with ok=false:
// nothing is guessed.
inline LowestBlockResult findLowestAvailableBlock(int tip, const std::function<BlockProbe(int)>& probe)
{
LowestBlockResult out;
if (tip <= 1) return out;
switch (probe(1)) {
case BlockProbe::Present: out.ok = true; out.lowest = 1; out.fullHistory = true; return out;
case BlockProbe::Unknown: return out;
case BlockProbe::Missing: break;
}
if (probe(tip) != BlockProbe::Present) return out; // an unreadable tip is as unusable as Unknown
int lo = 1, hi = tip; // invariant: lo Missing, hi Present
while (hi - lo > 1) {
const int mid = lo + (hi - lo) / 2;
switch (probe(mid)) {
case BlockProbe::Present: hi = mid; break;
case BlockProbe::Missing: lo = mid; break;
case BlockProbe::Unknown: return out;
}
}
out.ok = true;
out.lowest = hi;
out.fullHistory = false;
return out;
}
} // namespace services
} // namespace dragonx
+63
View File
@@ -0,0 +1,63 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Coalesces mining thread-count picks. The Mining tab reports every intermediate value of a gesture (a tile
// drag crosses up to ~24 tiles; the -/+ stepper is clicked repeatedly); only the LAST one should reach the
// settings file and the miner, once, when the input settles: on drag release, or kDebounceSec after the
// last click. Pure: time is passed in; nothing here touches settings, the miner or ImGui.
namespace dragonx {
namespace services {
class MiningThreadCommit {
public:
static constexpr double kDebounceSec = 0.5;
// The user picked `threads` at `now` (seconds, any monotonic clock). Latest wins.
void select(int threads, double now)
{
target_ = threads;
pending_ = true;
due_ = now + kDebounceSec;
}
// The drag that produced the pending pick ended: it is due now, not after the debounce.
void release(double now)
{
if (pending_ && due_ > now) due_ = now;
}
void cancel() { pending_ = false; }
bool pending() const { return pending_; }
// Once per frame. `held`: a drag is still in progress (nothing commits until it ends). Returns true
// exactly once per settled pick and sets `out` to it.
bool take(double now, bool held, int& out)
{
if (!pending_ || held || now < due_) return false;
pending_ = false;
out = target_;
return true;
}
private:
int target_ = 0;
bool pending_ = false;
double due_ = 0.0;
};
// The pool thread count a wanted count is compared with: the newest start/retarget posted (it includes a
// restart still queued on the mining-control thread), else the count the running xmrig was started with,
// else what its HTTP API last reported (that lags a restart by seconds, so it is only a last resort).
inline int effectivePoolThreads(int queuedTarget, int requested, int apiActive)
{
if (queuedTarget > 0) return queuedTarget;
if (requested > 0) return requested;
return apiActive;
}
} // namespace services
} // namespace dragonx
File diff suppressed because it is too large Load Diff
+156 -13
View File
@@ -4,6 +4,7 @@
#include "data/wallet_state.h"
#include "refresh_scheduler.h"
#include "rpc/rpc_worker.h"
#include "rpc/rpc_failure.h"
#include <nlohmann/json.hpp>
@@ -34,6 +35,13 @@ public:
virtual ~RefreshRpcGateway() = default;
virtual nlohmann::json call(const std::string& method,
const nlohmann::json& params) = 0;
// A call allowed to outlast the default timeout (a whole-wallet sweep on a large wallet).
// Gateways without a separate timeout just call().
virtual nlohmann::json callLong(const std::string& method, const nlohmann::json& params, long timeoutSec)
{
(void)timeoutSec;
return call(method, params);
}
};
enum class Job {
@@ -83,12 +91,16 @@ public:
bool ok = false;
bool encrypted = false;
std::int64_t unlockedUntil = 0;
std::optional<int> txCount; // getwalletinfo's txcount (the wallet's transactions)
};
struct WarmupPollResult {
bool ready = false;
ConnectionInfoResult info;
std::string errorMessage;
// Why the poll failed: a -28 warmup reply means the node is there; a refused/reset connection
// means it's gone (the caller counts those toward its lost-connection check).
rpc::RpcFailureKind errorKind = rpc::RpcFailureKind::None;
};
struct ConnectionInitResult {
@@ -112,6 +124,31 @@ public:
std::optional<int> longestChain;
std::optional<int> notarized;
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
// How those calls failed (None when they succeeded or weren't made), so a caller can tell a busy
// node (ReplyTimeout / QueueFull) from a gone one (NotListening) without reading what().
rpc::RpcFailureKind blockchainErrKind = rpc::RpcFailureKind::None;
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
// The minconf=0 call reached the daemon (it may still have failed). False when the balance was
// skipped (behind, busy chain read) or held back by the busy gate (Deferred) before it was sent.
bool balanceAttempted = false;
bool spendableQueried = false; // the minconf=1 call was made
std::optional<std::int64_t> totalBalanceZat; // the minconf=0 "total" in zatoshis, exact
// getmempoolinfo (CoreRefreshPlan::fetchMempool): a change signal that takes no cs_main.
bool mempoolFetched = false;
bool mempoolOk = false;
std::int64_t mempoolTxs = 0;
std::int64_t mempoolBytes = 0;
};
// What one Core refresh fetches. The minconf=1 (spendable) z_gettotalbalance is a second whole-wallet
// scan under cs_main; it only tells something the minconf=0 call doesn't while funds are unconfirmed
// or pending, so it runs only when needSpendable or the exact total moved since prevTotalZat.
struct CoreRefreshPlan {
bool includeBalance = true;
bool needSpendable = true;
std::optional<std::int64_t> prevTotalZat;
bool fetchMempool = false; // add getmempoolinfo after a successful getblockchaininfo
bool evenIfBehind = false; // the session's first balance read: also while the node is behind
};
struct MiningRefreshResult {
@@ -153,6 +190,9 @@ public:
double amount = 0.0; // note value, DRGX
bool locked = false; // tied up by an in-flight send
int confirmations = 0; // TRUE depth (rawconfirmations when present, else confirmations)
std::string address; // the z-address holding it
std::string txid; // the transaction that created it
bool change = false; // change from the wallet's own spend (not a receive)
};
struct AddressRefreshResult {
@@ -168,6 +208,12 @@ public:
// The wallet's unspent notes from this same z_listunspent scan, so a consumer (e.g. the chat
// note-budget) can be fed for free instead of running its own duplicate z_listunspent.
std::vector<UnspentNoteLite> unspentNotes;
bool balancesOk = true; // false: a balance call failed, the per-address balances may be stale
bool replyTimedOut = false; // a call hit the reply timeout (the daemon kept running it)
// The lists only: no z_listunspent / listunspent, or a balance call failed (the pass then degrades to
// list-only). Applying it carries the previous balances over.
bool listOnly = false;
bool nodeBusy = false; // a call failed because the node was busy (timeout, full queue, held back)
};
struct AddressRefreshSnapshot {
@@ -178,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;
@@ -199,14 +246,31 @@ public:
std::set<std::string> miningAddresses;
std::unordered_map<std::string, int> shieldedScanHeights;
std::size_t shieldedScanStartIndex = 0;
std::size_t maxShieldedReceiveScans = 0;
// How many blocks the tip may advance past an address's last scan before it counts as stale
// and needs re-scanning. 0 = strict (must be scanned at the exact current tip). A small
// tolerance lets a multi-cycle pass over many shielded addresses COMPLETE even though new
// blocks arrive mid-pass — otherwise the "scanned at tip" bar moves every block and the scan
// (and the transactions_dirty_ flag it drives) never finishes. It also naturally throttles
// full rescans to roughly once per `tolerance` blocks.
int shieldedScanTipTolerance = 0;
std::size_t maxShieldedReceiveScans = 0; // receive scans this cycle; 0 = no limit
// The pass's floor (services::ShieldedHistoryPass): an address scanned below it is scanned again.
// -1: only never-scanned addresses (and priority ones) are.
int shieldedScanFloorHeight = -1;
// Scanned first, whatever the cursor, when never scanned or scanned below their floor: new
// addresses, the chat reply address, addresses holding notes the history doesn't show yet.
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 (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.
bool reuseTransparentRows = false;
std::unordered_set<std::string> enrichBacklogTxids; // discovered earlier, not enriched yet
// Run a windowed sent sweep even though the receive pass isn't complete (the caller rate-limits
// this), so untracked sends (from the Console, or another wallet on the same seed) don't wait for a
// whole pass. The whole-wallet sweep stays tied to a completed pass.
bool allowMidPassSentSweep = false;
};
struct TransactionRefreshResult {
@@ -214,14 +278,36 @@ public:
std::vector<chat::HushChatTransactionMetadata> hushChatMetadata;
int blockHeight = -1;
TransactionViewCache newViewTxEntries;
// Tracked sends whose view result is final although it shows no outgoing output: the op only
// paid the wallet itself (shield, merge/consolidate, auto-shield, chat note split, a transfer
// between own addresses) and is now mined or conflicted. Applying the result stops tracking them.
std::unordered_set<std::string> resolvedSendTxids;
std::size_t nextShieldedScanStartIndex = 0;
std::size_t shieldedAddressesScanned = 0;
std::size_t shieldedAddressCount = 0;
std::unordered_map<std::string, int> shieldedScanHeights;
bool shieldedScanComplete = true;
// Wall-clock spent in the history scan (z_listreceivedbyaddress — O(mapWallet), holds cs_main).
// Lets the caller throttle the routine full rescan by its measured cost (txRefreshDue()).
// Wall-clock spent in the whole cycle: listtransactions, the receive scans and the sent sweep each
// hold cs_main. Lets the caller throttle wallet-wide scans by their measured cost (txRefreshDue()).
double scanMs = 0.0;
std::size_t shieldedScansSucceeded = 0; // shieldedAddressesScanned counts attempts
double receiveScanOkMs = 0.0; // time in the receive scans that succeeded
std::size_t transparentRowCount = 0; // rows the listtransactions chain returned
bool transparentChainOk = false;
bool transparentRowsReused = false;
bool sentSweepRan = false;
bool sentSweepOk = false;
bool sentSweepFull = false;
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
// keeps them and feeds them back as enrichBacklogTxids.
std::unordered_set<std::string> unenrichedTxids;
bool rpcError = false; // a wallet-wide call failed
bool replyTimedOut = false; // ...with a reply timeout (the daemon kept running it)
};
struct OperationStatusPollResult {
@@ -234,6 +320,16 @@ public:
bool anySuccess = false;
};
// Who started a tracked async operation, i.e. what its completion re-reads (App::markWalletMutated).
// Absent from the App's origin map = User.
enum class OperationOrigin : std::uint8_t { User, Auto, Chat };
struct OperationBatchPlan {
bool userSucceeded = false; // >= 1 User op succeeded: re-read the wallet, force the balance
bool autoSucceeded = false; // >= 1 Auto op (client auto-shield) succeeded: re-read, unforced
bool walletTerminal = false; // >= 1 non-chat op finished (any outcome) or went stale
bool chatTerminal = false; // >= 1 chat op finished or went stale: refresh the chat note view
};
struct TransactionCacheUpdate {
TransactionViewCache& viewTxCache;
std::unordered_set<std::string>& sendTxids;
@@ -246,6 +342,10 @@ public:
static Intervals intervalsForPage(ui::NavPage page) { return RefreshScheduler::intervalsForPage(page); }
static ConnectionInfoResult parseConnectionInfoResult(const nlohmann::json& info);
// The node knows the chain tip: longestchain stays 0 after a start until its first UpdateTip.
static bool connectionInfoTipKnown(const ConnectionInfoResult& info);
// The tip is known and the node is more than 2 blocks behind it (the sync tolerance).
static bool connectionInfoShowsBehind(const ConnectionInfoResult& info);
static WalletEncryptionResult parseWalletEncryptionResult(const nlohmann::json& walletInfo);
static WarmupPollResult collectWarmupPollResult(RefreshRpcGateway& rpc);
static ConnectionInitResult collectConnectionInitResult(
@@ -259,7 +359,24 @@ public:
// includeBalance=false skips z_gettotalbalance (which takes the wallet lock + cs_main) and only
// fetches getblockchaininfo — used while syncing, where the balance is incomplete anyway and the
// wallet should minimise lock contention with block connection.
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance = true);
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, const CoreRefreshPlan& plan);
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance = true)
{
CoreRefreshPlan plan;
plan.includeBalance = includeBalance;
return collectCoreRefreshResult(rpc, plan);
}
// An amount as the daemon formats it ("[-]digits[.up to 8 digits]") in zatoshis; nullopt for anything
// else or anything that would overflow. Exact, so "did the total move" never trips on rounding.
static std::optional<std::int64_t> parseMoneyZat(const std::string& text);
// Whether the next balance poll needs the minconf=1 (spendable) call whatever the total does: never
// fetched, unconfirmed or pending funds, or a forced refresh after the user's own send.
static bool spendableBalanceNeeded(const WalletState& state, bool pendingSendWork, bool forced);
// The total the next poll compares against (CoreRefreshPlan::prevTotalZat). None after a poll whose
// minconf=1 call was made but brought back no total (timeout, held back, error): that left spendable
// degraded to display, so the next poll must count the total as moved and measure it again.
static std::optional<std::int64_t> nextTotalBaseline(const CoreRefreshResult& result,
std::optional<std::int64_t> previous);
static MiningRefreshResult parseMiningRefreshResult(const nlohmann::json& miningInfo,
bool miningOk,
const nlohmann::json& localHashrate,
@@ -290,9 +407,13 @@ public:
static void applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
const nlohmann::json& unspent);
static AddressRefreshSnapshot buildAddressRefreshSnapshot(const WalletState& state);
// listOnly: enumerate the addresses only (cheap), without the wallet-wide balance scans; for while the
// scans are held (the node catching up, the connect gate). Both lists are enumerated first, and the
// balances are scanned only when both enumerations succeeded (a failed pass is discarded whole).
static AddressRefreshResult collectAddressRefreshResult(
RefreshRpcGateway& rpc,
const AddressRefreshSnapshot& snapshot = {});
const AddressRefreshSnapshot& snapshot = {},
bool listOnly = false);
static TransactionRefreshSnapshot buildTransactionRefreshSnapshot(const WalletState& state,
const TransactionViewCache& viewTxCache,
const std::unordered_set<std::string>& sendTxids);
@@ -307,9 +428,18 @@ 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).
static void appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
const std::string& txid,
const nlohmann::json& sentEntry);
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
const TransactionRefreshSnapshot& snapshot,
@@ -322,6 +452,17 @@ public:
int pageSize = 100);
static OperationStatusPollResult parseOperationStatusPoll(const nlohmann::json& result,
const std::vector<std::string>& requestedOpids);
// Params for z_getoperationstatus/result filtered to these opids: ONE argument, an array of ids, so
// [[id, ...]]. nullopt when no non-empty id remains: an empty filter means every op the node has
// retained. Built explicitly, because a one-element braced list of a json ({ids}) collapses to the
// inner array, which the daemon rejects ("JSON value is not an array as expected").
static std::optional<nlohmann::json> buildOperationFilterParams(const std::vector<std::string>& opids);
// Classify a poll's terminal ops by origin (absent from the map = User).
static OperationBatchPlan planOperationBatch(const OperationStatusPollResult& parsed,
const std::unordered_map<std::string, OperationOrigin>& origins);
// Any pending id is a User op (absent from the map = User).
static bool anyUserOperationPending(const std::vector<std::string>& pendingOpids,
const std::unordered_map<std::string, OperationOrigin>& origins);
struct DaemonRescanScan {
bool foundRescan = false;
@@ -375,6 +516,8 @@ public:
void reset(Timer timer) { scheduler_.reset(timer); }
void markDue(Timer timer) { scheduler_.markDue(timer); }
void setTimer(Timer timer, float seconds) { scheduler_.setTimer(timer, seconds); }
void deferFor(Timer timer, float seconds) { scheduler_.deferFor(timer, seconds); }
void retrySoon(Timer timer, float seconds) { scheduler_.retrySoon(timer, seconds); }
float timer(Timer timer) const { return scheduler_.timer(timer); }
float interval(Timer timer) const { return scheduler_.interval(timer); }
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
+649
View File
@@ -0,0 +1,649 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "note_consolidation.h"
#include <algorithm>
#include <cctype>
#include <climits>
#include <map>
#include <set>
namespace dragonx {
namespace services {
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
{
std::map<std::string, NoteGroup> byAddress;
if (zListUnspent.is_array()) {
for (const auto& n : zListUnspent) {
if (!n.is_object()) continue;
const std::string address = n.value("address", std::string());
if (address.empty()) continue;
if (n.value("confirmations", 0) < 1) continue; // z_mergetoaddress spends confirmed notes only
if (!n.value("spendable", true)) continue; // watch-only: no key to spend it
if (noteIsLocked(n)) continue; // held by an operation in flight
NoteGroup& g = byAddress[address];
g.address = address;
++g.notes;
g.amount += n.value("amount", 0.0);
}
}
std::vector<NoteGroup> out;
out.reserve(byAddress.size());
for (auto& kv : byAddress) out.push_back(std::move(kv.second));
std::sort(out.begin(), out.end(), [](const NoteGroup& a, const NoteGroup& b) {
if (a.notes != b.notes) return a.notes > b.notes;
return a.address < b.address;
});
return out;
}
bool noteIsLocked(const nlohmann::json& zListUnspentEntry)
{
if (!zListUnspentEntry.is_object()) return false;
const auto it = zListUnspentEntry.find("locked");
return it != zListUnspentEntry.end() && it->is_boolean() && it->get<bool>();
}
bool worthConsolidating(const NoteGroup& group, double fee)
{
// The daemon rejects a merge whose fee is more than what it nets (merged value < 2 x fee).
return group.notes >= 2 && group.amount > 2.0 * fee;
}
int estimateBatches(int notes, int limit)
{
if (notes < 2) return 0;
if (limit < 2) limit = 2;
return notes / limit + ((notes % limit) >= 2 ? 1 : 0);
}
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches)
{
if (!r.error.empty()) {
run.error = r.error; // earlier batches (if any) are already submitted and stand
return false;
}
run.opids.push_back(r.opid);
run.notesMerged += r.merging;
run.remaining = r.remaining;
// One leftover note can't be merged into its own address.
return r.remaining >= 2 && static_cast<int>(run.opids.size()) < maxBatches;
}
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches)
{
AddressRun run;
run.address = address;
if (maxBatches <= 0) return run;
while (recordMergeResult(run, call(address), maxBatches)) {}
return run;
}
namespace {
std::string lower(std::string s)
{
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
bool has(const std::string& haystack, const char* needle) { return haystack.find(needle) != std::string::npos; }
int clampedCount(const nlohmann::json& obj, const char* key)
{
const auto it = obj.find(key);
if (it == obj.end()) return 0;
if (it->is_number_unsigned()) {
const auto v = it->get<std::uint64_t>();
return v > static_cast<std::uint64_t>(INT_MAX) ? INT_MAX : static_cast<int>(v);
}
if (it->is_number_integer()) {
const auto v = it->get<std::int64_t>();
return v < 0 ? 0 : (v > INT_MAX ? INT_MAX : static_cast<int>(v));
}
return 0;
}
std::string stringField(const nlohmann::json& obj, const char* key)
{
const auto it = obj.find(key);
return (it != obj.end() && it->is_string()) ? it->get<std::string>() : std::string();
}
} // namespace
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out)
{
out = MergeCallResult{};
if (res.is_object()) {
out.opid = stringField(res, "opid");
out.merging = clampedCount(res, "mergingNotes");
out.remaining = clampedCount(res, "remainingNotes");
}
if (out.opid.empty()) {
out = MergeCallResult{};
out.error = "no operation id";
}
}
OpState opStateFromStatus(const std::string& status)
{
if (status == "queued") return OpState::Queued;
if (status == "executing") return OpState::Executing;
if (status == "success") return OpState::Success;
if (status == "failed" || status == "cancelled" || status == "canceled") return OpState::Failed;
return OpState::Unknown;
}
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted)
{
std::vector<OpReport> out;
if (!statusArray.is_array()) return out;
for (const auto& op : statusArray) {
if (!op.is_object()) continue;
OpReport rep;
rep.id = stringField(op, "id");
if (rep.id.empty()) continue;
if (!wanted.empty() && !wanted.count(rep.id)) continue;
rep.status = stringField(op, "status");
const auto err = op.find("error");
if (err != op.end() && err->is_object()) rep.error = stringField(*err, "message");
const auto res = op.find("result");
if (res != op.end() && res->is_object()) rep.txid = stringField(*res, "txid");
out.push_back(std::move(rep));
}
return out;
}
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message)
{
using K = rpc::RpcFailureKind;
switch (kind) {
case K::None: return MergeFailure::Uncertain; // answered without an opid: it may have queued the merge
case K::Deferred:
case K::NotListening:
case K::ConnectTimeout:
case K::Warmup:
case K::QueueFull: return MergeFailure::NotSent; // turned away before z_mergetoaddress ran
case K::ReplyTimeout: // the daemon keeps running it and may queue the merge
case K::Aborted: // our abort: if sent it may have queued; if not, the session is ending (result stale)
return MergeFailure::Uncertain;
case K::ShuttingDown:
case K::Unhandled: return MergeFailure::Transient; // the HTTP layer dropped it
case K::Auth: return MergeFailure::Fatal;
default: break; // AppError, Other: by message
}
const std::string m = lower(message);
if (has(m, "not connected")) return MergeFailure::NotSent; // RPCClient throws before sending
if (has(m, "still syncing") || has(m, "fully synced")) return MergeFailure::Syncing; // THROW_IF_SYNCING (cheap)
if (has(m, "spending key") || has(m, "wallet passphrase") || has(m, "walletpassphrase")) return MergeFailure::NeedsUnlock;
if (has(m, "queue is shutting down")) return MergeFailure::Transient; // "...; the operation was not queued"
if (has(m, "could not find any funds") || has(m, "already merged") || has(m, "insufficient funds") ||
has(m, "greater than the net amount"))
return MergeFailure::NothingLeft;
return MergeFailure::Fatal;
}
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e)
{
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
if (kind == rpc::RpcFailureKind::Other) {
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e); t && t->requestSent && t->httpStatus == 0)
return rpc::RpcFailureKind::ReplyTimeout;
}
return kind;
}
RunVerdict runVerdict(const RunSummary& s)
{
switch (s.end) {
case RunEnd::None: return RunVerdict::None;
case RunEnd::Stopped: return RunVerdict::Stopped;
case RunEnd::Halted: return RunVerdict::Halted;
case RunEnd::Aborted: return RunVerdict::Aborted;
case RunEnd::Completed: break;
}
if (s.batchesSubmitted == 0 && s.addressesUnconfirmed == 0) return RunVerdict::NothingSubmitted;
if (s.addressesFailed > 0) return RunVerdict::SomeFailed;
if (s.addressesUnconfirmed > 0) return RunVerdict::Unconfirmed;
return RunVerdict::Clean;
}
const char* runWaitKey(RunWait wait)
{
switch (wait) {
case RunWait::Node:
case RunWait::Busy: return "merge_waiting_node";
case RunWait::Syncing: return "merge_run_wait_sync";
case RunWait::Locked: return "merge_wait_locked";
case RunWait::UserSend: return "merge_run_wait_send";
case RunWait::Scan:
case RunWait::Batch: return "merge_run_wait_batch";
case RunWait::None:
case RunWait::InFlight: break;
}
return nullptr;
}
bool ConsolidationRun::start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now)
{
if (phase_ == Phase::Running) return false;
cfg.limit = std::max(2, cfg.limit);
cfg.maxBatches = std::max(1, cfg.maxBatches);
std::vector<RunAddress> addrs;
for (const auto& g : groups) {
if (g.address.empty() || !worthConsolidating(g, cfg.fee)) continue;
RunAddress a;
a.group = g;
a.run.address = g.address;
addrs.push_back(std::move(a));
}
if (addrs.empty()) return false;
const std::uint64_t gen = gen_ + 1;
*this = ConsolidationRun();
gen_ = gen;
cfg_ = cfg;
addrs_ = std::move(addrs);
phase_ = Phase::Running;
nextCallAt_ = nextPollAt_ = now;
lastPumpAt_ = now;
return true;
}
void ConsolidationRun::requestStop()
{
if (phase_ == Phase::Running) stopping_ = true;
}
void ConsolidationRun::dismiss()
{
if (phase_ == Phase::Running) return;
const std::uint64_t gen = gen_; // a stale result can never match a later run
*this = ConsolidationRun();
gen_ = gen;
}
bool ConsolidationRun::abort()
{
if (phase_ != Phase::Running) return false;
++gen_;
if (callInFlight_ && callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size())) {
auto& a = addrs_[callAddr_];
a.done = true;
worsen(a, AddrOutcome::Uncertain);
}
callInFlight_ = pollInFlight_ = false;
for (auto& b : batches_) {
if (!opStateTerminal(b.state)) applyOpState(b, OpState::Lost, nullptr); // "could not be confirmed"
}
phase_ = Phase::Finished;
end_ = RunEnd::Aborted;
lastWait_ = RunWait::None;
return true;
}
void ConsolidationRun::endAddress(RunAddress& a, AddrOutcome o, const std::string& msg)
{
a.done = true;
worsen(a, o);
if (a.run.error.empty()) a.run.error = msg;
if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg);
}
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep)
{
b.state = st;
if (b.addrIdx < 0 || b.addrIdx >= static_cast<int>(addrs_.size())) return;
RunAddress& a = addrs_[b.addrIdx];
switch (st) {
case OpState::Success:
if (rep) b.txid = rep->txid;
++a.opsDone;
opFailStreak_ = 0;
break;
case OpState::Failed:
if (rep) b.error = rep->error;
if (a.failure.empty()) a.failure = b.error;
a.done = true;
worsen(a, AddrOutcome::Failed);
if (++opFailStreak_ >= kRunMaxFailStreak) halt(b.error);
break;
case OpState::Lost:
a.done = true;
worsen(a, AddrOutcome::Uncertain);
break;
default:
break;
}
}
RunStep ConsolidationRun::next(const RunEnv& env, double now)
{
if (phase_ != Phase::Running) return {};
if (callInFlight_ && now - callPostedAt_ > kRunWatchdogSec) { // its result never came: a late reply is an orphan
callInFlight_ = false;
++gen_;
if (callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size()))
endAddress(addrs_[callAddr_], AddrOutcome::Uncertain, "");
nextCallAt_ = std::max(nextCallAt_, now + kRunMaxGapSec);
}
if (pollInFlight_ && now - pollPostedAt_ > kRunWatchdogSec) {
pollInFlight_ = false;
++gen_;
}
// An op the daemon never reports done is presumed lost after an hour in which it could have been polled
// (not while the node was gone or busy: it may simply not have been asked), or after 4 h whatever happened.
const double dt = std::max(0.0, now - lastPumpAt_);
lastPumpAt_ = now;
const bool pollable = env.nodeUsable && !env.busy;
for (auto& b : batches_) {
if (opStateTerminal(b.state)) continue;
if (pollable) b.pollableSec += std::min(dt, std::max(0.0, now - b.acceptedAt));
if (b.pollableSec > kRunOpMaxSec || now - b.acceptedAt > kRunOpHardMaxSec) applyOpState(b, OpState::Lost, nullptr);
}
while (cur_ < addrs_.size() && addrs_[cur_].done) ++cur_;
const bool moreCalls = !stopping_ && !halted_ && cur_ < addrs_.size();
const int outstanding = static_cast<int>(std::count_if(batches_.begin(), batches_.end(),
[](const RunBatch& b) { return !opStateTerminal(b.state); }));
auto wait = [&](RunWait w) {
lastWait_ = w;
return RunStep{};
};
if (!callInFlight_ && !pollInFlight_ && !moreCalls && outstanding == 0) {
phase_ = Phase::Finished;
end_ = halted_ ? RunEnd::Halted : (stopping_ && cur_ < addrs_.size()) ? RunEnd::Stopped : RunEnd::Completed;
lastWait_ = RunWait::None;
return {RunAction::Finish, -1}; // exactly once
}
if (callInFlight_ || pollInFlight_) return wait(RunWait::InFlight); // one run RPC at a time
if (!env.nodeUsable) return wait(RunWait::Node); // blocks polls too
if (env.busy) return wait(RunWait::Busy); // z_getoperationstatus needs cs_main
if (outstanding > 0 && now >= nextPollAt_) {
lastWait_ = RunWait::None;
return {RunAction::Poll, -1};
}
if (!moreCalls) return wait(RunWait::None); // waiting for submitted batches
if (!env.callsAllowed || now < syncHoldUntil_) return wait(RunWait::Syncing);
if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked);
if (env.userSendBusy) return wait(RunWait::UserSend);
if (now < nextCallAt_) return wait(RunWait::Batch);
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
lastWait_ = RunWait::None;
return {RunAction::Call, static_cast<int>(cur_)};
}
void ConsolidationRun::markCallPosted(int addrIdx, double now)
{
if (phase_ != Phase::Running || addrIdx < 0 || addrIdx >= static_cast<int>(addrs_.size())) return;
callInFlight_ = true;
callAddr_ = addrIdx;
callPostedAt_ = now;
worsen(addrs_[addrIdx], AddrOutcome::Running);
}
void ConsolidationRun::markPollPosted(double now)
{
if (phase_ != Phase::Running) return;
pollInFlight_ = true;
pollPostedAt_ = now;
}
std::string ConsolidationRun::onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r,
rpc::RpcFailureKind kind, double callSec, double now)
{
const bool accepted = r.error.empty() && !r.opid.empty();
if (gen != gen_ || phase_ != Phase::Running || !callInFlight_ || addrIdx != callAddr_)
return accepted ? r.opid : std::string();
callInFlight_ = false;
const double gapAt = now + std::clamp(callSec, kRunMinGapSec, kRunMaxGapSec);
nextCallAt_ = gapAt;
RunAddress& a = addrs_[addrIdx];
if (accepted) {
a.transient = 0;
deadStreak_ = 0;
const bool more = recordMergeResult(a.run, r, cfg_.maxBatches);
RunBatch b;
b.addrIdx = addrIdx;
b.opid = r.opid;
b.merging = r.merging;
b.acceptedAt = now;
batches_.push_back(std::move(b));
nextPollAt_ = now; // learn its state before the next call
if (!more) {
a.done = true;
worsen(a, AddrOutcome::Done);
}
return {};
}
const std::string msg = r.error.empty() ? std::string("no operation id") : r.error;
switch (classifyMergeFailure(kind, msg)) {
case MergeFailure::NotSent: // the same batch again, no retry budget
nextCallAt_ = std::max(gapAt, now + kRunNotSentRetrySec);
break;
case MergeFailure::Syncing: // no retry budget
syncHoldUntil_ = now + kRunSyncHoldSec;
break;
case MergeFailure::NeedsUnlock:
if (++a.unlockRefusals >= kRunMaxUnlockRefusals) {
endAddress(a, AddrOutcome::Failed, msg);
} else {
unlockHoldUntil_ = now + kRunUnlockHoldSec;
lockResync_ = true;
}
break;
case MergeFailure::NothingLeft:
if (a.run.opids.empty()) {
endAddress(a, AddrOutcome::Failed, msg);
} else {
a.done = true;
worsen(a, AddrOutcome::Done);
}
break;
case MergeFailure::Uncertain: // never resent
endAddress(a, AddrOutcome::Uncertain, msg);
break;
case MergeFailure::Transient:
if (++a.transient > kRunMaxTransientRetries) {
endAddress(a, AddrOutcome::Failed, msg);
} else {
nextCallAt_ = std::max(gapAt, now + std::min(60.0, 5.0 * static_cast<double>(1 << (a.transient - 1))));
}
break;
case MergeFailure::Fatal:
endAddress(a, AddrOutcome::Failed, msg);
break;
}
return {};
}
void ConsolidationRun::onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now)
{
if (gen != gen_ || phase_ != Phase::Running || !pollInFlight_) return;
pollInFlight_ = false;
nextPollAt_ = now + kRunPollIntervalSec;
if (!ok) return;
// No call can be in flight during a poll, so the non-terminal batches now are exactly the ids it asked about.
std::map<std::string, const OpReport*> byId;
for (const auto& rep : reports) byId[rep.id] = &rep;
for (auto& b : batches_) {
if (opStateTerminal(b.state)) continue;
const auto it = byId.find(b.opid);
if (it == byId.end()) {
if (++b.misses >= kRunMaxPollMisses) applyOpState(b, OpState::Lost, nullptr);
continue;
}
b.misses = 0;
const OpState st = opStateFromStatus(it->second->status);
if (st != OpState::Unknown) applyOpState(b, st, it->second);
}
}
bool ConsolidationRun::consumeLockResyncRequest()
{
const bool r = lockResync_;
lockResync_ = false;
return r;
}
std::vector<std::string> ConsolidationRun::outstandingOpids() const
{
std::vector<std::string> ids;
for (const auto& b : batches_) {
if (!opStateTerminal(b.state)) ids.push_back(b.opid);
}
return ids;
}
RunProgress ConsolidationRun::progress() const
{
RunProgress p;
p.addresses = static_cast<int>(addrs_.size());
for (const auto& a : addrs_) {
if (a.done) ++p.addressesDone;
const int submitted = static_cast<int>(a.run.opids.size());
p.batchesEstimated += a.done ? submitted
: std::max(std::min(estimateBatches(a.group.notes, cfg_.limit), cfg_.maxBatches), submitted);
}
p.batchesSubmitted = static_cast<int>(batches_.size());
for (const auto& b : batches_) {
if (opStateTerminal(b.state)) ++p.batchesDone;
if (b.state == OpState::Success) p.notesMerged += b.merging;
}
p.stopping = stopping_;
p.wait = lastWait_;
return p;
}
RunSummary ConsolidationRun::summary() const
{
RunSummary s;
s.end = end_;
s.addresses = static_cast<int>(addrs_.size());
for (const auto& a : addrs_) {
if (a.outcome == AddrOutcome::Failed) ++s.addressesFailed;
if (a.outcome == AddrOutcome::Uncertain) ++s.addressesUnconfirmed;
}
s.batchesSubmitted = static_cast<int>(batches_.size());
for (const auto& b : batches_) {
if (b.state == OpState::Success) {
++s.batchesSucceeded;
s.notesMerged += b.merging;
}
}
s.firstError = haltError_;
if (s.firstError.empty()) {
for (const auto& a : addrs_) {
if (a.outcome != AddrOutcome::Failed) continue;
s.firstError = !a.failure.empty() ? a.failure : a.run.error;
if (!s.firstError.empty()) break;
}
}
return s;
}
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in)
{
if (!in.mergeMode || scan.phase == ScopePhase::Loaded) return ScopeWant::Nothing;
if (scan.inFlight || in.busyWithOp) return ScopeWant::Nothing;
if (scan.phase == ScopePhase::Failed && !scan.autoRetry) return ScopeWant::Nothing; // waits for Retry
if (in.now < scan.retryAt) return ScopeWant::WaitBackoff;
if (!in.nodeReady) return ScopeWant::WaitNode;
return ScopeWant::Post;
}
std::uint32_t scopeScanBegin(ScopeScan& scan, double now)
{
scan.phase = ScopePhase::Loading;
scan.inFlight = true;
scan.inFlightGen = scan.gen;
scan.startedAt = now;
return scan.gen;
}
bool scopeScanFinish(ScopeScan& scan, std::uint32_t gen, const std::string& error, rpc::RpcFailureKind kind,
double now)
{
if (scan.inFlight && gen == scan.inFlightGen) scan.inFlight = false;
if (gen != scan.gen) return false; // a scan from an earlier session: its data is not this one's
if (error.empty()) {
scan.phase = ScopePhase::Loaded;
scan.failures = 0;
scan.autoRetry = false;
scan.retryAt = 0.0;
scan.error.clear();
return true;
}
scan.phase = ScopePhase::Failed;
++scan.failures;
scan.error = error;
scan.autoRetry = scopeFailureRetries(kind, error) && scan.failures <= kScopeMaxAutoRetries;
scan.retryAt = scan.autoRetry ? now + scopeRetryDelaySec(scan.failures) : 0.0;
return false;
}
void scopeScanOpen(ScopeScan& scan, double now)
{
if (scan.inFlight && now - scan.startedAt > kScopeInFlightMaxSec) scan.inFlight = false; // lost
if (scan.inFlight && scan.phase == ScopePhase::Loading) return; // still loading for the last session
++scan.gen;
scan.phase = ScopePhase::Idle;
scan.failures = 0;
scan.autoRetry = false;
scan.retryAt = 0.0;
scan.error.clear();
}
void scopeScanInvalidate(ScopeScan& scan)
{
++scan.gen;
scan.phase = ScopePhase::Idle;
scan.failures = 0;
scan.autoRetry = false;
scan.retryAt = 0.0;
scan.error.clear();
}
void scopeScanRetry(ScopeScan& scan)
{
if (scan.phase != ScopePhase::Failed) return;
scan.phase = ScopePhase::Idle;
scan.failures = 0;
scan.autoRetry = false;
scan.retryAt = 0.0;
scan.error.clear();
}
double scopeRetryDelaySec(int failures)
{
if (failures <= 1) return 10.0;
if (failures == 2) return 30.0;
return 60.0;
}
bool scopeFailureRetries(rpc::RpcFailureKind kind, const std::string& message)
{
using K = rpc::RpcFailureKind;
switch (kind) {
case K::NotListening:
case K::ConnectTimeout:
case K::Warmup:
case K::Deferred:
case K::ShuttingDown:
case K::Unhandled:
case K::Aborted:
return true;
case K::Other: {
std::string lower = message;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return lower.find("not connected") != std::string::npos;
}
default:
return false; // ReplyTimeout, QueueFull, AppError, Auth, parse/type errors
}
}
} // namespace services
} // namespace dragonx
+288
View File
@@ -0,0 +1,288 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Consolidating notes WITHOUT moving funds between addresses: each address's notes are merged back into
// that same address (z_mergetoaddress [addr] addr), so funds kept apart under different labels stay apart
// while the wallet sheds notes (fewer notes = smaller wallet, faster witness rebuilds and scans).
// Shielded only: a transparent address can't receive a non-coinbase output on DragonX (private chain).
// Pure apart from the merge call it is handed, so the test target can drive it.
#include "rpc/rpc_failure.h"
#include <nlohmann/json.hpp>
#include <chrono>
#include <cstdint>
#include <functional>
#include <set>
#include <string>
#include <vector>
namespace dragonx {
namespace services {
// One shielded address's spendable notes (confirmed, spendable), from z_listunspent.
struct NoteGroup {
std::string address;
int notes = 0;
double amount = 0.0;
};
// Spendable shielded notes grouped by address, most notes first. Transparent UTXOs and unconfirmed,
// watch-only or locked notes are left out (z_mergetoaddress only spends confirmed, unlocked notes it has
// keys for).
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent);
// A z_listunspent entry the wallet has locked, e.g. for an operation in flight ("locked": true).
bool noteIsLocked(const nlohmann::json& zListUnspentEntry);
// Worth consolidating in place: at least two notes, and more than one batch's fee to work with.
bool worthConsolidating(const NoteGroup& group, double fee);
// Merge calls needed to bring `notes` down when each call takes at most `limit` of them. A lone leftover
// note isn't a call (the daemon refuses to merge one note into its own address).
int estimateBatches(int notes, int limit);
// What one z_mergetoaddress call reported.
struct MergeCallResult {
std::string opid;
int merging = 0; // notes this call is merging
int remaining = 0; // notes of the address it left for a later call
std::string error; // non-empty: the call failed (nothing was submitted)
};
using MergeCall = std::function<MergeCallResult(const std::string& address)>;
// The outcome for one address.
struct AddressRun {
std::string address;
std::vector<std::string> opids; // one per submitted batch
int notesMerged = 0;
int remaining = 0; // notes still left after the last batch
std::string error; // the call that stopped it, if any (earlier batches still went out)
};
// Submit merges of `address` into itself until fewer than two of its notes are left unmerged. The daemon
// locks a batch's notes before the call returns, so each call takes the next batch. At most maxBatches.
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches = 20);
// ── Merge-call stepper ──
// One call's result into the address's run (r.error set: run.error = r.error, false). True while another call
// for this address is worth making: at least 2 unlocked notes left and fewer than maxBatches submitted.
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches = 20);
// z_mergetoaddress's reply. Never throws. A non-empty string "opid" = accepted; mergingNotes/remainingNotes only
// when integral (clamped to [0, INT_MAX]), else 0; no opid: error "no operation id".
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out);
// ── Async op status ──
enum class OpState : std::uint8_t { Unknown, Queued, Executing, Success, Failed, Lost };
inline bool opStateTerminal(OpState s) { return s == OpState::Success || s == OpState::Failed || s == OpState::Lost; }
struct OpReport {
std::string id, status, error, txid; // error = error.message, txid = result.txid
};
OpState opStateFromStatus(const std::string& status); // queued/executing/success; failed|cancelled|canceled: Failed
// Type-checked, never throws. Skips non-objects and missing/non-string/empty ids; keeps only `wanted` (all if empty).
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted = {});
// ── Merge-call failures ──
enum class MergeFailure : std::uint8_t {
NotSent, // turned away before z_mergetoaddress ran: the same batch again, later
Syncing, // THROW_IF_SYNCING: hold the run while the node catches up
NeedsUnlock, // a locked keystore ("could not find spending key")
NothingLeft, // no funds / already merged / too small for the fee
Uncertain, // it may have queued the merge (a reply timeout, an answer without an opid): never resent
Transient, // the HTTP layer dropped it, or the async queue is shutting down: retry with backoff
Fatal, // anything else: this address is done
};
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, 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.
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e);
// ── Paced per-address consolidation run (owned by App, pumped from App::update(); main thread) ──
// The "Keep in each address" mode used to post one task that called z_mergetoaddress for every batch of every
// address back to back. Each call is a whole-wallet scan under cs_main, and the daemon runs the ops one at a
// time anyway. Here: one run RPC at a time, the next call only after the previous batch left the queue and a
// gap as long as the last call took, failures classified (nothing that may have queued is ever resent), and a
// breaker after three dead addresses or three failed ops in a row.
inline constexpr double kRunMinGapSec = 2.0;
inline constexpr double kRunMaxGapSec = 120.0;
inline constexpr double kRunPollIntervalSec = 3.0;
inline constexpr long kRunCallTimeoutSec = 120;
inline constexpr double kRunWatchdogSec = 1800.0; // a call or poll outstanding this long is presumed lost
inline constexpr double kRunOpMaxSec = 3600.0; // an op not terminal after this much pollable time is presumed lost
inline constexpr double kRunOpHardMaxSec = 4 * kRunOpMaxSec; // ...and after this long whatever happened
inline constexpr double kRunNotSentRetrySec = 5.0;
inline constexpr double kRunSyncHoldSec = 30.0;
inline constexpr double kRunUnlockHoldSec = 30.0;
inline constexpr int kRunMaxTransientRetries = 5;
inline constexpr int kRunMaxUnlockRefusals = 2;
inline constexpr int kRunMaxFailStreak = 3;
inline constexpr int kRunMaxPollMisses = 2;
struct RunConfig {
double fee = 0.0001;
int limit = 50;
int maxBatches = 20;
};
struct RunEnv {
bool nodeUsable = false; // connected, not warming up or rescanning
bool busy = false; // the busy gate holds RPCs that need cs_main
bool callsAllowed = true; // not syncing / settling
bool locked = false;
bool userSendBusy = false; // a send / shield / auto-shield / chat op of ours is in progress
bool scanBusy = false; // another GUI wallet scan is in the daemon
};
enum class RunAction : std::uint8_t { None, Call, Poll, Finish };
enum class RunWait : std::uint8_t { None, InFlight, Node, Busy, Syncing, Locked, UserSend, Scan, Batch };
enum class RunEnd : std::uint8_t { None, Completed, Stopped, Halted, Aborted };
enum class AddrOutcome : std::uint8_t { Pending, Running, Done, Uncertain, Failed }; // ordered: worse wins
enum class RunVerdict : std::uint8_t { None, Clean, NothingSubmitted, SomeFailed, Unconfirmed, Stopped, Halted, Aborted };
struct RunStep {
RunAction action = RunAction::None;
int addrIdx = -1;
};
struct RunAddress {
NoteGroup group;
AddressRun run;
AddrOutcome outcome = AddrOutcome::Pending;
bool done = false;
int opsDone = 0;
int transient = 0;
int unlockRefusals = 0;
std::string failure;
};
struct RunBatch {
int addrIdx = 0;
std::string opid;
int merging = 0;
OpState state = OpState::Unknown;
std::string error, txid;
int misses = 0;
double acceptedAt = 0.0;
double pollableSec = 0.0; // time since accepted in which a status poll could have gone out
};
struct RunProgress {
int addresses = 0, addressesDone = 0, batchesSubmitted = 0, batchesEstimated = 0, batchesDone = 0, notesMerged = 0;
bool stopping = false;
RunWait wait = RunWait::None;
};
struct RunSummary {
RunEnd end = RunEnd::None;
int addresses = 0, addressesFailed = 0, addressesUnconfirmed = 0, batchesSubmitted = 0, batchesSucceeded = 0,
notesMerged = 0;
std::string firstError;
};
RunVerdict runVerdict(const RunSummary& s);
const char* runWaitKey(RunWait wait); // i18n key of the wait line, nullptr when there is nothing to say
class ConsolidationRun {
public:
bool start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now);
void requestStop();
bool abort();
RunStep next(const RunEnv& env, double now);
void markCallPosted(int addrIdx, double now);
void markPollPosted(double now);
// Returns an orphan opid (an accepted merge whose result is stale) for the caller to track, else "".
std::string onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r, rpc::RpcFailureKind kind,
double callSec, double now);
void onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now);
bool consumeLockResyncRequest();
std::vector<std::string> outstandingOpids() const;
void dismiss();
bool active() const { return phase_ == Phase::Running; }
bool hasRun() const { return phase_ != Phase::Idle; }
bool stopping() const { return stopping_; }
std::uint64_t gen() const { return gen_; }
RunWait lastWait() const { return lastWait_; }
RunEnd end() const { return end_; }
RunProgress progress() const;
RunSummary summary() const;
const std::vector<RunAddress>& addresses() const { return addrs_; }
const std::vector<RunBatch>& batches() const { return batches_; }
const RunConfig& config() const { return cfg_; }
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 halt(const std::string& msg) { halted_ = true; if (haltError_.empty()) haltError_ = msg; }
Phase phase_ = Phase::Idle;
RunEnd end_ = RunEnd::None;
RunConfig cfg_;
std::vector<RunAddress> addrs_;
std::vector<RunBatch> batches_;
std::uint64_t gen_ = 0;
std::size_t cur_ = 0;
int callAddr_ = -1;
bool callInFlight_ = false, pollInFlight_ = false, stopping_ = false, halted_ = false, lockResync_ = false;
double callPostedAt_ = 0, pollPostedAt_ = 0, nextCallAt_ = 0, nextPollAt_ = 0, syncHoldUntil_ = 0,
unlockHoldUntil_ = 0;
int deadStreak_ = 0, opFailStreak_ = 0;
std::string haltError_;
RunWait lastWait_ = RunWait::None;
double lastPumpAt_ = 0.0;
};
// ── Scope scan (the Merge/Consolidate dialog's listunspent + z_listunspent) ─────────────────────────
// Both calls are whole-wallet scans under the daemon's cs_main. The dialog used to post the pair again on
// the frame after any failure, every frame for as long as it stayed open (and forever on Lite, which has
// no node). This decides when a scan may be posted; the dialog owns one ScopeScan.
inline double steadySeconds() // ImGui::GetTime() stands still while the window is minimized
{
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
inline constexpr long kScopeCallTimeoutSec = 120; // each call; a large wallet can take minutes
inline constexpr double kScopeInFlightMaxSec = 1800; // a scan outstanding this long is presumed lost
inline constexpr int kScopeMaxAutoRetries = 3;
enum class ScopePhase { Idle, Loading, Loaded, Failed };
struct ScopeScan {
ScopePhase phase = ScopePhase::Idle;
std::uint32_t gen = 0; // bumped per dialog session; a result from another session is dropped
bool inFlight = false; // a scan is posted and its result not yet in (survives close/reopen)
std::uint32_t inFlightGen = 0; // the session that posted it: only its result clears inFlight
double startedAt = 0.0;
int failures = 0;
bool autoRetry = false; // the last failure was "node not ready yet": retry by itself
double retryAt = 0.0;
std::string error;
};
struct ScopeInputs {
bool mergeMode = false;
bool busyWithOp = false; // a merge was submitted or is in flight
bool nodeReady = false; // App::walletScanReady()
double now = 0.0;
};
enum class ScopeWant { Nothing, Post, WaitNode, WaitBackoff };
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in);
// Mark a scan posted: Loading, in flight for this session. Returns the generation to finish it with.
std::uint32_t scopeScanBegin(ScopeScan& scan, double now);
// Record a scan's result. True when it belongs to the current session and succeeded: apply its data.
bool scopeScanFinish(ScopeScan& scan, std::uint32_t gen, const std::string& error, rpc::RpcFailureKind kind,
double now);
// The dialog opened: start a new session, unless a scan of the last one is still loading (adopt it).
void scopeScanOpen(ScopeScan& scan, double now);
// The data went stale (reconnect): the next want is a fresh scan. An in-flight scan stays guarded.
void scopeScanInvalidate(ScopeScan& scan);
// The user pressed Retry after a failure.
void scopeScanRetry(ScopeScan& scan);
double scopeRetryDelaySec(int failures); // 10, 30, then 60 s
// Retry by itself only when the node wasn't ready (refused, warming up, held back as busy, aborted):
// not after a reply timeout or a real error, each of which would cost another pair of full scans.
bool scopeFailureRetries(rpc::RpcFailureKind kind, const std::string& message);
} // namespace services
} // namespace dragonx
+32
View File
@@ -26,6 +26,18 @@ RefreshScheduler::Intervals RefreshScheduler::intervalsForPage(ui::NavPage page)
}
}
RefreshScheduler::Intervals RefreshScheduler::selectIntervals(ui::NavPage page, bool syncing, bool background)
{
if (background) return kBackgroundProfile;
if (syncing) return kSyncProfile;
return intervalsForPage(page);
}
static_assert(RefreshScheduler::kSyncProfile.core * 2.0f <= RefreshScheduler::kSyncSettleSeconds,
"the sync settle window must outlast two Core polls");
static_assert(RefreshScheduler::kBackgroundProfile.core * 2.0f <= RefreshScheduler::kSyncSettleSeconds,
"the sync settle window must outlast two Core polls");
void RefreshScheduler::applyPage(ui::NavPage page)
{
setIntervals(intervalsForPage(page));
@@ -47,10 +59,16 @@ void RefreshScheduler::tick(float deltaSeconds)
timers_.fast += delta;
timers_.txAge += delta;
timers_.opid += delta;
for (float& h : holds_) {
if (h > 0.0f) h = std::max(0.0f, h - delta);
}
}
bool RefreshScheduler::isDue(Timer timer) const
{
const float h = hold(timer);
if (h > 0.0f) return false; // held by deferFor()
if (h == 0.0f) return true; // the hold ran out: due now, whatever the interval
float timerInterval = interval(timer);
return timerInterval > 0.0f && timerRef(timer) >= timerInterval;
}
@@ -65,19 +83,33 @@ bool RefreshScheduler::consumeDue(Timer timer)
void RefreshScheduler::reset(Timer timer)
{
timerRef(timer) = 0.0f;
holdRef(timer) = -1.0f;
}
void RefreshScheduler::markDue(Timer timer)
{
holdRef(timer) = -1.0f; // an explicit "now" (user action) overrides a backoff
float timerInterval = interval(timer);
timerRef(timer) = timerInterval > 0.0f ? timerInterval : 0.0f;
}
void RefreshScheduler::setTimer(Timer timer, float seconds)
{
holdRef(timer) = -1.0f;
timerRef(timer) = std::max(0.0f, seconds);
}
void RefreshScheduler::deferFor(Timer timer, float seconds)
{
holdRef(timer) = std::max(0.0f, seconds);
}
void RefreshScheduler::retrySoon(Timer timer, float seconds)
{
holdRef(timer) = -1.0f;
timerRef(timer) = std::max(0.0f, interval(timer) - std::max(0.0f, seconds));
}
float RefreshScheduler::timer(Timer timer) const
{
return timerRef(timer);
+24
View File
@@ -2,6 +2,8 @@
#include "ui/sidebar.h"
#include <array>
namespace dragonx {
namespace services {
@@ -41,8 +43,17 @@ public:
// the lightweight Console tab syncs faster than the Peers tab. Reverts to the per-tab profile
// once sync completes. (Tx/address/balance data is incomplete mid-sync anyway.)
static constexpr Intervals kSyncProfile{10.0f, 0.0f, 0.0f, 0.0f};
// While the window is in the background (minimized, hidden, or covered without focus): only the chain
// state, slower. It keeps sync detection, the lost-connection check and the balance's own rare poll
// (auto-shield) alive; the wallet scans and peers wait for the window to come back.
static constexpr Intervals kBackgroundProfile{15.0f, 0.0f, 0.0f, 0.0f};
// How long the sync profile holds after the node last read "behind" (see the Core callback). Must
// outlast two Core polls in every held profile, or it could lapse between two "behind" readings.
static constexpr float kSyncSettleSeconds = 30.0f;
static Intervals intervalsForPage(ui::NavPage page);
// The profile in force: background, else syncing, else the page's.
static Intervals selectIntervals(ui::NavPage page, bool syncing, bool background);
void applyPage(ui::NavPage page);
void setIntervals(Intervals intervals);
@@ -55,6 +66,14 @@ public:
void reset(Timer timer);
void markDue(Timer timer);
void setTimer(Timer timer, float seconds);
// Hold a timer for exactly `seconds`, whatever the page's interval (which only measures elapsed
// time): not due until then, due as soon as it runs out. consumeDue/reset/markDue/setTimer end it.
// For retry backoff: the old "setTimer(interval - delay)" collapsed to an immediate retry wherever
// the page interval was shorter than the delay (every frame on Overview/Send).
void deferFor(Timer timer, float seconds);
// Due again `seconds` from now (a scan the governor held back asks again soon). Unlike deferFor, never
// due on a 0-interval timer (the sync profile's), so a held-back scan can't fire mid-sync.
void retrySoon(Timer timer, float seconds);
float timer(Timer timer) const;
float interval(Timer timer) const;
@@ -79,9 +98,14 @@ private:
float& timerRef(Timer timer);
const float& timerRef(Timer timer) const;
static constexpr int kTimerCount = 8;
float& holdRef(Timer timer) { return holds_[static_cast<int>(timer)]; }
float hold(Timer timer) const { return holds_[static_cast<int>(timer)]; }
Intervals intervals_{kCoreDefault, kTransactionDefault, kAddressDefault, kPeerDefault};
Timers timers_;
// deferFor() holds: > 0 counting down (not due), 0 ran out (due), < 0 none.
std::array<float, kTimerCount> holds_{{-1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f}};
};
} // namespace services
+146
View File
@@ -0,0 +1,146 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// The hard gates in front of the wallet-wide scans: whether the node can take one at all right now. The
// WalletScanGovernor decides on top of them how often. The scan entry points (refreshAddressData,
// refreshTransactionData) check these themselves, so no caller (tab entry, refreshData, an import, the
// history's re-route through the address scan) can send a scan into a node that is syncing, warming up,
// rescanning, busy or locked, or while the window is in the background. The cheap address listing (no
// wallet scan) still runs for a stale list, and the session's first balances are read once even while the
// node catches up (otherwise every balance would read 0 for the whole catch-up).
//
// Pure, header-only.
#include "ui/sidebar.h"
#include <algorithm>
namespace dragonx {
namespace services {
enum class ScanTier {
None, // nothing
ListOnly, // the address lists only (z_listaddresses, getaddressesbyaccount): cheap, no wallet scan
Full, // the lists and the per-address balances (z_listunspent, listunspent)
};
struct ScanContext {
bool connected = false;
bool locked = false;
bool warmingUp = false;
bool runtimeRescan = false;
bool rpcBusy = false;
bool syncing = false; // effectivelySyncing(): behind, or settling after catching up
bool gateOpen = true; // the ConnectScanGate
bool background = false; // the window is minimized, hidden, or covered without focus
};
// The node answers RPCs that take cs_main without queueing behind something long.
inline bool nodeCanAnswerScans(const ScanContext& c)
{
return c.connected && !c.warmingUp && !c.runtimeRescan && !c.rpcBusy;
}
// A wallet-wide scan (O(wallet), holds cs_main) may be sent.
inline bool wholeWalletScanAllowed(const ScanContext& c)
{
return nodeCanAnswerScans(c) && !c.background && !c.locked && !c.syncing && c.gateOpen;
}
// The session's first read of a wallet value (the balance, the per-address balances): like
// wholeWalletScanAllowed, but also while the node catches up. Once, so the catch-up shows the wallet's last
// known values instead of 0.
inline bool firstReadAllowed(const ScanContext& c)
{
return nodeCanAnswerScans(c) && !c.background && !c.locked && c.gateOpen;
}
// listStale: never loaded this connection, or the address set may have changed (import, sweep,
// decrypt-import). A stale list still loads while the scans are held, without the balances.
// balancesOwed: no full pass has read the per-address balances yet this session.
inline ScanTier planAddressScan(const ScanContext& c, bool listStale, bool balancesOwed = false)
{
if (wholeWalletScanAllowed(c)) return ScanTier::Full;
if (balancesOwed && firstReadAllowed(c)) return ScanTier::Full;
return (nodeCanAnswerScans(c) && listStale) ? ScanTier::ListOnly : ScanTier::None;
}
// What entering a page does. Wallet data isn't fetched on entry any more (the periodic refresh and the
// scan governor decide), except what the page can't show without: a stale address list, a never-loaded
// history.
struct PageEntryPlan {
bool core = false;
bool peers = false;
bool mining = false;
ScanTier addressesNow = ScanTier::None;
bool transactionsNow = false;
bool markAddresses = false;
bool markTransactions = false;
};
inline PageEntryPlan planPageEntry(ui::NavPage page, const ScanContext& c, bool addressListStale,
bool transactionsNeverLoaded)
{
using NP = ui::NavPage;
PageEntryPlan p;
if (!c.connected) return p;
switch (page) {
case NP::Overview:
p.core = !c.runtimeRescan && !c.rpcBusy;
break;
case NP::Peers:
p.peers = !c.rpcBusy;
break;
case NP::Mining:
p.mining = !c.warmingUp && !c.runtimeRescan && !c.rpcBusy;
break;
case NP::Send:
case NP::Receive:
case NP::History: {
const ScanTier tier = planAddressScan(c, addressListStale);
const bool loadNow = addressListStale && tier != ScanTier::None;
if (loadNow) p.addressesNow = tier;
else if (page != NP::History) p.markAddresses = true; // History needs the addresses only when stale
if (page == NP::History) {
if (!loadNow && transactionsNeverLoaded && wholeWalletScanAllowed(c)) p.transactionsNow = true;
else p.markTransactions = true;
}
break;
}
default:
break;
}
return p;
}
// The window counts as in the background when it is minimized or hidden, or covered while none of the
// app's windows has focus (SDL reports occlusion on X11, Wayland and macOS, never on Windows).
inline bool windowLooksBackground(bool minimized, bool hidden, bool occluded, bool appHasFocus)
{
return minimized || hidden || (occluded && !appHasFocus);
}
constexpr double kBackgroundDebounceSeconds = 3.0;
constexpr double kBackgroundInputGraceSeconds = 2.0;
// Background once it has looked so for a few seconds with no input meanwhile (a restore is immediate).
inline bool isBackgroundWindow(bool flagged, double flaggedForSeconds, double secondsSinceInput)
{
return flagged && flaggedForSeconds >= kBackgroundDebounceSeconds &&
secondsSinceInput >= kBackgroundInputGraceSeconds;
}
// After a failed address enumeration, hold address refreshes for max(10 s, 10x what that pass cost). A pass
// that failed because the node was busy (a reply timeout, a full queue, held back by the busy gate) gets the
// floor: its wall time was the wait for cs_main, not its cost, and the busy gate already paces a busy node.
inline double addressRetryDelaySeconds(double failedScanMs, bool nodeBusy = false)
{
if (nodeBusy) return 10.0;
return std::max(10.0, (std::max(0.0, failedScanMs) / 1000.0) / 0.10);
}
} // namespace services
} // namespace dragonx
+20
View File
@@ -0,0 +1,20 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
namespace dragonx {
namespace services {
// The expensive z_gettotalbalance only changes the seed-migration adopt gate once the sweep is mined and
// we have no post-mine figure yet (priorRemaining < 0). A manual Refresh forces it; an active sync
// suppresses the automatic scan (the Refresh button still overrides).
inline bool migrationWantsBalanceScan(bool forceBalance, bool syncing, int confs, double priorRemaining)
{
if (forceBalance) return true;
return !syncing && confs >= 1 && priorRemaining < 0.0;
}
} // namespace services
} // namespace dragonx
+331
View File
@@ -0,0 +1,331 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "wallet_scan_governor.h"
#include <algorithm>
#include <limits>
namespace dragonx {
namespace services {
ScanVisibility scanVisibilityForPage(ui::NavPage page, bool windowBackground)
{
ScanVisibility v;
if (windowBackground) return v;
using NP = ui::NavPage;
switch (page) {
case NP::Overview:
v.balance = v.addresses = v.history = true; // totals, per-address balances, recent activity
break;
case NP::Send:
case NP::Receive:
v.addresses = v.history = true; // per-address balances, recent sends / receives
break;
case NP::History:
v.history = true;
break;
case NP::Market:
v.balance = true; // portfolio value
break;
case NP::Chat:
v.chat = true;
break;
default:
break;
}
return v;
}
double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed)
{
const double floorSec = autoShieldArmed ? 120.0 : 600.0;
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;
// The session's first read runs even while the node catches up, once per connection and only when
// someone can see it: otherwise the total reads 0 for the whole catch-up. After that (or if that one
// attempt failed) the balance waits for the sync to finish, a forced one too.
if (in.neverFetched && !(in.syncing && (in.background || in.firstReadTried))) return BalancePollDecision::Explicit;
if (in.syncing) return BalancePollDecision::Skip;
// The user's own send, now committed. In the background it waits for the window (the routine rules below
// still apply, e.g. the auto-shield poll); coming back forces it.
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;
return BalancePollDecision::AskGovernor;
}
if (in.sinceLastScanSec < hiddenBalanceIntervalSec(in.emaCostSec, in.autoShieldArmed))
return BalancePollDecision::Skip;
return BalancePollDecision::AskGovernor;
}
double WalletScanGovernor::budgetForTipLag(double base, int blocksBehind)
{
if (blocksBehind <= 0) return base;
if (blocksBehind == 1) return base * 2.0 / 3.0;
return base / 3.0;
}
bool WalletScanGovernor::signalsMatch(ScanStream stream, const ScanSignals& current) const
{
const Stream& st = at(stream);
if (!st.basisValid) return false;
const unsigned mask = cfg_.streams[static_cast<std::size_t>(stream)].signalMask;
if (mask & kScanSignalTip) {
if (current.tipHash.empty() || st.basis.tipHash.empty() || current.tipHash != st.basis.tipHash) return false;
}
if (mask & kScanSignalMempool) {
if (!current.mempoolKnown || !st.basis.mempoolKnown) return false;
if (current.mempoolTxs != st.basis.mempoolTxs || current.mempoolBytes != st.basis.mempoolBytes) return false;
}
if (mask & kScanSignalEpoch) {
if (current.walletEpoch != st.basis.walletEpoch) return false;
}
return true;
}
bool WalletScanGovernor::needed(ScanStream stream, const ScanSignals& current, double now) const
{
const Stream& st = at(stream);
if (!st.basisValid) return true;
if (!signalsMatch(stream, current)) return true;
const double maxAge = cfg_.streams[static_cast<std::size_t>(stream)].maxAgeSec;
return maxAge > 0.0 && now - st.basisAt >= maxAge;
}
double WalletScanGovernor::floorSec(const Stream& stream) const
{
return std::max(stream.askVisible ? cfg_.visibleFloorSec : cfg_.hiddenFloorSec,
cfg_.floorCostMultiple * stream.emaCostMs / 1000.0);
}
ScanStream WalletScanGovernor::bestWaiter(double now) const
{
const bool quiet = now < quietUntil_;
ScanStream best = ScanStream::Count;
double bestKey = std::numeric_limits<double>::infinity();
for (std::size_t i = 0; i < streams_.size(); ++i) {
const Stream& st = streams_[i];
if (st.waitingSince < 0.0) continue;
if (now - st.lastAskAt > cfg_.waiterTtlSec) continue; // stopped asking
if (quiet && now - st.waitingSince < floorSec(st)) continue; // during quiet, only via its floor
const double key = st.waitingSince - (st.askVisible ? cfg_.visibleHeadStartSec : 0.0);
if (key < bestKey) {
bestKey = key;
best = static_cast<ScanStream>(i);
}
}
return best;
}
ScanTicket WalletScanGovernor::issue(ScanStream stream, ScanKind kind, const ScanSignals& current, double now,
bool servesRoutine)
{
ScanTicket t;
t.stream = stream;
t.kind = kind;
t.id = ++nextId_;
t.generation = generation_;
t.dispatchedAt = now;
t.servesRoutine = servesRoutine;
t.basis = current;
live_.push_back(Live{t.id, stream, kind, now, servesRoutine});
return t;
}
ScanTicket WalletScanGovernor::ticketFor(const Live& live) const
{
ScanTicket t;
t.stream = live.stream;
t.kind = live.kind;
t.id = live.id;
t.generation = generation_;
t.dispatchedAt = live.dispatchedAt;
t.servesRoutine = live.servesRoutine;
return t;
}
ScanTicket WalletScanGovernor::tryAdmit(ScanStream stream, bool visible, const ScanSignals& current, double now)
{
Stream& st = at(stream);
if (st.waitingSince < 0.0 || now - st.lastAskAt > cfg_.waitLapseSec) st.waitingSince = now; // a new wait
st.lastAskAt = now;
st.askVisible = visible;
pruneLeases(now);
if (!live_.empty()) return {}; // one wallet scan in the daemon at a time
if (bestWaiter(now) != stream) return {};
ScanTicket t = issue(stream, ScanKind::Routine, current, now, true);
t.waitedSince = st.waitingSince;
st.waitingSince = -1.0;
return t;
}
ScanTicket WalletScanGovernor::beginExplicit(ScanStream stream, const ScanSignals& current, double now,
bool servesRoutine)
{
ScanTicket t = issue(stream, ScanKind::Explicit, current, now, servesRoutine);
if (servesRoutine) {
Stream& st = at(stream);
t.waitedSince = st.waitingSince;
st.waitingSince = -1.0; // served
}
return t;
}
void WalletScanGovernor::eraseLive(std::uint64_t id)
{
live_.erase(std::remove_if(live_.begin(), live_.end(), [id](const Live& l) { return l.id == id; }), live_.end());
}
namespace {
// A ticket that sent nothing gives back the wait it ended (keeping a wait begun since, if older).
void restoreWait(double& waitingSince, double waitedSince)
{
if (waitedSince < 0.0) return;
waitingSince = waitingSince < 0.0 ? waitedSince : std::min(waitingSince, waitedSince);
}
} // namespace
void WalletScanGovernor::cancel(const ScanTicket& ticket)
{
if (!ticket || ticket.generation != generation_) return;
const bool wasLive = std::any_of(live_.begin(), live_.end(), [&](const Live& l) { return l.id == ticket.id; });
if (!wasLive) return;
eraseLive(ticket.id);
restoreWait(at(ticket.stream).waitingSince, ticket.waitedSince);
}
void WalletScanGovernor::complete(const ScanTicket& ticket, double costMs, ScanOutcome outcome, double now)
{
if (!ticket || ticket.generation != generation_) return; // from before a reset()
const bool wasLive = std::any_of(live_.begin(), live_.end(), [&](const Live& l) { return l.id == ticket.id; });
if (!wasLive) return; // already completed (or dropped as an orphan)
eraseLive(ticket.id);
Stream& st = at(ticket.stream);
if (outcome == ScanOutcome::Skipped) { // nothing was sent: no charge, and it keeps its place in line
restoreWait(st.waitingSince, ticket.waitedSince);
return;
}
st.lastScanAt = std::max(st.lastScanAt, ticket.dispatchedAt);
double ms = std::max(0.0, costMs);
if (outcome == ScanOutcome::TimedOut && ticket.servesRoutine) ms = std::max(ms, st.emaCostMs);
if (ms > 0.0) {
// Quiet long enough that this scan was `budget` of the time since it started.
const double debt = ms / 1000.0 * (1.0 / budgetForTipLag(cfg_.budget, tipLag_) - 1.0);
const double cap = now + (ticket.kind == ScanKind::Explicit ? cfg_.explicitDebtCapSec : cfg_.maxQuietSec);
quietUntil_ = std::max(quietUntil_, std::min(std::max(quietUntil_, now) + debt, cap));
}
if (!ticket.servesRoutine) return; // charged; the routine scans' cost and basis are theirs
if (outcome == ScanOutcome::Ok || outcome == ScanOutcome::Partial || outcome == ScanOutcome::TimedOut) {
// Rises at once, decays slowly: a scan that got expensive is spaced out right away.
st.emaCostMs = (st.emaCostMs <= 0.0 || ms > st.emaCostMs) ? ms : 0.7 * st.emaCostMs + 0.3 * ms;
}
if (outcome == ScanOutcome::Failed || outcome == ScanOutcome::TimedOut) {
if (st.pendingValid) st.pendingPoisoned = true;
} else if (outcome == ScanOutcome::Partial) {
if (!st.pendingValid) {
st.pending = ticket.basis;
st.pendingAt = ticket.dispatchedAt;
st.pendingValid = true;
st.pendingPoisoned = false;
}
} else if (outcome == ScanOutcome::Ok) {
// A pass that started at the pending basis is current for that basis (the earliest), unless one of
// its dispatches failed.
if (!(st.pendingValid && st.pendingPoisoned)) {
st.basis = st.pendingValid ? st.pending : ticket.basis;
st.basisAt = st.pendingValid ? st.pendingAt : ticket.dispatchedAt;
st.basisValid = true;
}
st.pendingValid = false;
st.pendingPoisoned = false;
}
}
void WalletScanGovernor::dropOrphans(ScanStream stream, double now)
{
std::vector<Live> orphans;
for (const auto& l : live_) {
if (l.stream == stream) orphans.push_back(l);
}
for (const auto& l : orphans) complete(ticketFor(l), 0.0, ScanOutcome::TimedOut, now);
}
void WalletScanGovernor::pruneLeases(double now)
{
std::vector<Live> expired;
for (const auto& l : live_) {
const double lease = std::max(cfg_.minLeaseSec, 4.0 * at(l.stream).emaCostMs / 1000.0);
if (now - l.dispatchedAt > lease) expired.push_back(l);
}
for (const auto& l : expired) complete(ticketFor(l), 0.0, ScanOutcome::TimedOut, now);
}
void WalletScanGovernor::invalidate(ScanStream stream)
{
Stream& st = at(stream);
st.basisValid = false;
st.pendingValid = false;
st.pendingPoisoned = false;
}
void WalletScanGovernor::reset()
{
const std::uint64_t nextGeneration = generation_ + 1;
const std::uint64_t nextId = nextId_;
*this = WalletScanGovernor(cfg_);
generation_ = nextGeneration;
nextId_ = nextId;
}
bool WalletScanGovernor::inFlight(ScanStream stream) const
{
return std::any_of(live_.begin(), live_.end(), [stream](const Live& l) { return l.stream == stream; });
}
double WalletScanGovernor::secondsSinceScan(ScanStream stream, double now) const
{
const double at_ = at(stream).lastScanAt;
return at_ < 0.0 ? 1e9 : now - at_;
}
bool WalletScanGovernor::waiting(ScanStream stream, double now) const
{
const Stream& st = at(stream);
return st.waitingSince >= 0.0 && now - st.lastAskAt <= cfg_.waitLapseSec;
}
bool WalletScanGovernor::heldWithinFloor(ScanStream stream, double now) const
{
if (!waiting(stream, now)) return false;
const Stream& st = at(stream);
return now - st.waitingSince < floorSec(st) + st.emaCostMs / 1000.0;
}
double WalletScanGovernor::visibleFloorSecFor(ScanStream stream) const
{
return std::max(cfg_.visibleFloorSec, cfg_.floorCostMultiple * at(stream).emaCostMs / 1000.0);
}
} // namespace services
} // namespace dragonx
+219
View File
@@ -0,0 +1,219 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// One budget for every wallet-wide scan the GUI sends on its own: the balance (z_gettotalbalance), the
// address scan (z_listunspent + listunspent), the history (z_listreceivedbyaddress, z_listsentbyaddress) and
// the between-blocks probe. Each holds dragonxd's cs_main for its whole run, seconds per call on a large
// wallet, which starves block connection. They used to be throttled one by one at 10% each, which adds up
// to 30-40% with all of them due. Here: one scan in flight at a time across both RPC lanes, and one shared
// quiet clock that keeps the total near `budget` of wall-clock. A stream that keeps asking is served FIFO
// (visible ones a little first), and a floor scaled to its own cost keeps any stream from starving.
// Explicit needs (the user's own send, a dirty set, a never-loaded list) are never refused, but are charged.
//
// Each stream also keeps the signals it last scanned at (tip hash, mempool size, a wallet-mutation epoch):
// a routine scan is only needed when they changed, or after the stream's safety sweep.
//
// An explicit ticket that doesn't do the stream's routine work (a history cycle that only lists rows) is
// charged to the quiet clock but leaves the stream's wait, cost estimate and basis alone (servesRoutine).
//
// Pure and main-thread only: every call takes `now` in seconds of a steady clock.
#include "ui/sidebar.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace dragonx {
namespace services {
enum class ScanStream : std::uint8_t { Balance, Addresses, History, RecentTx, ChatNotes, ChatFast, Adhoc, Count };
enum class ScanKind : std::uint8_t { Routine, Explicit };
enum class ScanOutcome : std::uint8_t {
Ok, // applied: the stream's signals basis advances
Partial, // applied, more dispatches needed before the basis may advance (a multi-cycle pass)
Failed, // charged; the basis stays (and a pending pass is poisoned)
TimedOut, // a reply timeout: charged at least the stream's usual cost (the daemon still runs it)
Skipped, // admitted, but no scan was sent (node behind, busy, held back): not charged
};
enum : unsigned { kScanSignalTip = 1, kScanSignalMempool = 2, kScanSignalEpoch = 4, kScanSignalAll = 7 };
// What a scan's result depends on, read without cs_main: the tip (bestblockhash, fetched each Core cycle),
// the mempool (getmempoolinfo takes only mempool.cs) and the App's wallet-mutation epoch.
struct ScanSignals {
std::string tipHash;
bool mempoolKnown = false;
std::int64_t mempoolTxs = 0;
std::int64_t mempoolBytes = 0;
std::uint64_t walletEpoch = 0;
};
struct ScanTicket {
ScanStream stream = ScanStream::Balance;
ScanKind kind = ScanKind::Routine;
std::uint64_t id = 0;
std::uint64_t generation = 0;
double dispatchedAt = 0.0;
double waitedSince = -1.0; // when its wait began, if it ended one (cancel() / Skipped restore it)
bool servesRoutine = true; // does the stream's routine work: ends its wait, feeds its cost and basis
ScanSignals basis; // the signals at dispatch: what an Ok result is current for
explicit operator bool() const { return id != 0; }
};
// Which streams the current page shows (visible streams get a head start and a shorter starvation floor).
struct ScanVisibility {
bool balance = false;
bool addresses = false;
bool history = false;
bool chat = false;
};
ScanVisibility scanVisibilityForPage(ui::NavPage page, bool windowBackground);
// The balance part of a Core poll.
enum class BalancePollDecision { Skip, AskGovernor, Explicit };
struct BalancePollInputs {
bool allowBalance = true; // false for the locked wallet's chain-state poll
bool syncing = false; // the node is catching up (or settling after it): only a first read runs
bool background = false; // the window is in the background: no first read while syncing
bool firstReadTried = false; // the first read while syncing was already sent this connection
bool gateOpen = true; // the connect gate (C44): closed right after a connect, at most ~20 s
bool forced = false; // the user's own send (waits while the window is in the background)
bool neverFetched = false; // no balance yet this session
bool changed = true; // the governor's change gate
bool visible = true; // the page shows the aggregate balance
bool autoShieldArmed = false; // client-side auto-shield runs from balance results
double sinceLastScanSec = 1e9; // since the last balance scan that was sent
double emaCostSec = 0.0;
// Coming into view with an old balance (back to Overview, the window restored): one explicit poll
// instead of a fresh wait behind the other streams. Only when the stream isn't already waiting and
// both the value on screen and the last scan are older than the stream's visible floor.
bool waiting = false; // the Balance stream is waiting for admission
double shownAgeSec = 0.0; // age of the value on screen (last fetch, or last poll that confirmed it)
double visibleFloorSec = 30.0; // WalletScanGovernor::visibleFloorSecFor(Balance)
};
// How often a page that doesn't show the balance still polls it: max(10 min, 50x its cost), or every 2 min
// while client-side auto-shield needs it.
double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed);
BalancePollDecision decideBalancePoll(const BalancePollInputs& in);
class WalletScanGovernor {
public:
struct StreamConfig {
unsigned signalMask = kScanSignalAll; // which signals the change gate compares
double maxAgeSec = 0.0; // safety sweep: needed this long after its basis (0 = never)
};
struct Config {
double budget = 0.15; // share of wall-clock for all routine scans together
double explicitDebtCapSec = 120; // an explicit scan never pushes the quiet clock past now + this
double maxQuietSec = 900; // no scan pushes it past now + this (no runaway debt)
double visibleFloorSec = 30; // a waiting visible stream is admitted after this, quiet or not...
double hiddenFloorSec = 900; // ...a hidden one after this...
double floorCostMultiple = 15; // ...or after 15x its own cost if longer (<= ~6% each from floors)
double visibleHeadStartSec = 60; // visible waiters rank as if they had waited this much longer
double waiterTtlSec = 15; // a stream that hasn't asked for this long isn't waiting
double waitLapseSec = 60; // asking again after this long starts a new wait
double minLeaseSec = 600; // a ticket older than max(this, 4x its cost) is presumed lost
std::array<StreamConfig, static_cast<std::size_t>(ScanStream::Count)> streams{{
{kScanSignalAll, 300}, // Balance
{kScanSignalAll, 300}, // Addresses
{kScanSignalTip | kScanSignalEpoch, 0}, // History
{kScanSignalMempool | kScanSignalEpoch, 0}, // RecentTx (between blocks: the tip is History's)
{0, 0}, // ChatNotes: admission only; the need is services::shouldScanChatNotes (tip settle, chat ops)
{0, 0}, // ChatFast: admission only; the need is getwalletinfo's txcount (chatInboxScanNeeded). Not
// the tip or mempool: they move before the wallet notifier stores the tx, so a scan fired
// on them can read the old wallet and consume the change.
{0, 0}, // Adhoc
}};
};
WalletScanGovernor() = default;
explicit WalletScanGovernor(const Config& config) : cfg_(config) {}
// The budget while the node is behind: full at the tip, 2/3 one block behind, 1/3 two or more.
static double budgetForTipLag(double base, int blocksBehind);
// A routine scan of this stream is needed: no basis yet, the signals changed, or the sweep is due.
bool needed(ScanStream stream, const ScanSignals& current, double now) const;
// The stream's basis matches these signals (masked). An unknown tip or mempool never matches.
bool signalsMatch(ScanStream stream, const ScanSignals& current) const;
// Ask to run a routine scan now: a ticket when admitted, else an empty one (the stream keeps waiting
// while it keeps asking). Admitted only when no scan is in flight and this stream is the best waiter.
ScanTicket tryAdmit(ScanStream stream, bool visible, const ScanSignals& current, double now);
// An explicit need: never refused, charged (with capped debt). servesRoutine = false for one that
// doesn't do the stream's routine work: it is charged, but the stream keeps waiting for that work and
// its cost estimate and basis stay those of the routine scans.
ScanTicket beginExplicit(ScanStream stream, const ScanSignals& current, double now, bool servesRoutine = true);
// The scan wasn't sent after all (the job queue refused it): drop the ticket with no charge.
void cancel(const ScanTicket& ticket);
// The scan's result is in. `basis` may differ from the ticket's (e.g. what the worker saw).
void complete(const ScanTicket& ticket, double costMs, ScanOutcome outcome, double now);
// Tickets of this stream whose job is gone without a result: completed as timed out.
void dropOrphans(ScanStream stream, double now);
void setTipLag(int blocksBehind) { tipLag_ = blocksBehind > 0 ? blocksBehind : 0; }
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
bool inFlight(ScanStream stream) const;
bool anyInFlight() const { return !live_.empty(); }
double quietUntil() const { return quietUntil_; }
double emaCostMs(ScanStream stream) const { return at(stream).emaCostMs; }
// Since the dispatch of the last scan that was sent (cancelled and Skipped tickets don't count); 1e9 if none.
double secondsSinceScan(ScanStream stream, double now) const;
double currentBudget() const { return budgetForTipLag(cfg_.budget, tipLag_); }
// The stream asked within the lapse window and hasn't been admitted yet.
bool waiting(ScanStream stream, double now) const;
// Waiting, and still within what the budget promises it: its floor plus one scan of its own. Past that,
// or not asking, a stale value means something else is wrong.
bool heldWithinFloor(ScanStream stream, double now) const;
// The floor it would get as a visible waiter: max(visibleFloorSec, floorCostMultiple x its cost).
double visibleFloorSecFor(ScanStream stream) const;
private:
struct Stream {
double waitingSince = -1.0;
double lastAskAt = -1e18;
bool askVisible = false;
double emaCostMs = 0.0;
double lastScanAt = -1.0; // dispatch time of the last scan that was sent
bool basisValid = false;
ScanSignals basis;
double basisAt = 0.0;
bool pendingValid = false;
bool pendingPoisoned = false;
ScanSignals pending;
double pendingAt = 0.0;
};
struct Live {
std::uint64_t id = 0;
ScanStream stream = ScanStream::Balance;
ScanKind kind = ScanKind::Routine;
double dispatchedAt = 0.0;
bool servesRoutine = true;
};
Stream& at(ScanStream stream) { return streams_[static_cast<std::size_t>(stream)]; }
const Stream& at(ScanStream stream) const { return streams_[static_cast<std::size_t>(stream)]; }
double floorSec(const Stream& stream) const;
ScanStream bestWaiter(double now) const;
ScanTicket issue(ScanStream stream, ScanKind kind, const ScanSignals& current, double now, bool servesRoutine);
ScanTicket ticketFor(const Live& live) const;
void eraseLive(std::uint64_t id);
void pruneLeases(double now);
Config cfg_;
std::array<Stream, static_cast<std::size_t>(ScanStream::Count)> streams_{};
std::vector<Live> live_;
double quietUntil_ = 0.0;
int tipLag_ = 0;
std::uint64_t nextId_ = 0;
std::uint64_t generation_ = 1;
};
} // namespace services
} // namespace dragonx
@@ -64,8 +64,10 @@ WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::restartD
if (!waitForMs(preRestartDelayMs)) return {false, "", false};
if (daemon.isUsingEmbeddedDaemon()) {
daemon.stopEmbeddedDaemon();
const bool stopped = daemon.stopEmbeddedDaemon();
if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false};
// Never start a second node over one that hasn't exited (it holds wallet.dat and the datadir lock).
if (!stopped) return {false, "The node did not stop in time, so it was not restarted.", false};
if (!waitForMs(embeddedRestartSettleMs)) return {false, "", false};
daemon.startEmbeddedDaemon();
}
@@ -49,7 +49,7 @@ public:
public:
virtual ~DaemonGateway() = default;
virtual bool isUsingEmbeddedDaemon() const = 0;
virtual void stopEmbeddedDaemon() = 0;
virtual bool stopEmbeddedDaemon() = 0; // true: the node exited (false: still running; never killed)
virtual bool startEmbeddedDaemon() = 0;
virtual bool cancelled() const = 0;
virtual bool shuttingDown() const = 0;
+72 -162
View File
@@ -33,109 +33,6 @@ namespace dragonx {
namespace ui {
namespace effects {
// ============================================================================
// Composite Shader Source (GLEW Available)
// ============================================================================
namespace {
const char* COMPOSITE_VERTEX = R"glsl(
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 vTexCoord;
out vec2 vNoiseCoord;
uniform vec2 uTexScale; // For noise tiling
void main()
{
gl_Position = vec4(aPos, 0.0, 1.0);
vTexCoord = aTexCoord;
vNoiseCoord = aTexCoord * uTexScale; // Tile noise
}
)glsl";
const char* COMPOSITE_FRAGMENT = R"glsl(
#version 330 core
in vec2 vTexCoord;
in vec2 vNoiseCoord;
out vec4 FragColor;
uniform sampler2D uBlurTex;
uniform sampler2D uNoiseTex;
uniform vec4 uTintColor;
uniform float uTintOpacity;
uniform float uLuminosity;
uniform float uNoiseOpacity;
void main()
{
// Sample blurred background
vec3 blur = texture(uBlurTex, vTexCoord).rgb;
// Apply luminosity (desaturation effect)
float lum = dot(blur, vec3(0.299, 0.587, 0.114));
blur = mix(vec3(lum), blur, uLuminosity);
// Apply tint color
vec3 tinted = mix(blur, uTintColor.rgb, uTintOpacity);
// Sample and apply noise
float noise = texture(uNoiseTex, vNoiseCoord).r;
// Noise is centered at 0.5, so we subtract 0.5 and scale
float noiseOffset = (noise - 0.5) * 2.0 * uNoiseOpacity;
tinted += vec3(noiseOffset);
// Output with slight transparency
FragColor = vec4(tinted, uTintColor.a);
}
)glsl";
bool compileShader(GLuint shader, const char* source)
{
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint success;
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success) {
GLint logLength;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &logLength);
std::vector<char> log(logLength + 1);
glGetShaderInfoLog(shader, logLength, nullptr, log.data());
DEBUG_LOGF("Shader compile error:\n%s\n", log.data());
return false;
}
return true;
}
bool linkProgram(GLuint program)
{
glLinkProgram(program);
GLint success;
glGetProgramiv(program, GL_LINK_STATUS, &success);
if (!success) {
GLint logLength;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &logLength);
std::vector<char> log(logLength + 1);
glGetProgramInfoLog(program, logLength, nullptr, log.data());
DEBUG_LOGF("Program link error:\n%s\n", log.data());
return false;
}
return true;
}
} // anonymous namespace
// ============================================================================
// AcrylicMaterial Implementation (GLEW Available)
// ============================================================================
@@ -172,52 +69,6 @@ bool AcrylicMaterial::init()
return false;
}
// Create composite shader
GLuint vs = glCreateShader(GL_VERTEX_SHADER);
GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
if (!compileShader(vs, COMPOSITE_VERTEX)) {
glDeleteShader(vs);
glDeleteShader(fs);
return false;
}
if (!compileShader(fs, COMPOSITE_FRAGMENT)) {
glDeleteShader(vs);
glDeleteShader(fs);
return false;
}
compositeShader_ = glCreateProgram();
glAttachShader(compositeShader_, vs);
glAttachShader(compositeShader_, fs);
if (!linkProgram(compositeShader_)) {
glDeleteProgram(compositeShader_);
glDeleteShader(vs);
glDeleteShader(fs);
compositeShader_ = 0;
return false;
}
glDeleteShader(vs);
glDeleteShader(fs);
// Get uniform locations
uCompositeTintColor_ = glGetUniformLocation(compositeShader_, "uTintColor");
uCompositeTintOpacity_ = glGetUniformLocation(compositeShader_, "uTintOpacity");
uCompositeLuminosity_ = glGetUniformLocation(compositeShader_, "uLuminosity");
uCompositeNoiseOpacity_ = glGetUniformLocation(compositeShader_, "uNoiseOpacity");
uCompositeBlurTex_ = glGetUniformLocation(compositeShader_, "uBlurTex");
uCompositeNoiseTex_ = glGetUniformLocation(compositeShader_, "uNoiseTex");
uCompositeTexScale_ = glGetUniformLocation(compositeShader_, "uTexScale");
DEBUG_LOGF("AcrylicMaterial: Composite shader uniforms:\n");
DEBUG_LOGF(" uTintColor=%d, uTintOpacity=%d, uLuminosity=%d\n",
uCompositeTintColor_, uCompositeTintOpacity_, uCompositeLuminosity_);
DEBUG_LOGF(" uNoiseOpacity=%d, uBlurTex=%d, uNoiseTex=%d, uTexScale=%d\n",
uCompositeNoiseOpacity_, uCompositeBlurTex_, uCompositeNoiseTex_, uCompositeTexScale_);
// Detect system capabilities
refreshCapabilities();
currentFallback_ = detectFallback();
@@ -239,11 +90,6 @@ void AcrylicMaterial::shutdown()
quad_.destroy();
noiseTexture_.destroy();
if (compositeShader_) {
glDeleteProgram(compositeShader_);
compositeShader_ = 0;
}
initialized_ = false;
viewportWidth_ = 0;
viewportHeight_ = 0;
@@ -260,6 +106,7 @@ void AcrylicMaterial::resize(int width, int height)
viewportWidth_ = width;
viewportHeight_ = height;
blurCacheValid_ = false; // Invalidate cache on resize
if (shouldSkipEffect()) { needsRealloc_ = true; return; } // targets freed/rebuilt by syncBufferLifetime()
// Determine blur buffer size based on quality
int blurWidth = width;
@@ -301,16 +148,46 @@ void AcrylicMaterial::resize(int width, int height)
DEBUG_LOGF("AcrylicMaterial: Resized to %dx%d (blur: %dx%d)\n",
width, height, blurWidth, blurHeight);
needsRealloc_ = false;
dirtyFrames_ = 2; // recapture after resize
}
void AcrylicMaterial::releaseBuffers()
{
captureBuffer_.destroy();
blurBuffers_.destroy();
hasValidCapture_ = false; // drawRect -> drawTintedRect until a fresh capture
blurCacheValid_ = false;
needsRealloc_ = true;
DEBUG_LOGF("AcrylicMaterial: released targets (effect skipped)\n");
}
void AcrylicMaterial::syncBufferLifetime()
{
if (!initialized_) return;
if (shouldSkipEffect()) {
// Test the names, not isValid(): a half-allocated ping-pong pair must be freed too.
if (captureBuffer_.getFbo() || blurBuffers_.getSourceTexture() || blurBuffers_.getDestTexture())
releaseBuffers();
return;
}
if (needsRealloc_ && viewportWidth_ > 0 && viewportHeight_ > 0) {
needsRealloc_ = false; // one attempt; no per-frame retry on alloc failure
int w = viewportWidth_, h = viewportHeight_;
viewportWidth_ = viewportHeight_ = 0; // force resize() past its early-out
resize(w, h);
hasValidCapture_ = false;
markBackgroundDirty();
}
}
void AcrylicMaterial::captureBackground()
{
if (!initialized_ || shouldSkipEffect()) {
return;
}
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) {
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) {
return;
}
@@ -321,7 +198,7 @@ void AcrylicMaterial::captureBackground()
--dirtyFrames_;
// Capture current screen content
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
captureBuffer_.captureScreen(captureSrcWidth(), captureSrcHeight());
hasValidCapture_ = true;
blurCacheValid_ = false; // New capture invalidates blur cache
}
@@ -332,7 +209,7 @@ void AcrylicMaterial::captureBackgroundDirect()
return;
}
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) {
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) {
return;
}
@@ -349,7 +226,7 @@ void AcrylicMaterial::captureBackgroundDirect()
// draw framebuffer, and ImGui has scissor enabled during rendering.
// The subsequent ImDrawCallback_ResetRenderState re-enables it.
glDisable(GL_SCISSOR_TEST);
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
captureBuffer_.captureScreen(captureSrcWidth(), captureSrcHeight());
hasValidCapture_ = true;
blurCacheValid_ = false; // force re-blur with fresh capture
@@ -362,9 +239,9 @@ void AcrylicMaterial::captureLiveFramebuffer()
// inserted at the overlay window's draw-list start), so FB 0 already holds the app UI drawn
// below the overlay. A paired ImDrawCallback_ResetRenderState restores ImGui's GL state.
if (!initialized_ || shouldSkipEffect()) return;
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) return;
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) return;
glDisable(GL_SCISSOR_TEST);
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
captureBuffer_.captureScreen(captureSrcWidth(), captureSrcHeight());
hasValidCapture_ = true;
blurCacheValid_ = false; // force re-blur with the fresh live capture
}
@@ -390,7 +267,7 @@ void AcrylicMaterial::setQuality(AcrylicQuality quality)
void AcrylicMaterial::applyBlur(float radius, bool ignoreMultiplier)
{
if (!blurBuffers_.isValid() || !blurShader_.isValid()) {
if (!blurBuffers_.isValid() || !blurShader_.isValid() || !captureBuffer_.isValid()) {
return;
}
@@ -1188,7 +1065,7 @@ void AcrylicMaterial::resize(int width, int height)
viewportHeight_ = height;
blurCacheValid_ = false;
if (settings_.quality == AcrylicQuality::Off) return;
if (shouldSkipEffect()) { needsRealloc_ = true; return; } // targets freed/rebuilt by syncBufferLifetime()
// (Re)create capture buffer at full resolution — must be B8G8R8A8 to match swap chain for CopyResource
dx_releaseRenderTarget(dx_captureTex_, dx_captureSRV_, dx_captureRTV_);
@@ -1215,10 +1092,43 @@ void AcrylicMaterial::resize(int width, int height)
dx_blurWidth_ = bw;
dx_blurHeight_ = bh;
dx_blurCurrent_ = 0;
needsRealloc_ = false;
dirtyFrames_ = 2;
VERBOSE_LOGF("[Acrylic DX11] Resized %dx%d (blur %dx%d)\n", width, height, bw, bh);
}
void AcrylicMaterial::releaseBuffers()
{
dx_releaseRenderTarget(dx_captureTex_, dx_captureSRV_, dx_captureRTV_);
for (int i = 0; i < 2; ++i)
dx_releaseRenderTarget(dx_blurTex_[i], dx_blurSRV_[i], dx_blurRTV_[i]);
dx_blurWidth_ = dx_blurHeight_ = 0;
dx_blurCurrent_ = 0;
hasValidCapture_ = false; // drawRect -> drawTintedRect until a fresh capture
blurCacheValid_ = false;
needsRealloc_ = true;
DEBUG_LOGF("[Acrylic DX11] released targets (effect skipped)\n");
}
void AcrylicMaterial::syncBufferLifetime()
{
if (!initialized_) return;
if (shouldSkipEffect()) {
// Per texture: each dx_createRenderTarget can fail on its own.
if (dx_captureTex_ || dx_blurTex_[0] || dx_blurTex_[1])
releaseBuffers();
return;
}
if (needsRealloc_ && viewportWidth_ > 0 && viewportHeight_ > 0) {
needsRealloc_ = false; // one attempt; no per-frame retry on alloc failure
int w = viewportWidth_, h = viewportHeight_;
viewportWidth_ = viewportHeight_ = 0; // force resize() past its early-out
resize(w, h);
hasValidCapture_ = false;
markBackgroundDirty();
}
}
void AcrylicMaterial::captureBackground()
{
// Unused on DX11 — capture is done via captureBackgroundDirect()
+17 -16
View File
@@ -147,6 +147,13 @@ public:
* @param height Viewport height
*/
void resize(int width, int height);
/// Size of FB 0 in pixels (differs from the logical viewport on HiDPI GL). Used only as the capture
/// blit source. Kept apart from viewportWidth_/Height_ so setQuality()/syncBufferLifetime() resets
/// of those never lose it. DX11 ignores it (CopyResource needs identical dimensions, scale is 1).
void setFramebufferSize(int w, int h) {
if (w != fbWidth_ || h != fbHeight_) { fbWidth_ = w; fbHeight_ = h; markBackgroundDirty(); }
}
/**
* @brief Capture current screen content for blur source
@@ -194,6 +201,10 @@ public:
/// has actually been rendered (theme switch happens mid-frame, before the
/// BackgroundDrawList is rebuilt with new colors/images).
void markBackgroundDirty() { dirtyFrames_ = 2; }
/// Frame-boundary maintenance. Frees the capture/blur targets while the effect is skipped
/// (Off / disabled / reduced transparency) and rebuilds them when wanted again.
void syncBufferLifetime();
/**
* @brief Render an acrylic-filled rectangle
@@ -348,12 +359,6 @@ private:
*/
void applyBlur(float radius, bool ignoreMultiplier = false);
/**
* @brief Composite final acrylic appearance
*/
void compositeAcrylic(const ImVec2& pMin, const ImVec2& pMax,
const AcrylicParams& params);
/**
* @brief Check if effect should be skipped (settings/quality)
*/
@@ -377,6 +382,10 @@ private:
// Screen dimensions
int viewportWidth_ = 0;
int viewportHeight_ = 0;
int fbWidth_ = 0; // FB 0 pixel size (0 = unknown -> use the viewport size)
int fbHeight_ = 0;
int captureSrcWidth() const { return fbWidth_ > 0 ? fbWidth_ : viewportWidth_; }
int captureSrcHeight() const { return fbHeight_ > 0 ? fbHeight_ : viewportHeight_; }
// Noise texture config
int noiseTextureSize_ = 256; // Texture resolution (larger = less repetition)
@@ -386,6 +395,8 @@ private:
bool blurCacheValid_ = false;
int dirtyFrames_ = 2; // >0 means recapture needed; counts down each frame
bool hasValidCapture_ = false; // true once a clean BG capture exists
void releaseBuffers();
bool needsRealloc_ = false; // targets were freed or left stale while the effect was skipped
// Capture and blur framebuffers
Framebuffer captureBuffer_;
@@ -400,16 +411,6 @@ private:
// Noise texture
NoiseTexture noiseTexture_;
// Composite shader (tint + noise overlay)
GLuint compositeShader_ = 0;
GLint uCompositeTintColor_ = -1;
GLint uCompositeTintOpacity_ = -1;
GLint uCompositeLuminosity_ = -1;
GLint uCompositeNoiseOpacity_ = -1;
GLint uCompositeBlurTex_ = -1;
GLint uCompositeNoiseTex_ = -1;
GLint uCompositeTexScale_ = -1;
#ifdef DRAGONX_USE_DX11
// ---- DX11 acrylic resources (used instead of GL objects above) ----
ID3D11Device* dx_device_ = nullptr;
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <algorithm>
#include <cmath>
namespace dragonx { namespace ui { namespace effects {
/// Splits a rounded-rect border into line segments and writes the ascending perimeter fractions
/// out[0]=0 .. out[n]=1 (same parametrisation and piece order as ThemeEffects::perimeterPoint:
/// top, TR arc, right, BR arc, bottom, BL arc, left, TL arc). Straight edges get one segment per
/// `edgeStepPx` (<= 24 per edge); each corner arc gets clamp(ceil(r*0.4),3,8) chords, i.e. a
/// worst-case sag below 0.2 px. Returns n (>=1); needs maxSegs >= 128 + 1 entries in `out`.
inline int BorderSampleFractions(float w, float h, float r, float edgeStepPx,
float* out, int maxOut /* capacity of out, >= 129 */) {
constexpr float kPi = 3.14159265f;
out[0] = 0.0f;
if (w <= 0.0f || h <= 0.0f || maxOut < 129) { out[1] = 1.0f; return 1; }
r = std::min(r, std::min(w, h) * 0.5f);
if (r < 0.5f) r = 0.0f; // perimeterPoint treats tiny rounding as none
const float step = std::max(1.0f, edgeStepPx);
float len[8]; int segs[8]; int np;
auto edge = [&](float l) { return l <= 0.0f ? 0 : std::clamp((int)std::ceil(l / step), 1, 24); };
if (r <= 0.0f) {
np = 4;
len[0] = w; len[1] = h; len[2] = w; len[3] = h;
for (int i = 0; i < 4; ++i) segs[i] = edge(len[i]);
} else {
np = 8;
const float arc = kPi * r * 0.5f, ew = w - 2.0f * r, eh = h - 2.0f * r;
const int arcSegs = std::clamp((int)std::ceil(r * 0.4f), 3, 8);
const float L[8] = { ew, arc, eh, arc, ew, arc, eh, arc };
for (int i = 0; i < 8; ++i) { len[i] = L[i]; segs[i] = (i & 1) ? arcSegs : edge(L[i]); }
}
float perim = 0.0f; for (int i = 0; i < np; ++i) perim += len[i];
int n = 0; float d0 = 0.0f;
for (int i = 0; i < np; ++i) {
for (int s = 1; s <= segs[i]; ++s) {
out[++n] = (d0 + len[i] * (float)s / (float)segs[i]) / perim;
}
d0 += len[i];
}
if (n < 1) { out[1] = 1.0f; return 1; }
out[n] = 1.0f;
return n; // worst case 4*24 + 4*8 = 128
}
}}} // namespace

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