Back-fills the 29 new chat_sensitive_* keys additively into de/es/fr/pt/ru/zh/
ja/ko (2474 -> 2503 each), matching each file's established terminology and —
critically — keeping the sensitive "passphrase" worded distinctly from the
"seed phrase" (e.g. de Passphrase vs Seed-Phrase, es contraseña vs frase de
recuperación, fr phrase secrète vs phrase de récupération), translated +
reviewed per language. Rebuilds NotoSansCJK-Subset.ttf (2107 -> 2111 glyphs)
for 4 new CJK glyphs (ja 論; ko 눌/똑/론) the new strings introduced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires sensitive mode into the chat tab:
- A per-conversation lock toggle in the header (next to delete) opens a dialog
that, by vault state, sets up / unlocks the vault and marks or unmarks the
conversation. The mark dialog shows the honest warning (lose the passphrase =
gone forever; sent fully protected vs received still chain-recoverable; prior
history stays recoverable).
- A "Lock sensitive" / "Unlock sensitive (N)" opener beside Blocked/Restore
locks or unlocks the vault; unlocking surfaces the withheld sensitive threads.
- Passphrase fields use the password flag and are memset on close; marking
requires the vault unlocked (the App layer migrates rows + broadcasts the
on-chain signal).
~29 new English strings (fall back to English until translated). Phase B
complete (policy level); the cryptographic re-key level remains out of scope.
Needs a live smoke test (built unverified, like the delete UI).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the App-level glue that makes sensitive mode operable:
- Owns ChatSensitiveVault (path <configDir>/chat_sensitive.vault); provisioning
wires the service's local sensitive predicate to Settings::isChatSensitive and
points the vault at its path (left LOCKED until the user enters its passphrase).
- chatSensitiveVaultState() (NotConfigured/Locked/Unlocked) + create / unlock /
lock: unlock pushes the key to chat_db_ and pulls sensitive rows into the store
(+ re-harvest); lock drops the key and reloads so sensitive threads vanish.
- markChatConversationSensitive / unmark: migrate the thread's existing rows
to/from the vault key via upsert (so the whole thread hides when locked) and
broadcast the on-chain "sensitive"/"unsensitive" signal via sendChatControl.
(wallet lock already wipes the vault key via chat_db_.lock().)
Phase B, step 5a (App lifecycle). UI next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires the sensitive-conversation plumbing into ChatService + Settings:
- Settings gains a persisted local sensitive set (isChatSensitive /
setChatSensitive / sensitiveChatConversations), mirroring the blocked set, for
INSTANT effect the moment the user marks a conversation (before the on-chain
signal is harvested back).
- ChatService::isConversationSensitive() OR-combines that local mark with the
on-chain reduction, and setPersistence() wires it as the DB's per-row key
selector (sensitive rows -> vault key).
- ingest() withholds a sensitive conversation's messages while the vault key is
absent (fail-closed: not shown, not persisted); they re-import + persist under
the vault key once it is unlocked and a scan runs. loadFromDatabase() already
withholds via the DB's two-key try.
Service-tested end to end against a real ChatDatabase (ingest + reload).
Phase B, step 4 (service gating). App vault lifecycle + UI next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ChatDatabase gains a second AEAD key — the local vault key (setSensitiveKey /
lockSensitive) — and a sensitivity predicate. Rows of conversations the
predicate marks sensitive are encrypted under the vault key instead of the
seed-derived key, and are persisted only while the vault is unlocked (never
stored under the seed key). load() tries the seed key then the vault key, so a
sensitive row is WITHHELD whenever the vault key is absent — including on a
fresh instance that holds only the seed (the "fresh seed import can't read
sensitive conversations" guarantee); a wrong vault key withholds too, never
yields garbage. encrypt/decrypt are now key-parametrized; lock() also wipes the
vault key. Unit-tested end to end.
Phase B, step 3 (DB dual-key).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds ChatSensitiveVault: a per-wallet random 32-byte key wrapped under a user
passphrase (Argon2id MODERATE KEK + XSalsa20-Poly1305) in an 89-byte local file.
This key encrypts the at-rest rows of "sensitive" conversations INDEPENDENTLY of
the seed-derived chat key, so those rows can't be read by someone who has the
seed but not this passphrase — and a wallet restored from the seed alone can't
read them at all. Losing the passphrase/file makes them unrecoverable by design.
create/unlock/changePassphrase/remove, atomic write + 0600, keyOut zeroed on any
failure. Unit-tested (round-trip, wrong-passphrase zeroing, rekey, no-clobber).
Phase B, step 2 (local vault key).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Routes control ops to one of two independent per-conversation reductions: the
hide axis (del/res) and a new sensitive axis (sensitive/unsensitive), so a
conversation can be deleted AND sensitive without the two states clobbering
each other. Unknown future ops are ignored (not stored in either axis).
Adds conversationSensitiveOnChain() + onChainSensitiveConversations(); the
reduction stays monotonic by (compose-time, txid). Reducer-tested, including
axis independence and that marking sensitive neither hides nor erases a thread.
Phase B, step 1 (reducer) of per-conversation local-key encryption (policy level).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Back-fills the 14 new chat delete/restore keys additively into de/es/fr/pt/ru/
zh/ja/ko (2460 -> 2474 each), matching each file's established terminology
(conversation, Delete, Restore, sync, seed phrase, shielded/z address, scan,
device, chain, fee) — translated + reviewed per language. Rebuilds
NotoSansCJK-Subset.ttf (2104 -> 2107 glyphs) to cover 3 new CJK glyphs (微, 曾,
伝) the new zh/ja strings introduced, so they don't render as tofu.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Restore" opener (shown when a delete is known from chain — so it works
on a wallet freshly restored from seed — or something is locally blocked) and a
Restore dialog that: lists chain-derived deleted conversations
(onChainDeletedConversations) each with a Restore button; accepts a pasted chat
(z) address, matched against the address each delete signal carries (else
treated as a raw cid); and offers Quick scan (noteWalletMutation → re-run the
change-gated scans → re-harvest control memos). Restoring clears any local
block/hide and broadcasts Control("res") so every device un-hides; the messages
re-import from chain on the next scan.
New English strings fall back cleanly until translated (chat_restore_*).
Phase A complete: protocol -> compose -> reducer -> broadcast -> delete+restore UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a distinct, sync-gated "Delete everywhere" button to the conversation
delete dialog: it blocks the thread locally (instant + persistent, so it can't
flicker back mid-sync) AND broadcasts a self-addressed Control(del) via
sendChatControl so every other device — and a future restore from the seed —
hides it too. Carries the peer z-address as the control label so Restore can
match by address. The button is disabled until the wallet is fully synced
(lite uses its own sync-complete signal, since it never sets tip_known), with a
note explaining the on-chain signal + fee. The two local-only modes (Delete /
Delete & block) are unchanged.
New English strings fall back cleanly until translated (chat_delete_everywhere*,
chat_delete_needs_sync). Phase A, step 5 (delete UI). Restore dialog next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds App::sendChatControl(cid, op, label): composes a self-addressed Control
memo and hands it to the existing chat send coordinator (enqueueChatSend →
pump → the 0-value kChatMinFeeDrgx z_sendmany / lite send) exactly as a message
does — no new fund-path semantics. It carries no UI echo bubble (empty
echoLocalId; resolveOutgoing("") is a no-op), and the memo harvested back to us
is routed to the reducer, never shown as a message.
Phase A, step 4 (broadcast) of on-chain chat delete/restore. UI wiring next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ingest() now routes Control memos (self-addressed, so processed ahead of the
"skip our own memos" guard) into a per-cid reducer that keeps the latest op by
(compose-time, txid) — monotonic, so a replayed older op never flips state. A
conversation whose latest op is "del" is hidden: its messages are dropped from
ingest and erased from the store view (even rows loaded from the DB before the
signal was reduced), on every device and on a fresh seed import, until a later
"res" restores it. The DB rows are left intact so restore (or a rescan) brings
the thread back. State is re-derived from chain each session; no local
persistence. Adds conversationHiddenOnChain() + onChainDeletedConversations()
(the restore picker's source), fully reducer-tested.
Phase A, step 3 (reducer) of on-chain chat delete/restore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds buildOutgoingControl: builds the two 0-value memo outputs for a control
signal (a "Ctrl" header carrying `op` + a plaintext label payload) addressed to
the sender's OWN z-address, so only this wallet's seed harvests it and it never
reaches the peer. buildHeaderMemo gains an optional `op` (emitted only for
Control; existing Memo/Cont headers stay byte-identical). Round-trip + guard
tested through the receive parser.
Phase A, step 2 (compose) of on-chain chat delete/restore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces HushChatHeaderType::Control — a self-addressed control signal
(delete / restore / mark-sensitive) that rides a chat tx as a header memo
carrying an `op`, paired with a plaintext label payload so it groups like a
normal header/payload pair. The op VALUE is not restricted at parse time so a
future op reaches newer clients and is ignored by older ones; unknown header
types still fail-closed (grouped as an invalid header, never surfaced, never a
crash), preserving forward-compat.
Foundation for the on-chain chat delete/restore feature (Phase A).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3: for a wallet.dat the current daemon can't open, recover its keys from the OLD wallet's export file.
Settings → "Import From Backup File…" opens a dialog that takes the path to a z_exportwallet / dumpwallet
dump (or SilentDragon's "Export all private keys") and calls z_importwallet — the daemon reads every key
and does ONE rescan, far faster and simpler than importing keys one at a time. On success it nudges the user
to shield any mined coinbase from the Shield screen.
- New App::importWalletFile(path, cb): runs z_importwallet on the long-call lane (no client timeout — the
rescan can take a long time), then refreshAddresses + markWalletMutated. Full-node only.
- Only the PATH touches GUI memory, never the keys themselves (the daemon reads the file) — less secret
exposure than a pasted-key field.
- New renderImportWalletFileDialog (typed path + Paste/Clear, progress, result) + a Settings card.
Reuses the existing long-call + z_importwallet infra; the careful single-key import dialog is untouched.
No send/sweep/encrypt call changed. Build + ctest green; the import/rescan itself is a daemon RPC, not
pure-testable, and wasn't exercised end-to-end here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of legacy-wallet migration. The sweep (z_shieldcoinbase / z_mergetoaddress) needs an unlocked
wallet, so an encrypted wallet left the "Sweep all funds" button silently disabled with no way forward.
The Sweep step now, when the wallet is locked, shows a passphrase field + Unlock button that calls
unlockWallet(pass, 600); once it unlocks, the (already-present) canSweep gate re-enables the sweep. The
passphrase buffer is sodium_memzero'd immediately after submit.
No fund-path change: this only unlocks the wallet (the daemon's own walletpassphrase), and the sweep/adopt
logic is untouched. Batched-coinbase progress is already handled — the Confirming step shows the live
remaining balance and the existing "Sweep remaining" flow ("too many inputs for one transaction") drains it
across z_shieldcoinbase's 50-UTXO batches.
Runtime/UI change (build + ctest green; not pure-testable). Still to verify on a real degraded+encrypted
wallet end-to-end: unlock -> coinbase shield -> confirm -> adopt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of automating legacy wallet.dat migration: a persistent, actionable banner that detects a wallet
whose transparent funds are STUCK and offers the migrate-to-seed recovery flow (showSeedMigrationDialog).
Corrected trigger (the important part): it fires ONLY when wallet_degraded_ is set — i.e. z_getnewaddress
failed with "HD seed not found", so the wallet genuinely cannot create a shielded address to move its
funds — AND there is a transparent balance to move. It is NOT driven by "the wallet has no mnemonic": a
non-mnemonic but functional wallet (one that self-healed with a retrofit HD seed on load) has
wallet_degraded_ == false, can still shield/spend, and is deliberately never flagged — matching the
existing "legacy wallets are not nagged" policy. Full-node only.
Implemented in the already-unit-tested node banner: new NodeBannerReason::LegacyFundsStuck +
NodeBannerAction::RecoverLegacyFunds, gated on a new legacy_funds_stuck input (App computes it from
wallet_degraded_ + transparentBalance). NoPeers still takes priority (can't recover before the node sees
the chain); lite never shows it. Pure decision tests added: fires when stuck, never for a functional
wallet, yields to NoPeers, suppressed on lite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From a legacy-wallet migration support case (mined T-funds, 3y-old Silent Dragon wallet.dat): the balance
shows but new-address and migrate both fail with unhelpful/silent results. Two fixes:
1) Real address-creation error. createNewAddress() swallowed the daemon's z_getnewaddress error and the UI
showed a generic "Failed to create shielded address". It now keeps the daemon's reason (friendly-mapped
via friendlyRpcError) in last_address_error_, which the Receive tab and request-payment dialog show
instead of the generic string — so a legacy/degraded wallet sees the actual "HD seed not found". And when
that specific error appears it flips wallet_degraded_ + warns once, even for an EXTERNAL daemon
(detectWalletDegraded() only scans the embedded daemon's log and would otherwise miss it).
2) Migrate-to-seed sweep skipped coinbase. beginSweepToSeedWallet()'s z_mergetoaddress ignores coinbase
UTXOs, so a miner's coinbase-only balance produced "Could not find any funds to merge", the legacy
balance never reached ~0, and the adopt gate stalled silently. The sweep now falls back to
z_shieldcoinbase("*", dest, fee, 50) to the SAME shielded destination when the merge reports no funds.
z_shieldcoinbase returns an opid just like the merge, so the single-opid adopt gate (opid -> txid ->
confirm -> balance ~0 -> adopt) is unchanged, and a batch limit / mixed wallet drains via the existing
"Sweep remaining" re-sweep.
Fund-path: the z_mergetoaddress call and its args are byte-identical; the fallback only ADDS a
z_shieldcoinbase to the same dest when merge finds nothing, and nothing is adopted until the swept funds
confirm and the legacy balance is ~0 (gate unchanged). Runtime-verified behavior (clean build + ctest; the
RPC paths are not pure-testable).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Repairs dev: 3ee9099 switched the Explorer call sites (and a test) to ExplorerBlockCache::loadRangeCached()
/ revision(), but those definitions were sitting uncommitted in the working tree (left over from the
explorer block-search feature, ebbeb84) and were not staged with that commit — so dev as pushed referenced
undefined members and would not compile on a clean checkout.
The infra: loadRangeCached() memoizes loadRange() per (min,max), invalidated by revision_, which is bumped
on every mutation that can change what loadRange returns (open/close/store/clear/prune/spill-reset); the
memo is capped at 16 ranges. My local builds passed only because the working tree already had these.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backlog mediums (display/gating/caching only — no send/sweep/encrypt call or args changed):
Resilience:
- Gate Send/Review on a KNOWN peer-derived tip (SyncInfo::sendTipReady): a fresh reconnect or peerless node
has tip_known=false, so its local height isn't a trustworthy network tip — sending is disabled with a
clear tooltip until it is. Lite is exempt (its own sync gate). The z_sendmany call is untouched.
- History now shows "Dropped" for a conflicted tx (confirmations < 0) and "Expired?" for one unconfirmed
> 1h, instead of "Pending" forever (new TransactionInfo::isConflicted/isStalePending; the tab inlines the
check on its DisplayTx fields).
- A cached z_viewtransaction row's confirmations now advance by the blocks mined since it was sampled
(observed_height), instead of freezing at the cached value; never goes backwards; pending (0) isn't
extrapolated.
Performance:
- Explorer block list uses loadRangeCached() (memoized on the cache revision) instead of loadRange(), so it
only hits SQLite after the cache changes, not every frame.
Tests: testResilienceMediums (tip gate, confirmations-advance, dropped/stale predicates) + loadRangeCached
memoization in testExplorerBlockCache (isolated on its own cache). Held: Overview/Balance per-frame list
cache, peers clippers, off-thread tx-history cache load (larger refactors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to 3a33e9c: testLiteRefreshModelAppliesToWalletState built its balance bundle without
spendableShieldedBalance (left 0), so the new "pending = total − spendable" unconfirmedBalance formula
reported the whole 2.0 shielded balance as pending (actual 2.0 vs expected 0.5). In real data
spendable_zbalance is a required parser field and a synced wallet reports all of it spendable; set the
fixture to match (= shieldedBalance), so unconfirmedBalance is the 0.5 mempool-incoming amount as intended.
No production code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lite-parity audit mediums (display / spendable-gating only — never over-reports spendable; no send call):
- unconfirmedBalance was just the backend's mempool-incoming figure, but the shared UI (balance_tab "pending"
chip, the fast-poll hint) reads it as the full-node "total minus spendable" portion. Set it to the larger
of the two (the not-yet-spendable shielded part vs the mempool-incoming), so neither is hidden and a note
counted in both isn't double-reported.
- transparentBalance/spendableTransparentBalance included t-UTXOs already spent by a pending tx (the backend
tbalance sums every utxo with spent==None). Subtract unconfirmed-spent t-UTXOs from the SPENDABLE figure
only (clamped; gross transparent display unchanged) so the Send picker can't offer funds a pending tx
already consumed. Falls back to no-subtraction when the notes call failed that cycle (as before).
Test: testLiteUnconfirmedAndTransparentSpendable. Multi-recipient history rows held (TransactionInfo is
single-output, same as the full node).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fund-correctness audit, Tier-B (parse/validation/display only — no send/sweep/encrypt call touched; each
change can only reject bad input or remove drift, never over-report):
- lite_result_parsers: cap every backend-reported amount at the 21,000,000 DRGX supply (note value,
outgoing output value + running sum, received amount, the net-amount fallback magnitude), and replace the
two bare `return false` paths in the outgoing-metadata loop — which left ok=false with an EMPTY error and
silently rejected the whole tx list — with fail() carrying a reason.
- network_refresh_service: accumulate per-address balances in integer satoshis (util::toSats) instead of
summing doubles, so many notes can't drift by an ulp per addition (ten 0.1 notes now sum to exactly 1.0);
skip non-finite amounts.
- readBalanceString: strict decimal parse (digits / one '.' / optional leading '-') instead of std::stod,
which silently accepted "1.5abc", whitespace, hex, exponents and nan/inf.
Tests: testLiteParserAmountRangeChecks + integer-satoshi drift + strict-parse cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Concurrency audit (finding #2, previously held for sign-off): abortMaintenanceCalls() — the choke point run
before every UI-thread rpc_->disconnect() (wallet switch/adopt/restore/rebuild, handleLostConnection,
shutdown) — only called requestAbort() when maint_calls_>0. An ordinary in-flight refresh/scan (not counted
as a maintenance call) holds curl_mutex_ for its whole curl_easy_perform, so disconnect() blocked the UI
for up to the call timeout (≈30s, or 120s for a wallet scan).
Abort unconditionally so disconnect() never waits on the curl lock. The abort flag is cleared by the next
tryConnect()/onConnected() before reconnecting. A user send that happens to be in flight during a teardown
is now aborted too, but that is handled as a lost reply — "outcome unknown, check History", never a
double-send (see the lost-reply classifier, 08a4427) — so no funds are at risk. The z_sendmany/broadcast
call and its args are unchanged.
Threading fix — clean build + ctest; the UI-block is runtime-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Concurrency audit, connect-lifecycle races (no fund-path call touched — connect-result gating + abort-flag
lifetime only):
- The connect task's MainCb ran onConnected() on any successful probe without re-checking that a wallet
switch / adopt / restore / shutdown had already torn the connection down — so a probe that succeeded
against the old daemon could start a refresh against one about to be stopped. Capture rpc_session_ when
posting (main thread) and, in the MainCb, drop the result (requestAbort + disconnect, not a blocking bare
disconnect) when the session changed or daemon_restarting_/shutting_down_ is set. Same guard on the
cookie-auth retry post.
- RPCClient::open() called clearAbort(), so a requestAbort() issued while a connect was still queued was
erased, stalling teardown/shutdown. open() no longer clears it; the two reconnect entry points
(tryConnect for rpc_, onConnected for fast_rpc_) clear it on the main thread first, skipped while
shutting_down_ so a shutdown abort survives. A fresh client defaults to not-aborted, so first connects
are unaffected.
HELD (not in this commit): making abortMaintenanceCalls() abort unconditionally (the UI-freeze-on-disconnect
finding) — it would also abort an in-flight user send during teardown, a send-lifecycle behaviour change
that wants explicit sign-off (it is fund-safe via the lost-reply "outcome unknown" handling, but still a
behaviour change).
Threading fix — clean build + ctest; the races are runtime-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Concurrency audit: I18n::translate() returns a const char* pointing INTO strings_, and loadLanguage()
clears/rebuilds strings_ on the main thread at a language switch. The EmbeddedDaemon state/monitor callback
builds its status strings via TR() off the main thread, so a concurrent language switch could free the
map node out from under that pointer — a use-after-free data race.
- Add a shared_mutex guarding strings_: loadLanguage() takes it exclusively for the whole clear+rebuild
(its helpers loadBuiltinEnglish/overlayTranslations are called only from there and don't lock, so no
recursion); a new translateCopy()/trCopy() takes a shared lock and returns an owned std::string.
- The lock-free translate()/TR() stays for main-thread UI (same thread as the writer, so never races).
- The four daemon-state setDaemonStatus(TR(...)) calls in the monitor callback now use util::trCopy(...).
Synchronization only; no fund-path call touched. Threading fix — clean build + ctest; the race is runtime-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Concurrency audit: SecureVault::scope_ (std::string) is written by setWalletScope() on the main thread
(wallet switch) while getVaultPath() — reached from retrieve()/hasVault()/changePin()/store() — runs on
RPC worker threads during a send-time or startup unlock. A concurrent wallet switch and a worker vault op
is a data race on the string (and could resolve the wrong vault file).
Guard scope_ with a small mutex: setWalletScope() assigns under it; getVaultPath() copies it out under the
lock and uses the copy. The lock is held only for the string copy — never across Argon2id or file I/O —
and getVaultPath() never calls setWalletScope(), so no deadlock. Argon2id params, key derivation, AEAD,
the vault file format and retrieve/store/changePin are byte-identical. SecureVault is held via unique_ptr
and used by pointer, so the added (non-movable) mutex is fine.
Synchronization only. Threading fix — clean build + ctest; the race is runtime-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Concurrency audit: last_error_ was written unlocked at the five startProcess() error sites (while the
monitor thread could be in setState() writing it under error_mutex_) and read unlocked in start() — a
data race on a std::string. process_pid_ / stdout_fd_ (POSIX) and process_handle_ (Windows) are plain
scalars written by startProcess, the stop paths and the monitor (reap) and read by accessors.
- Route every startProcess() last_error_ write through a new locked setLastError(); read the start()
detail via the already-locked getLastError().
- Make process_pid_ / stdout_fd_ / process_handle_ std::atomic (trivially atomic scalars/handles), so
individual loads/stores no longer race. Process start/stop ordering and the shutdown hardening are
unchanged — this only removes the data races, not the (already-serialized) sequencing.
Synchronization only; no fund-path call touched. Threading fix — verified by a clean build + ctest; the
race itself is runtime-only. (The Windows process_handle_ atomic is reasoning/compile-only on this Linux box.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resilience audit, vendored SDXL core (post-hoc wallet bookkeeping only — the send/build/broadcast path,
note/UTXO selection, fees, recipients, memo and the post-broadcast pending-marking are byte-identical):
- cleanup_mempool never cleared unconfirmed_spent, so a send that was dropped / never mined / reorged out
locked its notes and UTXOs until the app was reloaded ("insufficient funds" on a funded wallet). It now
releases the lock once the send is provably dead. A send is built with Builder::new(tip) (expiry = tip+20,
mineable through tip+20), so the lock is held until the chain reaches tip+21 (MEMPOOL_KEEP = DELTA+1) —
one block past the old mempool-drop point, which would have released the notes while the tx could still be
mined. A txid already in `txs` was mined (scan settled its inputs) and is skipped. mempool_txs is released
before `txs` is taken, so the two locks are never nested.
- invalidate_block reset nd.spent on reorged-out spenders but never walked wtx.utxos, so a transparent UTXO
whose spender was reorged out kept spent=Some(removed txid) and dropped out of the balance until a rescan.
It now resets utxo.spent the same way (unconfirmed_spent left as-is, as for notes — the send may be re-mined
on the new branch).
- Removed the stale "TODO remove unconfirmed_spent on expiry" in data.rs (now done).
Verified with the scratch-mirror rtest.sh (cargo 1.63, scratch HOME): cargo test --release --lib green,
129 passed / 0 failed / 12 ignored, including the oracle differential tests (updated in lockstep to clear
the sim's pending flag at the same expiry point) and test_z_mempool_expiry. The C-ABI wrapper and the
artifact rebuild / lite_smoke (needs a live server) were not run; they don't touch these functions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lite-parity audit, FIX C (gating/validation/presentation only — no encrypt/unlock/send backend call changed):
- Lite unlock went straight to the backend with no lockout. New App::unlockLiteWallet() runs the SAME
escalating, persisted attempt-lockout as the full node (restoreLockoutState/applyUnlockFailure), wired into
the Settings unlock, the send-time prompt and the lock screen. A wrong-passphrase failure counts; an
already-unlocked/not-encrypted result does not.
- state_.locked is now cleared immediately on a successful unlock (the ~2s status poll lagged, so the UI
stayed "locked" after a correct passphrase).
- PIN quick-unlock controls are hidden in lite Settings — lite has no PIN vault, so they were dead.
- A create/restore whose passphrase encryption FAILS now raises a one-shot visible error
(takeEncryptionFailureNotice -> lite_encrypt_failed_unencrypted) instead of only a log line; the wallet is
left open but unencrypted. Fixed the flag on BOTH the lifecycle and the direct create/restore encrypt paths.
- A background sync save is skipped while an encrypted wallet is unlocked (the backend save re-locks it),
resuming once locked.
Compile- and unit-verified (testLiteWalletControllerLockUnlockParity covers the save-deferral + encrypt-failure
notice, run twice for the flaky guard); the lock-screen lockout/PIN-hiding UX itself is runtime/GUI-only and
not exercised here — worth a manual lite GUI pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resilience audit, migrate-to-seed (the sweep RPC is byte-identical — see below):
- Adopt was enabled at >= 1 confirmation and never re-read state before stopping the daemon, so a 1-block
reorg after the swap could un-mine the sweep and strand the funds in the .bak. Adopt now needs a
reorg-safe depth (>= 10, matching the History "confirmed" threshold), and the button runs
verifyThenAdoptSeedWallet(): a read-only gettransaction(confs) + z_gettotalbalance minconf 0 (includes
unconfirmed, fixing the old minconf-1 "no funds" gate) immediately before the daemon stop, refusing while
syncing or if any read fails. The remainder re-sweep still shows once mined (>=1).
- The wallet.dat swap was rename(legacy->bak) then non-atomic copy(new->wallet.dat): a hard kill left
wallet.dat missing or half-written. New order: probe the new file is a BDB wallet; stage a size-verified
copy beside wallet.dat; COPY (never overwrite) the legacy wallet to the timestamped .bak and verify it;
then ONE atomic fs::rename of the staged file over wallet.dat. At every instant wallet.dat is the whole
old or whole new wallet, and a verified .bak exists before the replace. On any failure only our scratch
files are removed; the user's wallet is untouched.
Byte-identical: z_mergetoaddress (beginSweepToSeedWallet, ["ANY_TADDR","ANY_ZADDR"], amounts, fee, recipient,
memo) and beginAdoptSeedWallet's disconnect/vault-removal/stop/rescan/restart logic are unchanged; only the
adopt gating, two new read-only RPCs, presentation, and the file-move safety change. Pure tests:
sweepConfsAreReorgSafe + adoptReverifyPasses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lite-parity audit, sync-progress semantics (state/presentation only — no send/sync backend call changed):
- A scan failure set scan->failed + lite_error, but no UI read lite_error, so the user saw "Syncing 99.9%"
forever. The status bar now shows the error text on a lite scan/server failure.
- After the initial catch-up, a server loss on the cheap probe path (ProbeFailed) left the last "Synced"
model on screen. The worker now re-publishes that model demoted (not complete, reason "server unreachable").
- refreshModelImpl's height-read "synced" override no longer fires while syncstatus says the backend is
still scanning or is indeterminate, and a server-bound read failure in a full cycle demotes to not-synced.
- The syncstatus parser no longer treats a missing/unrecognised "syncing" field as fully synced: it reports
an explicit "indeterminate" (not complete). Explicit "false" stays caught-up; the fake backend's
syncing:true 1000/1000 behaviour is unchanged.
Tests: parser cases for missing/odd/idle "syncing" (testLiteSyncStatus*).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lite-parity audit: per-address spendableBalance was set equal to the gross balance, ignoring the backend's
per-note 'spendable' flag (spending key present AND note at/below the anchor height), and has_spending_key
was effectively always true (mapAddress never set spendabilityKnown). So the Send auto-picker could select
an address that can't fund the send, and an imported view-only (zxviews) address looked spendable.
- Parser: carry the backend per-note 'spendable' flag when present (Sapling notes; transparent utxos keep
the confirmed/unspent rule). It can only narrow spendability, never widen it.
- Mapper: derive per-address spending-key evidence from positive signals only — a spendable note proves a
key (spendable), a confirmed long-mature (>=100 blocks) still-non-spendable note proves view-only
(!spendable); fresh/immature/no notes stay unknown so nothing is mislabelled.
- Controller: info.balance stays the gross sum; info.spendableBalance now counts only backend-spendable
outputs; a tolerated partial refresh keeps the last-known spendable balance and view-only verdict.
Display/selection only — gating can only get stricter for view-only/unanchored funds; no send/sweep/encrypt
call changes. Test: testLitePerAddressSpendableAndViewOnly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Concurrency audit (lite): LiteClientBridge::shutdownCalled_ was a plain bool, set by retire()/destruction
on one thread and read nowhere by the detached sync/console/broadcast threads. A retired controller's
in-flight execute() (send/save/sync) therefore still ran litelib_* against the PROCESS-GLOBAL client,
which may already belong to a NEW controller after a server switch.
- shutdownCalled_ is now std::atomic<bool>; shutdown() uses exchange() so exactly one caller tears the
client down (idempotent) and the flag is published for the detached threads.
- execute() and initializeNew/NewFromPhrase/Existing refuse once the bridge is shut down (shutDownResult),
so a stale command can't reach the global client.
Gating only — doSend/doShield, the JSON send payload, amounts/memo/recipients, persistAfterBroadcast,
encrypt/unlock/export and api_.execute argument passing are byte-identical when the bridge is live.
Test: testLiteClientBridgeRefusesAfterShutdown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Performance audit: tx_revision bumps on every new block because each tx's confirmation count rises, so
the History tab's groupByTxid + merge + sort + deep-copy display cache fully rebuilt once per ~36s block
on the UI thread, even when nothing but the counts changed.
Add WalletState::tx_structure_revision, bumped alongside tx_revision EXCEPT when setTransactionsIfChanged
sees a pure confirmation-count delta (new sameTransactionLayout: every row equal ignoring the confirmation
COUNT, but the pending/confirmations==0 state — which drives the pending-first sort and shield merge — must
still match). Every other writer bumps both (conservative default). The History tab's groups cache and
display cache now key on tx_structure_revision; on a confirmation-only delta it patches dtx.confirmations
in place (rows, order and indices into state.transactions are unchanged) instead of rebuilding, and re-stamps
the filter snapshot so "export current filter" stays valid. Displayed confirmations still update every block.
No fund path touched; display/caching only. Test: testTxStructureRevision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Concurrency audit: RPCWorker::stop() discards any MainCb the fast worker pushed after the last drain,
and lock_unlock_in_progress_ / pin_in_progress_ / encrypt_in_progress_ are cleared only inside those
callbacks. So a disconnect (3-failure threshold, daemon restart) mid-unlock destroyed the callback that
would have cleared the flag, leaving the lock screen stuck on "Unlocking..." until the app was restarted.
Clear the three flags in onDisconnected() right after the fast worker is torn down (gated on it having
existed, since only fast-lane-posted callbacks can be lost this way). Chosen over draining the worker,
which would run stale callbacks after state_/rpc_session_ were already reset. The user can retry after
reconnect and the real encryption state is re-read on connect. No fund-path call is touched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lite-parity audit, two History-correctness findings (display/parse only — no send/broadcast call touched):
- A lite send carried a POSITIVE tx.amount, so History, the Details dialog and CSV export rendered an
outgoing payment as a green "+" receipt (the full node uses a negative debit). The mapper already
computes signedAmountZatoshis (negative for sends, positive for receives); applyLiteRefreshModelToWalletState
now uses it for tx.amount, so the shared UI shows a red "-" and CSV a negative value. The summary cards
use std::abs and are unaffected.
- A send this wallet did not author (seed restore / another device) has empty outgoing_metadata, so the
parser summed it to 0 and showed amount 0 with a blank address, understating Sent totals. parseTransactionRecord
now falls back to |record.amount| (the net debit, incl. fee) when the metadata sum is 0, tolerant of a
missing/odd field; explicit outgoing_metadata still wins.
Tests: testLiteSendAmountIsNegative (send maps to <0, receive stays >0) and
testLiteForeignSendFallsBackToRecordAmount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resilience audit, highest-value finding: a z_sendmany (full node) or lite broadcast whose REPLY is
lost — a transport timeout / connection cut AFTER the request already left us — was reported as an
ordinary "Transaction failed". The user then retries and can broadcast the same payment twice, because
the daemon may already hold the operation (z_sendmany returns an opid and runs async).
Classify the ambiguous case and surface it distinctly, without touching the send itself:
- New pure helpers in rpc/rpc_failure.h: sendReplyMayBeLost() (an RpcTransportError that was requestSent
with no HTTP status and a non-OK curl code — reply-timeout / got-nothing / our-own-abort; definitive
rejections and any HTTP status are excluded), liteSendErrorMayBeInFlight() (a transport-style gRPC text
heuristic, vetoing connect-phase and server-rejection text), and a readable-English marker tag.
- app_network.cpp: the z_sendmany catch records the flag; the failure branch (non-background only) tags
the callback text and forces a wallet re-read so a committed send shows in History. onDisconnected()
tags already-pending send callbacks the same way (they exist only after an opid was returned).
- Lite UserSend (app.cpp): a transport-style broadcast error is tagged identically.
- Send tab: a tagged outcome shows a non-success warning pointing at History and clears the compose form
so any resend must be retyped deliberately; it is NOT presented as a retryable failure.
The actual rpc_->call("z_sendmany", {from, recipients, 1, fee}), the lite send/broadcast call, and all
amounts/fees/recipients/memo bytes are byte-identical; only result-handling and presentation change.
Pure classifier/marker regression tests added (testSendMaybeInFlight).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fund-correctness audit, sweep + lite paths:
- Key-import sweep: balAt() swallowed every RPC error as 0.0, so a transient
timeout/warmup after the import misclassified a funded key as "no funds". Let
the failure propagate to the outer handler so the user sees a real error and
can retry, instead of a false __NOFUNDS__.
- Lite send: the double→zatoshi conversion was an unchecked llround+cast — a
negative/NaN amount would wrap to a huge uint64. Route it through
util::coinsToZatoshis and reject invalid amounts before broadcast.
- Lite balance: the spend gate used the gross shielded total (including
unverified/unconfirmed notes). Use the backend's spendable_zbalance (a required
field) for spendablePrivateBalance/Total, clamped to the shielded total.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fund-correctness audit, send path:
- Flagship wrong-send: s_send_max was only cleared on a successful send, so after
Max → typing a smaller amount → changing the fee tier in the confirm dialog,
the amount was silently overwritten back to the maximum and Confirm sent far
more than the user typed. Clear s_send_max on any manual amount edit (DRGX and
USD inputs), so a later fee bump never rewrites a hand-entered amount.
- A valid exact-maximum send was blocked by 1 ulp because amount+fee<=available
was computed in IEEE doubles. New util::toSats/canAfford compare in int64
satoshis; wired into the Review gate, the confirm-time re-check, the Send
tooltip and the amount-error banner. This only unblocks a correct send; it
changes no sent amount.
- Added util::coinsToZatoshis (reject non-finite / <=0 / rounds-to-0 / over-supply)
and regression tests for formatAmountFixed, toSats/canAfford and coinsToZatoshis
(incl. the exact failing inputs the audit found).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Defense-in-depth against a local attacker / core dump reading un-zeroed heap
copies of secrets (threat model is local, not remote). Best-effort by nature —
std::string SSO/reallocation and ImGui buffer growth can still strand copies —
so these shrink the exposure window rather than guarantee absence:
- rpc_client performCall: a scope guard now sodium_memzeroes the request body +
payload tree (walletpassphrase / *_importkey args) after the call and detaches
CURLOPT_POSTFIELDS. Request content sent to curl is unchanged.
- New ui::scrubImGuiInputTextState() zeroes ImGui's retained InputText buffers
(live/revert/callback/deactivated) — the app memzeroed its own char[]s but
ImGui kept copies. Called from wipeSecrets()/wipeDisplayedSecrets(). PINNED to
ImGui 1.92.6 via a #error guard so a vendored bump forces a re-audit (the stb
undo buffer is an incomplete type in the header, so it's left as a noted gap).
- Lite exportSeed/exportPrivateKeys wipe result.value + the parsed JSON tree.
- Seed-word grid (SplitSeedWords/RenderSeedWordGrid) zeroes its per-frame word
and cell copies; the wizard word/chip vectors are zeroed before clearing.
- ChatStore::clear() + ResetChatTab() zero decrypted message bodies on
lock/switch/teardown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six bounded defensive fixes:
- Payment-URI amount is now strictly validated (reject NaN/inf/sign/garbage,
<=8 decimals, 0..21,000,000) at parse time, so a crafted QR/URI can't inject a
bogus amount into the Send form. The z_sendmany call the user confirms is
unchanged; this is input validation only.
- export-all-keys filename is sanitized (basename-only, no traversal) via a
shared util::sanitizeExportFilename (factored out of the CSV export dialog).
- Chat append()/upsert() wrap the strict serialize in try/catch so a malformed
(invalid-UTF-8, attacker-influenceable) body is skipped, not crashed on —
matching appendBatch's existing drop behavior.
- http_download (used by the updaters + pool stats) restricts transfers AND
redirects to HTTPS and bounds the download path's redirects, so a redirect
can't downgrade to http:// or another scheme.
- The default DRGX.conf (rpcuser/rpcpassword) is written 0600 from the start via
writeFileAtomically, closing the brief world-readable ofstream+chmod window.
- Auto-lock is suppressed only for SOLO mining (needs the wallet), not pool
mining (pays an external address), which was holding the wallet open forever.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The console redacted secret command ARGS but showed the OUTPUT of key-revealing
commands (dumpprivkey / z_exportkey / z_exportviewingkey / z_exportmnemonic /
dumpwallet / z_exportwallet, and the lite seed/export verbs) in full — visible
in the scrollback, Copy All, Ctrl+C and the filter. Now, when the submitted
command is key-revealing, its result is held ONLY in a member buffer (never in
the scrollback model, so Copy-All etc. can't leak it) and a masked placeholder
is shown. An explicit "Copy secret" button routes through the auto-clearing
clipboard; the buffer auto-wipes after 60s, on Clear, and on wallet lock
(via wipeDisplayedSecrets). The command string passed to the executor is
byte-identical. New pure helper ConsoleCommandRevealsSecret is unit-testable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- writeFileAtomically honored restrictPermissions only on POSIX (0600); on
Windows it was a no-op, so exported keys / seed backups / the vault / chat
exports inherited the parent dir's ACL. It now builds a PROTECTED owner-only
DACL (current-user SID, SE_DACL_PROTECTED) on the temp file before the atomic
rename; best-effort, falling back to default ACLs on failure. (Verified it
compiles under mingw-w64; the POSIX path is unchanged.)
- The key-export dialog's Copy wrote the private key to the plain, permanent
clipboard; it now goes through copySecretToClipboard() (45s armed auto-wipe +
its own "copied, auto-clears" notice), like the seed backup copy.
- Bootstrap archive verification dropped the collision-prone MD5 fallback: it now
requires a published SHA-256 and fails closed otherwise. (Authenticity vs a
malicious server still needs a detached ed25519 signature like the updaters —
a separate release-coordinated change.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Locking the wallet (manual / auto / lite) now calls wipeDisplayedSecrets(),
which scrubs + closes any revealed seed phrase, migration seed, or exported
key — previously a locked wallet could leave the phrase/key on screen and
resident. Runs synchronously, before the async lock round-trip.
- The console secret-arg redaction list only had full-node RPC names, so lite
verbs (import / timport / encrypt / decrypt / unlock) echoed keys and
passphrases into the scrollback and up-arrow history; added them.
- The offline wallet-rebuild helper was invoked via a shell string
(popen "sh -c") built from paths including the wallet filename, so a crafted
name could inject a command; it now refuses any path with a shell
metacharacter (rc stays -1 and the existing verify-before-swap fails safe).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- lite_owned_string: the destructor and move-assignment no longer copy the raw
value before freeing (nothing reads the copy there, and a std::string copy can
throw bad_alloc — from the noexcept destructor that meant std::terminate, and
it could also double-attempt the copy after a throw). They now free only; the
copy happens solely in intoResult(), which also moves (not copies) the value
into its result so no extra plaintext lingers.
- lite_result_parsers: jsonToUnsigned rejected nothing on the string path, so
std::stoull accepted "-1" and wrapped it to a huge balance; now requires a
leading digit. jsonToSigned rejects leading whitespace/'+'. The outgoing-
metadata sum rejects negative values and guards against signed overflow (UB),
so a hostile/buggy backend can't forge or overflow a transaction amount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finishes the feedback-conventions hygiene thread: ~30 hardcoded ImVec4 green /
red / amber literals used for success / error / warning text now use
material::Success/Error/WarningVec4(), so semantic status colors follow the
active skin and stay legible in every theme (several hardcoded greens/reds read
poorly on the light/pastel skins). Covers validate-address, transaction-details,
key-export, export-all-keys, about, the Settings confirmations/lock state, and
the encryption/decryption dialogs. Brand/logo colors and muted white/grey
(disabled) text are left as-is; dynamic-alpha animated indicators are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The import-key sweep and migrate-to-seed flows set several user-visible
status/error strings (shown via sweep_status_ / seed_migration_status_ /
rescan_status) as bare English; routed them through TR() with additive keys.
- Extended scripts/check-source-hygiene.sh with two low-false-positive content
guards so this debt can't regrow: a bare-English migrate/sweep status
assignment, and a Tactile button sized with a raw (unscaled) pixel literal.
Passes clean on the tree (362 files); the dev sweep harness is allowlisted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Feedback-convention + HiDPI cleanup (from the audit's living-hygiene threads):
- Copy actions that were silent now toast a confirmation: transaction
address/txid (context menu, click-to-copy, detail buttons), peer and banned-
peer addresses, the Settings data-dir copy, the chat message "Copy", and the
key-export result copy.
- The first-run wizard's Skip/Back buttons used raw ImVec2(120/80,0) widths that
rendered tiny at HiDPI; they now multiply by ui::Layout::dpiScale().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finishes the transactions-export partial. The dialog now offers three scopes:
- All transactions (unchanged default; exported with no copy).
- Current History filter — the Transactions tab publishes a TxFilterSnapshot of
the visible legs (a merged autoshield row contributes both its send+receive
legs, so the CSV stays a per-leg record like an unfiltered export); the radio
is disabled with an explanation when no filter is active, and a snapshot that
predates the current tx list (revision mismatch) is refused rather than
silently exporting everything.
- Date range (YYYY-MM-DD, inclusive end-of-day; pending/dateless rows excluded
with a note; invalid/reversed dates blocked inline).
The live count reflects what will actually be exported. formatTransactionsCsv
and the atomic write are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>