20 Commits
Author SHA1 Message Date
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
67 changed files with 8655 additions and 2313 deletions
+43 -1
View File
@@ -45,6 +45,7 @@ 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)
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 +155,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 +271,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 +284,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()
@@ -455,6 +467,7 @@ set(APP_SOURCES
src/app_sweep.cpp
src/app_wizard.cpp
src/services/network_refresh_service.cpp
src/services/note_consolidation.cpp
src/services/refresh_scheduler.cpp
src/services/wallet_security_controller.cpp
src/services/wallet_security_workflow.cpp
@@ -1105,6 +1118,27 @@ else()
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
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
# -----------------------------------------------------------------------------
@@ -1113,6 +1147,7 @@ if(BUILD_TESTING)
add_executable(ObsidianDragonTests
tests/test_phase4.cpp
src/services/network_refresh_service.cpp
src/services/note_consolidation.cpp
src/services/refresh_scheduler.cpp
src/services/wallet_security_controller.cpp
src/services/wallet_security_workflow.cpp
@@ -1211,6 +1246,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()
-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.
-61
View File
@@ -1,61 +0,0 @@
**DragonX (DRGX) Lite Desktop Wallet — v1.0.0**
ObsidianDragonLite is a lightweight companion to the ObsidianDragon full-node wallet. It skips the embedded full node entirely — **no multi-gigabyte blockchain download and near-instant startup** — by connecting to a DragonX lite-wallet (lightwalletd-style) server, while keeping the same native ImGui interface and shielded-first workflow.
This is the **first release** of the Lite variant.
---
## Why Lite?
- **No blockchain to download.** Sync in seconds instead of hours; a few MB of state instead of many GB.
- **Same wallet, lighter footprint.** The familiar ObsidianDragon UI, shielded and transparent addresses, and address book — without running a node or miner.
- **Portable.** A single self-contained binary that stores its data in its own `ObsidianDragonLite` folder, so it coexists cleanly alongside the full-node wallet.
---
## Features
- **Fast, node-free operation** — connect to a DragonX lite-wallet server and sync in seconds; minimal disk and RAM.
- **Wallet lifecycle** — create a new wallet, restore from a seed phrase, or open an existing one; wallets auto-open on startup, with a first-run welcome prompt and **guided seed backup** on creation.
- **Shielded + transparent** — send, shield, and receive DRGX; per-address balances computed from unspent notes and UTXOs.
- **Keys & seed** — export your seed phrase and keys, and import keys, from Settings.
- **Passphrase encryption** — encrypt, unlock, lock, and decrypt the wallet; you're prompted to unlock at send time and on startup when the wallet is locked.
- **Encrypted messaging (HushChat)** — built-in **Contacts** and **Chat** with a seed-derived, **SilentDragonXLite-compatible** identity and a seed-encrypted local message store, so you can message other DragonX users end-to-end.
- **Server management** — a built-in **server browser with automatic failover**; switch servers with live reconnect/recovery, and a **"Redownload blocks"** action to rescan from the server.
- **Status & diagnostics** — a **Network tab** showing connection and sync status, plus an **interactive Console** for running backend commands and copyable error output.
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (DMG + .app), x86-64.
- QR code generation for receiving addresses
- Extensive theme and appearance options
---
## How it differs from the full-node wallet
- **No embedded `dragonxd`, no mining, no local blockchain explorer.** Chain data comes from the lite-wallet server rather than being verified locally by a full node — a lighter footprint in exchange for trusting the server for chain state.
- Everything key-related stays **on your device**: seed, keys, and (when enabled) the encryption passphrase never leave the machine.
---
## Downloads
| File | SHA-256 |
|------|---------|
| ObsidianDragonLite-1.0.0.AppImage | `a1459d6081124a6b8df47aa898b60c0237875e8cbb132e4fd8ae637673055ed4` |
| ObsidianDragonLite-1.0.0-Linux-x64.zip | `573af502a6372334572e4dc88bd26e0cf36ed543b9df1e835fd8e4abd295108d` |
| ObsidianDragonLite-1.0.0.exe | `28659efb770fa573bd1b1d0fcab1c1a3f9c7e46574185c3916cc9f669684852c` |
| ObsidianDragonLite-1.0.0-Windows-x64.zip | `f6434a931e873e5936ebe249b044d6c7a413694767c667374c91ee438259b50c` |
| ObsidianDragonLite-1.0.0-macOS-x86_64.dmg | `10f976f1453e6b1978cb7a85026fd033c85a10c1e66f436475aa628379a298c7` |
| ObsidianDragonLite-1.0.0-macOS-x86_64.app.zip | `3e7259c363f7be2e9027c5282c94b742f4be280c998203752dc0a0551f6ab68b` |
## System Requirements
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
- **Windows:** x86-64, Windows 10 or later
- **macOS:** x86-64 (Intel; runs on Apple Silicon via Rosetta 2), macOS 10.15 Catalina or later
- Network access to a DragonX lite-wallet server.
## License
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.
-86
View File
@@ -1,86 +0,0 @@
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.0**
This is ObsidianDragon, a native ImGui-based wallet for the DragonX network. It ships with an embedded full-node daemon (`dragonxd`) and an integrated CPU miner (DRG-XMRig), giving users a complete, self-contained experience on Linux, Windows, and macOS.
---
## What's New in v2.0.0
v2.0.0 is a major release. It adds **end-to-end encrypted HushChat messaging** and **BIP39 seed-phrase wallets** (with migrate-to-seed for legacy wallets), lets the wallet **update its own node and miner** with each install cryptographically verified, and ships a **security-hardening pass** across the updater, RPC, and secret-storage surfaces — plus full support for the **new multi-threaded `dragonxd`** and a large stability and UX overhaul.
### New Features
- **Encrypted messaging (HushChat)** — new **Contacts** and **Chat** tabs bring DRGX-native, end-to-end encrypted messaging. Your chat identity is derived from your wallet seed, messages are encrypted with libsodium (XChaCha20-Poly1305 / secretstream) and stored in a **seed-encrypted local database**, and the wire format is interoperable with SilentDragonX / SilentDragonXLite.
- **BIP39 seed-phrase wallets & migrate-to-seed** — new wallets are backed by a mnemonic **seed phrase** you can back up from Settings. Existing **legacy wallets can be migrated into a seed wallet** — the wallet mints a new mnemonic wallet, sweeps your funds to it, and adopts it only once the sweep is mined, keeping a timestamped `wallet.dat` backup throughout. (The bundled `dragonxd` supports the required mnemonic RPCs; older nodes degrade gracefully.)
- **In-app daemon updater** — Settings → **Node & Security → Daemon binary → "Check for updates…"** downloads the latest `dragonxd` from the project Gitea, verifies its **SHA-256 and a detached ed25519 signature** before installing, and replaces the binary **atomically while the node keeps running** (the new build takes effect on the next daemon start). A two-pane version picker lets you pin, downgrade, or install any published/pre-release build.
- **In-app miner updater** — an **"Update miner…"** action in the Mining tab fetches, verifies (SHA-256 + ed25519), and installs the latest DRG-XMRig, with the same version picker and a display of current vs. latest.
- **Daemon binary management** — the wallet no longer auto-overwrites a node you've dropped in; a dedicated Settings panel shows the managed binary and gathers all node actions (restart, rescan, repair) onto one toolbar.
- **Repair Wallet** — a one-click `-zapwallettxes=2` recovery in Settings, plus automatic wallet reconciliation after a bootstrap and runtime rescan support for pruned nodes.
- **Explorer search** — fuzzy, live (debounced) filtering of the block list by partial hash or height.
- **History sorting & fast load** — sort transactions by date or amount, a "Loading older history (N%)" indicator during the initial bulk load, and persisted history that surfaces pending sends immediately.
- **Smarter mining** — the thread benchmark now measures **sustained (thermally-throttled) hashrate** instead of an inflated initial burst, with GPU-aware idle mining and corrected idle thread scaling.
### Security & Integrity
- **Mandatory signature verification** — both the daemon and miner updaters **require** a valid ed25519 signature (checked against a key pinned in the wallet) in addition to the SHA-256; an install is refused if the signature is missing or invalid.
- **Hardened secret handling** — RPC credentials are wiped from memory after use, RPC responses are size-capped and scrubbed of secrets in logs, generated node config is written **owner-only (0600)**, and secrets copied to the clipboard **auto-clear**.
- **Safe on-disk writes** — settings, address book, and secret files are written **atomically with owner-only permissions**; the PIN vault fsyncs its secure-delete overwrite; SQLite cache growth is bounded.
- **Recipient validation** — sends validate recipient address checksums (Base58Check + Bech32) before building a transaction.
- **Path-traversal protection** — archive extraction (chain bootstrap and the updaters) rejects any entry that would escape the target directory (zip-slip), and the bootstrap fails closed on a missing checksum rather than trusting an unverified archive.
- **Robustness** — malformed RPC error JSON is guarded and sends are single-flight, preventing duplicate/ill-formed submissions.
### Improvements
- **New multi-threaded daemon support** — live Sapling note-witness rebuild progress, accurate sync-speed display, reliable rescan-completion detection, and RPC polling throttled during sync so block download isn't slowed.
- **Networking** — DragonX DNS seed nodes, `-maxconnections` passed to the daemon, and a peer count that stays current on every tab.
- **Node resilience** — non-blocking warmup so you can connect while the daemon is still initializing, a live daemon-console tail on the startup overlay, fast-failing connect probes, and mid-session disconnect detection that keeps the UI responsive (in-flight RPC calls abort cleanly on disconnect/shutdown).
- **Address list** — modernized with drag-to-transfer, labels, and view-only handling.
- **UI / DPI** — overlay dialogs scale correctly with the font/DPI setting, improved CJK font rendering, native language names in the language picker, and format-incompatible translations are rejected.
- **Settings** — an "Open data folder" button and confirmation modals for rescan and restart-daemon.
### Bug Fixes
Extensive fixes across history (shielded-tx ordering, stuck "refreshing history" banners, unconfirmed-badge stickiness), sends (correct fee passed to `z_sendmany`, fast-lane worker restart on reconnect, note-selection fee-gap workaround), rescan (accurate completion detection, no false "complete", no per-second error flood), RPC (mid-session disconnect handling, stale-refresh invalidation), and UI layout/i18n. See the commit history for the full list.
---
## Features
- **Full-node wallet** — send, receive, and verify DRGX transactions with a bundled `dragonxd` daemon; no external setup required.
- **Self-updating** — verify-and-install the latest node and miner from within the app (SHA-256 + ed25519 signature enforced).
- **Encrypted messaging** — built-in HushChat with a seed-derived identity, end-to-end encryption, and a seed-encrypted local message store, interoperable with SilentDragonX / SilentDragonXLite.
- **Seed-phrase wallets** — BIP39 mnemonic backup, and migrate-to-seed to upgrade a legacy wallet into a seed wallet.
- **Built-in CPU mining** — start/stop DRG-XMRig from the Mining tab with real-time hashrate and pool statistics, mine-when-idle support with configurable delay, and sustained-hashrate benchmarking.
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (**universal** DMG + .app); Linux/Windows x86-64, macOS Intel + Apple Silicon.
- Shielded (z-address) and transparent (t-address) send/receive
- QR code generation for receiving addresses
- Transaction history with memo support, date/amount sorting, and pending-send tracking
- Blockchain explorer with live block/transaction search
- Blockchain rescan and Sapling witness-rebuild with status-bar progress
- Repair Wallet and daemon-binary management in Settings
- Built-in console
- Extensive theme and appearance options
---
## Downloads
| File | SHA-256 |
|------|---------|
| ObsidianDragon-2.0.0.AppImage | `34c9ea57ec27bf415e59d2890d995ed9069bfce04d979d7d8d58aa18f4570aa1` |
| ObsidianDragon-2.0.0-Linux-x64.zip | `563004b4e45650dbfa9411d62fa1c59a3ca2199bd43a647b1614f2683bbdb5fa` |
| ObsidianDragon-2.0.0.exe | `56100ae59dc3c67445d015cac5412ca40465bf459ba182c5ad2477a3b95ff548` |
| ObsidianDragon-2.0.0-Windows-x64.zip | `ce9d067f36c5296288a473d3c2cceca7fcf807672a93a2084afc53c3b6e780d9` |
| ObsidianDragon-2.0.0-macOS-universal.dmg | `8a01b6c0c2b5bebd64a222ba4a5ad04a1b8851138f28458b5c7f767fbb65db66` |
| ObsidianDragon-2.0.0-macOS-universal.app.zip | `1bce1e5be15d9a2f3c6f42c95b23a0e89df142a73d65895252e4d8f50d19f541` |
## System Requirements
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
- **Windows:** x86-64, Windows 10 or later
- **macOS:** Intel & Apple Silicon (universal binary), macOS 10.15 Catalina or later
## License
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.
-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.
+77 -30
View File
@@ -25,6 +25,7 @@
"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",
@@ -78,6 +79,7 @@
"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",
@@ -99,7 +101,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.",
@@ -120,11 +123,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.",
@@ -174,21 +173,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",
@@ -513,7 +511,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.",
@@ -709,8 +707,8 @@
"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?",
@@ -788,13 +786,13 @@
"faq_open_tooltip": "Hilfe & FAQ",
"faq_search_hint": "FAQ durchsuchen…",
"faq_title": "Hilfe & FAQ",
"faq_w_addr_1_a": "Transparente Adressen (sie beginnen mit R oder t) funktionieren wie bei den meisten Coins: Beträge und Adressen sind öffentlich auf der Blockchain einsehbar.\n\nGeschützte Adressen (z-Adressen, beginnend mit zs) nutzen Zero-Knowledge-Kryptografie — Betrag, Absender und Empfänger sind on-chain verschlüsselt. Guthaben auf geschützten Adressen sind privat.",
"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": "Bevorzugen Sie wann immer möglich geschützte (z-)Adressen — sie halten Ihr Guthaben und Ihren Zahlungsverlauf privat. DragonX ist eine Privacy-Coin, und geschützt ist die Standardeinstellung für empfangene Gelder.\n\nVerwenden Sie eine transparente Adresse nur, wenn ein Dienst, mit dem Sie interagieren, keine geschützten Adressen verarbeiten kann.",
"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": "Bei geschützten Adressen ist die Wiederverwendung unbedenklich und gibt keinen Verlauf preis. Bei transparenten Adressen verbessert eine neue Adresse pro Zahlung die Privatsphäre. Neue Adressen erstellen Sie im Tab Empfangen.",
"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 Schützen werden Coins von einer transparenten Adresse in eine geschützte verschoben und dadurch privat gemacht. Neu geschürfte Coins kommen transparent an und werden automatisch für Sie geschützt.\n\nSie können auch manuell aus der Wallet heraus schützen; ein Schutzvorgang ist einfach eine spezielle Transaktion und benötigt daher die normale Bestätigungszeit.",
"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.",
@@ -838,11 +836,11 @@
"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\nSendungen an geschützte Adressen sind privat; Sendungen an transparente Adressen sind öffentlich.",
"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. Transparente Adressen unterstützen keine Memos.",
"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?",
@@ -867,11 +865,6 @@
"file_save_location": "Datei wird gespeichert in: ~/.config/ObsidianDragon/",
"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",
@@ -1170,6 +1163,22 @@
"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_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.",
@@ -1182,6 +1191,7 @@
"merge_src_transparent": "Transparent",
"merge_started": "Zusammenführung gestartet",
"merge_title": "An Adresse zusammenführen",
"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…",
@@ -1233,6 +1243,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",
@@ -1246,6 +1258,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",
@@ -1290,6 +1304,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",
@@ -1302,6 +1317,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:",
@@ -1335,10 +1352,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.",
@@ -1358,7 +1375,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",
@@ -1511,6 +1540,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",
@@ -1539,7 +1570,6 @@
"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.",
@@ -1587,6 +1617,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",
@@ -1690,6 +1721,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",
@@ -1717,7 +1750,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",
@@ -1850,6 +1882,18 @@
"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",
@@ -1860,6 +1904,11 @@
"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",
"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)",
@@ -1906,7 +1955,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",
@@ -1941,8 +1989,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",
@@ -2014,6 +2060,7 @@
"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_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",
@@ -2106,6 +2153,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:",
@@ -2113,7 +2161,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...",
+77 -30
View File
@@ -25,6 +25,7 @@
"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",
@@ -78,6 +79,7 @@
"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",
@@ -99,7 +101,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.",
@@ -120,11 +123,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.",
@@ -174,21 +173,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",
@@ -513,7 +511,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.",
@@ -709,8 +707,8 @@
"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?",
@@ -788,13 +786,13 @@
"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 transparentes (empiezan con R o t) funcionan como la mayoría de las monedas: los montos y las direcciones son públicos en la cadena de bloques.\n\nLas direcciones blindadas (direcciones z, empiezan con zs) usan criptografía de conocimiento cero: el monto, el remitente y el destinatario quedan cifrados en la cadena. Los saldos en direcciones blindadas son privados.",
"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": "Prefiere las direcciones blindadas (z) siempre que sea posible: mantienen privados tu saldo y tu historial de pagos. DragonX es una moneda de privacidad y lo blindado es la opción predeterminada para los fondos recibidos.\n\nUsa una dirección transparente solo cuando un servicio con el que interactúas no admita direcciones 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": "Para las direcciones blindadas, reutilizarlas está bien y no filtra el historial. Para las direcciones transparentes, usar una dirección nueva por cada pago mejora la privacidad. Crea nuevas direcciones desde la pestaña Recibir.",
"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 a una blindada, haciéndolas privadas. Las monedas recién minadas llegan transparentes y se blindan automáticamente por ti.\n\nTambién puedes blindar manualmente desde la cartera; blindar es solo una transacción especial, así que tarda el tiempo de confirmación normal en completarse.",
"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.",
@@ -838,11 +836,11 @@
"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 envíos a direcciones blindadas son privados; los envíos a direcciones transparentes son públicos.",
"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. Las direcciones transparentes no admiten memos.",
"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é?",
@@ -867,11 +865,6 @@
"file_save_location": "El archivo se guardará en: ~/.config/ObsidianDragon/",
"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",
@@ -1170,6 +1163,22 @@
"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_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.",
@@ -1182,6 +1191,7 @@
"merge_src_transparent": "Transparentes",
"merge_started": "Operación de fusión iniciada",
"merge_title": "Fusionar a Dirección",
"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…",
@@ -1233,6 +1243,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",
@@ -1246,6 +1258,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",
@@ -1290,6 +1304,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",
@@ -1302,6 +1317,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:",
@@ -1335,10 +1352,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.",
@@ -1358,7 +1375,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",
@@ -1511,6 +1540,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",
@@ -1539,7 +1570,6 @@
"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.",
@@ -1587,6 +1617,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",
@@ -1690,6 +1721,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",
@@ -1717,7 +1750,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",
@@ -1850,6 +1882,18 @@
"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",
@@ -1860,6 +1904,11 @@
"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",
"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)",
@@ -1906,7 +1955,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",
@@ -1941,8 +1989,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",
@@ -2014,6 +2060,7 @@
"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_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",
@@ -2106,6 +2153,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:",
@@ -2113,7 +2161,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...",
+77 -30
View File
@@ -25,6 +25,7 @@
"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",
@@ -78,6 +79,7 @@
"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",
@@ -99,7 +101,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.",
@@ -120,11 +123,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.",
@@ -174,21 +173,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",
@@ -513,7 +511,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.",
@@ -709,8 +707,8 @@
"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 ?",
@@ -788,13 +786,13 @@
"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 transparentes (elles commencent par R ou t) fonctionnent comme la plupart des cryptomonnaies : les montants et adresses sont publics sur la blockchain.\n\nLes adresses blindées (adresses Z, commençant par zs) utilisent la cryptographie à divulgation nulle de connaissance — le montant, l'expéditeur et le destinataire sont chiffrés sur la chaîne. Les soldes détenus sur des adresses blindées sont privés.",
"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": "Privilégiez les adresses blindées (Z) autant que possible — elles préservent la confidentialité de votre solde et de votre historique de paiements. DragonX est une cryptomonnaie axée sur la confidentialité, et le blindage est le mode par défaut pour les fonds reçus.\n\nN'utilisez une adresse transparente que lorsqu'un service avec lequel vous interagissez ne peut pas gérer les adresses 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": "Pour les adresses blindées, la réutilisation ne pose aucun problème et ne révèle pas l'historique. Pour les adresses transparentes, utiliser une nouvelle adresse à chaque paiement améliore la confidentialité. Créez de nouvelles adresses depuis l'onglet Recevoir.",
"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 vers une adresse blindée, les rendant privées. Les pièces nouvellement minées arrivent en transparent et sont blindées automatiquement pour vous.\n\nVous pouvez aussi blinder manuellement depuis le portefeuille ; un blindage est simplement une transaction spéciale, il faut donc un temps de confirmation normal pour qu'elle se termine.",
"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.",
@@ -838,11 +836,11 @@
"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 envois vers des adresses blindées sont privés ; les envois vers des adresses transparentes sont publics.",
"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. Les adresses transparentes ne prennent pas en charge les mémos.",
"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 ?",
@@ -867,11 +865,6 @@
"file_save_location": "Le fichier sera enregistré dans : ~/.config/ObsidianDragon/",
"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",
@@ -1170,6 +1163,22 @@
"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_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.",
@@ -1182,6 +1191,7 @@
"merge_src_transparent": "Transparentes",
"merge_started": "Opération de fusion démarrée",
"merge_title": "Fusionner vers une adresse",
"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…",
@@ -1233,6 +1243,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",
@@ -1246,6 +1258,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",
@@ -1290,6 +1304,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",
@@ -1302,6 +1317,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 :",
@@ -1335,10 +1352,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.",
@@ -1358,7 +1375,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",
@@ -1511,6 +1540,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",
@@ -1539,7 +1570,6 @@
"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.",
@@ -1587,6 +1617,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",
@@ -1690,6 +1721,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",
@@ -1717,7 +1750,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",
@@ -1850,6 +1882,18 @@
"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é",
@@ -1860,6 +1904,11 @@
"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",
"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)",
@@ -1906,7 +1955,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",
@@ -1941,8 +1989,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",
@@ -2014,6 +2060,7 @@
"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_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",
@@ -2106,6 +2153,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 :",
@@ -2113,7 +2161,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...",
+77 -30
View File
@@ -25,6 +25,7 @@
"about_website": "ウェブサイト",
"acrylic": "アクリル",
"add": "追加",
"addr_type_mining": "マイニング",
"address": "アドレス",
"address_book_add": "アドレスを追加",
"address_book_add_new": "新規追加",
@@ -78,6 +79,7 @@
"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 の出力",
@@ -99,7 +101,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": "プールマイナーが接続し、ハッシュ計算中です。",
@@ -120,11 +123,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": "保存したことを確認するため、単語を正しい順番でタップしてください。",
@@ -174,21 +173,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": "ブロック",
@@ -513,7 +511,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": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。",
@@ -709,8 +707,8 @@
"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": "ノードの起動・停止はどうすればいいですか?",
@@ -788,13 +786,13 @@
"faq_open_tooltip": "ヘルプ&FAQ",
"faq_search_hint": "FAQを検索…",
"faq_title": "ヘルプ&FAQ",
"faq_w_addr_1_a": "透明アドレス(RまたはtまたはR始まり)はほとんどのコインと同様に動作します。金額とアドレスはブロックチェーン上で公開されます。\n\nシールドアドレス(zアドレス、zsで始まる)はゼロ知識暗号を使用し、金額・送信者・受信者はチェーン上で暗号化されます。シールドアドレスに保有された残高は非公開です。",
"faq_w_addr_1_a": "シールドアドレス(zs で始まる z アドレス)は、DragonX で使用するアドレスです。ゼロ知識暗号を使用するため、金額・送信者・受信者はチェーン上で暗号化され、残高は非公開に保たれます。\n\n透明アドレス(R で始まる)はマイニングアドレスです。DragonX は完全にプライベートなチェーンのため、マイニング報酬しか受け取れず、送られた支払いはネットワークによって拒否されます。そこに届いた報酬はシールドされるまで公開されますが、シールドはウォレットが自動的に行います。",
"faq_w_addr_1_q": "透明アドレスとシールドアドレスの違いは何ですか?",
"faq_w_addr_2_a": "できる限りシールド(z)アドレスを使用してください。残高と支払い履歴を非公開に保てます。DragonXはプライバシーコインであり、受け取る資金はデフォルトでシールドされます。\n\n透明アドレスは、やり取りするサービスがシールドアドレスに対応していない場合のみ使用してください。",
"faq_w_addr_2_a": "送受信にはすべてシールド(zs)アドレスを使用してください。DragonX で支払いを受け取れるのはこの種類だけです。\n\n透明(マイニング)アドレスはソロマイニング報酬専用です。支払いのために他人に教えないでください。ネットワークが拒否します。",
"faq_w_addr_2_q": "どちらのアドレスタイプを使うべきですか?",
"faq_w_addr_3_a": "シールドアドレスは再利用しても問題なく、履歴が漏れることもありません。透明アドレスは支払いごとに新しいアドレスを使うとプライバシーが向上します。新しいアドレスは受信タブから作成できます。",
"faq_w_addr_3_a": "シールドアドレスを再利用しても問題はなく、履歴が漏れることもありません。それでも「受信」タブで新しいアドレスを作成できます。たとえば連絡先ごとに 1 つ作れば、誰が支払ったかがわかります。マイニングアドレスはマイニング報酬専用で、新しく作る必要はほとんどありません。",
"faq_w_addr_3_q": "アドレスは使い回すべきですか、それとも新しく作るべきですか?",
"faq_w_addr_4_a": "シールド化とは、コインを透明アドレスからシールドアドレスへ移し、非公開にすることです。新しくマイニングされたコインは透明な状態で届き、自動的にシールドされます。\n\n手動でシールドすることもできます。シールドは特別な種類のトランザクションにすぎないため、完了までに通常の確認時間がかかります。",
"faq_w_addr_4_a": "シールドとは、透明(マイニング)アドレスのコインをシールドアドレスに移して非公開にすることです。新たにマイニングされたコインはマイニングアドレスに届き、成熟すると自動的にシールドされます。\n\nウォレットから手動でシールドすることもできます。シールドは特別なトランザクションにすぎないため、通常の承認時間がかかります。",
"faq_w_addr_4_q": "「シールド化」とは何ですか?",
"faq_w_addr_title": "アドレスとプライバシー",
"faq_w_bal_1_a": "ウォレットがまだ同期中の場合、残高は不完全で0と表示されることがあります。まだすべてのトランザクションをスキャンしていないためです。同期が完了すると反映されます。\n\n画面下部のステータスバーを確認してください。接続状態とブロック高が表示されます。同期が完了すれば残高は正確になります。",
@@ -838,11 +836,11 @@
"faq_w_seed_4_a": "シードフレーズは主要なバックアップであり、これだけですべてを復元できます。ウォレットファイルのコピーは便利な二次バックアップで、ラベルや設定も保持されます。どちらのバックアップもオフラインかつ非公開の場所に保管してください。",
"faq_w_seed_4_q": "wallet.dat もバックアップすべきですか?",
"faq_w_seed_title": "シードフレーズとバックアップ",
"faq_w_send_1_a": "送金タブを開き、受取人のアドレスを貼り付け、金額を入力して確認してください。ウォレットが暗号化されている場合、トランザクションのためにロック解除を求められます。\n\nシールドアドレスへの送金は非公開、透明アドレスへの送金は公開されます。",
"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_a": "はい — シールド(z)アドレスへ送金する際は、暗号化されたメモを添付できます。読めるのは受取人だけです。",
"faq_w_send_3_q": "支払いにメッセージを添付できますか?",
"faq_w_send_4_a": "トランザクションはブロックに取り込まれ、確認数を得るまで「保留中」となります。通常1~2分ほどかかります。シールドトランザクションでは、証明を生成するためにウォレットが同期済みである必要もあります。\n\n送金が異常に長く保留中のままの場合は、ウォレットが接続され、同期済みであることを確認してください(ウィンドウ下部のステータスバーを参照)。",
"faq_w_send_4_q": "支払いを送りましたが「保留中」のままです — なぜですか?",
@@ -867,11 +865,6 @@
"file_save_location": "ファイルの保存先:~/.config/ObsidianDragon/",
"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": "詳細情報",
@@ -1170,6 +1163,22 @@
"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_too_small": "少なすぎて統合できません。手数料でこの資金がなくなります。",
"merge_max_inputs": "バッチあたりの最大入力数",
"merge_no_spendable": "統合できる使用可能な入力がまだありません。",
"merge_progress": "統合中… 数分かかることがあります。このウィンドウは閉じても構いません。",
@@ -1182,6 +1191,7 @@
"merge_src_transparent": "透明",
"merge_started": "統合操作を開始しました",
"merge_title": "アドレスに統合",
"merge_where": "資金の移動先",
"mig_adopt": "これを自分のウォレットにする",
"mig_adopt_now": "シードウォレットを採用",
"mig_adopting": "新しいウォレットをインストールして再スキャン中…",
@@ -1233,6 +1243,8 @@
"miner_fee": "マイナー手数料",
"mining": "マイニング",
"mining_active": "アクティブ",
"mining_addr_explainer": "ソロマイニング報酬のみを受け取ります。ネットワークはこのアドレスへの支払いを拒否します。報酬は成熟すると自動的にシールドされます。",
"mining_address": "マイニングアドレス",
"mining_address_copied": "マイニングアドレスをコピーしました",
"mining_all_time": "全期間",
"mining_already_saved": "プールURLは既に保存済みです",
@@ -1246,6 +1258,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": "現在",
@@ -1290,6 +1304,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": "手数料",
@@ -1302,6 +1317,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": "保存済みアドレス:",
@@ -1335,10 +1352,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": "アドレスが見つかりません。上のボタンを使用して作成してください。",
@@ -1358,7 +1375,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": "デーモンに未接続",
@@ -1511,6 +1540,8 @@
"ram_wallet_gb": "ウォレット:%.1f GB",
"ram_wallet_mb": "ウォレット:%.0f MB",
"receive": "受信",
"receive_not_for_payments": "支払いには使えません",
"receive_use_shielded": "シールドアドレスを使う",
"received": "受信済み",
"received_filter": "受信済み",
"received_label": "受信済み",
@@ -1539,7 +1570,6 @@
"request_select_address": "アドレスを選択...",
"request_shielded_addrs": "-- シールドアドレス --",
"request_title": "支払いを請求",
"request_transparent_addrs": "-- 透明アドレス --",
"request_uri_copied": "支払いURIをクリップボードにコピーしました",
"rescan": "再スキャン",
"rescan_bootstrapped_msg": "ノードはブートストラップされているため、スナップショットより下のブロックはディスク上になく、ジェネシスからの再スキャンは失敗します。ウォレットの使用済み残高を照合するには、スナップショットに含まれる高さから再スキャンしてください。wallet.dat とチェーンデータは削除されません。",
@@ -1587,6 +1617,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": "再スキャン",
@@ -1690,6 +1721,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": "最近の送信なし",
@@ -1717,7 +1750,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",
@@ -1850,6 +1882,18 @@
"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": "金額が大きい順",
@@ -1860,6 +1904,11 @@
"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": "ノードを停止して待つ",
"stratum_allowip_hint": "マイナーの接続を許可するIPまたはCIDR(空欄=このコンピューターのみ)",
"stratum_expose_warn": "許可したネットワークにマイニングポートを公開します — 信頼できるLANでのみ使用してください。",
"stratum_host": "マイニングプールをホストする(stratum)",
@@ -1906,7 +1955,6 @@
"switch_stopnode_title": "実行中のノードを停止しますか?",
"switch_stopnode_warn": "このウォレットが起動していないノードがすでに実行中です。",
"syncing": "同期中...",
"t_address": "Tアドレス",
"t_addresses": "Tアドレス",
"test_connection": "テスト",
"theme": "テーマ",
@@ -1941,8 +1989,6 @@
"transfer_sent": "送金完了",
"transfer_sent_desc": "送金がネットワークに送信されました。",
"transfer_to": "送金先:",
"transparent": "透明",
"transparent_address": "トランスペアレントアドレス",
"try_again": "再試行",
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
"tt_address_book": "クイック送信用の保存済みアドレスを管理",
@@ -2014,6 +2060,7 @@
"tt_low_spec": "すべての重い視覚効果を無効化\\nホットキー:Ctrl+Shift+Down",
"tt_merge": "複数の UTXO を一つのアドレスに統合",
"tt_mine_idle": "システムがアイドル状態(キーボード/マウス入力なし)\\nのとき自動的にマイニングを開始",
"tt_new_mining_address": "ソロマイニング報酬用の透明(R…)アドレスを作成します。支払いは受け取れません。支払いにはシールドアドレスを共有してください。",
"tt_noise": "グレインテクスチャ強度(0% = オフ、100% = 最大)",
"tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
@@ -2106,6 +2153,7 @@
"validate_description": "DragonXアドレスを入力して、有効かどうか、そしてこのウォレットに属しているかどうかを確認します。",
"validate_invalid": "無効",
"validate_is_mine": "このウォレットがこのアドレスを所有しています",
"validate_mining_type": "マイニングアドレス(透明)",
"validate_not_mine": "このウォレットに属していません",
"validate_ownership": "所有者:",
"validate_results": "結果:",
@@ -2113,7 +2161,6 @@
"validate_shielded_type": "シールド(zアドレス)",
"validate_status": "ステータス:",
"validate_title": "アドレスを検証",
"validate_transparent_type": "透明(tアドレス)",
"validate_type": "タイプ:",
"validate_valid": "有効",
"validating": "検証中...",
+77 -30
View File
@@ -25,6 +25,7 @@
"about_website": "웹사이트",
"acrylic": "아크릴",
"add": "추가",
"addr_type_mining": "채굴",
"address": "주소",
"address_book_add": "주소 추가",
"address_book_add_new": "새로 추가",
@@ -78,6 +79,7 @@
"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 출력",
@@ -99,7 +101,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": "풀 채굴기가 연결되어 해싱 중입니다.",
@@ -120,11 +123,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": "저장한 내용을 확인하도록 단어를 올바른 순서대로 탭하세요.",
@@ -174,21 +173,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": "블록",
@@ -513,7 +511,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": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.",
@@ -709,8 +707,8 @@
"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": "노드를 시작하거나 중지하려면 어떻게 하나요?",
@@ -788,13 +786,13 @@
"faq_open_tooltip": "도움말 및 FAQ",
"faq_search_hint": "FAQ 검색…",
"faq_title": "도움말 및 FAQ",
"faq_w_addr_1_a": "투명 주소(R 또는 t로 시작)는 대부분의 코인처럼 작동합니다. 금액과 주소가 블록체인에 공개됩니다.\n\n보호 주소(zs로 시작하는 z-주소)는 영지식 암호화를 사용합니다 — 금액, 발신자, 수신자가 체인상에서 암호화됩니다. 보호 주소에 보관된 잔액은 비공개입니다.",
"faq_w_addr_1_a": "차폐 주소(zs로 시작하는 z 주소)는 DragonX에서 사용하는 주소입니다. 영지식 암호를 사용하므로 금액, 보낸 사람, 받는 사람이 체인에서 암호화되고 잔액이 비공개로 유지됩니다.\n\n투명 주소(R로 시작)는 채굴 주소입니다. DragonX는 완전한 프라이빗 체인이므로 이 주소는 채굴 보상만 받을 수 있으며, 네트워크가 이 주소로 보내는 모든 결제를 거부합니다. 그곳에 들어온 보상은 차폐되기 전까지 공개되며, 차폐는 지갑이 자동으로 처리합니다.",
"faq_w_addr_1_q": "투명 주소와 보호 주소의 차이는 무엇인가요?",
"faq_w_addr_2_a": "가능하면 항상 보호(z) 주소를 사용하세요 — 잔액과 결제 내역이 비공개로 유지됩니다. DragonX는 프라이버시 코인이며, 수신 자금의 기본값은 보호 주소입니다.\n\n상대방 서비스가 보호 주소를 처리할 수 없는 경우에만 투명 주소를 사용하세요.",
"faq_w_addr_2_a": "보내고 받는 모든 거래에 차폐(zs) 주소를 사용하세요. DragonX에서는 이 주소만 결제를 받을 수 있습니다.\n\n투명(채굴) 주소는 솔로 채굴 보상 전용입니다. 결제용으로 절대 알려주지 마세요. 네트워크가 거부합니다.",
"faq_w_addr_2_q": "어떤 주소 유형을 사용해야 하나요?",
"faq_w_addr_3_a": "보호 주소는 재사용해도 괜찮으며 내역이 노출되지 않습니다. 투명 주소는 결제마다 새 주소를 사용하는 것이 프라이버시에 좋습니다. 수신 탭에서 새 주소를 만들 수 있습니다.",
"faq_w_addr_3_a": "차폐 주소는 재사용해도 괜찮으며 기록이 노출되지 않습니다. 그래도 받기 탭에서 새 주소를 만들 수 있습니다. 예를 들어 연락처마다 하나씩 만들면 누가 보냈는지 알 수 있습니다. 채굴 주소는 채굴 보상 전용이라 새로 만들 일은 거의 없습니다.",
"faq_w_addr_3_q": "주소를 재사용해야 하나요, 아니면 새로 만들어야 하나요?",
"faq_w_addr_4_a": "보호(shielding)는 코인을 투명 주소에서 보호 주소로 옮겨 비공개로 만드는 것입니다. 새로 채굴된 코인은 투명 상태로 도착하며 자동으로 보호 처리됩니다.\n\n지갑에서 수동으로 보호 처리할 수도 있습니다. 보호는 일반 트랜잭션의 일종이므로 완료까지 일반적인 확인 시간이 걸립니다.",
"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하단 상태 표시줄을 확인하세요. 연결 상태와 블록 높이가 표시됩니다. 동기화가 완료되면 잔액이 정확해집니다.",
@@ -838,11 +836,11 @@
"faq_w_seed_4_a": "시드 문구는 기본 백업이며 모든 것을 복원하기에 충분합니다. 지갑 파일 사본은 라벨과 설정까지 보존하는 편리한 보조 백업입니다. 모든 백업은 오프라인에 비공개로 보관하세요.",
"faq_w_seed_4_q": "wallet.dat도 백업해야 하나요?",
"faq_w_seed_title": "시드 문구 및 백업",
"faq_w_send_1_a": "전송 탭을 열고 받는 사람의 주소를 붙여넣은 다음 금액을 입력하고 확인하세요. 지갑이 암호화되어 있다면 트랜잭션을 위해 잠금 해제를 요청받습니다.\n\n보호 주소로의 전송은 비공개이며, 투명 주소로의 전송은 공개됩니다.",
"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_a": "네 — 보호(z) 주소로 전송할 때 암호화된 메모를 첨부할 수 있습니다. 받는 사람만 읽을 수 있습니다.",
"faq_w_send_3_q": "결제에 메시지를 첨부할 수 있나요?",
"faq_w_send_4_a": "트랜잭션은 블록에 채굴되어 확인을 얻기 전까지 '대기 중'입니다. 보통 1~2분 정도 걸립니다. 보호 트랜잭션은 증명을 생성하기 위해 지갑이 동기화되어 있어야 합니다.\n\n전송이 유난히 오래 대기 중이라면 지갑이 연결되고 동기화되어 있는지 확인하세요(창 하단의 상태 표시줄 참고).",
"faq_w_send_4_q": "결제를 보냈는데 대기 중이라고 나와요 — 왜 그런가요?",
@@ -867,11 +865,6 @@
"file_save_location": "파일 저장 위치: ~/.config/ObsidianDragon/",
"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": "전체 세부 정보",
@@ -1170,6 +1163,22 @@
"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_too_small": "너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
"merge_max_inputs": "배치당 최대 입력 수",
"merge_no_spendable": "아직 통합할 사용 가능한 입력이 없습니다.",
"merge_progress": "통합 중… 몇 분 정도 걸릴 수 있습니다. 이 창을 닫아도 됩니다.",
@@ -1182,6 +1191,7 @@
"merge_src_transparent": "투명",
"merge_started": "통합 작업이 시작되었습니다",
"merge_title": "주소로 통합",
"merge_where": "자금을 어디로 보낼까요?",
"mig_adopt": "이 지갑을 내 지갑으로 사용",
"mig_adopt_now": "시드 지갑 사용",
"mig_adopting": "새 지갑을 설치하고 재스캔하는 중…",
@@ -1233,6 +1243,8 @@
"miner_fee": "채굴 수수료",
"mining": "채굴",
"mining_active": "활성",
"mining_addr_explainer": "솔로 채굴 보상만 받습니다. 네트워크가 이 주소로의 결제를 거부합니다. 보상은 성숙하면 자동으로 차폐됩니다.",
"mining_address": "채굴 주소",
"mining_address_copied": "채굴 주소가 복사되었습니다",
"mining_all_time": "전체 기간",
"mining_already_saved": "풀 URL이 이미 저장되어 있습니다",
@@ -1246,6 +1258,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": "현재",
@@ -1290,6 +1304,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": "수수료",
@@ -1302,6 +1317,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": "저장된 주소:",
@@ -1335,10 +1352,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": "주소가 없습니다. 위의 버튼을 사용하여 생성하세요.",
@@ -1358,7 +1375,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": "데몬에 연결되지 않음",
@@ -1511,6 +1540,8 @@
"ram_wallet_gb": "지갑: %.1f GB",
"ram_wallet_mb": "지갑: %.0f MB",
"receive": "수신",
"receive_not_for_payments": "결제용이 아님",
"receive_use_shielded": "차폐 주소 사용",
"received": "수신됨",
"received_filter": "수신됨",
"received_label": "수신됨",
@@ -1539,7 +1570,6 @@
"request_select_address": "주소 선택...",
"request_shielded_addrs": "-- 차폐 주소 --",
"request_title": "결제 요청",
"request_transparent_addrs": "-- 투명 주소 --",
"request_uri_copied": "결제 URI가 클립보드에 복사되었습니다",
"rescan": "재스캔",
"rescan_bootstrapped_msg": "노드가 부트스트랩되어 스냅샷 이전 블록은 디스크에 없으므로 제네시스부터의 재스캔은 실패합니다. 지갑의 사용된 잔액을 조정하려면 스냅샷에 포함된 높이부터 재스캔하세요. wallet.dat과 체인 데이터는 삭제되지 않습니다.",
@@ -1587,6 +1617,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": "재스캔",
@@ -1690,6 +1721,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": "최근 전송 없음",
@@ -1717,7 +1750,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",
@@ -1850,6 +1882,18 @@
"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": "금액 큰 순",
@@ -1860,6 +1904,11 @@
"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": "노드 중지 후 대기",
"stratum_allowip_hint": "채굴자를 허용할 IP 또는 CIDR (비워두면 이 컴퓨터만 허용)",
"stratum_expose_warn": "허용한 네트워크로 채굴 포트를 엽니다 — 신뢰할 수 있는 LAN에서만 사용하세요.",
"stratum_host": "마이닝 풀 호스팅 (stratum)",
@@ -1906,7 +1955,6 @@
"switch_stopnode_title": "실행 중인 노드를 중지할까요?",
"switch_stopnode_warn": "이 지갑이 시작하지 않은 노드가 이미 실행 중입니다.",
"syncing": "동기화 중...",
"t_address": "T 주소",
"t_addresses": "T 주소",
"test_connection": "테스트",
"theme": "테마",
@@ -1941,8 +1989,6 @@
"transfer_sent": "이체 전송됨",
"transfer_sent_desc": "이체가 네트워크에 제출되었습니다.",
"transfer_to": "이체 대상:",
"transparent": "투명",
"transparent_address": "투명 주소",
"try_again": "다시 시도",
"tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL",
"tt_address_book": "빠른 전송을 위해 저장된 주소 관리",
@@ -2014,6 +2060,7 @@
"tt_low_spec": "모든 고부하 시각 효과 비활성화\\n단축키: Ctrl+Shift+Down",
"tt_merge": "여러 UTXO를 하나의 주소로 통합",
"tt_mine_idle": "시스템이 유휴 상태(키보드/마우스 입력 없음)일 때\\n자동으로 채굴 시작",
"tt_new_mining_address": "솔로 채굴 보상용 투명(R…) 주소를 만듭니다. 결제는 받을 수 없으니, 결제용으로는 차폐 주소를 공유하세요.",
"tt_noise": "그레인 텍스처 강도 (0% = 끔, 100% = 최대)",
"tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다",
"tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다",
@@ -2106,6 +2153,7 @@
"validate_description": "DragonX 주소를 입력하여 유효한지 그리고 이 지갑에 속하는지 확인합니다.",
"validate_invalid": "유효하지 않음",
"validate_is_mine": "이 지갑이 이 주소를 소유합니다",
"validate_mining_type": "채굴 주소(투명)",
"validate_not_mine": "이 지갑에 속하지 않음",
"validate_ownership": "소유자:",
"validate_results": "결과:",
@@ -2113,7 +2161,6 @@
"validate_shielded_type": "차폐 (z 주소)",
"validate_status": "상태:",
"validate_title": "주소 검증",
"validate_transparent_type": "투명 (t 주소)",
"validate_type": "유형:",
"validate_valid": "유효함",
"validating": "검증 중...",
+77 -30
View File
@@ -25,6 +25,7 @@
"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",
@@ -78,6 +79,7 @@
"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",
@@ -99,7 +101,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.",
@@ -120,11 +123,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.",
@@ -174,21 +173,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",
@@ -513,7 +511,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.",
@@ -709,8 +707,8 @@
"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?",
@@ -788,13 +786,13 @@
"faq_open_tooltip": "Ajuda e FAQ",
"faq_search_hint": "Buscar no FAQ…",
"faq_title": "Ajuda e FAQ",
"faq_w_addr_1_a": "Endereços transparentes (começam com R ou t) funcionam como a maioria das moedas: os valores e endereços são públicos na blockchain.\n\nEndereços blindados (endereços-z, começam com zs) usam criptografia de conhecimento zero — o valor, remetente e destinatário são encriptados on-chain. Saldos em endereços blindados são privados.",
"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": "Prefira endereços blindados (z) sempre que possível — eles mantêm seu saldo e histórico de pagamentos privados. DragonX é uma moeda de privacidade, e blindado é o padrão para fundos recebidos.\n\nUse um endereço transparente apenas quando um serviço com o qual você interage não conseguir lidar com endereços 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": "Para endereços blindados, reutilizar é tranquilo e não expõe o histórico. Para endereços transparentes, usar um endereço novo a cada pagamento melhora a privacidade. Crie novos endereços na aba Receber.",
"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 para um blindado, tornando-as privadas. Moedas recém-mineradas chegam transparentes e são blindadas automaticamente para você.\n\nVocê também pode blindar manualmente pela carteira; um blindamento é apenas uma transação especial, então leva o tempo normal de confirmação para ser concluído.",
"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.",
@@ -838,11 +836,11 @@
"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\nEnvios para endereços blindados são privados; envios para endereços transparentes são públicos.",
"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. Endereços transparentes não suportam memos.",
"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ê?",
@@ -867,11 +865,6 @@
"file_save_location": "O arquivo será salvo em: ~/.config/ObsidianDragon/",
"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",
@@ -1170,6 +1163,22 @@
"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_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.",
@@ -1182,6 +1191,7 @@
"merge_src_transparent": "Transparente",
"merge_started": "Operação de fusão iniciada",
"merge_title": "Fundir para Endereço",
"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…",
@@ -1233,6 +1243,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",
@@ -1246,6 +1258,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",
@@ -1290,6 +1304,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",
@@ -1302,6 +1317,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:",
@@ -1335,10 +1352,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.",
@@ -1358,7 +1375,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",
@@ -1511,6 +1540,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",
@@ -1539,7 +1570,6 @@
"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.",
@@ -1587,6 +1617,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",
@@ -1690,6 +1721,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",
@@ -1717,7 +1750,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",
@@ -1850,6 +1882,18 @@
"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",
@@ -1860,6 +1904,11 @@
"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",
"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)",
@@ -1906,7 +1955,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",
@@ -1941,8 +1989,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",
@@ -2014,6 +2060,7 @@
"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_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",
@@ -2106,6 +2153,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:",
@@ -2113,7 +2161,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...",
+77 -30
View File
@@ -25,6 +25,7 @@
"about_website": "Веб-сайт",
"acrylic": "Акрил",
"add": "Добавить",
"addr_type_mining": "Майнинг",
"address": "Адрес",
"address_book_add": "Добавить адрес",
"address_book_add_new": "Добавить новый",
@@ -78,6 +79,7 @@
"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",
@@ -99,7 +101,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": "Пул-майнер подключён и вычисляет хеши.",
@@ -120,11 +123,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": "Нажимайте слова в правильном порядке, чтобы подтвердить, что вы их сохранили.",
@@ -174,21 +173,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": "Блок",
@@ -513,7 +511,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": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.",
@@ -709,8 +707,8 @@
"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": "Как запустить или остановить узел?",
@@ -788,13 +786,13 @@
"faq_open_tooltip": "Справка и FAQ",
"faq_search_hint": "Поиск по FAQ…",
"faq_title": "Справка и FAQ",
"faq_w_addr_1_a": "Прозрачные адреса (начинаются с R или t) работают как в большинстве монет: суммы и адреса видны в блокчейне.\n\nЗащищённые адреса (Z-адреса, начинаются с zs) используют криптографию с нулевым разглашением — сумма, отправитель и получатель зашифрованы в цепочке. Балансы на защищённых адресах приватны.",
"faq_w_addr_1_a": "Экранированные адреса (z-адреса, начинаются с zs) — это адреса, которыми вы пользуетесь в DragonX. Они используют криптографию с нулевым разглашением: сумма, отправитель и получатель шифруются в блокчейне, а ваш баланс остаётся приватным.\n\nПрозрачные адреса (начинаются с R) — это адреса для майнинга. DragonX — полностью приватная сеть, поэтому они могут получать только награды за майнинг: сеть отклоняет любой платёж на такой адрес. Поступившие туда награды остаются публичными, пока их не экранируют, а кошелёк делает это автоматически.",
"faq_w_addr_1_q": "В чём разница между прозрачными и защищёнными адресами?",
"faq_w_addr_2_a": "По возможности используйте защищённые (Z) адреса — они сохраняют приватность вашего баланса и истории платежей. DragonX — приватная монета, и защищённый адрес используется по умолчанию для получения средств.\n\nПрозрачный адрес стоит использовать только если сервис, с которым вы работаете, не поддерживает защищённые адреса.",
"faq_w_addr_2_a": "Используйте экранированный (zs) адрес для всего, что отправляете и получаете, — в DragonX только на такой адрес можно заплатить.\n\nПрозрачные адреса (для майнинга) предназначены только для наград за соло-майнинг. Никогда не давайте такой адрес для платежа: сеть его отклонит.",
"faq_w_addr_2_q": "Какой тип адреса мне использовать?",
"faq_w_addr_3_a": "Для защищённых адресов повторное использование допустимо и не раскрывает историю. Для прозрачных адресов использование нового адреса для каждого платежа повышает приватность. Создавайте новые адреса на вкладке «Получить».",
"faq_w_addr_3_a": "Повторно использовать экранированный адрес безопасно — это не раскрывает вашу историю. При желании можно создавать новые на вкладке «Получить», например по одному на контакт, чтобы видеть, кто вам заплатил. Адреса для майнинга нужны только для наград за майнинг; новый понадобится редко.",
"faq_w_addr_3_q": "Использовать один адрес повторно или создавать новый?",
"faq_w_addr_4_a": "Экранирование перемещает монеты с прозрачного адреса на защищённый, делая их приватными. Новые добытые монеты поступают как прозрачные и экранируются автоматически.\n\nВы также можете экранировать монеты вручную из кошелька — экранирование — это обычная транзакция, поэтому она подтверждается за обычное время.",
"faq_w_addr_4_a": "Экранирование переводит монеты с прозрачного адреса (для майнинга) на экранированный, делая их приватными. Новые намайненные монеты приходят на адрес для майнинга и экранируются автоматически, когда созревают.\n\nЭкранировать можно и вручную в кошельке; экранирование — это обычная транзакция особого вида, поэтому подтверждается в обычные сроки.",
"faq_w_addr_4_q": "Что такое «экранирование»?",
"faq_w_addr_title": "Адреса и приватность",
"faq_w_bal_1_a": "Пока кошелёк синхронизируется, ваш баланс неполный и может отображаться как 0 — не все транзакции ещё просканированы. Он заполнится после завершения синхронизации.\n\nСледите за строкой состояния внизу окна: она показывает состояние соединения и высоту блока. После завершения синхронизации баланс будет точным.",
@@ -838,11 +836,11 @@
"faq_w_seed_4_a": "Ваша сид-фраза — это основная резервная копия, и её достаточно для полного восстановления. Копия файла кошелька — удобная дополнительная резервная копия, которая также сохраняет метки и настройки. Храните любые резервные копии офлайн и в тайне.",
"faq_w_seed_4_q": "Нужно ли также сохранять резервную копию wallet.dat?",
"faq_w_seed_title": "Сид-фраза и резервное копирование",
"faq_w_send_1_a": "Откройте вкладку «Отправить», вставьте адрес получателя, укажите сумму и подтвердите. Если кошелёк зашифрован, вам предложат разблокировать его для транзакции.\n\nОтправка на защищённые адреса приватна; отправка на прозрачные адреса публична.",
"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_a": "Да — при отправке на защищённый (Z) адрес можно приложить зашифрованную заметку. Прочитать её сможет только получатель.",
"faq_w_send_3_q": "Можно ли прикрепить сообщение к платежу?",
"faq_w_send_4_a": "Транзакция остаётся «в ожидании», пока не будет добыта в блок и не получит подтверждения. Обычно это занимает минуту-две. Защищённым транзакциям также нужно, чтобы кошелёк был синхронизирован, для построения доказательства.\n\nЕсли отправка необычно долго остаётся в ожидании, проверьте, что кошелёк подключён и синхронизирован (см. строку состояния внизу окна).",
"faq_w_send_4_q": "Я отправил платёж, но он в статусе «в ожидании» — почему?",
@@ -867,11 +865,6 @@
"file_save_location": "Файл будет сохранён в: ~/.config/ObsidianDragon/",
"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": "Полные детали",
@@ -1170,6 +1163,22 @@
"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_too_small": "Слишком мало для объединения: комиссия съест эти средства.",
"merge_max_inputs": "Макс. входов за партию",
"merge_no_spendable": "Пока нет доступных для расходования входов для объединения.",
"merge_progress": "Объединение… это может занять несколько минут. Вы можете закрыть это окно.",
@@ -1182,6 +1191,7 @@
"merge_src_transparent": "Прозрачные",
"merge_started": "Операция объединения начата",
"merge_title": "Объединить на адрес",
"merge_where": "Куда направить средства?",
"mig_adopt": "Сделать это моим кошельком",
"mig_adopt_now": "Принять сид-кошелёк",
"mig_adopting": "Установка вашего нового кошелька и пересканирование…",
@@ -1233,6 +1243,8 @@
"miner_fee": "Комиссия майнера",
"mining": "Майнинг",
"mining_active": "Активен",
"mining_addr_explainer": "Принимает только награды за соло-майнинг — сеть отклоняет платежи на этот адрес. Награды экранируются автоматически, когда созревают.",
"mining_address": "Адрес для майнинга",
"mining_address_copied": "Адрес майнинга скопирован",
"mining_all_time": "За всё время",
"mining_already_saved": "URL пула уже сохранён",
@@ -1246,6 +1258,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": "Сейчас",
@@ -1290,6 +1304,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": "Комиссия",
@@ -1302,6 +1317,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": "Сохранённые адреса:",
@@ -1335,10 +1352,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": "Адреса не найдены. Создайте один, используя кнопки выше.",
@@ -1358,7 +1375,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",
@@ -1511,6 +1540,8 @@
"ram_wallet_gb": "Кошелёк: %.1f ГБ",
"ram_wallet_mb": "Кошелёк: %.0f МБ",
"receive": "Получить",
"receive_not_for_payments": "Не для платежей",
"receive_use_shielded": "Использовать экранированный адрес",
"received": "получено",
"received_filter": "Получено",
"received_label": "Получено",
@@ -1539,7 +1570,6 @@
"request_select_address": "Выбрать адрес...",
"request_shielded_addrs": "-- Экранированные адреса --",
"request_title": "Запрос оплаты",
"request_transparent_addrs": "-- Прозрачные адреса --",
"request_uri_copied": "URI платежа скопирован в буфер обмена",
"rescan": "Пересканировать",
"rescan_bootstrapped_msg": "Ваш узел был инициализирован из бутстрапа, поэтому блоки ниже снимка отсутствуют на диске, и пересканирование с генезиса не удастся. Пересканируйте с высоты, входящей в ваш снимок, чтобы согласовать потраченный баланс кошелька. Ваш wallet.dat и данные блокчейна не удаляются.",
@@ -1587,6 +1617,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": "Пересканирование",
@@ -1690,6 +1721,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": "Нет недавних отправлений",
@@ -1717,7 +1750,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",
@@ -1850,6 +1882,18 @@
"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": "Наибольшая сумма",
@@ -1860,6 +1904,11 @@
"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": "Остановить узел и подождать",
"stratum_allowip_hint": "Разрешить майнерам подключаться с IP или CIDR (пусто = только этот компьютер)",
"stratum_expose_warn": "Открывает порт для майнинга в разрешённую вами сеть — используйте только в доверенной локальной сети.",
"stratum_host": "Разместить майнинг-пул (stratum)",
@@ -1906,7 +1955,6 @@
"switch_stopnode_title": "Остановить запущенный узел?",
"switch_stopnode_warn": "Уже запущен узел, который не был запущен этим кошельком.",
"syncing": "Синхронизация...",
"t_address": "T-адрес",
"t_addresses": "T-адреса",
"test_connection": "Тест",
"theme": "Тема",
@@ -1941,8 +1989,6 @@
"transfer_sent": "Перевод отправлен",
"transfer_sent_desc": "Ваш перевод отправлен в сеть.",
"transfer_to": "Перевести на:",
"transparent": "Прозрачный",
"transparent_address": "Прозрачный адрес",
"try_again": "Повторить",
"tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков",
"tt_address_book": "Управление сохранёнными адресами для быстрой отправки",
@@ -2014,6 +2060,7 @@
"tt_low_spec": "Отключить все тяжёлые визуальные эффекты\\nГорячая клавиша: Ctrl+Shift+Down",
"tt_merge": "Объединить несколько UTXO в один адрес",
"tt_mine_idle": "Автоматически начать майнинг при\\nпростое системы (нет ввода с клавиатуры/мыши)",
"tt_new_mining_address": "Создаёт прозрачный адрес (R…) для наград за соло-майнинг. Он не принимает платежи — для них сообщайте экранированный адрес.",
"tt_noise": "Интенсивность зернистой текстуры (0% = выкл., 100% = максимум)",
"tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере",
"tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна",
@@ -2106,6 +2153,7 @@
"validate_description": "Введите адрес DragonX, чтобы проверить его действительность и принадлежность к этому кошельку.",
"validate_invalid": "НЕДЕЙСТВИТЕЛЕН",
"validate_is_mine": "Этот кошелёк владеет этим адресом",
"validate_mining_type": "Адрес для майнинга (прозрачный)",
"validate_not_mine": "Не принадлежит этому кошельку",
"validate_ownership": "Принадлежность:",
"validate_results": "Результаты:",
@@ -2113,7 +2161,6 @@
"validate_shielded_type": "Экранированный (z-адрес)",
"validate_status": "Статус:",
"validate_title": "Проверить адрес",
"validate_transparent_type": "Прозрачный (t-адрес)",
"validate_type": "Тип:",
"validate_valid": "ДЕЙСТВИТЕЛЕН",
"validating": "Проверка...",
+77 -30
View File
@@ -25,6 +25,7 @@
"about_website": "网站",
"acrylic": "亚克力",
"add": "添加",
"addr_type_mining": "挖矿",
"address": "地址",
"address_book_add": "添加地址",
"address_book_add_new": "添加新地址",
@@ -78,6 +79,7 @@
"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 输出",
@@ -99,7 +101,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": "矿池矿工已连接并正在计算哈希。",
@@ -120,11 +123,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": "按正确顺序点击这些词,以确认你已保存。",
@@ -174,21 +173,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": "区块",
@@ -513,7 +511,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": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。",
@@ -709,8 +707,8 @@
"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": "如何启动或停止节点?",
@@ -788,13 +786,13 @@
"faq_open_tooltip": "帮助与常见问题",
"faq_search_hint": "搜索常见问题…",
"faq_title": "帮助与常见问题",
"faq_w_addr_1_a": "透明地址(以 R 或 t 开头)的工作方式与大多数币种相同:金额和地址在区块链上是公开的。\n\n隐私地址(z 地址,以 zs 开头)使用零知识加密技术——金额、发送方和接收方都在链上加密。存放在隐私地址中的余额是私密的。",
"faq_w_addr_1_a": "屏蔽地址(z 地址,以 zs 开头)是您在 DragonX 上使用的地址。它们采用零知识密码学,金额、发送方和接收方在链上都是加密的,您的余额保持私密。\n\n透明地址(以 R 开头)是挖矿地址。DragonX 是完全私密的链,因此它们只能接收挖矿奖励——网络会拒绝任何发送给它们的付款。到达那里的奖励在被屏蔽之前是公开的,而钱包会自动完成屏蔽。",
"faq_w_addr_1_q": "透明地址和隐私地址有什么区别?",
"faq_w_addr_2_a": "请尽量使用隐私(z)地址——它们能保护您的余额和支付记录不被公开。DragonX 是一种隐私币,收款默认使用隐私地址。\n\n只有在您使用的服务无法处理隐私地址时,才使用透明地址。",
"faq_w_addr_2_a": "收发任何款项都请使用屏蔽(zs)地址——在 DragonX 上只有这种地址可以接收付款。\n\n透明(挖矿)地址仅用于单人挖矿奖励。切勿把它提供给他人用于付款:网络会拒绝该付款。",
"faq_w_addr_2_q": "应该使用哪种地址类型?",
"faq_w_addr_3_a": "对于隐私地址,重复使用没有问题,也不会泄露历史记录。对于透明地址,每次付款使用新地址可以提升隐私性。可以在“接收”标签页创建新地址。",
"faq_w_addr_3_a": "重复使用屏蔽地址没有问题,也不会泄露您的历史记录。您仍可以在“接收”页创建新地址,例如为每个联系人各建一个,以便知道是谁付的款。挖矿地址仅用于挖矿奖励,您很少需要新建。",
"faq_w_addr_3_q": "应该重复使用地址,还是新建一个?",
"faq_w_addr_4_a": "屏蔽会将币从透明地址转移到隐私地址,使其变为私密。新挖出的币到账时是透明的,会自动为您屏蔽。\n\n您也可以手动屏蔽;屏蔽只是一种特殊交易,因此需要正常的确认时间才能完成。",
"faq_w_addr_4_a": "屏蔽是把币从透明(挖矿)地址转移到屏蔽地址,使其变为私密。新挖出的币会到达挖矿地址,并在成熟后自动屏蔽。\n\n您也可以在钱包中手动屏蔽;屏蔽只是一种特殊交易,因此需要正常的确认时间。",
"faq_w_addr_4_q": "什么是“屏蔽”?",
"faq_w_addr_title": "地址与隐私",
"faq_w_bal_1_a": "在钱包同步完成之前,您的余额是不完整的,可能显示为 0——因为它还没有扫描完您的所有交易。同步完成后余额会自动补齐。\n\n请留意窗口底部的状态栏:它显示连接状态和区块高度。同步完成后,余额即为准确值。",
@@ -838,11 +836,11 @@
"faq_w_seed_4_a": "助记词是主要备份,足以恢复一切。钱包文件的副本是一个便捷的次要备份,还能保留标签和设置。请将所有备份妥善离线私密保管。",
"faq_w_seed_4_q": "我还需要备份 wallet.dat 吗?",
"faq_w_seed_title": "助记词与备份",
"faq_w_send_1_a": "打开“发送”标签页,粘贴收款方地址,输入金额并确认。如果钱包已加密,系统会要求您解锁以完成交易。\n\n发送到隐私地址是私密的;发送到透明地址是公开的。",
"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_a": "可以——向隐私(z)地址发送时,您可以附加一条加密备注。只有收款方能够读取它。",
"faq_w_send_3_q": "我可以在付款时附加消息吗?",
"faq_w_send_4_a": "交易在被打包进区块并获得确认之前处于「待处理」状态。这通常需要一两分钟。屏蔽交易还需要您的钱包已完成同步才能生成证明。\n\n如果一笔发款长时间处于待处理状态,请检查您的钱包是否已连接并同步(见窗口底部的状态栏)。",
"faq_w_send_4_q": "我发送了一笔付款,但显示待处理——为什么?",
@@ -867,11 +865,6 @@
"file_save_location": "文件将保存至:~/.config/ObsidianDragon/",
"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": "完整详情",
@@ -1170,6 +1163,22 @@
"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_too_small": "金额太小,无法整合:手续费会耗尽这些资金。",
"merge_max_inputs": "每批最大输入数",
"merge_no_spendable": "暂无可合并的可用输入。",
"merge_progress": "正在合并…这可能需要几分钟。您可以关闭此窗口。",
@@ -1182,6 +1191,7 @@
"merge_src_transparent": "透明",
"merge_started": "合并操作已开始",
"merge_title": "合并到地址",
"merge_where": "资金应转到哪里?",
"mig_adopt": "将其设为我的钱包",
"mig_adopt_now": "采用助记词钱包",
"mig_adopting": "正在安装您的新钱包并重新扫描…",
@@ -1233,6 +1243,8 @@
"miner_fee": "矿工费",
"mining": "挖矿",
"mining_active": "活跃",
"mining_addr_explainer": "仅接收单人挖矿奖励——网络会拒绝向此地址付款。奖励成熟后会自动屏蔽。",
"mining_address": "挖矿地址",
"mining_address_copied": "挖矿地址已复制",
"mining_all_time": "所有时间",
"mining_already_saved": "矿池 URL 已保存",
@@ -1246,6 +1258,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": "现在",
@@ -1290,6 +1304,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": "费用",
@@ -1302,6 +1317,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": "已保存地址:",
@@ -1335,10 +1352,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": "未找到地址。请使用上方按钮创建一个。",
@@ -1358,7 +1375,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": "未连接到守护进程",
@@ -1511,6 +1540,8 @@
"ram_wallet_gb": "钱包:%.1f GB",
"ram_wallet_mb": "钱包:%.0f MB",
"receive": "接收",
"receive_not_for_payments": "不用于付款",
"receive_use_shielded": "使用屏蔽地址",
"received": "已接收",
"received_filter": "已接收",
"received_label": "已接收",
@@ -1539,7 +1570,6 @@
"request_select_address": "选择地址...",
"request_shielded_addrs": "-- 屏蔽地址 --",
"request_title": "请求付款",
"request_transparent_addrs": "-- 透明地址 --",
"request_uri_copied": "付款 URI 已复制到剪贴板",
"rescan": "重新扫描",
"rescan_bootstrapped_msg": "您的节点使用了引导程序,因此快照以下的区块不在磁盘上,从创世区块重新扫描将失败。请从快照所包含的高度开始重新扫描,以核对钱包的已花费余额。您的 wallet.dat 和区块链数据不会被删除。",
@@ -1587,6 +1617,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": "重新扫描",
@@ -1690,6 +1721,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": "没有最近的发送",
@@ -1717,7 +1750,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",
@@ -1850,6 +1882,18 @@
"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": "金额最大",
@@ -1860,6 +1904,11 @@
"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": "停止节点并等待",
"stratum_allowip_hint": "允许来自指定 IP 或 CIDR 的矿工连接(留空 = 仅限本机)",
"stratum_expose_warn": "会向您允许的网络开放挖矿端口——仅在受信任的局域网中使用。",
"stratum_host": "托管矿池(stratum)",
@@ -1906,7 +1955,6 @@
"switch_stopnode_title": "停止正在运行的节点?",
"switch_stopnode_warn": "已有一个并非由此钱包启动的节点正在运行。",
"syncing": "同步中...",
"t_address": "T 地址",
"t_addresses": "T 地址",
"test_connection": "测试",
"theme": "主题",
@@ -1941,8 +1989,6 @@
"transfer_sent": "转账已发送",
"transfer_sent_desc": "您的转账已提交到网络。",
"transfer_to": "转账至:",
"transparent": "透明",
"transparent_address": "透明地址",
"try_again": "重试",
"tt_addr_url": "在区块浏览器中查看地址的基础 URL",
"tt_address_book": "管理已保存的地址以快速发送",
@@ -2014,6 +2060,7 @@
"tt_low_spec": "禁用所有重度视觉效果\\n快捷键:Ctrl+Shift+Down",
"tt_merge": "将多个 UTXO 合并到一个地址",
"tt_mine_idle": "系统空闲时自动开始挖矿\\n(无键盘/鼠标输入)",
"tt_new_mining_address": "创建一个用于单人挖矿奖励的透明(R…)地址。它无法接收付款——收款请提供屏蔽地址。",
"tt_noise": "颗粒纹理强度(0% = 关闭,100% = 最大)",
"tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)",
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
@@ -2106,6 +2153,7 @@
"validate_description": "输入一个 DragonX 地址来检查它是否有效以及是否属于此钱包。",
"validate_invalid": "无效",
"validate_is_mine": "此钱包拥有该地址",
"validate_mining_type": "挖矿地址(透明)",
"validate_not_mine": "不属于此钱包",
"validate_ownership": "所有权:",
"validate_results": "结果:",
@@ -2113,7 +2161,6 @@
"validate_shielded_type": "屏蔽(z 地址)",
"validate_status": "状态:",
"validate_title": "验证地址",
"validate_transparent_type": "透明(t 地址)",
"validate_type": "类型:",
"validate_valid": "有效",
"validating": "验证中...",
+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",
+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
+678 -278
View File
File diff suppressed because it is too large Load Diff
+115 -11
View File
@@ -21,6 +21,9 @@
#include "data/wallet_index.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
#include "daemon/stop_delivery.h"
#include "daemon/daemon_busy_detect.h"
#include "rpc/rpc_busy_gate.h"
#include "services/network_refresh_service.h"
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
@@ -190,11 +193,29 @@ public:
*/
std::vector<std::string> tailDaemonDebugLog(int maxLines) const;
// True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort
// heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch.
bool daemonWitnessRebuildActive() const;
// Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress).
// Whether beginShutdown() should pause and confirm before stopping the daemon: this quit would stop it,
// and it is rebuilding witnesses (its own log says so) or has been alive but too busy to answer RPC for
// a while. Stopping mid-rebuild throws the rebuild away, and a node killed then replays and redoes it.
bool shouldConfirmDaemonStop() const;
// The node is alive and its RPC has only timed out / been refused as busy for >= 30s (a rebuild that
// logged no marker, e.g. an external node whose output we can't read).
bool rpcBusyWhileAlive() const;
// The node is rebuilding witnesses (our own node's log says so) or has been too busy to answer for a
// while: stopping it now either waits up to an hour or makes it start over. UI thread only.
bool nodeBusyForStop() const;
// May quitting (or a default-policy stop) stop a node this session didn't launch? With stop_external_daemon
// on, or when it is the node an earlier session of ours launched and left running (its ownership record
// matches a live process). That one is stopped gracefully and waited for, never killed.
bool mayStopExternalNode() const;
// For an operation that would stop the node (restart, rescan, switch, reinstall, bootstrap, ...): when
// nodeBusyForStop(), tell the user to try again later and return true; the caller then does nothing.
bool refuseIfNodeBusy();
// Record how an RPC to the node just went, for rpcBusyWhileAlive(): busy kinds (ReplyTimeout /
// QueueFull) start the clock, an answer stops it, anything else leaves it alone.
void noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind);
// Called by main.cpp: brings the window back (restore + raise) when a quit is deferred for the confirm
// modal while minimized or behind other windows — the modal only renders in a visible frame.
void setRaiseWindowCallback(std::function<void()> cb) { raise_window_ = std::move(cb); }
// The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render().
void renderDaemonStopConfirm();
@@ -480,12 +501,22 @@ public:
// Embedded daemon control
bool startEmbeddedDaemon();
void stopEmbeddedDaemon();
// Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on
// -wallet=<name>. Unlike stopEmbeddedDaemon() (whose DisconnectOnly policy leaves an adopted/external
// daemon running), this sends a graceful RPC "stop" — using our own connection creds, so only our
// daemon obeys it — even to an adopted daemon, then waits (bounded) for the port to actually release.
// Returns true once the RPC port is free (or we're shutting down).
// How long stopEmbeddedDaemon() may wait, and who decides. It never kills the node on its own.
enum class StopMode {
AppShutdown, // quitting: no time limit; the shutdown screen offers "Leave running" (and "Kill" when stuck)
Background, // restart/rescan/switch/...: up to 120s, and let go as soon as the app starts quitting
Fallback, // the synchronous shutdown fallback on the UI thread: up to 20s
};
// Which node it may stop. Policy: our own, or an external one only when the "stop external daemon"
// setting says so. Connected: also the node we're connected to (adopted or external), with the
// credentials we connected with — a wallet switch, a reinstall and decrypt must free the port, the binary
// and wallet.dat. Only a node that accepts those credentials obeys.
enum class StopScope { Policy, Connected };
daemon::StopOutcome stopEmbeddedDaemon(StopMode mode = StopMode::Background, StopScope scope = StopScope::Policy);
// Stop the node for a wallet switch / adopt / restore / rebuild, which swap wallet.dat or relaunch on
// -wallet=<name>. Unlike stopEmbeddedDaemon()'s default policy (which leaves an adopted/external node
// running) it stops the node we're connected to too (StopScope::Connected). True only once the process
// has exited and the RPC port is free; false if it is still running, or the app started quitting.
bool stopDaemonForWalletSwitch();
bool isEmbeddedDaemonRunning() const;
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
@@ -632,6 +663,8 @@ public:
private:
friend class AppDaemonLifecycleRuntime;
friend class AppStopRuntime;
friend class AppStopTaskContext;
friend class AppDaemonLifecycleTaskContext;
// Global keyboard-shortcut handling, dispatched once per frame from update().
@@ -751,6 +784,7 @@ private:
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
bool chat_identity_provisioned_ = false; // identity set on the service this session
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure
// Bumped by resetChatSession() on every wallet change; an in-flight identity fetch captures the
// value at post time and its completion callback discards its (previous-wallet) secret if the id
// no longer matches — so wallet A's seed can't be provisioned under wallet B via a stale job.
@@ -964,9 +998,19 @@ private:
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
float shutdown_timer_ = 0.0f;
bool force_quit_confirm_ = false;
std::chrono::steady_clock::time_point shutdown_start_time_;
// One stop session at a time (stopEmbeddedDaemon), and the shutdown screen's view of it.
std::mutex stop_mutex_;
std::atomic<bool> stop_session_active_{false};
std::atomic<int> stop_user_choice_{0}; // daemon::StopUserChoice, set by the shutdown screen
std::atomic<bool> stop_kill_offered_{false}; // the node looks stuck: the screen may offer "Kill"
std::atomic<int> stop_idle_minutes_{0};
std::mutex stop_client_mutex_;
rpc::RPCClient* active_stop_client_ = nullptr; // aborted on "Leave running" so a pending attempt ends at once
bool stop_kill_confirm_ = false;
void chooseStopAction(daemon::StopUserChoice choice);
// Daemon restart (e.g. after changing debug log categories)
std::atomic<bool> daemon_restarting_{false};
@@ -1047,6 +1091,11 @@ private:
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.
std::atomic<int> pending_delete_result_{-1};
std::atomic<bool> pending_reinstall_node_busy_{false}; // reinstall gave up: the old node never exited
// A worker stopped the node for an operation and it didn't exit: the operation was cancelled. Told (and,
// for a rescan/repair, its armed state undone) on the UI thread. Bits: kStopFailed*.
static constexpr int kStopFailedNotify = 1, kStopFailedUndoRescan = 2;
std::atomic<int> pending_stop_failed_{0};
// Encryption state check timeout
float encryption_check_timer_ = 0.0f;
@@ -1070,6 +1119,7 @@ private:
bool seed_backup_loading_ = false;
bool seed_backup_no_mnemonic_ = false;
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
std::chrono::steady_clock::time_point seed_backup_reminder_retry_at_{}; // after an inconclusive probe
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
@@ -1133,6 +1183,49 @@ private:
void setDaemonStatus(std::string s);
mutable std::string daemon_mem_diag_; // diagnostic info for daemon memory detection
size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection)
// What the node's own output says it is doing (witness rebuild / startup replay). Fed on the UI thread
// from the captured output; node_rebuilding_ is its snapshot for the shutdown thread's stop loop.
daemon::DaemonBusyTracker daemon_busy_;
std::atomic<bool> node_rebuilding_{false};
std::atomic<bool> daemon_busy_reset_pending_{false}; // set from any thread; applied by feedDaemonBusy()
std::chrono::steady_clock::time_point rpc_busy_since_{}; // epoch = not busy
std::function<void()> raise_window_;
// S6: once the node is too busy to answer (a reply timeout or a full queue), the polling clients
// (rpc_, fast_rpc_) hold back everything that needs cs_main, and one canary request on its own
// client waits for the node to answer again. rpc_busy_gate_ is shared with those clients.
std::shared_ptr<rpc::RpcBusyGate> rpc_busy_gate_ = std::make_shared<rpc::RpcBusyGate>();
rpc::ConnectRetryBackoff connect_backoff_; // S8: per-failure-kind delay before the next connect attempt
std::thread canary_thread_;
std::atomic<int> canary_state_{0}; // CanaryState
std::mutex canary_mutex_;
rpc::RPCClient* canary_client_ = nullptr; // aborted on shutdown
rpc::ConnectRetryBackoff canary_backoff_; // a canary refused as queue-full waits before the next
std::chrono::steady_clock::time_point canary_retry_at_{};
void pumpRpcBusyCanary();
// A node this session didn't launch (left running by an earlier session, or found running): feed the busy
// tracker from its <datadir>/debug.log, while that log is tied to the live process (see
// daemon::externalLogFeedOffset). Our own running node's output feeds the tracker directly. UI thread.
void pumpExternalDaemonLog();
long externalNodePid() const; // that node: our ownership record, or (POSIX) its hushd.pid; 0 if none
bool busyTrackerIsLive() const; // the tracker describes a node that is running now
bool ext_log_tied_ = false;
long ext_log_pid_ = 0;
std::uintmax_t ext_log_offset_ = 0;
double ext_log_next_at_ = 0.0;
void abortRpcBusyCanary();
void feedDaemonBusy(const std::string& chunk);
void resetDaemonBusy();
std::chrono::steady_clock::time_point busy_first_seen_{}; // when the tracker last turned busy (UI thread)
// What to tell the user while the node is busy (overlay, banner, quit prompt). busy=false: it isn't.
struct NodeBusyText {
bool busy = false;
bool rebuilding = false;
std::string title; // e.g. "Node busy: rebuilding note witnesses — do not close"
std::string detail; // running time, what it's doing, progress when the node reports any
std::string elapsed; // "12:03"
std::string compact; // one line for the banner: "Running for 12:03 · Reading blocks: 45438 / 62583"
};
NodeBusyText nodeBusyText() const;
// Export/Import state
char export_result_[256] = {0}; // SECRET exported key — fixed buffer so it can be sodium_memzero'd
@@ -1521,6 +1614,17 @@ private:
// `reachableButBusy` is true when the probe connected but got no RPC reply (a timeout),
// false when the daemon is merely launching (not bound yet). Returns the status title.
std::string applyDaemonInitStatus(bool reachableButBusy);
// The overlay text applyDaemonInitStatus last produced. It runs only on a connect failure, which a busy
// node backs off to every 30s, so update() re-applies it once a second (the elapsed time and the
// node's own progress tick) for as long as nothing else has replaced the status.
std::string init_status_title_;
bool init_status_busy_ = false;
double status_refresh_at_ = 0.0;
// The warm-up overlay text, from the node's last -28 message — unless its own log says it is rebuilding
// witnesses: a startup rebuild runs under "Activating best chain...", which hides the step users must
// not interrupt. update() re-applies it once a second too (see init_status_title_).
void applyWarmupText(const std::string& rpcMessage);
std::string warmup_rpc_message_;
// Tear down a connection that died mid-session (daemon crash / restart / dropped
// socket) so update()'s reconnect branch re-enters tryConnect(). Unlike onDisconnected
// alone, this also rpc_->disconnect()s so rpc_->isConnected() actually flips to false.
+171 -51
View File
@@ -264,6 +264,79 @@ void App::detectWalletDegraded()
VERBOSE_LOGF("[recovery] Daemon opened wallet in DEGRADED mode — new-key derivation disabled\n");
}
// One canary per busy period: a getinfo on its own client with NO client timeout, so it is the one request
// we let wait in the node's queue instead of re-sending (each re-send would take another slot). Its answer
// means the node serves cs_main requests again.
enum CanaryState { kCanaryIdle = 0, kCanaryRunning, kCanaryAnswered, kCanaryRefused, kCanaryGone };
void App::abortRpcBusyCanary()
{
std::lock_guard<std::mutex> lk(canary_mutex_);
if (canary_client_) canary_client_->requestAbort();
}
void App::pumpRpcBusyCanary()
{
const int st = canary_state_.load();
if (st >= kCanaryAnswered) {
if (canary_thread_.joinable()) canary_thread_.join();
canary_state_ = kCanaryIdle;
if (st == kCanaryAnswered) {
DEBUG_LOGF("[busy] the node answered again after %.0fs; resuming normal polling\n",
rpc_busy_gate_->busySeconds());
rpc_busy_gate_->clear();
canary_backoff_.reset();
connect_backoff_.reset();
network_refresh_.markImmediateRefresh(); // also makes a pending connect attempt due at once
} else if (st == kCanaryRefused) {
const float wait = canary_backoff_.next(rpc::RpcFailureKind::QueueFull);
canary_retry_at_ = std::chrono::steady_clock::now() +
std::chrono::milliseconds(static_cast<int>(wait * 1000.0f));
DEBUG_LOGF("[busy] canary refused (queue full); next one in %.0fs\n", wait);
} else {
// Nothing listening any more (or our own abort): there's no busy node to wait for. Normal
// connect / lost-connection handling takes over.
DEBUG_LOGF("[busy] canary got no answer; leaving busy mode\n");
rpc_busy_gate_->clear();
canary_backoff_.reset();
}
}
if (!rpc_busy_gate_->busy() || canary_state_.load() != kCanaryIdle || shutting_down_) return;
if (std::chrono::steady_clock::now() < canary_retry_at_) return;
const rpc::ConnectionConfig cfg = (!saved_config_.rpcuser.empty() || !saved_config_.rpcpassword.empty())
? saved_config_ : rpc::Connection::autoDetectConfig();
DEBUG_LOGF("[busy] the node is too busy to answer (%d request(s) given up on): holding back polls; "
"one canary getinfo waits for it\n", rpc_busy_gate_->abandoned());
canary_state_ = kCanaryRunning;
canary_thread_ = std::thread([this, cfg]() {
rpc::RPCClient client; // deliberately ungated: this is the one request we allow
{
std::lock_guard<std::mutex> lk(canary_mutex_);
canary_client_ = &client;
}
int result = kCanaryGone;
if (!shutting_down_ && client.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, cfg.use_tls)) {
try {
rpc::RPCClient::TraceScope trace("Busy node / Canary");
client.call("getinfo", json::array(), 0L); // 0 = no client timeout
result = kCanaryAnswered;
} catch (const std::exception& e) {
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
if (kind == rpc::RpcFailureKind::AppError || kind == rpc::RpcFailureKind::Warmup)
result = kCanaryAnswered; // a worker ran it: cs_main is free again
else if (kind == rpc::RpcFailureKind::QueueFull)
result = kCanaryRefused;
}
}
{
std::lock_guard<std::mutex> lk(canary_mutex_);
canary_client_ = nullptr;
}
canary_state_ = result;
});
}
void App::tryConnect()
{
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
@@ -324,8 +397,7 @@ void App::tryConnect()
if (++daemon_wait_attempts_ == kDaemonWaitWarnAttempts) {
ui::Notifications::instance().warning(TR("daemon_port_busy_warn"), 20.0f);
}
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, 4.0f);
return;
}
daemon_wait_attempts_ = 0; // port is free — clear the bounded-wait counter
@@ -338,8 +410,7 @@ void App::tryConnect()
if (startEmbeddedDaemon()) {
// Will retry connection after daemon starts
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", connect_attempt);
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, 1.0f);
} else {
// The daemon couldn't be started (binary not found, Sapling params missing, spawn
// failure, …). Surface the actual reason instead of leaving the status stuck on
@@ -356,8 +427,7 @@ void App::tryConnect()
detail.empty() ? std::string(TR("sb_daemon_start_failed")) : detail, 30.0f);
}
// Keep retrying: a missing binary/params can be fixed without a restart.
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, 5.0f);
}
} else if (!isUsingEmbeddedDaemon()) {
VERBOSE_LOGF("[connect #%d] Embedded daemon disabled (using external). No config found at %s\n",
@@ -444,10 +514,14 @@ void App::tryConnect()
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
std::string connectErr = rpc_->getLastConnectError();
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
bool warmingUp = rpc_->isWarmingUp();
std::string warmupStatus = rpc_->getWarmupStatus();
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr]() {
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
connectKind]() {
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
if (connected) connect_backoff_.reset();
if (connected) {
VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
warmingUp ? " (daemon warming up)" : "");
@@ -459,22 +533,13 @@ void App::tryConnect()
// the UI shows status instead of a blocking overlay.
state_.warming_up = true;
if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock
auto wt = translateWarmup(warmupStatus);
state_.warmup_status = wt.title;
state_.warmup_description = wt.description;
// Append current block height from daemon output
if (daemon_controller_) {
int h = daemon_controller_->lastBlockHeight();
if (h > 0)
state_.warmup_status += " (Block " + std::to_string(h) + ")";
}
connection_status_ = state_.warmup_status;
applyWarmupText(warmupStatus);
}
} else {
// HTTP 401 = authentication failure. The daemon is running
// but our rpcuser/rpcpassword don't match. Don't retry
// endlessly — tell the user what's wrong.
bool authFailure = (connectErr.find("401") != std::string::npos);
bool authFailure = (connectKind == rpc::RpcFailureKind::Auth);
if (authFailure) {
rpc::ConnectionConfig cookieConfig;
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
@@ -520,18 +585,20 @@ void App::tryConnect()
// a connect refusal means it hasn't bound the RPC port yet. Either way, show a
// clear "node initializing" overlay (status + phase + block height from the
// daemon's own console output) instead of a bare technical error.
const bool reachableButBusy = connectErr.find("Timeout") != std::string::npos;
// Reached the node but no answer (reply timeout / full queue / held back): up but busy.
// A refusal or connect timeout means it hasn't bound (or has already unbound) RPC.
const bool reachableButBusy = rpc::rpcKindMeansBusy(connectKind);
applyDaemonInitStatus(reachableButBusy);
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, will retry...\n",
attempt, connectErr.c_str());
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
const float retryIn = connect_backoff_.next(connectKind);
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, retry in %.0fs\n",
attempt, connectErr.c_str(), retryIn);
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, retryIn);
} else if (externalDetected) {
state_.connected = false;
// An external daemon is on the RPC port but not answering. A timeout means it's
// up and busy initializing; surface that as the init overlay (we can't read its
// console since we didn't launch it, so no phase line — just a clear message).
if (connectErr.find("Timeout") != std::string::npos) {
if (rpc::rpcKindMeansBusy(connectKind)) {
applyDaemonInitStatus(/*reachableButBusy=*/true);
} else if (!connectErr.empty()) {
char buf[256]; snprintf(buf, sizeof(buf), TR("sb_connecting_err"), connectErr.c_str());
@@ -539,10 +606,10 @@ void App::tryConnect()
} else {
connection_status_ = TR("sb_connecting_external");
}
VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), will retry...\n",
attempt, connectErr.c_str());
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
const float retryIn = connect_backoff_.next(connectKind);
VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), retry in %.0fs\n",
attempt, connectErr.c_str(), retryIn);
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, retryIn);
} else {
onDisconnected("Connection failed");
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
@@ -593,7 +660,11 @@ void App::tryConnect()
if (startEmbeddedDaemon()) {
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", attempt);
} else if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) {
connection_status_ = TR("sb_connecting_generic");
// A node already running (left running, or started by hand). If our probe reached
// it but got no answer, say what it is doing (its log may show a rebuild) rather
// than a bare "Connecting…" until the next attempt, up to 30s away.
if (rpc::rpcKindMeansBusy(connectKind)) applyDaemonInitStatus(/*reachableButBusy=*/true);
else connection_status_ = TR("sb_connecting_generic");
VERBOSE_LOGF("[connect #%d] External daemon detected, will connect via RPC...\n", attempt);
} else {
VERBOSE_LOGF("[connect #%d] Failed to start embedded daemon — lastError: %s\n",
@@ -625,6 +696,7 @@ void App::onConnected()
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
}
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
init_status_title_.clear();
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock
// A repair's rescan finished and connected — retire the recovery session so its crash-toast/error-card
@@ -689,6 +761,7 @@ void App::onConnected()
// behind the main refresh batch.
if (!fast_rpc_) {
fast_rpc_ = std::make_unique<rpc::RPCClient>();
fast_rpc_->setBusyGate(rpc_busy_gate_);
}
if (!fast_worker_) {
fast_worker_ = std::make_unique<rpc::RPCWorker>();
@@ -795,19 +868,33 @@ std::string App::applyDaemonInitStatus(bool reachableButBusy)
}
}
// Most specific first: the node's own log says it is rebuilding witnesses or replaying blocks (then
// "Launching dragonxd…" over a node that has been busy for an hour invited closing it), else an init
// phase line, else "reachable but busy", else not bound yet.
const auto kind = daemon::chooseDaemonStatus(!phaseLine.empty(), reachableButBusy, daemon_busy_);
std::string busyTitle, busyDetail;
WarmupText wt;
if (!phaseLine.empty()) {
switch (kind) {
case daemon::DaemonStatusKind::Rebuilding:
case daemon::DaemonStatusKind::Replaying: {
const NodeBusyText busy = nodeBusyText();
busyTitle = busy.title;
busyDetail = busy.detail;
wt = {busyTitle.c_str(), busyDetail.c_str()};
break;
}
case daemon::DaemonStatusKind::InitPhase:
wt = translateWarmup(phaseLine);
} else if (reachableButBusy) {
// The probe connected but got no RPC reply within the timeout: the node is up but busy
// initializing (it isn't printing a recognizable phase, or we didn't launch it).
wt = {"Starting DragonX node…",
"The node is reachable but still initializing and isn't answering yet. "
"This is normal after an update or on first launch — it can take a few minutes."};
} else {
break;
case daemon::DaemonStatusKind::Busy:
// The probe reached the node but got no answer (a reply timeout or a full queue): it is up but
// too busy to answer (it isn't printing a recognizable phase, or we didn't launch it).
wt = {TR("node_starting_title"), TR("node_starting_busy_desc")};
break;
case daemon::DaemonStatusKind::Launching:
// The daemon is launching but hasn't bound its RPC port yet.
wt = {"Starting DragonX node…",
"Launching dragonxd and waiting for it to come online…"};
wt = {TR("node_starting_title"), TR("node_starting_launch_desc")};
break;
}
std::string title = wt.title;
@@ -817,9 +904,29 @@ std::string App::applyDaemonInitStatus(bool reachableButBusy)
state_.warmup_status = title;
state_.warmup_description = wt.description ? wt.description : "";
connection_status_ = title;
init_status_title_ = title;
init_status_busy_ = reachableButBusy;
return title;
}
void App::applyWarmupText(const std::string& rpcMessage)
{
warmup_rpc_message_ = rpcMessage;
if (daemon_busy_.rebuilding()) {
const NodeBusyText busy = nodeBusyText();
state_.warmup_status = busy.title;
state_.warmup_description = busy.detail;
} else {
const WarmupText wt = translateWarmup(rpcMessage);
state_.warmup_status = wt.title;
state_.warmup_description = wt.description;
}
// Append current block height from daemon output
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
if (h > 0) state_.warmup_status += " (Block " + std::to_string(h) + ")";
connection_status_ = state_.warmup_status;
}
void App::handleLostConnection(const std::string& reason)
{
DEBUG_LOGF("[Connection] %s — tearing down for reconnect\n", reason.c_str());
@@ -1288,6 +1395,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
return;
}
if (refuseIfNodeBusy()) return;
// W1-1: verify the target wallet file actually exists before switching. dragonxd auto-CREATES a
// fresh empty wallet for a missing -wallet=<name>, so without this a moved/deleted wallet file would
// silently "open" as a brand-new empty wallet with a zero balance — looking exactly like fund loss.
@@ -1724,15 +1832,7 @@ void App::refreshCoreData()
refreshData();
} else {
// Still warming up — update status
auto wt = translateWarmup(result.errorMessage);
state_.warmup_status = wt.title;
state_.warmup_description = wt.description;
if (daemon_controller_) {
int h = daemon_controller_->lastBlockHeight();
if (h > 0)
state_.warmup_status += " (Block " + std::to_string(h) + ")";
}
connection_status_ = state_.warmup_status;
applyWarmupText(result.errorMessage);
VERBOSE_LOGF("[warmup] Still warming up: %s\n", result.errorMessage.c_str());
}
};
@@ -1792,10 +1892,16 @@ void App::refreshCoreData()
// RPCs failing together means the daemon connection is dead (a busy daemon
// fails them individually, not both at once). Warmup is excluded — both fail
// with -28 there legitimately, and counting it would cause a reconnect loop.
noteDaemonRpcResult(result.blockchainOk || result.balanceOk,
result.blockchainOk ? result.balanceErrKind : result.blockchainErrKind);
constexpr int kCoreFailuresBeforeDisconnect = 3;
if (!state_.warming_up) {
if (!result.balanceOk && !result.blockchainOk) {
if (++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
// Only failures that put the connection itself in doubt count. A node that is merely
// busy (reply timeouts, a full queue, calls held back) is still there: tearing down
// then wiped the wallet state and restarted the connect probes that fill its queue.
if (rpc::rpcKindMeansLost(result.blockchainErrKind) &&
++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
state_.connected) {
consecutive_core_failures_ = 0;
handleLostConnection("Lost connection to daemon");
@@ -2583,10 +2689,12 @@ void App::startPoolMining(int threads)
// Validate the payout address at EVERY start entry point (manual Start button, idle auto-start, thread
// scaling) — not just the UI gate — since a stale/hand-edited/wrong-chain address here silently loses
// mining rewards. (M-01) worker_name IS the pool login the rewards are credited to (see below).
// A pool pays with ordinary transactions, so only a shielded address can be paid (private chain).
if (!cfg.worker_name.empty() && cfg.worker_name != "x" &&
!util::isValidRecipientAddress(cfg.worker_name)) {
ui::Notifications::instance().error(
"Pool payout address is not a valid DragonX address — mining not started.");
ui::Notifications::instance().error(util::isTransparentAddress(cfg.worker_name)
? TR("mining_payout_needs_shielded")
: "Pool payout address is not a valid DragonX address — mining not started.");
return;
}
// The algo follows the pool: official pools use their own algo (pool.dragonx.cc
@@ -3131,6 +3239,9 @@ void App::maybeProvisionChatIdentity()
if (chat_service_.hasIdentity() || chat_identity_provisioned_ ||
chat_identity_fetch_in_flight_ || chat_identity_unavailable_) return;
// After a transient failure (connection blip, a busy node) wait before asking again: retrying on the
// next frame re-sent the request continuously.
if (std::chrono::steady_clock::now() < chat_identity_retry_at_) return;
if (supportsLiteBackend()) {
// Lite: the backend's `seed` command returns the 24-word mnemonic synchronously.
@@ -3201,6 +3312,7 @@ void App::maybeProvisionChatIdentity()
}
chat_identity_fetch_in_flight_ = false;
if (unavailable) chat_identity_unavailable_ = true;
if (transientFail) chat_identity_retry_at_ = std::chrono::steady_clock::now() + std::chrono::seconds(10);
// Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can
// land during the blocking fetch, and provisioning then would unlock the chat DB +
// load decrypted history while the wallet is locked. Leaving the flag cleared here
@@ -4446,6 +4558,9 @@ void App::maybeRemindSeedBackup()
if (!state_.connected || !state_.encryption_state_known) return;
if (state_.isLocked()) return; // wait until unlocked to read it
if (seed_backup_reminder_in_flight_) return;
// An inconclusive answer (an error other than "not derived from a mnemonic" — e.g. a daemon without
// the RPC, or a busy one) used to re-fire this probe on the very next frame, hammering the daemon.
if (std::chrono::steady_clock::now() < seed_backup_reminder_retry_at_) return;
seed_backup_reminder_in_flight_ = true;
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
const std::string& /*error*/) {
@@ -4455,6 +4570,8 @@ void App::maybeRemindSeedBackup()
if (ok || noMnemonic) {
settings_->setSeedBackupReminded(true);
settings_->save();
} else {
seed_backup_reminder_retry_at_ = std::chrono::steady_clock::now() + std::chrono::minutes(5);
}
if (ok)
ui::Notifications::instance().info(TR("seed_backup_reminder"), 12.0f);
@@ -4961,6 +5078,7 @@ void App::locateSweepTxid()
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
void App::beginAdoptSeedWallet()
{
if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free
seed_migration_step_ = SeedMigrationStep::Adopting;
seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…";
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
@@ -5076,6 +5194,7 @@ void App::restoreOriginalWallet()
ui::Notifications::instance().warning(TR("wallet_restore_busy"));
return;
}
if (refuseIfNodeBusy()) return;
// Keep the recovery dialog OPEN and drive it into the Working phase — it shows progress and the
// honest outcome in place (pumpWalletRestore flips it to Done/Failed). Presentation only; every
// file-safety step below is unchanged.
@@ -5243,6 +5362,7 @@ void App::rebuildWalletDatabase()
return;
}
if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; }
if (refuseIfNodeBusy()) return;
const std::string helper = findWalletRebuildHelper();
if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; }
+24 -13
View File
@@ -214,7 +214,7 @@ public:
: app_(app), token_(token) {}
bool isUsingEmbeddedDaemon() const override { return app_.isUsingEmbeddedDaemon(); }
void stopEmbeddedDaemon() override { app_.stopEmbeddedDaemon(); }
bool stopEmbeddedDaemon() override { return app_.stopEmbeddedDaemon() == daemon::StopOutcome::Exited; }
bool startEmbeddedDaemon() override { return app_.startEmbeddedDaemon(); }
bool cancelled() const override { return token_.cancelled(); }
bool shuttingDown() const override { return app_.isShuttingDown(); }
@@ -292,8 +292,13 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (!token.cancelled() && !shutting_down_) {
stopEmbeddedDaemon();
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
if (stopEmbeddedDaemon() == daemon::StopOutcome::Exited) {
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
} else if (!shutting_down_) {
// Still running: never start a second node over it. The connect loop goes back to it.
DEBUG_LOGF("[App] encryption restart: the node did not exit; not restarting it\n");
pending_stop_failed_.fetch_or(kStopFailedNotify);
}
}
} catch (...) {}
daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop)
@@ -306,6 +311,7 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
void App::encryptWalletWithPassphrase(const std::string& passphrase) {
if (!rpc_ || !rpc_->isConnected()) return;
if (refuseIfNodeBusy()) return; // encrypting ends in a node restart
encrypt_in_progress_ = true;
encrypt_status_ = TR("sec_encrypting_wallet");
@@ -1561,7 +1567,9 @@ void App::renderDecryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || decryptState.inProgress);
if (ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) {
// Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click).
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) &&
!refuseIfNodeBusy()) {
std::string passphrase(decrypt_pass_buf_);
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
wallet_security_workflow_.start(std::chrono::steady_clock::now());
@@ -1613,15 +1621,18 @@ void App::renderDecryptWalletDialog() {
// Continue with step 3
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context);
});
services::WalletSecurityWorkflowExecutor::stopDaemon(decryptRpc);
// Wait for daemon to fully stop
for (int i = 0; i < 30 && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Stop the node and wait for its PROCESS to exit before wallet.dat is
// renamed: dragonxd keeps the file open until it exits, which can take
// minutes on a large wallet (a fixed 3s wait let the rename run under a
// live node). Ours or the one we're connected to; bounded, never killed.
const bool stopped = stopEmbeddedDaemon(StopMode::Background, StopScope::Connected) ==
daemon::StopOutcome::Exited;
if (!stopped) {
App::scrubAndRemoveExport(filePlan.exportPath); // nothing will import it
return [this]() {
wallet_security_workflow_.fail(TR("appx_node_not_stopped"));
};
}
return [this, filePlan]() {
wallet_security_workflow_.advanceTo(DecryptStep::BackupWallet,
+18 -1
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"
@@ -102,7 +103,7 @@ 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();
@@ -370,6 +371,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 +401,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) {
@@ -588,6 +600,11 @@ void App::buildSweepCatalog()
[](App& a) { a.state_.connected = false; a.state_.encryption_state_known = false; },
[](App& a) { a.applyHealthyDemoState(); });
// 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); },
+9 -5
View File
@@ -77,6 +77,7 @@ void App::restartWizard()
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
@@ -874,7 +875,7 @@ 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>();
@@ -974,9 +975,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;
@@ -1058,7 +1062,7 @@ 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>();
@@ -1077,7 +1081,7 @@ 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_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) {
stopDaemonForBootstrap();
bootstrap_ = std::make_unique<util::Bootstrap>();
std::string dataDir = util::Platform::getDragonXDataDir();
+196
View File
@@ -0,0 +1,196 @@
#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);
}
/**
* 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
+5
View File
@@ -52,6 +52,11 @@ void DaemonController::stop(int waitMs)
daemon_->stop(waitMs);
}
void DaemonController::markStopRequested()
{
daemon_->markStopRequested();
}
bool DaemonController::isRunning() const
{
return daemon_->isRunning();
+235 -9
View File
@@ -1,9 +1,11 @@
#pragma once
#include "embedded_daemon.h"
#include "stop_delivery.h"
#include <algorithm>
#include <cstddef>
#include <deque>
#include <memory>
#include <string>
#include <vector>
@@ -61,17 +63,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;
};
@@ -90,6 +96,7 @@ public:
bool start(const config::Settings* settings);
void stop(int waitMs);
void markStopRequested();
bool isRunning() const;
bool externalDaemonDetected() const;
@@ -191,22 +198,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 +242,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_;
};
+574 -130
View File
@@ -6,16 +6,22 @@
// binary discovery, process spawn, stdout/stderr monitoring, crash recovery.
#include "embedded_daemon.h"
#include "daemon_busy_detect.h"
#include "../config/version.h"
#include "../resources/embedded_resources.h"
#include "../util/platform.h"
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <chrono>
#include <cstring>
#include <ctime>
#include <algorithm>
#include <fstream>
#include <iterator>
#include <limits>
#include <vector>
#include "../util/logger.h"
@@ -33,11 +39,13 @@
#include <signal.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#ifdef __APPLE__
#include <libproc.h>
#include <sys/sysctl.h>
#endif
#endif
@@ -51,7 +59,30 @@ EmbeddedDaemon::EmbeddedDaemon() = default;
EmbeddedDaemon::~EmbeddedDaemon()
{
stop(3000); // Wait up to 3 seconds for clean shutdown
// Never stop() from here: a destructor must not kill a node. On Windows stop() also used to fall
// back to killing ANY dragonxd.exe by name, so destroying an object that had already stopped its own
// process (seed migration's isolated node) could take down the user's main node. Owners call stop()
// explicitly (the app's own daemon is never destroyed: main() ends in _Exit). Just stop watching and
// release what we hold; a process still running is left to finish on its own.
should_stop_ = true;
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
#ifdef _WIN32
if (process_handle_ != nullptr) {
CloseHandle(process_handle_);
process_handle_ = nullptr;
}
if (stdout_read_ != nullptr) {
CloseHandle(stdout_read_);
stdout_read_ = nullptr;
}
#else
if (stdout_fd_ >= 0) {
close(stdout_fd_);
stdout_fd_ = -1;
}
#endif
}
std::string EmbeddedDaemon::findDaemonBinary()
@@ -193,11 +224,11 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
DEBUG_LOGF("[INFO] Using %s\n", dbcache_arg.c_str());
}
// No -printtoconsole on any platform: the node writes its timestamped <datadir>/debug.log, which we
// tail (and which keeps going if the node is left running after we quit); its stdout/stderr stay piped
// on POSIX for crash diagnostics.
return {
"-tls=only",
#ifndef _WIN32
"-printtoconsole",
#endif
"-clientname=ObsidianDragon",
"-ac_name=DRAGONX",
"-ac_algo=randomx",
@@ -244,6 +275,7 @@ void EmbeddedDaemon::appendOutput(const char* data, size_t len)
{
// Caller must hold output_mutex_
process_output_.append(data, len);
output_total_ += len;
if (process_output_.size() > MAX_OUTPUT_BYTES + MAX_OUTPUT_BYTES / 4) {
// Trim to MAX_OUTPUT_BYTES, cutting at a newline boundary
size_t trim_to = process_output_.size() - MAX_OUTPUT_BYTES;
@@ -545,6 +577,19 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-maxconnections=" + std::to_string(max_connections_));
}
// More RPC workers for a node we launch: dragonxd's default 8 workers + 16-deep queue fill up with
// requests waiting behind a long cs_main holder (a witness rebuild), after which even "stop" is refused.
// The GUI itself no longer floods it; this is headroom for dragonx-cli and other clients — a delay,
// not a fix. Not for an isolated start, and DRAGONX.conf wins (a flag would override the user's own).
if (override_datadir_.empty()) {
std::string conf;
{
std::ifstream in((fs::path(util::Platform::getDragonXDataDir()) / "DRAGONX.conf").string(), std::ios::binary);
if (in) conf.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
if (!confSetsKey(conf, "rpcthreads")) args.push_back("-rpcthreads=16");
}
// Host a RandomX stratum pool from this node (-stratum). Only v1.3.0+ daemons implement it; older
// ones ignore the unknown flag (no fatal arg check), and the Settings toggle is gated on daemon
// version, so this is only enabled against a daemon that supports it. Without -stratumallowip the
@@ -599,9 +644,22 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-datadir=" + override_datadir_);
}
for (const auto& a : override_extra_args_) args.push_back(a);
const bool isolated = !override_datadir_.empty();
// Tail this node's debug.log — in the isolated datadir for an isolated start — from its current end.
{
debug_log_path_ = (fs::path(isolated ? override_datadir_ : util::Platform::getDragonXDataDir()) /
"debug.log").string();
std::error_code ec;
const auto size = fs::file_size(debug_log_path_, ec);
debug_log_offset_ = ec ? 0 : static_cast<size_t>(size);
launch_utc_ = static_cast<std::int64_t>(std::time(nullptr));
DEBUG_LOGF("[INFO] Will tail daemon output from: %s (offset %zu)\n", debug_log_path_.c_str(),
debug_log_offset_);
}
override_datadir_.clear();
override_extra_args_.clear();
stop_requested_ = false; // a new process: its exits are crashes until a stop is asked for
if (!startProcess(daemon_path, args)) {
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
// ... not executable or wrong architecture"). Surface THAT via setState — which also
@@ -616,6 +674,20 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
setState(State::Running, "dragonxd started");
DEBUG_LOGF("[INFO] dragonxd process started successfully\\n");
// Record it as ours, so a later session recognises it if it is left running (main datadir only).
if (!isolated) {
#ifdef _WIN32
const long pid = process_handle_ ? static_cast<long>(GetProcessId(process_handle_)) : 0;
#else
const long pid = static_cast<long>(process_pid_);
#endif
OwnershipRecord rec;
rec.pid = pid;
rec.startToken = processStartToken(pid);
rec.exePath = daemon_path;
if (pid > 0 && !rec.startToken.empty()) writeOwnershipRecord(ownershipRecordPath(), rec);
}
// Start monitor thread (if not already running)
should_stop_ = false;
@@ -677,29 +749,6 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
}
}
// Determine debug.log path for output tailing.
// The daemon writes to %APPDATA%\Hush\DRAGONX\debug.log when -printtoconsole is NOT used.
{
char appdata[MAX_PATH];
if (SUCCEEDED(SHGetFolderPathA(NULL, CSIDL_APPDATA, NULL, 0, appdata))) {
debug_log_path_ = std::string(appdata) + "\\Hush\\DRAGONX\\debug.log";
} else {
const char* home = getenv("APPDATA");
if (home) {
debug_log_path_ = std::string(home) + "\\Hush\\DRAGONX\\debug.log";
}
}
// Record current file size so we only read NEW output from this launch
if (!debug_log_path_.empty() && fs::exists(debug_log_path_)) {
debug_log_offset_ = static_cast<size_t>(fs::file_size(debug_log_path_));
} else {
debug_log_offset_ = 0;
}
DEBUG_LOGF("[INFO] Will tail daemon output from: %s (offset %zu)\n",
debug_log_path_.c_str(), debug_log_offset_);
}
// Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE
// allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is
// visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no
@@ -824,7 +873,13 @@ void EmbeddedDaemon::drainOutput()
// Witness …" lines the warmup progress bar parses only exist in debug.log on Windows. On a shrink,
// restart from the new beginning; the parser is monotonic and converges as it reaches current output.
if (currentSize < debug_log_offset_) {
debug_log_offset_ = 0;
std::string content(currentSize, '\0');
DWORD got = 0;
LARGE_INTEGER zero{};
SetFilePointerEx(hFile, zero, NULL, FILE_BEGIN);
if (currentSize > 0 && ReadFile(hFile, &content[0], static_cast<DWORD>(currentSize), &got, NULL))
content.resize(got);
debug_log_offset_ = shrinkResumeOffset(content, launch_utc_);
}
if (currentSize <= debug_log_offset_) {
CloseHandle(hFile);
@@ -855,8 +910,9 @@ void EmbeddedDaemon::drainOutput()
CloseHandle(hFile);
}
void EmbeddedDaemon::stop(int wait_ms)
bool EmbeddedDaemon::stop(int wait_ms)
{
stop_requested_ = true;
should_stop_ = true;
setState(State::Stopping, "Stopping dragonxd...");
@@ -882,6 +938,7 @@ void EmbeddedDaemon::stop(int wait_ms)
}
};
bool exitedOnItsOwn = true;
if (tracked_alive) {
// Our tracked process (dragonxd.exe launched directly) is still alive.
// The RPC "stop" was already sent by the caller — wait for it to exit.
@@ -890,40 +947,21 @@ void EmbeddedDaemon::stop(int wait_ms)
DEBUG_LOGF("Timeout — forcing daemon termination...\n");
TerminateProcess(process_handle_, 1);
WaitForSingleObject(process_handle_, 2000);
exitedOnItsOwn = false;
}
drainOutput();
CloseHandle(process_handle_);
process_handle_ = nullptr;
} else {
// Tracked handle is dead (batch file case: cmd.exe already exited).
// The real dragonxd.exe may still be running as an orphan.
// No live process of OUR OWN to stop. Never go looking for one by name: findProcessByName returns
// the first dragonxd.exe of any kind (an old node still shutting down, a node on another datadir,
// an isolated seed-migration node), and killing it is how an in-progress shutdown got
// TerminateProcess'd. Nodes we didn't launch only ever get an RPC stop from the caller.
if (process_handle_ != nullptr) {
CloseHandle(process_handle_);
process_handle_ = nullptr;
}
// Find the real dragonxd.exe process by name
DWORD dragonxd_pid = findProcessByName("dragonxd.exe");
if (dragonxd_pid != 0) {
DEBUG_LOGF("Found orphaned dragonxd.exe (PID %lu) — waiting for RPC stop to take effect...\n", dragonxd_pid);
HANDLE hProc = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, dragonxd_pid);
if (hProc) {
// RPC stop was already sent — wait for graceful exit
if (!pollWait(hProc, wait_ms)) {
DEBUG_LOGF("Timeout — forcing dragonxd.exe (PID %lu) termination...\n", dragonxd_pid);
TerminateProcess(hProc, 1);
WaitForSingleObject(hProc, 2000);
}
drainOutput();
CloseHandle(hProc);
DEBUG_LOGF("dragonxd.exe stopped\n");
} else {
DEBUG_LOGF("Could not open dragonxd.exe process (PID %lu), error %lu\n",
dragonxd_pid, GetLastError());
}
} else {
DEBUG_LOGF("No dragonxd.exe process found — daemon may have already exited\n");
}
DEBUG_LOGF("No tracked daemon process to stop (any other dragonxd.exe is not ours and is left alone)\n");
}
if (stdout_read_ != nullptr) {
@@ -938,6 +976,79 @@ void EmbeddedDaemon::stop(int wait_ms)
}
setState(State::Stopped, "dragonxd stopped");
return exitedOnItsOwn;
}
bool EmbeddedDaemon::beginStop()
{
stop_requested_ = true;
should_stop_ = true;
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
if (!isRunning()) return false; // handle-based: our own process only
setState(State::Stopping, "Stopping dragonxd...");
return true;
}
bool EmbeddedDaemon::pollOwnedProcess()
{
drainOutput();
return isRunning();
}
bool EmbeddedDaemon::signalGracefulStop()
{
return false; // no graceful channel on Windows: the GUI launches dragonxd without a console
}
void EmbeddedDaemon::forceKillOwnedProcess()
{
if (process_handle_ == nullptr || !isRunning()) return;
DEBUG_LOGF("Killing dragonxd at the user's request (TerminateProcess on our own handle)\n");
TerminateProcess(process_handle_, 1);
WaitForSingleObject(process_handle_, 5000);
drainOutput();
}
EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
{
ResourceUsage usage;
if (process_handle_ == nullptr) return usage;
FILETIME created, exited, kernel, user;
if (GetProcessTimes(process_handle_, &created, &exited, &kernel, &user)) {
auto ticks = [](const FILETIME& ft) {
return (static_cast<unsigned long long>(ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
};
usage.cpuSeconds = static_cast<double>(ticks(kernel) + ticks(user)) / 1e7; // 100ns units
}
IO_COUNTERS io;
if (GetProcessIoCounters(process_handle_, &io))
usage.ioBytes = static_cast<double>(io.ReadTransferCount + io.WriteTransferCount);
return usage;
}
void EmbeddedDaemon::endStop(bool keepWatching)
{
if (keepWatching && isRunning()) {
// Still running and still ours: watch it again. Its exit is booked as a clean stop.
should_stop_ = false;
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
return;
}
const bool leftRunning = isRunning();
if (leftRunning)
DEBUG_LOGF("Leaving dragonxd running; it will finish shutting down on its own\n");
if (process_handle_ != nullptr) {
CloseHandle(process_handle_); // only our handle: the process carries on
process_handle_ = nullptr;
}
if (stdout_read_ != nullptr) {
CloseHandle(stdout_read_);
stdout_read_ = nullptr;
}
drainOutput();
setState(State::Stopped, leftRunning ? "dragonxd left running" : "dragonxd stopped");
}
// Translate Windows NTSTATUS / exit codes to human-readable descriptions
@@ -971,6 +1082,13 @@ void EmbeddedDaemon::monitorProcess()
// Read any remaining output after process exits
drainOutput();
// An exit after markStopRequested() is the stop we asked for (it can land before stop() runs).
if (!should_stop_ && !isRunning() && stop_requested_) {
DEBUG_LOGF("dragonxd exited after a requested stop\n");
setState(State::Stopped, "dragonxd stopped");
return;
}
// Check if process exited unexpectedly
if (!should_stop_ && !isRunning()) {
// Get exit code for diagnostics
@@ -1292,12 +1410,24 @@ bool EmbeddedDaemon::isRunning() const
return (s == State::Running || s == State::Stopping);
}
void EmbeddedDaemon::drainOutput()
void EmbeddedDaemon::tailDebugLog()
{
if (stdout_fd_ < 0) return;
if (debug_log_path_.empty()) return;
const int fd = open(debug_log_path_.c_str(), O_RDONLY | O_CLOEXEC);
if (fd < 0) return; // not created yet
struct stat st{};
if (fstat(fd, &st) != 0) { close(fd); return; }
const size_t size = static_cast<size_t>(st.st_size);
if (size < debug_log_offset_) { // shrunk at startup: resume at this run's banner (see shrinkResumeOffset)
std::string content(size, '\0');
const ssize_t got = size ? pread(fd, &content[0], size, 0) : 0;
content.resize(got > 0 ? static_cast<size_t>(got) : 0);
debug_log_offset_ = shrinkResumeOffset(content, launch_utc_);
}
char buffer[4096];
for (;;) {
ssize_t n = read(stdout_fd_, buffer, sizeof(buffer) - 1);
while (debug_log_offset_ < size) {
const size_t want = std::min(sizeof(buffer) - 1, size - debug_log_offset_);
const ssize_t n = pread(fd, buffer, want, static_cast<off_t>(debug_log_offset_));
if (n <= 0) break;
buffer[n] = '\0';
{
@@ -1305,82 +1435,223 @@ void EmbeddedDaemon::drainOutput()
appendOutput(buffer, static_cast<size_t>(n));
}
VERBOSE_LOGF("[dragonxd] %s", buffer);
debug_log_offset_ += static_cast<size_t>(n);
}
close(fd);
}
void EmbeddedDaemon::drainOutput()
{
if (stdout_fd_ >= 0) {
char buffer[4096];
for (;;) {
ssize_t n = read(stdout_fd_, buffer, sizeof(buffer) - 1);
if (n <= 0) break;
buffer[n] = '\0';
{
std::lock_guard<std::mutex> lk(output_mutex_);
appendOutput(buffer, static_cast<size_t>(n));
}
VERBOSE_LOGF("[dragonxd] %s", buffer);
}
}
tailDebugLog();
}
static std::string describeExitStatus(int status)
{
if (WIFEXITED(status)) return "exit code: " + std::to_string(WEXITSTATUS(status));
if (WIFSIGNALED(status)) return "killed by signal " + std::to_string(WTERMSIG(status));
return "unknown exit status";
}
bool EmbeddedDaemon::reapWithin(pid_t pid, int timeout_ms, int& status)
{
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
for (;;) {
drainOutput();
const pid_t r = waitpid(pid, &status, WNOHANG);
if (r == pid) return true;
if (r < 0 && errno != EINTR) return true; // ECHILD: nothing left to reap
if (std::chrono::steady_clock::now() >= deadline) return false;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
void EmbeddedDaemon::stop(int wait_ms)
bool EmbeddedDaemon::stop(int wait_ms)
{
stop_requested_ = true;
should_stop_ = true;
if (process_pid_ > 0) {
bool exitedOnItsOwn = true;
// Join the monitor FIRST (it sees should_stop_ within one 100ms tick) so that from here on this is
// the only reaper. isRunning() is state-based and stays true until we set Stopped below, so it can't
// tell us the child exited: poll waitpid() on a cached pid instead. If the child exited inside that
// tick, the monitor already reaped it and, seeing stop_requested_, recorded a clean stop (pid now 0).
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
const pid_t pid = process_pid_;
if (pid > 0) {
setState(State::Stopping, "Stopping dragonxd...");
int status = 0;
// Phase 1: Wait for the daemon to exit naturally.
// The caller (stopEmbeddedDaemon) already sent an RPC "stop" which
// tells the daemon to flush LevelDB, close sockets, and exit cleanly.
// On macOS/APFS the LevelDB flush can take several seconds — we must
// NOT send SIGTERM until the daemon has had enough time to finish.
DEBUG_LOGF("Waiting up to %d ms for daemon to exit after RPC stop...\n", wait_ms);
auto start = std::chrono::steady_clock::now();
while (isRunning()) {
drainOutput();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
if (elapsed >= wait_ms) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// Phase 2: If still running, send SIGTERM and wait a further 10s.
if (isRunning()) {
DEBUG_LOGF("Daemon did not exit gracefully — sending SIGTERM to process group -%d\n", process_pid_);
kill(-process_pid_, SIGTERM);
auto sigterm_start = std::chrono::steady_clock::now();
while (isRunning()) {
drainOutput();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - sigterm_start).count();
if (elapsed >= 10000) {
// Phase 3: Force kill
DEBUG_LOGF("Forcing dragonxd termination with SIGKILL (group -%d)...\n", process_pid_);
kill(-process_pid_, SIGKILL);
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
if (reapWithin(pid, wait_ms, status)) {
DEBUG_LOGF("Daemon exited cleanly after RPC stop (%s)\n", describeExitStatus(status).c_str());
} else {
DEBUG_LOGF("Daemon exited cleanly after RPC stop\n");
// Phase 2: still running — SIGTERM (dragonxd treats it like RPC stop) and a further 10s.
DEBUG_LOGF("Daemon did not exit gracefully — sending SIGTERM to process group -%d\n", pid);
kill(-pid, SIGTERM);
if (!reapWithin(pid, 10000, status)) {
// Phase 3: Force kill
DEBUG_LOGF("Forcing dragonxd termination with SIGKILL (group -%d)...\n", pid);
kill(-pid, SIGKILL);
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
exitedOnItsOwn = false;
}
DEBUG_LOGF("dragonxd exited (%s)\n", describeExitStatus(status).c_str());
}
drainOutput(); // read any remaining output
// Reap the child process
int status;
waitpid(process_pid_, &status, 0);
process_pid_ = 0;
}
if (stdout_fd_ >= 0) {
close(stdout_fd_);
stdout_fd_ = -1;
}
setState(State::Stopped, "dragonxd stopped");
return exitedOnItsOwn;
}
bool EmbeddedDaemon::beginStop()
{
stop_requested_ = true;
should_stop_ = true;
// The monitor sees should_stop_ within one 100ms tick. If the child exited in that window the
// monitor reaped it (as a clean stop: stop_requested_ is set) and the pid is now 0.
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
setState(State::Stopped, "dragonxd stopped");
if (process_pid_ <= 0) return false;
setState(State::Stopping, "Stopping dragonxd...");
return true;
}
bool EmbeddedDaemon::pollOwnedProcess()
{
const pid_t pid = process_pid_;
if (pid <= 0) return false;
drainOutput();
int status = 0;
const pid_t r = waitpid(pid, &status, WNOHANG);
if (r == 0 || (r < 0 && errno == EINTR)) return true;
if (r == pid) DEBUG_LOGF("dragonxd exited (%s)\n", describeExitStatus(status).c_str());
drainOutput();
process_pid_ = 0;
return false;
}
bool EmbeddedDaemon::signalGracefulStop()
{
const pid_t pid = process_pid_;
if (pid <= 0) return false;
// The whole group: with a bash wrapper (embedded_daemon.cpp startProcess) a SIGTERM to the wrapper
// alone would orphan dragonxd.
DEBUG_LOGF("Sending SIGTERM to dragonxd's process group -%d (graceful stop)\n", pid);
return kill(-pid, SIGTERM) == 0;
}
void EmbeddedDaemon::forceKillOwnedProcess()
{
const pid_t pid = process_pid_;
if (pid <= 0) return;
DEBUG_LOGF("Killing dragonxd (group -%d) at the user's request\n", pid);
kill(-pid, SIGKILL);
int status = 0;
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
drainOutput();
process_pid_ = 0;
}
EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
{
ResourceUsage usage;
#ifdef __APPLE__
return usage; // no sampler yet (would need proc_pid_rusage): never "idle", so Kill is never offered
#else
const pid_t pgid = process_pid_;
if (pgid <= 0) return usage;
// The tracked pid may be a bash wrapper that itself uses ~0 CPU: sum the whole process group.
unsigned long long ticks = 0;
double io = 0.0;
bool anyCpu = false, anyIo = false;
std::error_code ec;
fs::directory_iterator it("/proc", ec), end;
for (; !ec && it != end; it.increment(ec)) {
const std::string name = it->path().filename().string();
if (name.empty() || name[0] < '0' || name[0] > '9') continue;
std::ifstream statFile((it->path() / "stat").string());
std::string line;
long pgrp = 0;
unsigned long long t = 0;
if (!statFile || !std::getline(statFile, line) || !parseProcStatLine(line, pgrp, t) || pgrp != pgid)
continue;
ticks += t;
anyCpu = true;
std::ifstream ioFile((it->path() / "io").string());
std::string key;
unsigned long long value = 0;
while (ioFile >> key >> value) {
if (key == "read_bytes:" || key == "write_bytes:") {
io += static_cast<double>(value);
anyIo = true;
}
}
}
const long hz = sysconf(_SC_CLK_TCK);
if (anyCpu && hz > 0) usage.cpuSeconds = static_cast<double>(ticks) / static_cast<double>(hz);
if (anyIo) usage.ioBytes = io;
return usage;
#endif
}
void EmbeddedDaemon::endStop(bool keepWatching)
{
const pid_t pid = process_pid_;
if (pid > 0 && keepWatching) {
// Still running and still ours: watch it again. Its exit is booked as a clean stop.
should_stop_ = false;
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
return;
}
if (pid > 0) {
// Leave it running. Close our end of its output pipe: its further writes fail with EPIPE, which
// dragonxd ignores (it ignores SIGPIPE), instead of blocking once a pipe nobody drains is full.
// A detached waiter reaps it when it exits, if this process is still around then.
DEBUG_LOGF("Leaving dragonxd (pid %d) running; it will finish shutting down on its own\n", pid);
std::thread([pid] {
int st = 0;
while (waitpid(pid, &st, 0) < 0 && errno == EINTR) {}
}).detach();
process_pid_ = 0;
}
if (stdout_fd_ >= 0) {
close(stdout_fd_);
stdout_fd_ = -1;
}
setState(State::Stopped, pid > 0 ? "dragonxd left running" : "dragonxd stopped");
}
void EmbeddedDaemon::monitorProcess()
{
char buffer[4096];
while (!should_stop_) {
// Check if process exited (non-blocking waitpid captures exit status)
if (process_pid_ > 0) {
@@ -1390,38 +1661,196 @@ void EmbeddedDaemon::monitorProcess()
// Process exited — drain remaining output
drainOutput();
crash_count_++;
std::string exitInfo;
if (WIFEXITED(status)) {
exitInfo = "exit code: " + std::to_string(WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
exitInfo = "killed by signal " + std::to_string(WTERMSIG(status));
} else {
exitInfo = "unknown exit status";
}
const std::string exitInfo = describeExitStatus(status);
process_pid_ = 0;
if (stop_requested_) {
// The stop we asked for (markStopRequested() / stop()) landed before stop() could
// reap it: a clean stop, not a crash. A crash recorded earlier keeps its count.
DEBUG_LOGF("dragonxd exited after a requested stop (%s)\n", exitInfo.c_str());
setState(State::Stopped, "dragonxd stopped");
return;
}
crash_count_++;
setState(State::Error, "dragonxd exited unexpectedly (" + exitInfo + ")");
return;
}
}
// Read process output (non-blocking)
ssize_t bytes_read = read(stdout_fd_, buffer, sizeof(buffer) - 1);
if (bytes_read > 0) {
buffer[bytes_read] = '\0';
{
std::lock_guard<std::mutex> lk(output_mutex_);
appendOutput(buffer, static_cast<size_t>(bytes_read));
}
VERBOSE_LOGF("[dragonxd] %s", buffer);
}
// Read process output (non-blocking): the stdout/stderr pipe and the node's debug.log
drainOutput();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
#endif // _WIN32
std::string EmbeddedDaemon::processStartToken(long pid)
{
if (pid <= 0) return {};
#ifdef _WIN32
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
if (!h) return {};
std::string token;
DWORD code = 0;
FILETIME created{}, exited{}, kernel{}, user{};
if (GetExitCodeProcess(h, &code) && code == STILL_ACTIVE &&
GetProcessTimes(h, &created, &exited, &kernel, &user)) {
token = std::to_string((static_cast<unsigned long long>(created.dwHighDateTime) << 32) |
created.dwLowDateTime);
}
CloseHandle(h);
return token;
#elif defined(__linux__)
std::ifstream f("/proc/" + std::to_string(pid) + "/stat");
std::string line;
if (!f || !std::getline(f, line)) return {};
char state = 0;
unsigned long long startTicks = 0;
if (!parseProcStatStart(line, state, startTicks) || state == 'Z' || state == 'X') return {};
std::string bootId; // start ticks count from boot: tie them to this boot
std::ifstream b("/proc/sys/kernel/random/boot_id");
if (b) std::getline(b, bootId);
return bootId + "/" + std::to_string(startTicks);
#elif defined(__APPLE__)
struct proc_bsdinfo info{};
if (proc_pidinfo(static_cast<int>(pid), PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != sizeof(info)) return {};
if (info.pbi_status == 5) return {}; // SZOMB
return std::to_string(info.pbi_start_tvsec) + "." + std::to_string(info.pbi_start_tvusec);
#else
return {};
#endif
}
std::int64_t EmbeddedDaemon::processStartUtc(long pid)
{
if (pid <= 0) return 0;
#ifdef _WIN32
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
if (!h) return 0;
std::int64_t utc = 0;
FILETIME created{}, exited{}, kernel{}, user{};
if (GetProcessTimes(h, &created, &exited, &kernel, &user)) {
const unsigned long long t = (static_cast<unsigned long long>(created.dwHighDateTime) << 32) |
created.dwLowDateTime; // 100ns since 1601
if (t > 116444736000000000ULL) utc = static_cast<std::int64_t>((t - 116444736000000000ULL) / 10000000ULL);
}
CloseHandle(h);
return utc;
#elif defined(__linux__)
std::ifstream f("/proc/" + std::to_string(pid) + "/stat");
std::string line;
char state = 0;
unsigned long long startTicks = 0;
if (!f || !std::getline(f, line) || !parseProcStatStart(line, state, startTicks)) return 0;
std::ifstream s("/proc/stat"); // "btime <boot time, UTC seconds>"
std::string key;
long long btime = 0;
while (s >> key) {
if (key == "btime") { s >> btime; break; }
s.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}
const long hz = sysconf(_SC_CLK_TCK);
if (btime <= 0 || hz <= 0) return 0;
return static_cast<std::int64_t>(btime + static_cast<long long>(startTicks / static_cast<unsigned long long>(hz)));
#elif defined(__APPLE__)
struct proc_bsdinfo info{};
if (proc_pidinfo(static_cast<int>(pid), PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != sizeof(info)) return 0;
return static_cast<std::int64_t>(info.pbi_start_tvsec);
#else
return 0;
#endif
}
bool EmbeddedDaemon::processIsDaemon(long pid)
{
if (pid <= 0) return false;
#ifdef _WIN32
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
if (!h) return false;
char path[MAX_PATH];
bool is = false;
// A device path (\Device\HarddiskVolume3\...\dragonxd.exe); only its file name matters.
const DWORD len = GetProcessImageFileNameA(h, path, MAX_PATH);
if (len > 0) {
std::string name(path, len);
const size_t slash = name.find_last_of("\\/");
if (slash != std::string::npos) name.erase(0, slash + 1);
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); });
is = name == "dragonxd.exe";
}
CloseHandle(h);
return is;
#elif defined(__linux__)
const fs::path proc = fs::path("/proc") / std::to_string(pid);
std::ifstream f((proc / "comm").string());
std::string comm;
if (f) std::getline(f, comm);
std::error_code ec;
return isDaemonProcessIdentity(comm, fs::read_symlink(proc / "exe", ec).filename().string());
#elif defined(__APPLE__)
char path[PROC_PIDPATHINFO_MAXSIZE];
if (proc_pidpath(static_cast<int>(pid), path, sizeof(path)) <= 0) return false;
return isDaemonProcessIdentity("", fs::path(path).filename().string());
#else
return false;
#endif
}
std::string EmbeddedDaemon::ownershipRecordPath()
{
return (fs::path(util::Platform::getDragonXDataDir()) / "obsidiandragon-dragonxd.pid").string();
}
size_t EmbeddedDaemon::shrinkResumeOffset(const std::string& content, std::int64_t launchUtc)
{
size_t pos = 0;
while (pos < content.size()) {
size_t end = content.find('\n', pos);
if (end == std::string::npos) end = content.size();
const std::string line = content.substr(pos, end - pos);
if ((line.find("DragonX version ") != std::string::npos || line.find("Hush version ") != std::string::npos)) {
const std::int64_t at = parseDaemonLogUtc(line);
if (at > 0 && at >= launchUtc - 5) return pos; // 5s: the log's second vs our clock read
}
pos = end + 1;
}
return content.size();
}
void EmbeddedDaemon::writeOwnershipRecord(const std::string& path, const OwnershipRecord& r)
{
const std::string tmp = path + ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out) return;
out << formatOwnershipRecord(r);
if (!out) return;
}
std::error_code ec;
fs::rename(tmp, path, ec);
if (ec) fs::remove(tmp, ec);
else DEBUG_LOGF("[INFO] Recorded our dragonxd (pid %ld) in %s\n", r.pid, path.c_str());
}
bool EmbeddedDaemon::liveOwnershipRecord(const std::string& path, OwnershipRecord* out)
{
std::string text;
{
std::ifstream in(path, std::ios::binary);
if (!in) return false;
text.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
OwnershipRecord r;
if (parseOwnershipRecord(text, r) && processStartToken(r.pid) == r.startToken) {
if (out) *out = r;
return true;
}
std::error_code ec; // that process is gone (or the pid now belongs to another): the record is stale
if (fs::remove(path, ec)) DEBUG_LOGF("[INFO] Removed stale dragonxd record (pid %ld)\n", r.pid);
return false;
}
bool EmbeddedDaemon::isRpcPortInUse()
{
int port = std::atoi(DRAGONX_DEFAULT_RPC_PORT);
@@ -1438,8 +1867,8 @@ bool EmbeddedDaemon::isDaemonProcessRunning()
#ifdef _WIN32
return findProcessByName("dragonxd.exe") != 0;
#elif defined(__linux__)
// Scan /proc for a process whose comm is exactly "dragonxd". Iterate with an error_code so a proc
// entry vanishing mid-scan (a process exiting) can't throw.
// Scan /proc for a dragonxd process (by comm or by executable: see isDaemonProcessIdentity). Iterate
// with an error_code so a proc entry vanishing mid-scan (a process exiting) can't throw.
std::error_code ec;
fs::directory_iterator it("/proc", ec), end;
for (; !ec && it != end; it.increment(ec)) {
@@ -1447,11 +1876,26 @@ bool EmbeddedDaemon::isDaemonProcessRunning()
if (pid.empty() || pid[0] < '0' || pid[0] > '9') continue; // numeric pid dirs only
std::ifstream f((it->path() / "comm").string());
std::string comm;
if (f && std::getline(f, comm) && comm == "dragonxd") return true;
if (f) std::getline(f, comm);
std::error_code linkEc; // another user's process: EACCES, leaving the name empty
const std::string exe = fs::read_symlink(it->path() / "exe", linkEc).filename().string();
if (isDaemonProcessIdentity(comm, exe)) return true;
}
return false;
#elif defined(__APPLE__)
// Match executables named dragonxd. Not the RPC port: dragonxd frees it early in its shutdown, long
// before the process exits and lets go of wallet.dat and the datadir lock.
const int count = proc_listallpids(nullptr, 0);
if (count <= 0) return isPortInUse(std::atoi(DRAGONX_DEFAULT_RPC_PORT)); // can't list: best effort
std::vector<pid_t> pids(static_cast<size_t>(count) + 64); // room for processes started meanwhile
const int n = proc_listallpids(pids.data(), static_cast<int>(pids.size() * sizeof(pid_t)));
char path[PROC_PIDPATHINFO_MAXSIZE];
for (int i = 0; i < n && i < static_cast<int>(pids.size()); ++i) {
if (pids[i] <= 0 || proc_pidpath(pids[i], path, sizeof(path)) <= 0) continue;
if (isDaemonProcessIdentity("", fs::path(path).filename().string())) return true;
}
return false;
#else
// macOS has no /proc; fall back to the RPC-port probe (best-effort).
return isPortInUse(std::atoi(DRAGONX_DEFAULT_RPC_PORT));
#endif
}
+190 -12
View File
@@ -4,6 +4,9 @@
#pragma once
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <functional>
#include <memory>
@@ -55,11 +58,63 @@ public:
bool start(const std::string& binary_path = "");
/**
* @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
*/
@@ -93,16 +148,26 @@ 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 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)
{
const size_t base = total - buffer.size(); // absolute position of buffer[0]
if (offset < base) offset = base; // trimmed before it was read: resume at what's left
if (offset >= total) {
offset = total;
return {};
}
std::string result = process_output_.substr(offset);
offset = process_output_.size();
std::string result = buffer.substr(offset - base);
offset = total;
return result;
}
@@ -245,6 +310,109 @@ 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;
}
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
struct StartLockGateDecision {
bool proceed = true; // false => bail before spawning
@@ -292,24 +460,34 @@ private:
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
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;
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;
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::atomic<bool> stop_requested_{false}; // a stop was asked for: the next exit is not a crash
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)
+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
+8 -4
View File
@@ -1319,6 +1319,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
@@ -2147,10 +2152,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);
+168
View File
@@ -0,0 +1,168 @@
#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)
{
return method == "stop" || method == "help" || method == "getrescaninfo";
}
// 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
+109 -67
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();
@@ -218,13 +229,18 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// 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 +248,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 +284,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,6 +360,8 @@ 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);
@@ -335,38 +377,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,7 +444,7 @@ 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)
@@ -481,34 +545,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 +556,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"];
+38 -15
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);
@@ -274,6 +288,11 @@ private:
// 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).
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);
@@ -286,6 +305,10 @@ private:
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
+156
View File
@@ -0,0 +1,156 @@
// 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 <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;
}
} // namespace rpc
} // namespace dragonx
+10 -1
View File
@@ -1,4 +1,5 @@
#include "network_refresh_service.h"
#include "rpc/rpc_busy_gate.h"
#include "../util/logger.h"
#include <algorithm>
@@ -296,6 +297,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
bool balanceOk = false;
bool blockOk = false;
double balanceScanMs = 0.0;
rpc::RpcFailureKind blockErrKind = rpc::RpcFailureKind::None;
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
// getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else
// (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance
@@ -306,6 +309,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
blockInfo = rpc.call("getblockchaininfo", json::array());
blockOk = true;
} catch (const std::exception& e) {
blockErrKind = rpc::rpcFailureKindOf(e);
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
}
@@ -321,7 +325,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
if (lc > 0 && b < lc - 2) behind = true;
}
if (includeBalance && !behind) {
// Don't follow a chain-info call that found the node busy (a reply timeout or a full queue) with two
// balance scans: they need the same cs_main, and each would only take another of its few RPC slots.
if (includeBalance && !behind && !rpc::rpcKindMeansBusy(blockErrKind)) {
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
// (balanceRefreshDue()), keeping balance scans from starving block connection.
@@ -330,6 +336,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
balanceOk = true;
} catch (const std::exception& e) {
balanceErrKind = rpc::rpcFailureKindOf(e);
DEBUG_LOGF("Balance error: %s\n", e.what());
}
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
@@ -341,6 +348,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
result.balanceScanMs = balanceScanMs;
result.blockchainErrKind = blockErrKind;
result.balanceErrKind = balanceErrKind;
return result;
}
+6
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>
@@ -112,6 +113,10 @@ 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;
};
struct MiningRefreshResult {
@@ -381,6 +386,7 @@ 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); }
float timer(Timer timer) const { return scheduler_.timer(timer); }
float interval(Timer timer) const { return scheduler_.interval(timer); }
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
+71
View File
@@ -0,0 +1,71 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "note_consolidation.h"
#include <algorithm>
#include <map>
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
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 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);
}
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches)
{
AddressRun run;
run.address = address;
for (int b = 0; b < maxBatches; ++b) {
MergeCallResult r = call(address);
if (!r.error.empty()) {
run.error = r.error; // earlier batches (if any) are already submitted and stand
break;
}
run.opids.push_back(r.opid);
run.notesMerged += r.merging;
run.remaining = r.remaining;
if (r.remaining < 2) break; // one leftover note can't be merged into its own address
}
return run;
}
} // 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
// 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 <nlohmann/json.hpp>
#include <functional>
#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 or
// watch-only notes are left out (z_mergetoaddress only spends confirmed notes it has keys for).
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent);
// 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);
} // namespace services
} // namespace dragonx
+14
View File
@@ -47,10 +47,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 +71,27 @@ 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);
}
float RefreshScheduler::timer(Timer timer) const
{
return timerRef(timer);
+12
View File
@@ -2,6 +2,8 @@
#include "ui/sidebar.h"
#include <array>
namespace dragonx {
namespace services {
@@ -55,6 +57,11 @@ 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);
float timer(Timer timer) const;
float interval(Timer timer) const;
@@ -79,9 +86,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
@@ -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;
+19
View File
@@ -32,6 +32,7 @@ enum class NodeBannerReason {
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
LiteOpenFailed, // lite build: the wallet failed to open
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
};
struct NodeBannerState {
@@ -64,6 +65,12 @@ struct NodeBannerInputs {
bool daemon_running = false;
int daemon_crash_count = 0;
// The node is running but busy with something it can't be interrupted out of (a witness rebuild, the
// startup replay) or too busy to answer RPC. Wallet data may be stale; a reconnect would only add
// requests, so the banner offers no action. node_busy_detail is the live, translated description.
bool node_busy = false;
std::string node_busy_detail;
std::string connection_status; // human-readable status line (already translated)
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
std::string lite_open_error; // lite: last wallet-open failure reason
@@ -89,6 +96,18 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
return s;
}
// Full node, busy. Before the startup-phase returns below: the overlay sets daemon_initializing while
// the node is busy too, and the banner must still say why the wallet isn't updating. No action: a
// "Reconnect" would only queue more requests behind the busy node.
if (in.node_busy) {
s.show = true;
s.severity = NodeBannerSeverity::Warning;
s.reason = NodeBannerReason::NodeBusy;
s.action = NodeBannerAction::None;
s.detail = in.node_busy_detail;
return s;
}
// Full node. In an expected startup phase → the loading/warmup overlay owns the screen, so no
// banner. An active connect attempt likewise shows progress, not an error — don't flicker a
// banner over it. (These return even if `connected` has flipped early, matching prior behavior.)
+1 -1
View File
@@ -71,7 +71,7 @@ public:
// Address preview
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(),
s_isZ ? TR("shielded_address") : TR("transparent_address"));
s_isZ ? TR("shielded_address") : TR("mining_address"));
ImGui::Spacing();
{
std::string display = s_address;
+12 -8
View File
@@ -213,12 +213,13 @@ void RenderSharedAddressList(App* app, float listH, float availW,
});
}
ImGui::SameLine();
if (TactileButton(TR("new_t_address"), ImVec2(buttonWidth, 0),
if (TactileButton(TR("new_mining_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
app->createNewTAddress([](const std::string& addr) {
DEBUG_LOGF("Created new t-address: %s\n", addr.c_str());
});
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_new_mining_address"));
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -402,13 +403,13 @@ void RenderSharedAddressList(App* app, float listH, float availW,
const auto& target = rows[s_dropTargetIdx];
material::Tooltip("%s\n%s\n\n%s %s",
truncateAddress(addr.address, 32).c_str(),
row.isZ ? TR("shielded") : TR("transparent"),
row.isZ ? TR("shielded") : TR("addr_type_mining"),
TR("transfer_to"),
truncateAddress(target.info->address, 32).c_str());
} else {
material::Tooltip("%s\n%s\n\n%s",
truncateAddress(addr.address, 32).c_str(),
row.isZ ? TR("shielded") : TR("transparent"),
row.isZ ? TR("shielded") : TR("addr_type_mining"),
TR("address_reorder_hint"));
}
}
@@ -440,7 +441,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
if (s_dragActive && s_dragIdx != row_idx && material::IsRectHovered(rowPos, rowEnd)) {
s_dropTargetIdx = row_idx;
const float relY = rowH > 0.0f ? (mousePos.y - rowPos.y) / rowH : 0.5f;
s_dropMode = (relY < 0.30f || relY > 0.70f) ? 1 : 0;
// A mining (transparent) address can't be paid, so it is never a transfer target: reorder only.
s_dropMode = (!row.isZ || relY < 0.30f || relY > 0.70f) ? 1 : 0;
if (s_dropMode == 0) {
// Transfer: highlight the whole target row.
dl->AddRectFilled(rowPos, rowEnd, WithAlpha(Primary(), 40), 4.0f * dp);
@@ -542,9 +544,10 @@ void RenderSharedAddressList(App* app, float listH, float availW,
// ---- Type label (first line) ----
float labelX = rowLayout.labelX;
{
const char* typeLabel = row.isZ ? TR("shielded") : TR("transparent");
const char* typeLabel = row.isZ ? TR("shielded") : TR("addr_type_mining");
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
const char* miningTag = row.mining ? TR("mining_tag") : "";
// A transparent row is already typed "Mining"; the tag is for shielded payout addresses.
const char* miningTag = (row.mining && row.isZ) ? TR("mining_tag") : "";
char typeBuf[64];
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
@@ -653,7 +656,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
ImGui::SetCursorScreenPos(ImVec2(rowPos.x, rowY[row_idx]));
ImGui::InvisibleButton("##addr", ImVec2(innerW, rowH));
if (ImGui::IsItemHovered() && !s_dragActive) {
material::Tooltip("%s", addr.address.c_str());
if (row.isZ) material::Tooltip("%s", addr.address.c_str());
else material::Tooltip("%s\n\n%s", addr.address.c_str(), TR("mining_addr_explainer"));
}
// Context menu
@@ -682,7 +686,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Viewing);
}
if (ImGui::MenuItem(TR("show_qr_code")))
QRPopupDialog::show(addr.address, row.isZ ? TR("z_address") : TR("t_address"));
QRPopupDialog::show(addr.address, row.isZ ? TR("z_address") : TR("mining_address"));
ImGui::Separator();
if (row.hidden) {
if (ImGui::MenuItem(TR("restore_address")))
+22 -15
View File
@@ -12,6 +12,7 @@
#include "qr_popup_dialog.h"
#include "address_label_dialog.h"
#include "address_transfer_dialog.h"
#include "shield_dialog.h"
#include "send_tab.h"
#include "../../app.h"
#include "../../config/settings.h"
@@ -531,7 +532,7 @@ static void RenderBalanceClassic(App* app)
float cy = cMin.y + cardPadLg;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
OnSurfaceMedium(), TR("transparent"));
OnSurfaceMedium(), TR("mining_rewards"));
cy += ovFont->LegacySize + ovGap;
snprintf(buf, sizeof(buf), "%.8f", s_dispTransparent);
@@ -543,18 +544,24 @@ static void RenderBalanceClassic(App* app)
OnSurfaceMedium(), DRAGONX_TICKER);
cy += sub1->LegacySize + valGap;
snprintf(buf, sizeof(buf), TR("baltab_t_addresses_count"),
(int)state.t_addresses.size());
// Rewards waiting at a mining address: say so, and let a click shield them now.
const bool unshielded = s_dispTransparent > 0.0;
if (unshielded) snprintf(buf, sizeof(buf), "%s", TR("mining_rewards_unshielded"));
else snprintf(buf, sizeof(buf), TR("baltab_mining_addresses_count"), (int)state.t_addresses.size());
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
OnSurfaceDisabled(), buf);
unshielded ? amberCol : OnSurfaceDisabled(), buf);
// Hover glow + click to Receive
// Hover glow + explain; click shields waiting rewards (else opens Receive)
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::Receive);
material::Tooltip("%s%s%s", TR("mining_card_tooltip"), unshielded ? "\n\n" : "",
unshielded ? TR("mining_card_click_to_shield") : "");
if (ImGui::IsMouseClicked(0)) {
if (unshielded) ShieldDialog::showShieldCoinbase();
else app->setCurrentPage(NavPage::Receive);
}
}
}
@@ -803,7 +810,7 @@ static void RenderBalanceDonut(App* app) {
// Transparent legend
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Warning());
snprintf(buf, sizeof(buf), TR("baltab_transparent_amount"), s_dispTransparent);
snprintf(buf, sizeof(buf), TR("baltab_mining_amount"), s_dispTransparent);
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Warning(), buf);
legendY += capFont->LegacySize + legendSectionGap;
@@ -976,7 +983,7 @@ static void RenderBalanceConsolidated(App* app) {
// Transparent bar
float transX = cardMin.x + pad * 2 + halfW;
barY = divY + Layout::spacingSm();
std::string transLabelUpper = TR("transparent");
std::string transLabelUpper = TR("mining_rewards");
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
[](unsigned char c){ return (char)std::toupper(c); });
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), transLabelUpper.c_str());
@@ -1100,7 +1107,7 @@ static void RenderBalanceDashboard(App* app) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
return s;
};
std::string lblShielded = upperTR("shielded"), lblTransparent = upperTR("transparent");
std::string lblShielded = upperTR("shielded"), lblTransparent = upperTR("mining_rewards");
std::string lblQuickSend = upperTR("quick_send"), lblQuickReceive = upperTR("quick_receive");
TileInfo tiles[4] = {
@@ -1240,7 +1247,7 @@ static void RenderBalanceVerticalStack(App* app) {
RowInfo rowInfos[4] = {
{TR("baltab_total_balance"), ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f},
{TR("baltab_shielded"), ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio},
{TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio},
{TR("mining_rewards"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio},
{TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f},
};
@@ -1437,7 +1444,7 @@ static void RenderBalanceVertical2x2(App* app) {
// Row 1: Market (left), Transparent (right)
{
{TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false},
{TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
{TR("mining_rewards"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
},
};
@@ -1696,7 +1703,7 @@ static void RenderBalanceShield(App* app) {
std::string shieldLabelUpper = TR("shielded");
std::transform(shieldLabelUpper.begin(), shieldLabelUpper.end(), shieldLabelUpper.begin(),
[](unsigned char c){ return (char)std::toupper(c); });
std::string transLabelUpper = TR("transparent");
std::string transLabelUpper = TR("mining_rewards");
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
[](unsigned char c){ return (char)std::toupper(c); });
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), shieldLabelUpper.c_str());
@@ -1855,7 +1862,7 @@ static void RenderBalanceTimeline(App* app) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
return s;
};
std::string cShielded = upperTR("shielded"), cTransparent = upperTR("transparent"), cMarket = upperTR("market");
std::string cShielded = upperTR("shielded"), cTransparent = upperTR("mining_rewards"), cMarket = upperTR("market");
SumCard cards[3] = {
{cShielded.c_str(), Success(), s_dispShielded, false},
{cTransparent.c_str(), Warning(), s_dispTransparent, false},
@@ -2137,7 +2144,7 @@ static void RenderBalanceMinimal(App* app) {
snprintf(buf, sizeof(buf), TR("balance_shielded_fmt"), s_dispShielded);
Type().textColored(TypeStyle::Caption, Success(), buf);
ImGui::SameLine(0, Layout::spacingLg());
snprintf(buf, sizeof(buf), TR("balance_transparent_fmt"), s_dispTransparent);
snprintf(buf, sizeof(buf), TR("balance_mining_fmt"), s_dispTransparent);
Type().textColored(TypeStyle::Caption, Warning(), buf);
// USD value
+1 -1
View File
@@ -624,7 +624,7 @@ void RenderContactsTab(App* app)
float cy = origin.y + std::max(0.0f, (a.y - blockH) * 0.42f);
float cx = origin.x + a.x * 0.5f;
struct Chip { bool sh; const char* lbl; };
Chip chips[2] = { { true, TR("contact_avatar_shielded") }, { false, TR("contact_avatar_transparent") } };
Chip chips[2] = { { true, TR("contact_avatar_shielded") }, { false, TR("contact_chip_mining") } };
for (int i = 0; i < 2; i++) {
float ccx = cx + (i == 0 ? -(chipR + chipGap * 0.5f) : (chipR + chipGap * 0.5f));
ImVec2 cc(ccx, cy + chipR);
+1 -1
View File
@@ -624,7 +624,7 @@ static void pfDrawAddressSection(App* app)
// Funded (right). Select-all is deferred so it applies to the freshly-filtered set below.
bool selAllClicked = false;
{
const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") };
const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("addr_type_mining") };
const char* selLbl = TR("portfolio_select_shown");
const char* clrLbl = TR("portfolio_clear_sel");
ImGuiStyle& st = ImGui::GetStyle();
+3 -1
View File
@@ -957,7 +957,9 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
else if (poolBlockedBySolo)
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
else if (poolNeedsPayout)
material::Tooltip("%s", TR("mining_pool_needs_payout_tooltip"));
material::Tooltip("%s", util::isTransparentAddress(poolPayoutStr)
? TR("mining_payout_needs_shielded")
: TR("mining_pool_needs_payout_tooltip"));
else
material::Tooltip("%s", isMiningActive ? TR("stop_mining") : TR("start_mining"));
}
+4 -1
View File
@@ -413,7 +413,10 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
if (currentWorkerStr.empty()) {
material::Tooltip("%s", TR("mining_generate_z_address_hint"));
} else if (currentWorkerStr != "x" && !util::isValidRecipientAddress(currentWorkerStr)) {
material::Tooltip("%s", TR("mining_payout_invalid")); // block start below too (M-01)
// Block start below too (M-01). A pool pays with ordinary transactions, which can't reach
// a transparent (mining) address on this private chain: say so rather than "invalid".
material::Tooltip("%s", util::isTransparentAddress(currentWorkerStr)
? TR("mining_payout_needs_shielded") : TR("mining_payout_invalid"));
} else {
material::Tooltip("%s", TR("mining_payout_tooltip"));
}
+301 -209
View File
@@ -130,13 +130,17 @@ static void RenderAddressDropdown(App* app, float width) {
// Header row: label + address type toggle buttons
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("address_upper"));
// All / Shielded / Mining: each segment as wide as the widest label (they are words, not Z/T).
const char* filterLabels[] = { TR("all_filter"), TR("shielded"), TR("addr_type_mining") };
float toggleBtnW = std::max(schema::UI().drawElement("tabs.receive", "toggle-btn-min-width").size, schema::UI().drawElement("tabs.receive", "toggle-btn-width").size * Layout::hScale(width));
for (const char* lbl : filterLabels)
toggleBtnW = std::max(toggleBtnW, Type().caption()->CalcTextSizeA(Type().caption()->LegacySize, FLT_MAX, 0, lbl).x +
2.0f * Layout::spacingMd());
float toggleGap = schema::UI().drawElement("tabs.receive", "toggle-gap").size;
float toggleTotalW = toggleBtnW * 3 + toggleGap * 2;
float segH = ImGui::GetFrameHeight();
ImGui::SameLine(width - toggleTotalW);
ImVec2 segOrigin = ImGui::GetCursorScreenPos();
const char* filterLabels[] = { TR("all_filter"), "Z", "T" };
int segClk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, toggleTotalW, segH,
filterLabels, 3, s_addr_type_filter, Type().caption(),
"##addrFilter", Layout::dpiScale());
@@ -147,14 +151,18 @@ static void RenderAddressDropdown(App* app, float width) {
TrackNewAddresses(state);
// Auto-select address with the largest balance on first load
// Auto-select the shielded address with the largest balance on first load. A mining (transparent)
// address can't be paid, so it is picked only when the wallet has no shielded address yet.
if (!s_auto_selected && app->isConnected() && !state.addresses.empty()) {
int bestIdx = -1;
double bestBal = -1.0;
bool bestIsZ = false;
for (size_t i = 0; i < state.addresses.size(); i++) {
if (state.addresses[i].balance > bestBal) {
const bool z = state.addresses[i].type == "shielded";
if ((z && !bestIsZ) || (z == bestIsZ && state.addresses[i].balance > bestBal)) {
bestBal = state.addresses[i].balance;
bestIdx = static_cast<int>(i);
bestIsZ = z;
}
}
if (bestIdx >= 0) {
@@ -222,6 +230,12 @@ static void RenderAddressDropdown(App* app, float width) {
bool isZ = state.addresses[i].type == "shielded";
if (s_addr_type_filter == 1 && !isZ) continue;
if (s_addr_type_filter == 2 && isZ) continue;
// "All" lists a mining address only when it matters: it holds rewards, it is marked as a
// mining address, we are solo mining, or it is the one selected. The Mining filter lists all.
if (s_addr_type_filter == 0 && !isZ && state.addresses[i].balance < 1e-9 &&
!app->isMiningAddress(state.addresses[i].address) && !state.mining.generate &&
s_selected_address_idx != static_cast<int>(i))
continue;
sortedIdx.push_back(i);
}
std::sort(sortedIdx.begin(), sortedIdx.end(),
@@ -272,9 +286,12 @@ static void RenderAddressDropdown(App* app, float width) {
s_cached_qr_data.clear(); // Force QR regeneration
}
if (ImGui::IsItemHovered()) {
material::Tooltip(TR("grpb_tooltip_address_balance"),
addr.address.c_str(), addr.balance, DRAGONX_TICKER,
isCurrent ? TR("grpb_selected_suffix") : "");
char tip[512];
snprintf(tip, sizeof(tip), TR("grpb_tooltip_address_balance"),
addr.address.c_str(), addr.balance, DRAGONX_TICKER,
isCurrent ? TR("grpb_selected_suffix") : "");
if (isZ) material::Tooltip("%s", tip);
else material::Tooltip("%s\n\n%s", tip, TR("mining_addr_explainer"));
}
ImGui::PopID();
}
@@ -325,10 +342,10 @@ static void RenderAddressDropdown(App* app, float width) {
app->createNewTAddress([](const std::string& addr) {
s_generating_address = false;
if (addr.empty())
Notifications::instance().error(TR("failed_create_transparent"));
Notifications::instance().error(TR("failed_create_mining_address"));
else {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_transparent_created"));
Notifications::instance().success(TR("new_mining_address_created"));
}
});
}
@@ -337,6 +354,60 @@ static void RenderAddressDropdown(App* app, float width) {
ImGui::EndDisabled();
}
void SweepSelectReceiveAddress(App* app, const char* address) {
(void)app;
if (address) {
s_pending_select_address = address;
s_addr_type_filter = 0;
} else {
s_auto_selected = false; // re-run the default (shielded-first) choice
}
s_cached_qr_data.clear();
}
// ============================================================================
// Mining address notice (instead of a payment request)
// ============================================================================
// A mining (transparent) address only ever receives mining rewards: the network rejects payments to it.
// So it gets no payment request and no QR code — just what it is for, and a way to a shielded address.
static void RenderMiningAddressNotice(App* app, const WalletState& state, float width) {
Type().textColored(TypeStyle::Overline, Warning(), TR("mining_address"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + width);
Type().textWrapped(TypeStyle::Body2, TR("mining_addr_explainer"));
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
int bestZ = -1;
double bestBal = -1.0;
for (size_t i = 0; i < state.addresses.size(); i++) {
if (state.addresses[i].type == "shielded" && state.addresses[i].balance > bestBal) {
bestBal = state.addresses[i].balance;
bestZ = static_cast<int>(i);
}
}
ImGui::BeginDisabled(bestZ < 0 && (!app->isConnected() || s_generating_address));
if (TactileButton(TrId("receive_use_shielded", "mining").c_str(), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
if (bestZ >= 0) {
s_selected_address_idx = bestZ;
s_cached_qr_data.clear();
} else {
s_generating_address = true; // no shielded address yet: make one, as "New" does
app->createNewZAddress([](const std::string& addr) {
s_generating_address = false;
if (addr.empty())
Notifications::instance().error(TR("failed_create_shielded"));
else {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_shielded_created"));
}
});
}
}
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(width, Layout::spacingSm()));
}
// ============================================================================
// Helpers: DrawRecvIcon (local copy — original is static in send_tab).
// Relative time uses the shared util::formatTimeAgoShort ("14d ago"), matching Overview/Send.
@@ -580,9 +651,9 @@ void RenderReceiveTab(App* app)
const AddressInfo& selected = state.addresses[s_selected_address_idx];
bool isZ = selected.type == "shielded";
// Generate QR data
std::string qr_data = selected.address;
if (s_request_amount > 0) {
// Generate QR data — shielded only: a mining (transparent) address can't be paid, so it gets no QR.
std::string qr_data = isZ ? selected.address : std::string();
if (isZ && s_request_amount > 0) {
// Canonical, URL-encoded "drgx:" URI via the shared builder. The old inline code emitted an
// unparseable "dragonx:" scheme and appended the memo raw (spaces/&/= corrupted the QR).
qr_data = util::buildPaymentUri(selected.address, s_request_amount, "",
@@ -593,8 +664,10 @@ void RenderReceiveTab(App* app)
FreeQRTexture(s_qr_texture);
s_qr_texture = 0;
}
int w, h;
s_qr_texture = GenerateQRTexture(qr_data.c_str(), &w, &h);
if (!qr_data.empty()) {
int w, h;
s_qr_texture = GenerateQRTexture(qr_data.c_str(), &w, &h);
}
s_cached_qr_data = qr_data;
}
@@ -644,205 +717,210 @@ void RenderReceiveTab(App* app)
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// ---- PAYMENT REQUEST ----
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("payment_request"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Amount input with currency toggle
float toggleW = S.drawElement("tabs.receive", "currency-toggle-width").size * Layout::dpiScale();
float amtInputW = addrColW - toggleW - Layout::spacingMd();
if (amtInputW < S.drawElement("tabs.receive", "amount-input-min-width").size) amtInputW = S.drawElement("tabs.receive", "amount-input-min-width").size;
double usd_price = state.market.price_usd;
ImGui::PushFont(body2);
if (s_request_usd_mode && usd_price > 0) {
ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##RequestAmountUSD", &s_request_usd_amount, 0, 0, "$%.2f")) {
if (s_request_usd_amount < 0) s_request_usd_amount = 0;
s_request_amount = s_request_usd_amount / usd_price;
}
{
ImVec2 iMin = ImGui::GetItemRectMin();
ImVec2 iMax = ImGui::GetItemRectMax();
snprintf(buf, sizeof(buf), "\xe2\x89\x88 %.4f %s", s_request_amount, DRAGONX_TICKER);
ImVec2 sz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
float tx = iMax.x - sz.x - ImGui::GetStyle().FramePadding.x;
float ty = iMin.y + (iMax.y - iMin.y - sz.y) * 0.5f;
dl->AddText(capFont, capFont->LegacySize, ImVec2(tx, ty),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "input-overlay-text-alpha").size), buf);
}
ImGui::PopItemWidth();
} else {
ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##RequestAmount", &s_request_amount, 0, 0, "%.8f")) {
if (s_request_amount < 0) s_request_amount = 0;
if (usd_price > 0)
s_request_usd_amount = s_request_amount * usd_price;
}
if (usd_price > 0 && s_request_amount > 0) {
ImVec2 iMin = ImGui::GetItemRectMin();
ImVec2 iMax = ImGui::GetItemRectMax();
double usd_value = s_request_amount * usd_price;
if (usd_value >= 0.01)
snprintf(buf, sizeof(buf), "\xe2\x89\x88 $%.2f", usd_value);
else
snprintf(buf, sizeof(buf), "\xe2\x89\x88 $%.6f", usd_value);
ImVec2 sz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
float tx = iMax.x - sz.x - ImGui::GetStyle().FramePadding.x;
float ty = iMin.y + (iMax.y - iMin.y - sz.y) * 0.5f;
dl->AddText(capFont, capFont->LegacySize, ImVec2(tx, ty),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "input-overlay-text-alpha").size), buf);
}
ImGui::PopItemWidth();
}
// Toggle button
ImGui::SameLine(0, Layout::spacingMd());
{
const char* currLabel = s_request_usd_mode ? "DRGX" : "USD";
bool canToggle = (usd_price > 0);
ImGui::BeginDisabled(!canToggle);
if (TactileButton("##ToggleCurrencyRecv", ImVec2(toggleW, 0), S.resolveFont("button"))) {
s_request_usd_mode = !s_request_usd_mode;
if (s_request_usd_mode && usd_price > 0)
s_request_usd_amount = s_request_amount * usd_price;
}
{
ImVec2 bMin = ImGui::GetItemRectMin();
ImVec2 bMax = ImGui::GetItemRectMax();
float bH = bMax.y - bMin.y;
ImFont* font = ImGui::GetFont();
ImVec2 textSz = font->CalcTextSizeA(font->LegacySize, 10000, 0, currLabel);
float iconW = schema::UI().drawElement("tabs.receive", "currency-icon-width").size * Layout::dpiScale();
float iconGap2 = schema::UI().drawElement("tabs.receive", "currency-icon-gap").size * Layout::dpiScale();
float totalW2 = iconW + iconGap2 + textSz.x;
float startX = bMin.x + ((bMax.x - bMin.x) - totalW2) * 0.5f;
float cy = bMin.y + bH * 0.5f;
ImU32 iconCol = ImGui::GetColorU32(ImGuiCol_Text);
float ss = iconW * 0.5f;
float cx = startX + ss;
float ag = S.drawElement("tabs.receive", "swap-icon-arrow-gap").size * cardDp;
float al = ss * S.drawElement("tabs.receive", "swap-icon-arrow-length-ratio").size;
float hss = S.drawElement("tabs.receive", "swap-icon-arrowhead-size").size * cardDp;
float ay1 = cy - ag;
dl->AddLine(ImVec2(cx - al, ay1), ImVec2(cx + al, ay1), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size * cardDp);
dl->AddTriangleFilled(
ImVec2(cx + al, ay1),
ImVec2(cx + al - hss, ay1 - hss),
ImVec2(cx + al - hss, ay1 + hss), iconCol);
float ay2 = cy + ag;
dl->AddLine(ImVec2(cx + al, ay2), ImVec2(cx - al, ay2), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size * cardDp);
dl->AddTriangleFilled(
ImVec2(cx - al, ay2),
ImVec2(cx - al + hss, ay2 - hss),
ImVec2(cx - al + hss, ay2 + hss), iconCol);
float tx = startX + iconW + iconGap2;
float ty = cy - textSz.y * 0.5f;
dl->AddText(font, font->LegacySize, ImVec2(tx, ty), iconCol, currLabel);
}
ImGui::EndDisabled();
}
ImGui::PopFont();
// Preset amount chips
{
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
float chipRound = schema::UI().drawElement("tabs.receive", "chip-rounding").size;
float chipGap = schema::UI().drawElement("tabs.receive", "chip-gap").size * cardDp;
float chipH = schema::UI().drawElement("tabs.receive", "chip-height").size * cardDp;
struct Preset { const char* label; double amount; };
Preset presets[] = {
{ "0.1", 0.1 },
{ "1", 1.0 },
{ "10", 10.0 },
{ "100", 100.0 },
{ "1K", 1000.0 },
{ "10K", 10000.0 },
{ "50K", 50000.0 },
{ "100K", 100000.0 },
};
constexpr int presetCount = 8;
// Calculate equal chip widths to span full addrColW
float totalGap = chipGap * (presetCount - 1);
float chipW = (addrColW - totalGap) / presetCount;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, chipRound);
ImFont* chipFont = S.resolveFont("button");
for (int ci = 0; ci < presetCount; ci++) {
if (ci > 0) ImGui::SameLine(0, chipGap);
bool active = false;
if (s_request_usd_mode) {
active = (usd_price > 0 && std::abs(s_request_usd_amount - presets[ci].amount) < 0.001);
} else {
active = (std::abs(s_request_amount - presets[ci].amount) < 1e-8);
}
if (active) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "chip-active-bg-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Primary()));
}
char chipId[32];
snprintf(chipId, sizeof(chipId), "%s##presetAmt%d", presets[ci].label, ci);
if (TactileButton(chipId, ImVec2(chipW, chipH), chipFont)) {
if (s_request_usd_mode) {
s_request_usd_amount = presets[ci].amount;
if (usd_price > 0)
s_request_amount = s_request_usd_amount / usd_price;
} else {
s_request_amount = presets[ci].amount;
if (usd_price > 0)
s_request_usd_amount = s_request_amount * usd_price;
}
}
if (active) ImGui::PopStyleColor(2);
}
ImGui::PopStyleVar();
ImGui::PopStyleColor();
}
// Memo (z-addresses only)
if (isZ) {
// ---- PAYMENT REQUEST ----
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("payment_request"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("memo_optional"));
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.receive", "memo-label-gap").size));
float memoInputH = std::max(schema::UI().drawElement("tabs.receive", "memo-input-min-height").size, schema::UI().drawElement("tabs.receive", "memo-input-height").size * vScale);
ImGui::PushItemWidth(addrColW);
ImGui::InputTextMultiline("##RequestMemo", s_request_memo, sizeof(s_request_memo),
ImVec2(addrColW, memoInputH));
ImGui::PopItemWidth();
// Amount input with currency toggle
float toggleW = S.drawElement("tabs.receive", "currency-toggle-width").size * Layout::dpiScale();
float amtInputW = addrColW - toggleW - Layout::spacingMd();
if (amtInputW < S.drawElement("tabs.receive", "amount-input-min-width").size) amtInputW = S.drawElement("tabs.receive", "amount-input-min-width").size;
double usd_price = state.market.price_usd;
// The Sapling memo limit is in BYTES and matches the enforced buffer size
// (InputTextMultiline caps at sizeof(s_request_memo)); count against that, not the
// old display-only 256 value, and warn in colour once the field is at its cap.
size_t memo_len = strlen(s_request_memo);
size_t memoMax = (size_t)S.drawElement("business", "memo-max-length").size;
bool memoAtCap = memo_len + 1 >= memoMax;
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
}
ImGui::PushFont(body2);
if (s_request_usd_mode && usd_price > 0) {
ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##RequestAmountUSD", &s_request_usd_amount, 0, 0, "$%.2f")) {
if (s_request_usd_amount < 0) s_request_usd_amount = 0;
s_request_amount = s_request_usd_amount / usd_price;
}
{
ImVec2 iMin = ImGui::GetItemRectMin();
ImVec2 iMax = ImGui::GetItemRectMax();
snprintf(buf, sizeof(buf), "\xe2\x89\x88 %.4f %s", s_request_amount, DRAGONX_TICKER);
ImVec2 sz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
float tx = iMax.x - sz.x - ImGui::GetStyle().FramePadding.x;
float ty = iMin.y + (iMax.y - iMin.y - sz.y) * 0.5f;
dl->AddText(capFont, capFont->LegacySize, ImVec2(tx, ty),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "input-overlay-text-alpha").size), buf);
}
ImGui::PopItemWidth();
} else {
ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##RequestAmount", &s_request_amount, 0, 0, "%.8f")) {
if (s_request_amount < 0) s_request_amount = 0;
if (usd_price > 0)
s_request_usd_amount = s_request_amount * usd_price;
}
if (usd_price > 0 && s_request_amount > 0) {
ImVec2 iMin = ImGui::GetItemRectMin();
ImVec2 iMax = ImGui::GetItemRectMax();
double usd_value = s_request_amount * usd_price;
if (usd_value >= 0.01)
snprintf(buf, sizeof(buf), "\xe2\x89\x88 $%.2f", usd_value);
else
snprintf(buf, sizeof(buf), "\xe2\x89\x88 $%.6f", usd_value);
ImVec2 sz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
float tx = iMax.x - sz.x - ImGui::GetStyle().FramePadding.x;
float ty = iMin.y + (iMax.y - iMin.y - sz.y) * 0.5f;
dl->AddText(capFont, capFont->LegacySize, ImVec2(tx, ty),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "input-overlay-text-alpha").size), buf);
}
ImGui::PopItemWidth();
}
// URI preview
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (s_request_amount > 0 && !s_cached_qr_data.empty()) {
float uriWrapW = addrColW;
dl->AddText(capFont, capFont->LegacySize, ImGui::GetCursorScreenPos(),
OnSurfaceDisabled(), s_cached_qr_data.c_str(), nullptr, uriWrapW);
ImVec2 uriSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX,
uriWrapW, s_cached_qr_data.c_str());
ImGui::Dummy(ImVec2(addrColW, uriSz.y + Layout::spacingSm()));
// Toggle button
ImGui::SameLine(0, Layout::spacingMd());
{
const char* currLabel = s_request_usd_mode ? "DRGX" : "USD";
bool canToggle = (usd_price > 0);
ImGui::BeginDisabled(!canToggle);
if (TactileButton("##ToggleCurrencyRecv", ImVec2(toggleW, 0), S.resolveFont("button"))) {
s_request_usd_mode = !s_request_usd_mode;
if (s_request_usd_mode && usd_price > 0)
s_request_usd_amount = s_request_amount * usd_price;
}
{
ImVec2 bMin = ImGui::GetItemRectMin();
ImVec2 bMax = ImGui::GetItemRectMax();
float bH = bMax.y - bMin.y;
ImFont* font = ImGui::GetFont();
ImVec2 textSz = font->CalcTextSizeA(font->LegacySize, 10000, 0, currLabel);
float iconW = schema::UI().drawElement("tabs.receive", "currency-icon-width").size * Layout::dpiScale();
float iconGap2 = schema::UI().drawElement("tabs.receive", "currency-icon-gap").size * Layout::dpiScale();
float totalW2 = iconW + iconGap2 + textSz.x;
float startX = bMin.x + ((bMax.x - bMin.x) - totalW2) * 0.5f;
float cy = bMin.y + bH * 0.5f;
ImU32 iconCol = ImGui::GetColorU32(ImGuiCol_Text);
float ss = iconW * 0.5f;
float cx = startX + ss;
float ag = S.drawElement("tabs.receive", "swap-icon-arrow-gap").size * cardDp;
float al = ss * S.drawElement("tabs.receive", "swap-icon-arrow-length-ratio").size;
float hss = S.drawElement("tabs.receive", "swap-icon-arrowhead-size").size * cardDp;
float ay1 = cy - ag;
dl->AddLine(ImVec2(cx - al, ay1), ImVec2(cx + al, ay1), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size * cardDp);
dl->AddTriangleFilled(
ImVec2(cx + al, ay1),
ImVec2(cx + al - hss, ay1 - hss),
ImVec2(cx + al - hss, ay1 + hss), iconCol);
float ay2 = cy + ag;
dl->AddLine(ImVec2(cx + al, ay2), ImVec2(cx - al, ay2), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size * cardDp);
dl->AddTriangleFilled(
ImVec2(cx - al, ay2),
ImVec2(cx - al + hss, ay2 - hss),
ImVec2(cx - al + hss, ay2 + hss), iconCol);
float tx = startX + iconW + iconGap2;
float ty = cy - textSz.y * 0.5f;
dl->AddText(font, font->LegacySize, ImVec2(tx, ty), iconCol, currLabel);
}
ImGui::EndDisabled();
}
ImGui::PopFont();
// Preset amount chips
{
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
float chipRound = schema::UI().drawElement("tabs.receive", "chip-rounding").size;
float chipGap = schema::UI().drawElement("tabs.receive", "chip-gap").size * cardDp;
float chipH = schema::UI().drawElement("tabs.receive", "chip-height").size * cardDp;
struct Preset { const char* label; double amount; };
Preset presets[] = {
{ "0.1", 0.1 },
{ "1", 1.0 },
{ "10", 10.0 },
{ "100", 100.0 },
{ "1K", 1000.0 },
{ "10K", 10000.0 },
{ "50K", 50000.0 },
{ "100K", 100000.0 },
};
constexpr int presetCount = 8;
// Calculate equal chip widths to span full addrColW
float totalGap = chipGap * (presetCount - 1);
float chipW = (addrColW - totalGap) / presetCount;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, chipRound);
ImFont* chipFont = S.resolveFont("button");
for (int ci = 0; ci < presetCount; ci++) {
if (ci > 0) ImGui::SameLine(0, chipGap);
bool active = false;
if (s_request_usd_mode) {
active = (usd_price > 0 && std::abs(s_request_usd_amount - presets[ci].amount) < 0.001);
} else {
active = (std::abs(s_request_amount - presets[ci].amount) < 1e-8);
}
if (active) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "chip-active-bg-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Primary()));
}
char chipId[32];
snprintf(chipId, sizeof(chipId), "%s##presetAmt%d", presets[ci].label, ci);
if (TactileButton(chipId, ImVec2(chipW, chipH), chipFont)) {
if (s_request_usd_mode) {
s_request_usd_amount = presets[ci].amount;
if (usd_price > 0)
s_request_amount = s_request_usd_amount / usd_price;
} else {
s_request_amount = presets[ci].amount;
if (usd_price > 0)
s_request_usd_amount = s_request_amount * usd_price;
}
}
if (active) ImGui::PopStyleColor(2);
}
ImGui::PopStyleVar();
ImGui::PopStyleColor();
}
// Memo (z-addresses only)
if (isZ) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("memo_optional"));
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.receive", "memo-label-gap").size));
float memoInputH = std::max(schema::UI().drawElement("tabs.receive", "memo-input-min-height").size, schema::UI().drawElement("tabs.receive", "memo-input-height").size * vScale);
ImGui::PushItemWidth(addrColW);
ImGui::InputTextMultiline("##RequestMemo", s_request_memo, sizeof(s_request_memo),
ImVec2(addrColW, memoInputH));
ImGui::PopItemWidth();
// The Sapling memo limit is in BYTES and matches the enforced buffer size
// (InputTextMultiline caps at sizeof(s_request_memo)); count against that, not the
// old display-only 256 value, and warn in colour once the field is at its cap.
size_t memo_len = strlen(s_request_memo);
size_t memoMax = (size_t)S.drawElement("business", "memo-max-length").size;
bool memoAtCap = memo_len + 1 >= memoMax;
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
}
// URI preview
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (s_request_amount > 0 && !s_cached_qr_data.empty()) {
float uriWrapW = addrColW;
dl->AddText(capFont, capFont->LegacySize, ImGui::GetCursorScreenPos(),
OnSurfaceDisabled(), s_cached_qr_data.c_str(), nullptr, uriWrapW);
ImVec2 uriSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX,
uriWrapW, s_cached_qr_data.c_str());
ImGui::Dummy(ImVec2(addrColW, uriSz.y + Layout::spacingSm()));
} else {
ImGui::Dummy(ImVec2(addrColW, capFont->LegacySize + Layout::spacingSm()));
}
} else {
ImGui::Dummy(ImVec2(addrColW, capFont->LegacySize + Layout::spacingSm()));
// A mining (transparent) address can't be paid: no payment request and no QR code.
RenderMiningAddressNotice(app, state, addrColW);
}
}
@@ -874,7 +952,21 @@ void RenderReceiveTab(App* app)
DrawGlassPanel(dl, qrPanelMin, qrPanelMax, qrGlass);
ImGui::SetCursorScreenPos(ImVec2(qrPanelMin.x + qrPadding, qrPanelMin.y + qrPadding));
if (s_qr_texture) {
if (!isZ) {
// A mining address: nothing to scan. Mark the panel so the empty square isn't a puzzle.
ImGui::Dummy(ImVec2(qrSize, qrSize));
ImFont* iconF = Type().iconXL();
const char* noPay = TR("receive_not_for_payments");
ImVec2 iSz = iconF->CalcTextSizeA(iconF->LegacySize, FLT_MAX, 0, ICON_MD_CONSTRUCTION);
ImVec2 tSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, qrSize, noPay);
const float blockH = iSz.y + Layout::spacingSm() + tSz.y;
const float cx = qrPanelMin.x + totalQrSize * 0.5f;
const float top = qrPanelMin.y + (totalQrSize - blockH) * 0.5f;
dl->AddText(iconF, iconF->LegacySize, ImVec2(cx - iSz.x * 0.5f, top), WithAlpha(Warning(), 200),
ICON_MD_CONSTRUCTION);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx - tSz.x * 0.5f, top + iSz.y + Layout::spacingSm()),
OnSurfaceDisabled(), noPay, nullptr, qrSize);
} else if (s_qr_texture) {
RenderQRCode(s_qr_texture, qrSize);
} else {
ImGui::Dummy(ImVec2(qrSize, qrSize));
@@ -886,12 +978,12 @@ void RenderReceiveTab(App* app)
ImGui::SetCursorScreenPos(qrPanelMin);
ImGui::InvisibleButton("##QRClickCopy", ImVec2(totalQrSize, totalQrSize));
if (ImGui::IsItemHovered()) {
if (isZ && ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s",
s_request_amount > 0 ? TR("click_copy_uri") : TR("click_copy_address"));
}
if (ImGui::IsItemClicked()) {
if (isZ && ImGui::IsItemClicked()) {
ImGui::SetClipboardText(qr_data.c_str());
Notifications::instance().info(s_request_amount > 0
? TR("payment_uri_copied") : TR("address_copied"));
+2
View File
@@ -14,6 +14,8 @@ namespace ui {
* Shows addresses for receiving funds with QR codes
*/
void RenderReceiveTab(App* app);
// UI sweep: select `address` on the Receive tab (nullptr: back to the default choice).
void SweepSelectReceiveAddress(App* app, const char* address);
} // namespace ui
} // namespace dragonx
+6 -20
View File
@@ -6,6 +6,7 @@
#include "../../app.h"
#include "../../util/i18n.h"
#include "../../util/payment_uri.h"
#include "../../util/address_validation.h"
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../widgets/qr_code.h"
@@ -47,8 +48,10 @@ void RequestPaymentDialog::show(const std::string& address)
s_label[0] = '\0';
s_uri_dirty = true;
if (!address.empty()) {
// Only a shielded address can be paid (DragonX is a private chain): never prefill a mining address.
if (!address.empty() && !util::isTransparentAddress(address)) {
strncpy(s_address, address.c_str(), sizeof(s_address) - 1);
s_address[sizeof(s_address) - 1] = '\0';
} else {
s_address[0] = '\0';
}
@@ -78,9 +81,6 @@ void RequestPaymentDialog::render(App* app)
auto zAddrLbl = S.label("dialogs.request-payment", "z-addr-label");
auto zAddrFrontLbl = S.label("dialogs.request-payment", "z-addr-front-label");
auto zAddrBackLbl = S.label("dialogs.request-payment", "z-addr-back-label");
auto tAddrLbl = S.label("dialogs.request-payment", "t-addr-label");
auto tAddrFrontLbl = S.label("dialogs.request-payment", "t-addr-front-label");
auto tAddrBackLbl = S.label("dialogs.request-payment", "t-addr-back-label");
auto amountInput = S.input("dialogs.request-payment", "amount-input");
auto memoInput = S.input("dialogs.request-payment", "memo-input");
auto qr = S.drawElement("dialogs.request-payment", "qr-code");
@@ -124,22 +124,8 @@ void RequestPaymentDialog::render(App* app)
}
}
// T-addresses
if (!state.t_addresses.empty()) {
ImGui::TextDisabled("%s", TR("request_transparent_addrs"));
for (size_t i = 0; i < state.t_addresses.size(); i++) {
const auto& addr = state.t_addresses[i];
std::string label = addr.address;
if (label.length() > static_cast<size_t>(tAddrLbl.truncate)) {
label = label.substr(0, tAddrFrontLbl.truncate) + "..." + label.substr(label.length() - tAddrBackLbl.truncate);
}
if (ImGui::Selectable(label.c_str(), addr.address == s_address)) {
strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1);
s_uri_dirty = true;
}
}
}
// No mining (transparent) addresses: they only receive mining rewards, so a request to one
// could never be paid.
ImGui::EndCombo();
}
+20 -5
View File
@@ -693,6 +693,14 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
// ============================================================================
// Confirmation dialog
// ============================================================================
void SweepSetSendRecipient(App* app, const char* to) {
(void)app;
std::snprintf(s_from_address, sizeof(s_from_address), "%s",
to ? "zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000" : "");
std::snprintf(s_to_address, sizeof(s_to_address), "%s", to ? to : "");
s_amount = to ? 1.0 : 0.0;
}
void SweepShowSendConfirm(App* app, bool show) {
(void)app;
if (show) {
@@ -975,7 +983,7 @@ static bool RenderZeroBalanceCTA(App* app, ImDrawList* dl, float width) {
// Action buttons (Send + Clear + Undo)
// ============================================================================
static void RenderActionButtons(App* app, float width, float vScale,
bool is_valid_address, double available,
bool is_valid_address, bool is_mining_addr, double available,
const char* suffix = "") {
auto& S = schema::UI();
const float dp = Layout::dpiScale();
@@ -1027,6 +1035,8 @@ static void RenderActionButtons(App* app, float width, float vScale,
material::Tooltip("%s", TR("send_tooltip_syncing"));
else if (s_from_address[0] == '\0')
material::Tooltip("%s", TR("send_tooltip_select_source"));
else if (is_mining_addr)
material::Tooltip("%s", TR("send_mining_addr_blocked"));
else if (!is_valid_address)
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
else if (s_amount <= 0)
@@ -1255,8 +1265,10 @@ void RenderSendTab(App* app)
double available = GetAvailableBalance(app);
bool is_valid_z = IsValidShieldedAddr(s_to_address);
// A transparent address is a mining address: it only ever receives mining rewards, and the network
// rejects a payment to it (DragonX is a private chain). Recognise it, but never send to it.
bool is_valid_t = IsValidTransparentAddr(s_to_address);
bool is_valid_address = is_valid_z || is_valid_t;
bool is_valid_address = is_valid_z;
float sectionGap = Layout::spacingXl() * vScale;
char buf[128];
@@ -1365,7 +1377,9 @@ void RenderSendTab(App* app)
if (vz)
Type().textColored(TypeStyle::Caption, Success(), TR("send_valid_shielded"));
else
Type().textColored(TypeStyle::Caption, Warning(), TR("send_valid_transparent"));
Type().textColored(TypeStyle::Caption, Error(), TR("send_mining_addr_short"));
if (vt && ImGui::IsItemHovered())
material::Tooltip("%s", TR("send_mining_addr_blocked"));
} else if (!checkPreview) {
ImGui::SameLine();
Type().textColored(TypeStyle::Caption, Error(), TR("invalid_address"));
@@ -1405,7 +1419,8 @@ void RenderSendTab(App* app)
trimmed.erase(trimmed.begin());
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\n' || trimmed.back() == '\r' || trimmed.back() == '\t'))
trimmed.pop_back();
bool looksValid = dragonx::util::isValidRecipientAddress(trimmed);
// Preview any address, a mining one too: once pasted, the field explains why it can't be paid.
bool looksValid = dragonx::util::isDragonXAddress(trimmed);
if (looksValid && s_to_address[0] == '\0') {
s_preview_text = trimmed;
s_paste_previewing = true;
@@ -1626,7 +1641,7 @@ void RenderSendTab(App* app)
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
// ---- ACTION BUTTONS (full width) ----
RenderActionButtons(app, innerW, vScale, is_valid_address, available, "##main");
RenderActionButtons(app, innerW, vScale, is_valid_address, is_valid_t, available, "##main");
// Add bottom padding
ImGui::Dummy(ImVec2(0, pad * vScale));
+2
View File
@@ -30,6 +30,8 @@ void RenderSendConfirmPopup(App* app);
* the only way an external driver (the full UI sweep) can reveal it. Fires no send.
*/
void SweepShowSendConfirm(App* app, bool show);
// UI sweep: put `to` (nullptr: clear) in the recipient field, e.g. a mining address to show it can't be paid.
void SweepSetSendRecipient(App* app, const char* to);
/**
* @brief Pre-fill the "from" address in the send tab
+269 -6
View File
@@ -8,12 +8,15 @@
#include "../../data/wallet_state.h"
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
#include "../../services/note_consolidation.h"
#include "../../util/i18n.h"
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "imgui.h"
#include <algorithm>
#include <set>
#include <vector>
#include <string>
#include <cstdio>
@@ -44,6 +47,18 @@ static bool s_scope_loaded = false;
static int s_t_count = 0, s_z_count = 0;
static double s_t_amount = 0.0, s_z_amount = 0.0;
static bool s_creating_addr = false; // z_getnewaddress in flight (empty state)
// "Keep each address's funds in that address": every ticked address's notes merge back into it, so
// separately labelled funds stay apart. Shielded only (a transparent address can't take change).
static bool s_per_address = false;
static std::vector<services::NoteGroup> s_groups; // spendable shielded notes by address (scope load)
static std::set<std::string> s_selected; // addresses ticked for per-address consolidation
struct PerAddressRun {
services::AddressRun run;
std::vector<int> state; // per opid: 0 = running, 1 = done, 2 = failed
std::string failure; // first failed op's message
};
static std::vector<PerAddressRun> s_runs; // after submit
static bool s_runs_submitting = false;
static void resetTransient()
{
@@ -53,6 +68,8 @@ static void resetTransient()
s_status_message.clear();
s_operation_id.clear();
s_creating_addr = false;
s_runs.clear();
s_runs_submitting = false;
}
// Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a
@@ -63,6 +80,7 @@ static void loadScope(App* app)
s_scope_loading = true; s_scope_loaded = false;
app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error;
std::vector<services::NoteGroup> groups;
try {
rpc::RPCClient::TraceScope trace("Shield dialog / Scope count");
nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0}));
@@ -73,10 +91,15 @@ static void loadScope(App* app)
if (zs.is_array()) for (const auto& z : zs) {
if (z.value("confirmations", 0) >= 1) { ++zC; zA += z.value("amount", 0.0); }
}
groups = services::groupSpendableNotes(zs);
} catch (const std::exception& e) { error = e.what(); }
return [tC, zC, tA, zA, error]() {
return [tC, zC, tA, zA, error, groups]() {
s_scope_loading = false; s_scope_loaded = error.empty();
s_t_count = tC; s_z_count = zC; s_t_amount = tA; s_z_amount = zA;
s_groups = groups;
s_selected.clear(); // every address worth it, ticked by default
for (const auto& g : s_groups)
if (services::worthConsolidating(g, s_fee)) s_selected.insert(g.address);
// Clamp the source to what actually has inputs (unless the user is mid-op).
const bool tOk = tC > 0, zOk = zC > 0;
if (!s_operation_pending) {
@@ -108,6 +131,9 @@ void ShieldDialog::show(Mode mode)
s_scope_loaded = false; s_scope_loading = false;
s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0;
s_last_poll = 0.0;
s_per_address = false;
s_groups.clear();
s_selected.clear();
}
void ShieldDialog::showShieldCoinbase(const std::string& fromAddress)
@@ -128,6 +154,7 @@ void ShieldDialog::showConsolidate()
show(Mode::MergeToAddress);
s_consolidate = true;
s_src = 1; // wallet-bloat consolidation targets shielded notes (witness bloat)
s_per_address = true; // shed notes without moving funds between (labelled) addresses
}
void ShieldDialog::hide()
@@ -142,6 +169,14 @@ static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_am
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
// A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap).
static void wrappedCaption(ImU32 color, const char* text)
{
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color));
material::Type().textWrapped(material::TypeStyle::Caption, text);
ImGui::PopStyleColor();
}
static std::string shortAddr(const std::string& a)
{
if (a.size() <= 20) return a;
@@ -226,6 +261,121 @@ static void submitOperation(App* app)
});
}
// The ticked addresses that are worth consolidating, in list order.
static std::vector<services::NoteGroup> selectedGroups()
{
std::vector<services::NoteGroup> out;
for (const auto& g : s_groups)
if (s_selected.count(g.address) && services::worthConsolidating(g, s_fee)) out.push_back(g);
return out;
}
// Per-address consolidation. One background job submits, address by address, merges of each address's
// notes back into that address — several batches when it holds more notes than the batch size.
// z_mergetoaddress locks a batch's notes before it returns, so back-to-back calls take disjoint notes.
static void submitPerAddress(App* app)
{
const auto groups = selectedGroups();
if (groups.empty() || !app->worker()) return;
s_runs_submitting = true;
s_operation_pending = true;
s_op_terminal = false;
s_status_message = TR("shield_submitting");
s_last_poll = ImGui::GetTime();
const double fee = s_fee;
const int limit = s_utxo_limit;
app->worker()->post([app, rpc = app->rpc(), groups, fee, limit]() -> rpc::RPCWorker::MainCb {
std::vector<services::AddressRun> runs;
for (const auto& g : groups) {
runs.push_back(services::consolidateInPlace(g.address, [&](const std::string& addr) {
services::MergeCallResult r;
try {
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place");
// fromaddrs = [addr], toaddr = addr: the address's notes merge back into it.
const nlohmann::json res = rpc->call("z_mergetoaddress",
{nlohmann::json::array({addr}), addr, fee, limit, limit});
r.opid = res.value("opid", std::string());
r.merging = res.value("mergingNotes", 0);
r.remaining = res.value("remainingNotes", 0);
if (r.opid.empty()) r.error = "no operation id";
} catch (const std::exception& e) { r.error = e.what(); }
return r;
}));
}
return [app, runs]() {
s_runs_submitting = false;
s_runs.clear();
int submitted = 0;
for (const auto& r : runs) {
PerAddressRun pr;
pr.run = r;
pr.state.assign(r.opids.size(), 0);
for (const auto& id : r.opids) { app->trackOperation(id); ++submitted; }
s_runs.push_back(std::move(pr));
}
if (submitted > 0) {
s_status_message = TR("merge_progress");
Notifications::instance().success(TR("merge_started"));
} else {
s_operation_pending = false; s_op_terminal = true;
s_status_message = TR("merge_keep_each_nothing_submitted");
Notifications::instance().error(TR("merge_keep_each_nothing_submitted"));
}
};
});
}
// Progress of a per-address run: poll every ~2s until each batch's operation is done or failed. Unfiltered,
// like the shared poller (some daemons reject the per-opid form); only our operations are read.
static void pollPerAddress(App* app)
{
if (s_runs.empty() || s_op_terminal || !app->worker()) return;
const double now = ImGui::GetTime();
if (now - s_last_poll < 2.0) return;
s_last_poll = now;
app->worker()->post([rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
nlohmann::json result; std::string error;
try {
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place status");
result = rpc->call("z_getoperationstatus", nlohmann::json::array());
} catch (const std::exception& e) { error = e.what(); }
return [result, error]() {
if (!error.empty() || !result.is_array()) return; // transient — retry next tick
for (const auto& op : result) {
if (!op.is_object() || !op.contains("id") || !op["id"].is_string()) continue;
const std::string id = op["id"].get<std::string>();
const std::string status = (op.contains("status") && op["status"].is_string())
? op["status"].get<std::string>() : std::string();
for (auto& r : s_runs) {
for (size_t i = 0; i < r.run.opids.size(); ++i) {
if (r.run.opids[i] != id || r.state[i] != 0) continue;
if (status == "success") r.state[i] = 1;
else if (status == "failed" || status == "cancelled") {
r.state[i] = 2;
if (r.failure.empty() && op.contains("error") && op["error"].is_object())
r.failure = op["error"].value("message", std::string());
}
}
}
}
bool allDone = true;
int failed = 0;
for (const auto& r : s_runs)
for (int st : r.state) { if (st == 0) allDone = false; if (st == 2) ++failed; }
if (!allDone) return;
s_operation_pending = false;
s_op_terminal = true;
if (failed == 0) {
s_status_message = TR("shield_completed");
Notifications::instance().success(TR("shield_merge_done"));
} else {
s_status_message = TR("merge_keep_each_some_failed");
Notifications::instance().error(TR("merge_keep_each_some_failed"));
}
};
});
}
// Live-progress self-poll: while an op is in flight, poll z_getoperationstatus every ~2s so the modal
// shows "Consolidating… → Done/Failed" without a manual button. (The shared poller also tracks it for
// balance refresh; this drives only the inline display.)
@@ -290,6 +440,7 @@ void ShieldDialog::render(App* app)
const auto& state = app->getWalletState();
autoSelectDestination(state);
pollOperation(app);
pollPerAddress(app);
if (isMerge && !s_scope_loaded && !s_scope_loading && s_operation_id.empty()) loadScope(app);
// ── Description ──────────────────────────────────────────────────────────────────────────────
@@ -298,7 +449,9 @@ void ShieldDialog::render(App* app)
: TR("shield_description"));
ImGui::Spacing();
const bool opInFlight = !s_operation_id.empty(); // submitted — inputs frozen, showing progress
// Submitted — inputs frozen, showing progress.
const bool opInFlight = !s_operation_id.empty() || s_runs_submitting || !s_runs.empty();
const bool perAddress = isMerge && s_per_address;
// ── Merge: scope + source selector ──────────────────────────────────────────────────────────
if (isMerge && !opInFlight) {
@@ -313,9 +466,17 @@ void ShieldDialog::render(App* app)
}
ImGui::Spacing();
// Where the funds go: all into one address, or each address's notes back into that address.
ImGui::TextUnformatted(TR("merge_where")); // own line: the choices are long in some languages
int where = s_per_address ? 1 : 0;
ImGui::RadioButton(TR("merge_into_one"), &where, 0); ImGui::SameLine();
ImGui::RadioButton(TR("merge_keep_each"), &where, 1);
s_per_address = (where == 1);
ImGui::Spacing();
// Source selector — only offer the types that actually have inputs.
const bool tOk = s_t_count > 0, zOk = s_z_count > 0;
if (tOk && zOk) {
if (tOk && zOk && !s_per_address) {
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("merge_source"));
ImGui::SameLine(0, Layout::spacingLg());
@@ -324,6 +485,46 @@ void ShieldDialog::render(App* app)
ImGui::RadioButton(TR("merge_src_both"), &s_src, 2);
ImGui::Spacing();
}
// Keep each address's funds in place: pick the addresses. Shielded only — a transparent address
// can't take a merge's output, its funds can only be shielded ("Into one address").
if (s_per_address) {
wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_desc"));
ImGui::Spacing();
int listed = 0;
for (const auto& g : s_groups) if (g.notes >= 2) ++listed;
if (s_scope_loaded && listed == 0) {
wrappedCaption(material::Warning(), TR("merge_keep_each_none"));
} else if (listed > 0) {
const float rowH = ImGui::GetFrameHeightWithSpacing();
const float listH = std::min(static_cast<float>(listed), 7.0f) * rowH + ImGui::GetStyle().WindowPadding.y * 2.0f;
if (ImGui::BeginChild("##ConsolidateEach", ImVec2(-1, listH), ImGuiChildFlags_Borders)) {
for (const auto& g : s_groups) {
if (g.notes < 2) continue;
const bool worth = services::worthConsolidating(g, s_fee);
bool ticked = worth && s_selected.count(g.address) > 0;
const std::string label = app->getAddressLabel(g.address);
char row[256];
std::snprintf(row, sizeof(row), TR("merge_keep_each_row_fmt"),
label.empty() ? shortAddr(g.address).c_str() : label.c_str(),
g.notes, fmtAmt(g.amount).c_str());
ImGui::PushID(g.address.c_str());
ImGui::BeginDisabled(!worth);
if (ImGui::Checkbox(row, &ticked)) {
if (ticked) s_selected.insert(g.address);
else s_selected.erase(g.address);
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s%s%s", g.address.c_str(), worth ? "" : "\n\n",
worth ? "" : TR("merge_keep_each_too_small"));
ImGui::PopID();
}
}
ImGui::EndChild();
}
ImGui::Spacing();
}
}
// ── Shield coinbase: from address ───────────────────────────────────────────────────────────
@@ -335,6 +536,7 @@ void ShieldDialog::render(App* app)
// ── Destination (z-address) ─────────────────────────────────────────────────────────────────
if (!opInFlight) {
if (!perAddress) {
ImGui::TextUnformatted(TR("shield_to_address"));
if (state.z_addresses.empty()) {
material::Type().textColored(material::TypeStyle::Caption, material::Warning(), TR("shield_no_zaddr_hint"));
@@ -381,6 +583,7 @@ void ShieldDialog::render(App* app)
}
}
ImGui::Spacing();
} // !perAddress
// ── Advanced (fee + batch size) ─────────────────────────────────────────────────────────
ImDrawList* dl = ImGui::GetWindowDrawList();
@@ -406,7 +609,16 @@ void ShieldDialog::render(App* app)
}
// Batch hint: one run only merges up to the limit; large sets need repeats.
if (isMerge && s_scope_loaded && srcCount() > s_utxo_limit) {
if (perAddress) {
bool multi = false;
for (const auto& g : selectedGroups()) if (services::estimateBatches(g.notes, s_utxo_limit) > 1) multi = true;
if (multi) {
char hb[200];
std::snprintf(hb, sizeof(hb), TR("merge_keep_each_batches_fmt"), s_utxo_limit);
ImGui::Spacing();
wrappedCaption(material::Warning(), hb);
}
} else if (isMerge && s_scope_loaded && srcCount() > s_utxo_limit) {
char hb[160];
std::snprintf(hb, sizeof(hb), TR("merge_batch_fmt"), s_utxo_limit);
ImGui::Spacing();
@@ -415,6 +627,46 @@ void ShieldDialog::render(App* app)
ImGui::Spacing();
}
// ── Per-address progress ────────────────────────────────────────────────────────────────────
if (!s_runs.empty()) {
bool leftovers = false;
for (const auto& r : s_runs) {
const std::string label = app->getAddressLabel(r.run.address);
const char* name = label.empty() ? nullptr : label.c_str();
const std::string shortA = shortAddr(r.run.address);
int done = 0, failed = 0;
for (int st : r.state) { if (st == 1) ++done; if (st == 2) ++failed; }
const int batches = static_cast<int>(r.run.opids.size());
char line[320];
ImU32 col = material::OnSurfaceMedium();
if (batches == 0) {
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_not_started"), name ? name : shortA.c_str(),
r.run.error.c_str());
col = material::Error();
} else if (failed > 0) {
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_failed"), name ? name : shortA.c_str(),
r.failure.c_str());
col = material::Error();
} else if (done == batches) {
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_done"), name ? name : shortA.c_str(),
r.run.notesMerged, batches);
col = material::Success();
} else {
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_running"), name ? name : shortA.c_str(),
r.run.notesMerged, done, batches);
}
wrappedCaption(col, line);
if (batches > 1 || r.run.remaining >= 2) leftovers = true;
}
if (leftovers) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
ImGui::TextWrapped("%s", TR("merge_keep_each_run_again"));
ImGui::PopStyleColor();
}
ImGui::Spacing();
}
// ── Live progress / status ──────────────────────────────────────────────────────────────────
if (!s_status_message.empty()) {
if (s_operation_pending && !s_op_terminal)
@@ -447,7 +699,16 @@ void ShieldDialog::render(App* app)
// Confirm summary (inline, before the fund-moving call). Merge/consolidate shows amount + input
// count; shield-coinbase just gets the button relabel (its inputs aren't enumerated here).
if (s_confirm && isMerge) {
if (s_confirm && perAddress) {
int notes = 0, batches = 0;
const auto sel = selectedGroups();
for (const auto& g : sel) { notes += g.notes; batches += services::estimateBatches(g.notes, s_utxo_limit); }
char cb[320];
std::snprintf(cb, sizeof(cb), TR("merge_keep_each_confirm_fmt"), notes, static_cast<int>(sel.size()),
fmtAmt(s_fee * batches).c_str());
ImGui::TextWrapped("%s", cb);
ImGui::Spacing();
} else if (s_confirm && isMerge) {
char cb[200];
std::snprintf(cb, sizeof(cb), TR("merge_confirm_fmt"),
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
@@ -457,19 +718,21 @@ void ShieldDialog::render(App* app)
bool can_submit = haveDest && connected && !syncing;
if (isMerge && s_scope_loaded && srcCount() == 0) can_submit = false;
if (perAddress) can_submit = connected && !syncing && s_scope_loaded && !selectedGroups().empty();
float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x;
material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false);
if (!can_submit) ImGui::BeginDisabled();
if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
if (s_confirm) { submitOperation(app); }
if (s_confirm) { if (perAddress) submitPerAddress(app); else submitOperation(app); }
else { s_confirm = true; } // first click → show the confirm summary
}
if (!can_submit) ImGui::EndDisabled();
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
if (!connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
else if (syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
else if (perAddress) material::Tooltip("%s", TR("merge_keep_each_select"));
else if (!haveDest) material::Tooltip("%s", TR("shield_select_z"));
else if (isMerge && srcCount() == 0) material::Tooltip("%s", TR("merge_no_spendable"));
}
+22 -1
View File
@@ -22,6 +22,7 @@ bool ValidateAddressDialog::s_validating = false;
char ValidateAddressDialog::s_address_input[512] = "";
bool ValidateAddressDialog::s_is_valid = false;
bool ValidateAddressDialog::s_is_mine = false;
bool ValidateAddressDialog::s_is_mining = false;
std::string ValidateAddressDialog::s_address_type;
std::string ValidateAddressDialog::s_error_message;
@@ -33,10 +34,22 @@ void ValidateAddressDialog::show()
s_address_input[0] = '\0';
s_is_valid = false;
s_is_mine = false;
s_is_mining = false;
s_address_type.clear();
s_error_message.clear();
}
void ValidateAddressDialog::sweepShowMiningResult(const char* address)
{
show();
snprintf(s_address_input, sizeof(s_address_input), "%s", address);
s_validated = true;
s_is_valid = true;
s_is_mine = true;
s_is_mining = true;
s_address_type = TR("validate_mining_type");
}
bool ValidateAddressDialog::isOpen()
{
return s_open;
@@ -112,6 +125,7 @@ void ValidateAddressDialog::render(App* app)
s_is_valid = valid;
s_is_mine = mine;
s_address_type = TR("validate_shielded_type");
s_is_mining = false;
} else {
s_error_message = error;
s_is_valid = false;
@@ -138,7 +152,8 @@ void ValidateAddressDialog::render(App* app)
if (error.empty()) {
s_is_valid = valid;
s_is_mine = mine;
s_address_type = TR("validate_transparent_type");
s_address_type = TR("validate_mining_type");
s_is_mining = true;
} else {
s_error_message = error;
s_is_valid = false;
@@ -204,6 +219,12 @@ void ValidateAddressDialog::render(App* app)
} else {
ImGui::TextDisabled("%s", TR("validate_not_mine"));
}
if (s_is_mining) { // valid, but not for payments
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
ImGui::TextWrapped("%s", TR("mining_addr_explainer"));
ImGui::PopStyleColor();
}
}
}
} else if (!app->isConnected()) {
+4
View File
@@ -38,6 +38,9 @@ public:
*/
static void hide();
/** UI sweep: show the result for a valid mining (transparent) address of ours. */
static void sweepShowMiningResult(const char* address);
private:
static bool s_open;
static bool s_validated;
@@ -47,6 +50,7 @@ private:
// Validation results
static bool s_is_valid;
static bool s_is_mine;
static bool s_is_mining; // a valid transparent (mining) address: explain it can't be paid
static std::string s_address_type;
static std::string s_error_message;
};
+13 -1
View File
@@ -164,10 +164,22 @@ bool isShieldedAddress(const std::string& s)
|| hrp == "zregtestsapling"; // regtest
}
bool isValidRecipientAddress(const std::string& s)
bool isDragonXAddress(const std::string& s)
{
return isTransparentAddress(s) || isShieldedAddress(s);
}
bool isValidRecipientAddress(const std::string& s)
{
return isShieldedAddress(s);
}
RecipientKind classifyRecipient(const std::string& s)
{
if (isShieldedAddress(s)) return RecipientKind::Shielded;
if (isTransparentAddress(s)) return RecipientKind::MiningOnly;
return RecipientKind::Invalid;
}
} // namespace util
} // namespace dragonx
+11 -1
View File
@@ -45,8 +45,18 @@ bool isTransparentAddress(const std::string& s);
// with a valid Bech32 checksum. Distinguishes a payment address from a viewing key (e.g. "zivks…").
bool isShieldedAddress(const std::string& s);
// True if `s` is any address a payment can be sent to (transparent or shielded).
// True if `s` is a DragonX address of either kind (transparent or shielded) — "is this an address at all".
bool isDragonXAddress(const std::string& s);
// True if `s` is an address a payment can be sent to. DragonX is a private chain (-ac_private=1): consensus
// rejects any non-coinbase output to a transparent address, so only a shielded address can be paid — sends,
// payment requests and pool payouts included. A transparent address only ever receives mining rewards.
bool isValidRecipientAddress(const std::string& s);
// What sending to `s` would mean: a shielded address (payable), a mining address (transparent: valid, but
// it can only receive mining rewards, so a payment to it is rejected), or not an address.
enum class RecipientKind { Invalid, Shielded, MiningOnly };
RecipientKind classifyRecipient(const std::string& s);
} // namespace util
} // namespace dragonx
+9 -3
View File
@@ -162,11 +162,17 @@ void Bootstrap::start(const std::string& dataDir, const std::string& url) {
worker_running_ = false;
return;
}
// Step 3: Ensure daemon is fully stopped before touching chain data
// Step 3: Ensure daemon is fully stopped before touching chain data. Gone means the PROCESS has
// exited, not just its RPC port: dragonxd unbinds RPC early in its shutdown and keeps its files
// (and the datadir lock) until it exits — deleting chain data under it corrupts it, or fails on
// Windows' locked files.
{
setProgress(State::Extracting, "Waiting for daemon to stop...");
auto daemonUp = [] {
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
};
int waited = 0;
while (daemon::EmbeddedDaemon::isRpcPortInUse() && waited < 60 && !cancel_requested_) {
while (daemonUp() && waited < 120 && !cancel_requested_) {
std::this_thread::sleep_for(std::chrono::seconds(1));
waited++;
if (waited % 5 == 0)
@@ -177,7 +183,7 @@ void Bootstrap::start(const std::string& dataDir, const std::string& url) {
worker_running_ = false;
return;
}
if (daemon::EmbeddedDaemon::isRpcPortInUse()) {
if (daemonUp()) {
setProgress(State::Failed, "Daemon is still running — stop it before using bootstrap");
worker_running_ = false;
return;
+80 -30
View File
@@ -582,11 +582,42 @@ void I18n::loadBuiltinEnglish()
strings_["incorrect_passphrase"] = "Incorrect passphrase";
strings_["pin_not_set"] = "PIN not set. Use passphrase to unlock.";
strings_["restarting_after_encryption"] = "Restarting daemon after encryption...";
strings_["force_quit"] = "Force Quit";
strings_["force_quit_warning"] = "This will immediately kill the daemon without a clean shutdown. May require a blockchain resync.";
strings_["force_quit_confirm_title"] = "Force Quit?";
strings_["force_quit_confirm_msg"] = "This will immediately kill the daemon without a clean shutdown.\nThis may corrupt the blockchain index and require a resync.";
strings_["force_quit_yes"] = "Force Quit";
// Stopping the node on quit: deliver stop, wait, never kill unasked
strings_["shutdown_leave_running"] = "Close wallet — leave node running";
strings_["shutdown_leave_running_tip"] = "Closes the wallet now. The node keeps running on its own and finishes what it is doing; nothing is lost.";
strings_["shutdown_kill_node"] = "Kill node (unsafe)";
strings_["shutdown_kill_node_tip"] = "Ends the node at once. Anything it has not saved is lost, and its next start replays the blocks since its last save.";
strings_["shutdown_kill_confirm_title"] = "Kill the node?";
strings_["shutdown_kill_confirm_msg"] = "The node has shown no CPU or disk activity for a long time and may be stuck.\nKilling it loses everything it has not saved; its next start can take much longer.";
strings_["shutdown_kill_confirm_yes"] = "Kill node";
strings_["shutdown_stop_asking"] = "Asking the node to stop…";
strings_["shutdown_stop_queued"] = "The node has the stop request but is busy — it will stop when it can";
strings_["shutdown_stop_delivered"] = "The node is shutting down and saving its state";
strings_["shutdown_stop_cpu_suffix"] = " · CPU %d%%";
strings_["shutdown_stop_idle"] = "No CPU or disk activity for %d min — the node may be stuck";
// The node is busy (S9): overlay, banner, status bar
strings_["node_busy_rebuild_title"] = "Node busy: rebuilding note witnesses";
strings_["node_busy_running"] = "Running for %s";
strings_["node_busy_rebuild_desc"] = "Running for %s. Large wallets can take about an hour, and the node can't answer the wallet until it finishes.";
strings_["node_busy_rebuild_reading"] = "Reading blocks: %ld / %ld";
strings_["node_busy_rebuild_extending"] = "Extending witnesses (the node reports no progress for this step).";
strings_["node_busy_replay_title"] = "Node busy: catching up on blocks";
strings_["node_busy_replay_desc"] = "Running for %s. The node is re-applying blocks it received before it last stopped.";
strings_["node_busy_rpc_title"] = "Node busy: not answering yet";
strings_["node_busy_rpc_desc"] = "The node is running but has been too busy to answer the wallet for %s, usually because of a long operation such as a witness rebuild.";
strings_["node_starting_title"] = "Starting DragonX node…";
strings_["node_starting_busy_desc"] = "The node is reachable but busy and isn't answering yet. This is normal after an update or on first launch; it can take a few minutes.";
strings_["node_starting_launch_desc"] = "Launching dragonxd and waiting for it to come online…";
strings_["sb_node_busy"] = "Node busy";
// Quit while the node is busy (S10)
strings_["stopconfirm_rebuild_title"] = "The node is rebuilding note witnesses";
strings_["stopconfirm_rebuild_body"] = "It has been running for %s, and large wallets can take about an hour. Stopping the node now either waits for the rebuild to finish or makes its next start redo the rebuild and any catch-up.\n\nRecommended: close the wallet and leave the node running. It finishes on its own.";
strings_["stopconfirm_busy_title"] = "The node is busy";
strings_["stopconfirm_busy_body"] = "It has been too busy to answer the wallet for %s, probably because of a long operation such as a witness rebuild. Stopping it now can make its next start redo that work.\n\nRecommended: close the wallet and leave the node running. It finishes on its own.";
strings_["stopconfirm_stop_wait"] = "Stop node & wait";
strings_["appx_daemon_reinstall_node_busy"] = "The node is still running, so the daemon was not reinstalled. Try again once it has stopped.";
strings_["appx_node_busy_try_later"] = "The node is busy finishing a long task (such as rebuilding note witnesses). Try again when it is done; stopping it now could make it start that work over.";
strings_["appx_node_not_stopped"] = "The node didn't stop in time, so nothing was changed. Try again in a few minutes.";
strings_["reduce_motion"] = "Reduce Motion";
strings_["tt_reduce_motion"] = "Disable animated transitions and balance lerp for accessibility";
strings_["ago"] = "ago";
@@ -1000,9 +1031,7 @@ void I18n::loadBuiltinEnglish()
strings_["copied"] = "Copied!";
strings_["hidden_tag"] = " (hidden)";
strings_["z_address"] = "Z-Address";
strings_["t_address"] = "T-Address";
strings_["shielded_address"] = "Shielded Address";
strings_["transparent_address"] = "Transparent Address";
strings_["label_placeholder"] = "e.g. Savings, Mining...";
strings_["choose_icon"] = "Choose Icon";
strings_["clear_icon"] = "Clear Icon";
@@ -1138,7 +1167,6 @@ void I18n::loadBuiltinEnglish()
// Balance Tab
strings_["summary"] = "Summary";
strings_["shielded"] = "Shielded";
strings_["transparent"] = "Transparent";
strings_["total"] = "Total";
strings_["unconfirmed"] = "Unconfirmed";
strings_["recent_transactions"] = "RECENT TRANSACTIONS";
@@ -1149,7 +1177,6 @@ void I18n::loadBuiltinEnglish()
strings_["privacy_low"] = "Low privacy — shield funds";
strings_["balance_history_collecting"] = "Balance history — collecting data...";
strings_["balance_shielded_fmt"] = "Shielded: %.8f";
strings_["balance_transparent_fmt"] = "Transparent: %.8f";
strings_["total_balance_label"] = "Total Balance";
strings_["balance_syncing_pct"] = "Syncing %.1f%%";
strings_["balance_mining_rate"] = "Mining %s";
@@ -1163,7 +1190,6 @@ void I18n::loadBuiltinEnglish()
strings_["t_addresses"] = "T-Addresses";
strings_["no_addresses"] = "No addresses found. Create one using the buttons above.";
strings_["new_z_address"] = "New Z-Address";
strings_["new_t_address"] = "New T-Address";
strings_["type"] = "Type";
strings_["address"] = "Address";
strings_["copy_address"] = "Copy Full Address";
@@ -1606,21 +1632,21 @@ void I18n::loadBuiltinEnglish()
// Wallet > Addresses & Privacy
strings_["faq_w_addr_1_q"] = "What's the difference between transparent and shielded addresses?";
strings_["faq_w_addr_1_a"] = "Transparent addresses (they start with R or t) work like most coins: the amounts and addresses are public on the blockchain.\n\nShielded addresses (z-addresses, starting with zs) use zero-knowledge cryptography \xE2\x80\x94 the amount, sender, and receiver are encrypted on-chain. Balances held in shielded addresses are private.";
strings_["faq_w_addr_1_a"] = "Shielded addresses (z-addresses, starting with zs) are the ones you use on DragonX. They use zero-knowledge cryptography, so the amount, sender and receiver are encrypted on-chain and your balance stays private.\n\nTransparent addresses (starting with R) are mining addresses. DragonX is a fully private chain, so they can only receive mining rewards — the network rejects any payment sent to one. Rewards that arrive there are public until they are shielded, which the wallet does automatically.";
strings_["faq_w_addr_2_q"] = "Which address type should I use?";
strings_["faq_w_addr_2_a"] = "Prefer shielded (z) addresses whenever possible \xE2\x80\x94 they keep your balance and payment history private. DragonX is a privacy coin and shielded is the default for received funds.\n\nUse a transparent address only when a service you interact with cannot handle shielded addresses.";
strings_["faq_w_addr_2_a"] = "Use a shielded (zs) address for everything you send and receive — on DragonX it is the only kind that can be paid.\n\nTransparent (mining) addresses are only for solo-mining rewards. Never give one out for a payment: the network will reject it.";
strings_["faq_w_addr_3_q"] = "Should I reuse an address or make a new one?";
strings_["faq_w_addr_3_a"] = "For shielded addresses, reuse is fine and does not leak history. For transparent addresses, using a fresh address per payment improves privacy. Create new addresses from the Receive tab.";
strings_["faq_w_addr_3_a"] = "Reusing a shielded address is fine and does not leak your history. You can still create new ones from the Receive tab, for example one per contact to see who paid you. Mining addresses are only for mining rewards; you rarely need a new one.";
strings_["faq_w_addr_4_q"] = "What is 'shielding'?";
strings_["faq_w_addr_4_a"] = "Shielding moves coins from a transparent address into a shielded one, making them private. Newly mined coins arrive transparent and are shielded for you automatically.\n\nYou can also shield manually from the wallet; a shield is just a special transaction, so it takes a normal confirmation time to complete.";
strings_["faq_w_addr_4_a"] = "Shielding moves coins from a transparent (mining) address into a shielded one, making them private. Newly mined coins arrive at a mining address and are shielded for you automatically once they mature.\n\nYou can also shield manually from the wallet; a shield is just a special transaction, so it takes a normal confirmation time to complete.";
// Wallet > Sending & Receiving
strings_["faq_w_send_1_q"] = "How do I send funds?";
strings_["faq_w_send_1_a"] = "Open the Send tab, paste the recipient's address, enter an amount, and confirm. If the wallet is encrypted you'll be asked to unlock it for the transaction.\n\nSends to shielded addresses are private; sends to transparent addresses are public.";
strings_["faq_w_send_1_a"] = "Open the Send tab, paste the recipient's address, enter an amount, and confirm. If the wallet is encrypted you'll be asked to unlock it for the transaction.\n\nPayments go to shielded (zs) addresses and stay private. The wallet won't send to a transparent (mining) address: the network rejects payments to one.";
strings_["faq_w_send_2_q"] = "What fee do I pay?";
strings_["faq_w_send_2_a"] = "DragonX fees are very low and are set automatically. The fee is shown before you confirm. You do not normally need to change it.";
strings_["faq_w_send_3_q"] = "Can I attach a message to a payment?";
strings_["faq_w_send_3_a"] = "Yes \xE2\x80\x94 when sending to a shielded (z) address you can include an encrypted memo. Only the recipient can read it. Transparent addresses do not support memos.";
strings_["faq_w_send_3_a"] = "Yes — when sending to a shielded (z) address you can include an encrypted memo. Only the recipient can read it.";
strings_["faq_w_send_4_q"] = "I sent a payment but it says pending \xE2\x80\x94 why?";
strings_["faq_w_send_4_a"] = "A transaction is 'pending' until it is mined into a block and gains confirmations. This usually takes a minute or two. Shielded transactions also need your wallet to be synced to build the proof.\n\nIf a send stays pending unusually long, check that your wallet is connected and synced (see the status bar at the bottom of the window).";
@@ -1761,7 +1787,6 @@ void I18n::loadBuiltinEnglish()
strings_["contact_avatar_image"] = "Image";
strings_["contact_avatar_badge_hint"] = "The badge is chosen automatically from the address type.";
strings_["contact_avatar_shielded"] = "Shielded";
strings_["contact_avatar_transparent"] = "Transparent";
strings_["contact_avatar_choose"] = "Choose image\xE2\x80\xA6";
strings_["contact_avatar_remove"] = "Remove";
strings_["contact_avatar_image_hint"] = "The image is copied into the app so it stays available if the original moves.";
@@ -2139,6 +2164,27 @@ void I18n::loadBuiltinEnglish()
strings_["mining_payout_address"] = "Payout Address";
strings_["mining_payout_tooltip"] = "Address to receive mining rewards";
strings_["mining_payout_invalid"] = "Not a valid DragonX address — fix it before starting, or mining rewards are lost.";
strings_["send_mining_addr_short"] = "Mining address — can't be paid";
strings_["send_mining_addr_blocked"] = "This is a mining (transparent) address. On DragonX these can only receive mining rewards — the network rejects payments to them. Ask the recipient for a shielded address (it starts with zs).";
strings_["mining_payout_needs_shielded"] = "Pools pay out with ordinary transactions, which can't reach a mining (transparent, R…) address on DragonX. Use a shielded address (it starts with zs) as your payout address.";
strings_["new_mining_address"] = "New mining address";
strings_["tt_new_mining_address"] = "Creates a transparent (R…) address for solo-mining rewards. It can't receive payments — share a shielded address for those.";
strings_["mining_address"] = "Mining address";
strings_["addr_type_mining"] = "Mining";
strings_["mining_rewards"] = "Mining rewards";
strings_["mining_rewards_unshielded"] = "Not yet shielded";
strings_["baltab_mining_addresses_count"] = "%d mining addresses";
strings_["baltab_mining_amount"] = "Mining rewards %.8f";
strings_["balance_mining_fmt"] = "Mining rewards: %.8f";
strings_["mining_card_tooltip"] = "Solo-mining rewards arrive at your mining (transparent, R…) addresses. Those addresses can't receive payments. Rewards are shielded automatically once they mature.";
strings_["mining_card_click_to_shield"] = "Click to shield matured rewards now.";
strings_["contact_chip_mining"] = "Mining only";
strings_["failed_create_mining_address"] = "Failed to create mining address";
strings_["new_mining_address_created"] = "New mining address created";
strings_["mining_addr_explainer"] = "Receives solo-mining rewards only — the network rejects payments to it. Rewards are shielded automatically once they mature.";
strings_["receive_use_shielded"] = "Use a shielded address";
strings_["receive_not_for_payments"] = "Not for payments";
strings_["validate_mining_type"] = "Mining address (transparent)";
strings_["mining_est_daily_pool_sub"] = "rough solo-equivalent, before pool fee";
strings_["mining_generate_z_address_hint"] = "Generate a Z address in the Receive tab to use as your payout address";
strings_["mining_pool_needs_payout_tooltip"] = "Enter a payout address first (generate a Z address)";
@@ -2357,9 +2403,7 @@ void I18n::loadBuiltinEnglish()
strings_["click_copy_uri"] = "Click to copy URI";
strings_["generating"] = "Generating";
strings_["failed_create_shielded"] = "Failed to create shielded address";
strings_["failed_create_transparent"] = "Failed to create transparent address";
strings_["new_shielded_created"] = "New shielded address created";
strings_["new_transparent_created"] = "New transparent address created";
strings_["payment_request_copied"] = "Payment request copied";
strings_["payment_uri_copied"] = "Payment URI copied";
@@ -2374,7 +2418,6 @@ void I18n::loadBuiltinEnglish()
strings_["request_select_address"] = "Select address...";
strings_["request_shielded_addrs"] = "-- Shielded Addresses --";
strings_["request_title"] = "Request Payment";
strings_["request_transparent_addrs"] = "-- Transparent Addresses --";
strings_["request_uri_copied"] = "Payment URI copied to clipboard";
// --- Send Tab ---
@@ -2422,7 +2465,6 @@ void I18n::loadBuiltinEnglish()
strings_["send_txid_copied"] = "TxID copied to clipboard";
strings_["send_txid_label"] = "TxID: %s";
strings_["send_valid_shielded"] = "Valid shielded address";
strings_["send_valid_transparent"] = "Valid transparent address";
strings_["send_wallet_empty"] = "Your wallet is empty";
strings_["send_yes_clear"] = "Yes, Clear";
@@ -2477,6 +2519,23 @@ void I18n::loadBuiltinEnglish()
strings_["merge_back"] = "Back";
strings_["merge_progress"] = "Consolidating\xE2\x80\xA6 this can take a few minutes. You can close this window.";
strings_["merge_no_spendable"] = "No spendable inputs to consolidate yet.";
strings_["merge_where"] = "Where should the funds go?";
strings_["merge_into_one"] = "Into one address";
strings_["merge_keep_each"] = "Keep in each address";
strings_["merge_keep_each_desc"] = "Each address's notes are merged back into that same address, so funds you keep apart under different labels stay apart. Shielded addresses only — transparent funds can only be shielded (Into one address).";
strings_["merge_keep_each_none"] = "Every shielded address already holds a single note — nothing to consolidate.";
strings_["merge_keep_each_row_fmt"] = "%s · %d notes · %s DRGX";
strings_["merge_keep_each_too_small"] = "Too small to consolidate: the fee would use up these funds.";
strings_["merge_keep_each_batches_fmt"] = "Addresses with more than %d notes are merged in several batches; run it again after they confirm to merge the rest.";
strings_["merge_keep_each_confirm_fmt"] = "Consolidate %d notes in %d addresses? Each address keeps its own funds. Fees: about %s DRGX (one per batch).";
strings_["merge_keep_each_select"] = "Tick at least one address to consolidate.";
strings_["merge_keep_each_nothing_submitted"] = "Nothing was consolidated — the node refused every merge.";
strings_["merge_keep_each_some_failed"] = "Some merges failed — see the list above.";
strings_["merge_keep_each_status_not_started"] = "%s — not started: %s";
strings_["merge_keep_each_status_failed"] = "%s — failed: %s";
strings_["merge_keep_each_status_done"] = "%s — done: %d notes merged in %d batch(es)";
strings_["merge_keep_each_status_running"] = "%s — merging %d notes (%d of %d batches done)…";
strings_["merge_keep_each_run_again"] = "Addresses merged in several batches now hold one note per batch. Run this again after those confirm to merge them into one.";
strings_["done"] = "Done";
// --- Transaction Details Dialog ---
@@ -2503,7 +2562,6 @@ void I18n::loadBuiltinEnglish()
strings_["validate_shielded_type"] = "Shielded (z-address)";
strings_["validate_status"] = "Status:";
strings_["validate_title"] = "Validate Address";
strings_["validate_transparent_type"] = "Transparent (t-address)";
strings_["validate_type"] = "Type:";
strings_["validate_valid"] = "VALID";
@@ -2625,10 +2683,7 @@ void I18n::loadBuiltinEnglish()
strings_["appx_restoring_your_wallet"] = "Restoring your wallet…";
strings_["appx_recovery_phrase_word_count"] = "Recovery phrase should be 24 words — you have %d.";
strings_["appx_could_not_start_restore"] = "Could not start restore";
strings_["appx_node_rebuilding_witness_cache"] = "Node is rebuilding its witness cache";
strings_["appx_stopping_node_discards_rebuild"] = "Stopping the node now discards the in-progress rebuild and restarts it (several minutes) the next time you open the wallet. You can keep the node running instead.";
strings_["appx_keep_node_running_and_quit"] = "Keep node running & quit";
strings_["appx_stop_anyway_and_quit"] = "Stop anyway & quit";
strings_["appx_cancel"] = "Cancel";
strings_["appx_last_used_wallet_not_found_prefix"] = "Your last-used wallet file (";
strings_["appx_last_used_wallet_not_found_suffix"] = ") was not found — opened the default wallet instead. If you moved it, restore it and switch back from the wallet list.";
@@ -2658,8 +2713,6 @@ void I18n::loadBuiltinEnglish()
strings_["appx_shutdown_complete"] = "Shutdown complete";
strings_["appx_n_seconds"] = "%d seconds";
strings_["appx_n_min_n_sec"] = "%d min %d sec";
strings_["appx_still_status_prefix"] = "Still \"";
strings_["appx_still_status_suffix"] = "\" — force quitting now may corrupt chain data.";
strings_["appx_rebuilding_witness_cache_blocks_left"] = "Rebuilding witness cache %.0f%% — %d blocks left";
strings_["appx_rebuilding_witness_cache_pct"] = "Rebuilding witness cache %.0f%%";
strings_["appx_setting_initial_sapling_witnesses"] = "Setting initial Sapling witnesses %.0f%%";
@@ -2682,15 +2735,12 @@ void I18n::loadBuiltinEnglish()
// balance_tab.cpp
strings_["baltab_shielded_amount"] = "Shielded %.8f";
strings_["baltab_transparent_amount"] = "Transparent %.8f";
strings_["baltab_market_price_4dp"] = "Market: $%.4f";
strings_["baltab_market_price_8dp"] = "Market: $%.8f";
strings_["baltab_pct_of_total_zaddr"] = "%.0f%% of total · %d Z-addr";
strings_["baltab_t_addresses_count"] = "%d T-addresses";
strings_["baltab_pct_change_24h"] = "%s%.1f%% 24h";
strings_["baltab_total_balance"] = "Total Balance";
strings_["baltab_shielded"] = "Shielded";
strings_["baltab_transparent"] = "Transparent";
strings_["baltab_market"] = "Market";
// settings_window.cpp
+1663 -29
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
# fake_dragonxd
A deliberately slow, scriptable stand-in for `dragonxd`, used to reproduce and regression-test how
ObsidianDragon stops (or kills) a busy node. It is test tooling: off by default, never packaged.
The incident it models (2026-09):
1. A 13.6k-note wallet spent ~1 h in a witness rebuild that held `cs_main`.
2. The GUI's own abandoned polls filled the node's 24 RPC slots, so `stop` was never delivered.
3. On close, the GUI hard-killed the node mid-rebuild, so every restart replayed ~62k blocks and redid the rebuild.
What it copies from the real daemon (DragonX v1.3.0):
- **HTTP-RPC queue.** 8 workers plus a 16-deep queue (or `-rpcthreads` / `-rpcworkqueue` from the
command line or `DRAGONX.conf`). When the queue is full the request gets an immediate plain-text
`HTTP 500 "Work queue depth exceeded"`. A request whose client timed out **keeps its slot** until
a worker reaches it.
- **cs_main.** One global lock. Every method except `stop`, `help` and `getrescaninfo` (lock-free in dragonxd too) takes it. A "witness rebuild"
holds it for `FAKE_REBUILD_SECS` and logs the real markers:
- `Reading blocks for witness rebuild: <off> / <total>` (Phase 1, abortable);
- nothing during Phase 2, which ignores shutdown;
- `BuildWitnessCache: rebuilt N note witness cache(s) to height H in Tms using 8 thread(s)` at the end.
- **Warmup.** `-28` errors carrying the current init message, using the daemon's warmup allow-list.
Optionally an `Activating best chain...` replay, which logs `UpdateTip` lines and stops between steps on shutdown.
- **Shutdown.** Triggered by `stop`, or by SIGTERM/SIGINT on POSIX (Windows has no signal channel,
as in v1.3.0). In order, the stub:
1. unbinds the listener at once;
2. answers new requests on open connections with `503`;
3. waits for the rebuild to finish;
4. logs `Shutdown: In progress...` and renames its main thread to `hush-shutoff` (Linux `comm`);
5. answers never-run queued requests with `500 Unhandled request`;
6. flushes for `FAKE_FLUSH_SECS`, logs `Shutdown: done`, and exits 0.
- **Logging.** With `-printtoconsole`, untimestamped lines go to stdout (this is what the GUI uses on
Linux and macOS). Otherwise, timestamped UTC lines go to `<datadir>/debug.log` (the Windows path the GUI tails).
- **Datadir.** It takes `.lock`, writes `hushd.pid` (POSIX) and `.cookie` (when there is no
`rpcpassword`), and uses the same default datadir as dragonxd (`~/.hush/DRAGONX`, `%APPDATA%\Hush\DRAGONX`).
Its replies are canned: `getinfo` reports version `1030050`, the balance is fixed, and there are two
peers on TEST-NET addresses. Unknown methods get the real `-32601 Method not found` (HTTP 404).
## Events file
Everything that matters for a test is appended to `<datadir>/fake_dragonxd.events`, one
`<name> t=<unix ms> [k=v ...]` per line:
| Event | Meaning |
|---|---|
| `start`, `listening`, `warmup-end` | startup; `start` lists every knob |
| `rebuild-start`, `rebuild-end`, `rebuild-aborted` | the cs_main hold |
| `queue-full` | the first `Work queue depth exceeded` |
| `max-abandoned n=` | a new high for client-abandoned requests still holding a slot |
| `stop-received n=` | a `stop` RPC ran (every one is logged, so resends are visible) |
| `stop-rejected status=` | a `stop` never reached a worker (500 queue-full, or 503 after interrupt) |
| `sigterm` | SIGTERM/SIGINT received (POSIX) |
| `http-interrupted`, `shutdown-begin` | shutdown progress |
| `stats ...` | counters, every 30 s when they change |
| `methods ...` | the 10 most-requested JSON-RPC methods with counts (with `stats`, and at exit): what the GUI is polling |
| `clean-exit ...` | **graceful exit**, with final counters. If this is missing, the process was killed. |
## Build
```bash
cmake -S . -B build/linux -DDRAGONX_BUILD_FAKE_DAEMON=ON
cmake --build build/linux --target fake_dragonxd # -> build/linux/fake_dragonxd/dragonxd
# Windows (mingw cross, from Linux/WSL): builds it with the Windows test suite in build/windows-tests
scripts/test-windows.sh --no-run # -> build/windows-tests/fake_dragonxd/dragonxd.exe
```
The output goes to its own `fake_dragonxd/` directory so it never replaces the real `bin/dragonxd`.
## Run the GUI against it (Linux)
```bash
FAKE_REBUILD_SECS=300 tools/fake_dragonxd/run-gui-with-fake.sh [--build-dir build/linux] [--scratch DIR]
```
The script builds a throwaway `HOME` with:
- copies of the GUI and the stub (as `dragonxd`, next to the GUI, where `findDaemonBinary()` looks first);
- a `DRAGONX.conf` and an empty `blocks/`, so the first-run wizard is skipped;
- `settings.json` with `verbose_logging`.
It then starts the GUI and prints the events file when the GUI exits. It refuses to run if
something already listens on 21769, because the GUI would adopt that process as an external node.
It also refuses a Lite-configured `--build-dir`: Lite has no embedded daemon, and any
`bin/ObsidianDragon` in such a tree is stale.
To "close the window" from a script, send SIGTERM to the GUI (`kill -TERM <gui pid>`); SDL turns it
into a normal quit. For scripted runs nobody needs to watch, add `SDL_VIDEODRIVER=offscreen`: the GUI
runs with no window at all (OpenGL still loads, and connecting and shutting down work as usual).
To run the stub alone: `FAKE_... build/linux/fake_dragonxd/dragonxd -datadir=/some/dir [-printtoconsole]`
with a `DRAGONX.conf` (`rpcuser`/`rpcpassword`/`rpcport`) in that dir.
## Windows
Use a **separate Windows user or a VM**:
- The datadir is `%APPDATA%\Hush\DRAGONX` and can't be redirected for the GUI.
- Until S3 lands, the GUI `TerminateProcess`es **any** `dragonxd.exe` of the same user, including a real node.
Put `dragonxd.exe` (the stub) next to `ObsidianDragon.exe`. Set the `FAKE_*` variables in that
user's environment, or start the stub by hand from a console for case E. Each new GUI-launched stub
inherits the GUI's environment.
## Knobs (environment)
| Variable | Default | Meaning |
|---|---|---|
| `FAKE_WARMUP_SECS` | 3 | `-28 Loading block index...` phase |
| `FAKE_REPLAY_SECS` / `FAKE_REPLAY_BPS` | 0 / 12 | startup replay (`Activating best chain...`, UpdateTip lines) |
| `FAKE_REBUILD_DELAY_SECS` | 20 | wait after `Done Loading` before the rebuild, so the GUI connects first |
| `FAKE_REBUILD_SECS` | 300 | how long the rebuild holds cs_main; `0` = healthy node, no rebuild |
| `FAKE_PHASE1_SECS` | 2 | the logged, abortable part of the rebuild |
| `FAKE_POST_REBUILD_LOCK_SECS` | 0 | cs_main held after the end line (models the 18–36 s flush + wallet `SetBestChain`) |
| `FAKE_FLUSH_SECS` | 3 | `Shutdown: In progress...` → `Shutdown: done` |
| `FAKE_UPDATETIP_SECS` | 36 | block interval once loaded |
| `FAKE_NOISE_SECS` | 20 | an unrelated log line this often (defeats "newest line" heuristics) |
| `FAKE_SERVER_TIMEOUT_SECS` | 30 | idle keep-alive connections are closed (`-rpcservertimeout`) |
| `FAKE_PREFILL` | 0 | inject N abandoned requests when the rebuild starts (`24` = saturated) |
| `FAKE_MINING` | 0 | internal miner on: `stop` waits for cs_main first (`GenerateBitcoins` join) |
| `FAKE_REBUILD_SILENT` | 0 | hold cs_main like a rebuild but log no rebuild markers (a busy node whose output says nothing) |
| `FAKE_STUCK_SHUTDOWN` | 0 | the Sep 29 orphan: hold the datadir lock, no listener, ignore SIGTERM, never exit |
| `FAKE_WALLET_NOTES` | (off) | a note wallet: `addr:count:amount,...` shielded notes; enables `z_mergetoaddress` + its operations (dragonxd rules: confirmed unlocked notes, a batch locked on return, merged note next block) |
| `FAKE_MERGE_SECS` | 6 | how long a `z_mergetoaddress` operation runs before it succeeds |
| `FAKE_HEIGHT`, `FAKE_REBUILD_TOTAL`, `FAKE_NOTES`, `FAKE_VERSION` | 3327145, 62583, 13632, 1030050 | cosmetic numbers |
## Test cases
"Today" is the behaviour of v2.0.1 / `master` @ `46189da`. "Fixed" is the target behaviour of the
daemon-stop hardening work.
| Case | Setup | Today (bug) | Fixed |
|---|---|---|---|
| A | defaults; leave the GUI open ~5 min into the rebuild | ~24 abandoned, then queue-full 500s; overlay "Launching dragonxd…" | busy overlay; `max-abandoned` ≤ 7 |
| B | close the GUI during the rebuild | Linux: `sigterm` at +20 s, killed at +30 s, no `clean-exit`. Windows: killed at +20 s, no `stop-received` | modal; `stop-received`/`sigterm` within 2 s; waits; `clean-exit` |
| C | close so the rebuild ends ~8 s later | killed | `stop` retried, `clean-exit` |
| D | `FAKE_PREFILL=24`, close | `stop` never delivered | POSIX via SIGTERM; Windows `stop` retried until it lands |
| E | Windows: start the stub by hand with `FAKE_STUCK_SHUTDOWN=1`, launch the GUI, close it | "Found orphaned dragonxd.exe … forcing termination" | no kill |
| F | Detach on the shutdown screen | n/a | GUI exits at once; stub runs on to `clean-exit` |
| G | Overview page during the 500 phase; count `[connect #N]` per minute in the GUI log | ~1,800–3,200/min | ≤ ~2–4/min |
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Run ObsidianDragon against tools/fake_dragonxd in a throwaway, fully isolated HOME (Linux).
#
# tools/fake_dragonxd/run-gui-with-fake.sh [--build-dir DIR] [--scratch DIR] [-- GUI args...]
#
# FAKE_* variables in the environment pass through to the stub (see README.md). It never touches
# the real ~/.hush or ~/.config: HOME, XDG_CONFIG_HOME and TMPDIR all point into the scratch dir.
# Re-using a --scratch dir keeps its settings, conf and GUI log; the stub's events file is appended.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
build_dir="$repo/build/linux"
scratch=""
port=21769 # the GUI's external-node / port checks are hard-wired to the default RPC port
usage() { sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//'; }
while [[ $# -gt 0 ]]; do
case "$1" in
--build-dir) build_dir="$(cd "$2" && pwd)"; shift 2 ;;
--scratch) scratch="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
gui="$build_dir/bin/ObsidianDragon"
stub="$build_dir/fake_dragonxd/dragonxd"
# A Lite tree has no embedded daemon and builds ObsidianDragonLite; any bin/ObsidianDragon in it is stale.
if grep -q '^DRAGONX_BUILD_LITE:BOOL=ON' "$build_dir/CMakeCache.txt" 2>/dev/null; then
echo "$build_dir is configured as the Lite variant; pass --build-dir of a full-node tree" >&2
exit 1
fi
if [[ ! -x "$gui" ]]; then
echo "missing $gui; build the app first (./build.sh)" >&2
exit 1
fi
if [[ ! -x "$stub" ]]; then
echo "missing $stub; build it with:" >&2
echo " cmake -S . -B ${build_dir#"$repo"/} -DDRAGONX_BUILD_FAKE_DAEMON=ON && cmake --build ${build_dir#"$repo"/} --target fake_dragonxd" >&2
exit 1
fi
if ss -ltn 2>/dev/null | grep -qE "[:.]$port( |$)"; then
echo "port $port is already in use (a real dragonxd?). The GUI would adopt it as an external node; stop it first." >&2
exit 1
fi
scratch="${scratch:-$(mktemp -d "${TMPDIR:-/tmp}/fake-dragonxd.XXXXXX")}"
mkdir -p "$scratch"
scratch="$(cd "$scratch" && pwd)"
app="$scratch/app"
home="$scratch/home"
datadir="$home/.hush/DRAGONX"
config_dir="$home/.config/ObsidianDragon"
mkdir -p "$app" "$datadir/blocks" "$config_dir" "$scratch/tmp"
# Copies, not symlinks: the GUI looks for dragonxd next to /proc/self/exe, which resolves links.
cp -f "$gui" "$app/ObsidianDragon"
cp -f "$stub" "$app/dragonxd"
ln -sfn "$build_dir/bin/res" "$app/res"
for f in sapling-spend.params sapling-output.params asmap.dat; do
if [[ -e "$build_dir/bin/$f" ]]; then ln -sf "$build_dir/bin/$f" "$app/$f"; fi
done
# The GUI also keeps a dragonxd copy in its daemon dir; keep that one the stub too.
if [[ -e "$config_dir/dragonx/dragonxd" ]]; then cp -f "$stub" "$config_dir/dragonx/dragonxd"; fi
# A pre-made conf + blocks/ dir skips the first-run wizard (App::isFirstRun).
if [[ ! -e "$datadir/DRAGONX.conf" ]]; then
printf 'rpcuser=fake\nrpcpassword=fake-%s\nrpcport=%s\n' "$RANDOM$RANDOM" "$port" > "$datadir/DRAGONX.conf"
fi
if [[ ! -e "$config_dir/settings.json" ]]; then
printf '{\n "wizard_completed": true,\n "verbose_logging": true\n}\n' > "$config_dir/settings.json"
fi
cat <<EOF
scratch : $scratch
stub events : $datadir/fake_dragonxd.events
GUI log : $config_dir/dragonx-debug.log
FAKE_* : $(env | grep -E '^FAKE_' | tr '\n' ' ')
EOF
rc=0
HOME="$home" XDG_CONFIG_HOME="$home/.config" TMPDIR="$scratch/tmp" "$app/ObsidianDragon" "$@" || rc=$?
echo "--- GUI exited ($rc). Stub events:"
cat "$datadir/fake_dragonxd.events" 2>/dev/null || echo "(none)"
if pgrep -f "^$app/dragonxd" >/dev/null; then
echo "--- the stub is still running (pid $(pgrep -f "^$app/dragonxd" | tr '\n' ' ')); it exits after its rebuild + flush."
fi
exit "$rc"