A manual consolidation's merges compete with the GUI's routine wallet scans for
the daemon's cs_main: the run won't fire the next merge while any scan is in
flight, and because each merge mutates the wallet it re-triggers the balance /
history scans, which then delay the following merge. The WalletScanGovernor
already serializes all of these one-at-a-time under a cs_main budget, so the fix
is to pause the heavy routine streams — not every RPC — while a user run owns the
budget.
WalletScanGovernor gains per-stream routine suppression (tryAdmit returns empty
for a paused stream, which keeps waiting and resumes the instant it is unpaused;
explicit needs via beginExplicit are never paused), plus a convenience that
pauses the balance / addresses / history / recent-tx group together. App::update
pauses that group while consolidation_.active() AND the run is user-started —
an auto (background) run is left gentle and untouched, and the cheap sync / peer
/ mempool / operation-status polls keep running so sync and disconnects stay
visible. The end-of-run forced balance refresh repaints once the run finishes.
Note: a manual run is bounded (≤ maxBatches per click, ~minutes), so the balance
and recent-tx views are only briefly stale — an incoming payment shows once the
run finishes or is stopped. Unit-tested (testWalletScanGovernor): a paused stream
is not admitted but keeps waiting, explicit bypasses it, unpausing re-admits, and
chat/Adhoc are never in the paused group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A mining/pool wallet can accumulate tens of thousands of shielded notes, and the
manual "Consolidate" run — capped at 20 batches per address per click — needs
many re-runs to flatten a 10k+ note address. This adds a hands-off alternative:
a Settings toggle ("Auto-consolidate notes", full-node, off by default) that
trickle-merges notes in the background while the wallet is idle, so it sheds
notes over hours/days without the user ever opening the dialog.
Deliberately gentle, per the request: a small batch (15 inputs/tx vs 50 manual)
and only 3 batches per address per cycle, with cycles ~10 min apart — each cycle
is a handful of tiny merges that barely touch the node. It only acts when the
wallet is genuinely idle (walletScanReady: connected, synced, unlocked, node not
busy, not rescanning) and no send / shield / manual run is in progress, and it
goes dormant (re-scanning hourly) once every address is tidy.
Implementation reuses the existing paced services::ConsolidationRun engine in
"keep in each address" mode, so there is NO fund-path change and the run's
pacing / failure breakers / note-locking all apply:
- App::pumpAutoConsolidation (pumped from update() after pumpConsolidation):
finalizes a finished cycle (schedules the cooldown + dismisses the run so the
manual dialog never shows a stale auto result), else starts a new cycle when
idle and the cooldown has elapsed.
- postAutoConsolidationScan: a quiet z_listunspent scan (the dialog's loadScope
minus the UI state), session-guarded; on result picks candidates and starts.
- startConsolidationQuiet: the same guards as startConsolidation but silent, with
a gentle RunConfig; finishConsolidation stays silent for auto runs (logs only).
- services::autoConsolidateCandidates (pure, unit-tested): addresses above the
note floor that are worth merging; plus the gentle kAutoConsolidate* constants.
- The one-time large-wallet nudge is suppressed while auto-consolidation is on.
Settings plumbing (auto_consolidate, default false) + a daemon-gated toggle in
the Wallet tab's options, with a tooltip. Build + tests + hygiene green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Until now an incoming large message was invisible until every part landed.
ChatService::pendingLargeMessages() exposes the in-progress tier-3 receives
(conversation, chunks gathered / chunks needed) from the reassembly buffer, and
the chat tab renders a subtle centred line below the thread — "Receiving a large
message… 24 / 36 parts" — that updates each scan and disappears when the message
completes and surfaces as an ordinary bubble. A complete-but-withheld (sensitive
vault locked) group is not reported as incoming.
New string chat_receiving_large (English; 8 locales fall back). Unit test extended:
after the first slice the receive is reported in progress; after the whole message
arrives it is no longer pending.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second half of tier-3 larger messages: the receive side. ChatService now buffers
the slices of a multi-transaction message (keyed by the shared message id) as
they arrive — across transactions and across scans — and reassembles the whole
ciphertext once every chunk is present, then decrypts and surfaces it as one
message. The buffer is NOT persisted: a rescan re-sends every slice, so it
re-derives from chain exactly like the control reducer, and survives a restart
mid-receive (the next full scan re-feeds the siblings and completes it).
Ingest's per-message build/decrypt/guard logic is refactored into one shared
lambda so a reassembled tier-3 message passes the identical block / on-chain-hide
/ sensitive-vault / tombstone checks as a single-tx message. A completed group is
retired after it surfaces (deduped across scans by its stable canonical txid —
the smallest sibling txid); a group withheld only because the sensitive vault is
locked is kept so a later unlock + scan completes it. Our own slices (sender ==
us) are skipped — the local echo records the outgoing message. The pending map is
bounded (256 groups) against a flood of never-completing partials.
Unit-tested (testHushChatTier3Reassembly): a ~9000-char message's first slice
alone surfaces nothing; once every slice has arrived it reassembles, decrypts to
the original and surfaces once; a re-scan dedups; our own slices never ingest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for messages too large for a single transaction (tier 2's 16-output
ceiling, ~4000 chars). A long message is now split across SEVERAL transactions
that share a random message id; each carries a slice of the one ciphertext with
GLOBAL chunk indices, and the receiver reassembles across the siblings.
Protocol: the Message header gains an optional "mid" (message id); "n" is reused
as the TOTAL chunk count and its bound raised to kHushChatMaxMessageChunks (64 ⇒
up to ~16 KB / 4 txs, tunable, ≤256 for the 2-hex index). The grouper, when a
tx's header carries "mid", emits a PARTIAL (its global-indexed chunks + message
id + total) instead of reassembling in-place; HushChatTransactionMetadata
carries those partials through. Crypto is untouched — one secretstream session
for the whole message, chunking is transport-only.
Compose: splitOutgoingMessage() returns one OutgoingChatMemos per transaction
(one element = the classic tier-1/2 single tx, byte-identical; several = tier 3).
buildOutgoingMessage (single-tx) is unchanged. Old clients bound "n" to 16 so a
tier-3 header fails to parse and its memos are dropped — graceful degradation.
Unit-tested (testHushChatLargeMessageTier3): a ~9000-char message splits into 3
txs, every output shuffled, slices gathered across siblings, reassembled and
decrypted back to the original; short message stays single-tx (no mid); the
total-chunk cap is enforced. The stateful accumulator (persisted, confirm-gated),
sequential broadcast, and progressive/incomplete UI follow in later commits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A message whose ciphertext exceeds one 512-byte memo is now split across up to
kHushChatMaxMessageParts (16) payload outputs in a SINGLE transaction, lifting
the ~239-byte cap to a few KB while staying atomic, instant (0-conf), and
one-fee. The crypto is untouched (one secretstream push/pull); chunking is a
transport concern:
- Header gains an optional part count `n` (emitted only when >1, so a 1-part
message stays byte-identical on the wire); bounded in the parser.
- Each chunk is a 2-hex part-index prefix + its ciphertext slice, so the
receiver reorders them after the daemon's output shuffle.
- OutgoingChatMemos carries a payload vector; chatSendOutputs emits header + N
payloads (both broadcast call sites already iterate the result).
- The grouper gains a multi-part pre-scan: a lone Message header with n>1 →
collect the N indexed chunks, sort, strip, concat → one pair; anything else
falls through to the classic single-pair path (older clients mis-pair and
drop — graceful). A body beyond the cap is refused as TooLong (tier 3 later).
Round-trip (shuffled outputs), 1-part byte-identical, and cap all unit-tested.
Larger-messages tier 2; tier 3 (multi-tx) + the denser raw-memo encoding remain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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 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>
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>
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>
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, 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>
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>
When the node rejects the wallet's RPC login (bad rpcuser/rpcpassword), the
status bar showed developer jargon ("Auth failed — check rpcuser/rpcpassword")
and the only action was Reconnect, which just repeats the failure. Now a
dedicated AuthFailed banner reason surfaces "Wallet can't sign in to the node"
with a plain-language detail and a real fix: Restart node for the embedded
daemon (regenerates its cookie creds) or Open config folder for an external one
(to fix DRAGONX.conf). Driven by a new rpc_auth_failed_ flag (set on the auth
path, cleared on each connect attempt), with evaluator unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The no-peers banner headline ("Connected, but no network peers") was followed
by a detail line that just repeated connection_status ("Connected"/"Peers: 0"),
reading as a contradiction, and it offered no action at the exact moment the
wallet looks synced but is silently stalled at a stale height.
- NoPeers now clears the passthrough detail; App renders a translated,
actionable hint (node_banner_no_peers_detail) for this reason instead.
- When the embedded daemon is the node, offer a Restart button (re-triggers
peer discovery via the -addnode seeds); an external node stays action-less
since it is the user's own to restart.
- Update + extend the evaluator unit tests (external = no action/empty detail;
embedded = RestartNode).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A full z_listsentbyaddress holds the daemon's cs_main for the whole sweep --
~140 s on a large wallet on a daemon without the sent-history decrypt cache --
which starves block connection and makes every other RPC (getinfo, balances)
wait, so the node looks "busy". This runs the full sweep in depth bands so no
single call holds the lock for long.
- The full case now covers [0, tip] in bands: z_listsentbyaddress(z, minconf=a,
filter-2, maxDepth=b) returns exactly depth [a, b]. Newest band first (its
minconf=0 includes the mempool); results dedupe by txid across the overlap.
- Each band is a separate call, so cs_main is held only ~5 s at a time and the
node stays responsive between bands (block connection isn't starved, the busy
canary answers). Total CPU is unchanged -- just not in one long lock.
- sentSweepNextBandSize adapts the next band toward ~5 s: grows up to 4x in
sparse depth (empty regions covered fast), shrinks to 1/2x in dense depth.
- W advances only after every band succeeds; a mid-sweep failure re-sweeps
rather than marking a partial result. The windowed sweep is unchanged.
- Each band also fits the 120 s timeout on its own, so the seed completes even
on a daemon where the single whole-wallet call used to time out.
This removes the ~2.5 min hang on the first-run seed, the weekly safety-net
re-sweep, and any invalidation. Multi-band + band-sizing covered by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Persisting the sent-sweep watermark means a relaunch runs a windowed sweep,
which can't see a change made outside the GUI (a Console z_importviewingkey,
an external same-seed restore) that adds an old send below the window. This
adds a periodic full re-sweep so that case is still caught within ~7 days.
- sentSweepSafetyNetDue(now, lastFull) = lastFull > 0 && now - lastFull > 7d
(a wallet that has never full-swept doesn't trigger one; the seeding sweep
records the time). lastFullTime / lastFullHeight ride in the sent_sweep
snapshot; any successful full sweep (seed, verify-mismatch or safety-net)
resets the clock. On load, a valid watermark without a clock starts it now,
so even watermarks seeded before this field eventually re-sweep.
- When due, the snapshot forces a full sweep for that cycle. Gated on: a
watermark exists, a routine (non-syncing) cycle, not the Mining page, and
not in full-sweep backoff -- so a full sweep that can't finish on a slow
daemon backs off instead of retrying every cycle.
Degrades gracefully: ~0.4 s on a note-cache daemon; callLong(120 s) with
backoff on an old one (the deferred banding would keep each cs_main hold
short so it fits there too). Helper + persistence covered by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a wallet with a very large shielded note set, the daemon re-decrypts all
notes under cs_main on every z_gettotalbalance / z_listunspent (GetFilteredNotes),
~15-33s per pass. The GUI issued these roughly every 15s and timed them out at
the 30s RPC limit, so the node looked permanently "busy" and per-address
balances read 0 (nothing to carry on a fresh session) while the total was fine.
Two wallet-side mitigations:
- A cost-proportional floor on VISIBLE balance polls (wallet_scan_governor): the
adaptive backoff previously applied only when the balance was off-screen. It
now also holds a visible poll until ~3x the measured scan cost has elapsed, so
a wallet whose sweep costs tens of seconds can't re-poll before the node
recovers. Negligible for small wallets (cost ~ms); forced / first-read bypass it.
- The heavy balance RPCs (z_gettotalbalance, z_listunspent) now use a 120s reply
timeout via the existing callLong path, so a slow pass completes and populates
per-address balances instead of aborting at 30s and leaving them at 0.
The root fix is daemon-side (cache decrypted notes so GetFilteredNotes isn't
O(notes x decrypt) per call); specced separately. Adds governor test coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
z_listsentbyaddress re-walks the whole wallet when there's no usable
watermark, which the daemon does under cs_main for every send -- hundreds
of seconds, 400+ s and ~19 GB RSS on a large wallet. The watermark lived
only in memory, so every relaunch, reconnect or daemon restart paid for a
full sweep. This persists it so those cases run a bounded windowed sweep
([z, 0, 2, tip - W + margin]) instead (SENT-HISTORY-SWEEP Part 1).
- The watermark (W = last swept tip), its anchor (A = W - margin) and anchor
hash (H_A = getblockhash(A)), plus the enrichment backlog and the full-
sweep backoff, ride in the encrypted history snapshot as an optional
`sent_sweep` object (schema stays v1; a missing/inconsistent one reads as
"no watermark" = today's behaviour, so older builds and payloads are safe).
- Before the first windowed sweep after a load, the anchor is verified
(getblockhash(A) == H_A). A match means any fork is inside the window; a
mismatch (reorg deeper than the margin, different chain/datadir) or an
inability to check drops the watermark and sweeps the whole wallet.
- The first sweep after a load uses a bigger margin (absorbs notifier lag),
and a watermark older than 2400 blocks uses the long timeout (callLong).
- The backlog is persisted WITH W: a pure z->z send's txid lives only in a
sweep result and would be lost if the window moved past an un-enriched one.
- Watermark changes reach disk (folded into the store signature), and every
hard case still forces a full sweep: reorg (anchor verify), GUI rescan /
zap / import / address change, restoreOriginalWallet, and disconnect.
Deferred (follow-ups, each only turns a correct-but-slow case faster, never a
correctness risk): the banded full sweep for seeding on a pre-note-cache
daemon, the external-import tripwire, and the weekly safety-net re-sweep.
Part 2 (the daemon's decrypt cost) is already on the daemon's
perf/sapling-note-cache branch.
Pure helpers + cache round-trip + anchor-verify/large-window covered by
tests; the getblockhash-after-sweep updates the pinned RPC sequences.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds src/data/earnings_series.h: a pure, header-only aggregator that sums
wallet inflows (received + mined) over the current calendar period (hour /
day / month / year), optionally net of sends, both per receiving address
and wallet-wide, at "as seen" values. Attribution is best-effort: inflows
map to the receiving address; in net mode a send debits from_address when
present (a send with no from_address still counts in the wallet total).
Includes a revision-keyed EarningsCache (mirrors ui::MiningEarningsCache)
and a 13-assertion unit test covering inflows vs net, per-address
attribution, period exclusion, period nesting, and cache invalidation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The change-gated refresh worker only runs `balance` on a full-refresh
cycle, so testLiteWalletControllerRetire could no longer reliably park
the worker inside a blocked `balance` (it passed only by timing). Bump
the fake scanned height to force a full-refresh cycle, which enters the
now-blocked `balance` deterministically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The background 2 s refresh worker re-queried and re-copied the whole
wallet every cycle (a fresh TLS refresh plus a multi-megabyte JSON
build, copies and DOM parse), even when nothing had changed, so CPU and
the network never went idle.
The worker now skips the full refresh and the re-publish on a cycle that
is provably unchanged, while never letting the UI show stale confirmed
balance or transactions:
- The lite bridge keeps a monotonic mutation epoch, bumped after any
command not on a read-only allow-list, so a local change made on any
thread (send, shield, import, new address, encrypt/unlock/lock,
console) is detected at one choke point.
- Each cycle the worker runs one cheap sync probe (one connection, the
mandatory block ingest) and a local height read, and refreshes only
when the scanned height advanced, the mutation epoch moved, the
encryption state changed, a sync is in progress, or a ~10 s heartbeat
elapsed; otherwise it skips. Direct/synchronous callers are not gated.
- A failed probe backs off instead of running a second sync, so a dead
server is not retried back-to-back.
Confirmed funds can never be stale beyond one cycle (every confirmed
change advances the height or bumps the epoch); only unconfirmed mempool
rows can lag up to the heartbeat, and the spend path re-queries the
backend directly rather than reading the gated model. Average idle cost
drops from three data connections per cycle to about one.
Tests: a bridge mutation-epoch test and a worker change-gate test
(skip-when-unchanged; refresh on height advance, local mutation and
encryption change) against the fake backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On quit the lite controller's destructor never runs, because main()
ends the process with _Exit(). Any state the background refresh had
scanned since the last save was lost: the destructor is where that
final save would normally happen, and it was being skipped on the
one path that always runs.
- shutdown() now calls a new flushOnExit() that persists once on the
way out, bounded to 4 s so the existing 15 s shutdown watchdog
budget still holds. On timeout it abandons the save thread and lets
_Exit follow.
- It is a plain save through the same backend path, which writes a
temp file and renames it into place. That cannot corrupt the wallet
if it is cut off or abandoned, and it does not change the wallet
file format - it only persists sooner on the quit path.
- It reuses the destructor's guards and also skips when a wallet op
is already in progress: shutdown() has already waited for ops to go
idle, and a still-pending op holds the sync lock and issues its own
save, so flushOnExit would only block the budget on a save that
cannot take the lock.
Tests cover a save on the open, synced, idle path (exactly once), a
blocked save returning within the budget, and the retired and
never-opened guards skipping the save.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Transparent history is gathered by skip-paging listtransactions. The
underlying list can shift between the per-page RPCs - a new
transaction arriving pushes one page's tail into the next page's head
- so a row could be collected twice and show up as a duplicate in
History.
- Pages are now de-duplicated on the daemon's stable per-output
identity: txid, send/non-send class, and vout. Keying on the class
rather than the exact category means a coinbase that matures
(immature to generate) between two fetches is still recognised as
the same row; keeping vout means two equal-value outputs of one
transaction stay distinct. No real transaction row is dropped or
duplicated.
- Rows with no txid or vout ("move") are never keyed and pass through
(they are dropped downstream anyway), so the de-dup fails open.
- The raw page size still drives the stop condition and the next-run
row hint, independent of how many rows were unique.
Tests cover a shifted page whose head repeats the prior page's last
row (dropped), a sibling output of the same tx (kept), and an
un-keyable row (kept).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
listtransactions was called with the default "" account, which
returns only rows in the unnamed account. Transactions tied to a
labelled address were left out of the History tab entirely.
- All three listtransactions call sites now pass "*", which spans
every account, so labelled-address rows appear alongside the rest.
- With "*" the daemon also emits "move" accounting rows that have no
txid, address or vout. Those are not wallet transactions, so they
are skipped and never become blank History rows. No real
transaction row is dropped by this filter.
Tests cover that a "move" row is skipped while a labelled receive row
is kept, and that the transparent collectors now page with "*".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon monitor thread drained output and then slept a fixed short
interval on every tick, waking several times a second for the life of
the node even long after startup, when the node emits almost nothing.
Stopping it also meant waiting out the current sleep before it saw the
stop flag.
- The monitor now waits on a condition variable instead of a plain
sleep, so a stop request wakes it immediately rather than after the
interval. The stop flag is set under the monitor's mutex so the wake
cannot be lost.
- The poll interval stays fast for the first 30 s after the thread
starts and for 3 s after any output, and otherwise doubles per idle
tick up to a 500 ms cap. Any new output drops it straight back to
fast, so responsiveness during startup, rescan and shutdown is
unchanged while a quiet node is polled far less often. The interval
choice is a pure function.
Tests cover the interval function across the warmup window, the
recent-data window, the reset on new output, the doubling, and the cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wallet pins a -dbcache value on the daemon command line. A
command-line flag overrides the same key in DRAGONX.conf, so a user who
set their own dbcache in the conf never got it: the wallet's pin always
won.
- Before spawning, a normal start now reads DRAGONX.conf once and, if it
sets dbcache, drops the wallet's own -dbcache pin from the arguments
so the user's value takes effect. An isolated start keeps its own
datadir and conf and is left alone.
- The conf is now read a single time for both this check and the
existing rpcthreads check instead of twice.
Tests cover dropping the pin when the conf sets dbcache (in various
forms), and leaving the default arguments untouched when it is
commented out, empty, or sets something else.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the daemon was restarted, the output-parse offset was reset to 0.
The daemon's output buffer is retained across runs, so the next poll
re-read the whole retained tail of the previous run and fed it to the
busy tracker as if it were fresh. A witness rebuild or rescan line left
over from the old run could then be mistaken for activity in the new
one.
- On restart the offset is now reset to the absolute end of the output
at that moment, captured on the lifecycle thread after the stop and
before the start, so the UI resumes parsing only the new run's output
rather than replaying the old tail.
- The capture is handed to the UI thread through a pending flag and
applied there before the next read, both on the normal poll and when
skipping the backlog, so the offset is only ever moved on the thread
that owns it.
Tests cover reading from the captured watermark (only the new run is
seen) versus the old reset-to-0 behaviour (the previous run replays).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The embedded daemon and the xmrig manager each kept their own copy of
the console output ring: append new bytes, trim the front to a cap at a
newline boundary, and hand a reader everything since its offset. The two
copies had drifted. The daemon tracked an absolute byte total so a
reader's offset stayed valid across a front-trim, while the miner keyed
its reader off the live buffer size, so a trim could silently rewind the
console feed.
- The append/trim and the since-offset logic now live in one small
header, outputWindowSince and appendBounded, included by both. The
trim keeps the newline-boundary cut and the guard that stops a lone
trailing newline from erasing the whole buffer.
- The xmrig manager now carries the same absolute byte total, so its
reader resolves against the window the same way and survives a trim
(and a process restart that clears the buffer but keeps the total).
Tests cover feeding the ring well past the trim point while a reader
polls each chunk (the feed is continuous and never dies at the trim),
the lone-trailing-newline guard, and a cleared buffer with a retained
total.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ingesting a scan's worth of chat messages persisted each new message
with its own append() call, so a batch of fresh messages opened and
committed one SQLite transaction per row.
- A new appendBatch persists many messages in a single transaction,
using the same INSERT OR IGNORE dedup key as append() and preserving
input order so load()'s rowid ordering is unchanged.
- Every message is serialized and encrypted before the transaction
opens, so a message with a non-UTF-8 body is skipped on its own
without leaving a transaction open; the valid siblings still persist.
- Ingest collects the newly stored messages and writes them once after
the loop instead of per message. It is inert (returns 0) while locked
or on empty input.
Tests cover a batch insert, dedup on re-insert, empty input, bad-UTF-8
isolation with a valid sibling and a following append(), insert order,
and the locked no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening the transaction-history cache always derived its encryption key
with Argon2id, even for an unencrypted wallet. For those wallets the
passphrase is just a value derivable from the database itself, so the
~50-100 ms Argon2id stall bought no additional secrecy and ran on the
UI thread at open.
- Unencrypted wallets now derive the cache key with a cheap,
domain-separated BLAKE2b KDF over the wallet identity, keyed by the
per-wallet salt. The secret is derivable from the DB, so this is
security-equivalent for the unencrypted case and avoids the stall.
- Passphrase-encrypted wallets are unchanged: they keep the Argon2id
key derivation. The two paths use separate domain labels, so the
fast-path key and the Argon2id key never collide for the same DB and
salt; a cache written under one cannot be read under the other.
Tests cover the fast-path round-trip and reopen, and assert that an
Argon2id unlock of the same DB produces a different key and fails to
read the fast-path snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Theme images were decoded straight to RGBA at their full source size
and uploaded to a GPU texture as-is. A large or over-sized image wasted
VRAM and could exceed the GL maximum texture size, and each code path
also hand-managed the decoded pixel buffer with its own free calls.
- Loading from file and from memory now goes through the shared
size-aware decoder with a 2048-pixel cap on the long side. An image
larger than that is downscaled with its aspect ratio kept; an image
whose area is absurd is rejected; a small image is left untouched.
- The decoder owns the pixel buffer for the life of the upload, so the
manual free calls are gone.
Tests cover downscaling a wide image to a 2048 long side with the aspect
kept, rejecting an over-area image, and leaving an under-cap image as-is,
plus the fit-dims math at the theme-art cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Before verifying a downloaded archive, both updaters slurped it into a
string with an istreambuf_iterator, which grows the buffer repeatedly
and reallocates as it reads a multi-megabyte file. It also placed no
upper bound on the size it would read into memory.
- A shared readFileExact helper sizes the file first and reads it in a
single allocation, failing closed if the file is missing, empty,
larger than a caller-supplied cap, or comes up short. A truncated read
can never reach the SHA-256 / signature verifier.
- Both updaters use it with the existing max-archive cap.
Tests cover a good read, a read exactly at the cap, over the cap, an
empty file, and a missing file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several settings were read with json's value()/get<T>() helpers that
assume the stored type. A hand-edited settings file, or one re-saved
after an inf/NaN float was written back as JSON null, could therefore
hold a value of the wrong type and throw during load. The whole load is
wrapped so a throw quarantines the entire settings file to a
.corrupt- copy, meaning one bad field reset every unrelated setting.
- ui_opacity is accepted only when it is actually a number, and it is
clamped to the same 0.3..1.0 range the setter enforces; the legacy
reduced_transparency migration likewise runs only when that key is a
boolean.
- A non-string name in a blocked chat conversation, and a non-string
scope in a portfolio entry, now fall back to an empty string instead
of throwing.
The file loads with the offending field defaulted, and the rest of the
settings survive.
Tests cover ui_opacity as a string/null/int/float/bool, a non-string
blocked-conversation name and portfolio scope, and the legacy
reduced_transparency migration, asserting no quarantine file is written.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The single-instance lock lived in /tmp, which is world-writable. Creating
it with mode 0666 there gets masked to 0644, so a second local user
cannot open it O_RDWR and is wrongly blocked from running the wallet at
all. A world-writable directory also lets another user pre-create or
symlink the lock path.
- The lock now lives in a per-user directory: $XDG_RUNTIME_DIR when it is
an absolute directory the user owns, otherwise the per-user, per-variant
config directory that startup has already created. Never /tmp.
- It is created mode 0600 with O_NOFOLLOW, and after opening we fstat it
and refuse anything that is not a regular file we own, rejecting a
planted symlink or a foreign-owned file.
- The lock file is no longer unlinked on release; flock drops on close,
and leaving the file in place avoids an unlink/relock race (a stale
file is harmless).
Tests cover acquiring and conflicting on the lock, the 0600 mode, that
release leaves the file in place, and that a symlinked lock path is
refused.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>