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>
35 KiB
Sent-history sweep: GUI watermark fix, then daemon follow-up
Audience: ObsidianDragon (GUI) team and the DragonX daemon maintainer. Date: 2026-10-16. Status: proposal.
Code cited
- GUI:
ObsidianDragon@064c9d1(unprefixed paths). - Daemon:
dragonx@e9247f5c6(perf/history-rpc-copies), paths prefixeddx:.test/v1.3.1-candidate@ff28e34c8is the same plus a crash fix; on that branch,dx:wallet.cpplines past ~1263 shift by +10.
Anything not marked (inference) or (estimate) was verified in the code or measured.
1. Problem
The wallet measured: an isolated copy of the owner's mainnet wallet, run in a network namespace with no peers.
- 16,206 Sapling notes, all unspent.
- 5 z-addresses and 11,889 wallet txs.
- Unencrypted;
wallet.datis about 2.1 GB.
| Call | v1.3.0 | After daemon PR-A+B+C |
|---|---|---|
| Balance RPCs | ~14 s | ~50 ms |
Windowed z_listsentbyaddress (filter type 2) |
19.5 s | 128 ms |
Full z_listsentbyaddress (no watermark) |
424 s; RSS 8 → 19.4 GB, never released | 113 s |
| Full-sweep reply | 26.2 MB (all 11,889 txs) | same |
Why every launch pays for the full sweep
- The watermark lives only in memory.
history_sent_scan_height_starts at-1(src/app.h:1764). - Every disconnect resets it to
-1. That covers daemon restarts, reconnects and wallet switches. The path isonDisconnected→resetTransactionHistoryCacheSession→invalidateShieldedHistoryScanProgress(false)(src/app_network.cpp:936,:1847,:1889-1895). - Other resets:
- a locked-wallet connect;
- imports (
:4013); - rescans (
:7279,:7302;src/app.cpp:6298) and zap (app.cpp:6352); - a new, never-scanned z-address (
app_network.cpp:2593-2597); - the end of a warmup during which a rescan was observed (
:2186-2195).
- No watermark means a full sweep.
sentSweepIsFullis then true (src/services/history_scan_policy.cpp:35-38). The GUI sends[z,0]viacallLong(..., kFullSentSweepTimeoutSec = 120)(src/services/network_refresh_service.cpp:1324-1325,history_scan_policy.h:31). - The daemon ignores the address argument. It walks every wallet tx under
LOCK2(cs_main, cs_wallet)and callszsWalletTxJSON(wtx, ret, "*", false, 0, …)for each one (dx:src/wallet/rpcwallet.cpp:3719-3746; the call is at:3743). - The full sweep barely fits, or never fits, the timeout.
- On the fixed daemon the sweep finishes only 7 s inside the 120 s timeout.
- On v1.3.0 it never finishes. Failures back off 10 min → 1 h → 6 h (
fullSentSweepRetryDelaySec). That backoff is also kept only in memory (app.h:1765-1766).
What the GUI uses from the reply
- Sweep consumer (
network_refresh_service.cpp:1328-1332,appendSentTransparentOutputs~:1076-1123):- It reads only
txid,confirmations,blocktime/timeandsends.transparentSends. - It never reads
received(20.8 MB of the 26.2 MB) orspends.
- It reads only
- Shielded sends come from enrichment. Every txid goes into
knownTxidsforz_viewtransactionenrichment, capped at 25 per cycle (app.h:1753). The rest wait in the in-memoryhistory_enrich_backlog_. - One exception:
locateSweepTxid(seed-migration resume) does readsends.saplingSends(app_network.cpp:6394).
2. Part 1: GUI fix (persist the watermark)
Goal: a relaunch, reconnect or daemon restart runs only the windowed sweep, [z,0,2,(tip-W)+margin]. A full sweep then happens only in three cases:
- there is no persisted state;
- the persisted state fails verification;
- the periodic safety-net re-sweep is due.
2.1 Where to store it
Use the existing encrypted history snapshot. Store an optional sent_sweep object in its JSON payload, not in settings.json or wallets.json. Why:
- The watermark is only valid together with the rows it produced. Both are written in one
INSERT OR REPLACEin WAL mode (src/data/transaction_history_cache.cpp:438-457), so a crash leaves the previous pair intact. - The snapshot already provides what the watermark needs:
- per-wallet keying (BLAKE2b of the sorted z+t address set,
:157-186); - encryption (Argon2id from the passphrase, or salted BLAKE2b when the wallet is unencrypted);
- a tip check on load (
:338-344); - one-wallet pruning (
pruneOtherWallets,:655-672).
- per-wallet keying (BLAKE2b of the sorted z+t address set,
- Keep
kSchemaVersion = 1.load()rejects any other version (:351), so a bump would throw away every user's cache.- Unknown keys are ignored, so older builds read new payloads safely.
- A missing
sent_sweepmeansW = -1, which is today's behaviour.
| Field | Type | Meaning |
|---|---|---|
height |
int | W: the tip used for the sweep's maxDepth. Read in the same worker job, right before the call |
anchor_height |
int | A = W - margin: the bottom edge of the window the sweep covered |
anchor_hash |
hex | H_A = getblockhash(A), fetched by the worker after the sweep succeeds |
backlog |
[txid] | history_enrich_backlog_. Required, see 2.4 |
full_failures, full_retry_delay_s |
int | Full-sweep backoff. The delay is relative to save time, not absolute |
daemon_id |
string | Daemon version + start time, from getinfo / uptime. If it changes, the restored backoff resets (2.5) |
last_full_height, last_full_time |
int | Last successful full sweep; drives the safety net (2.6) |
txcount, k |
int | Optional tripwire (2.3). Both captured in the sweep's worker job |
Do not reuse the snapshot's tip_height/tip_hash columns. They record the tip at store time, not at sweep time (app_network.cpp:2069-2070).
2.2 Code changes
| File | Change |
|---|---|
src/data/transaction_history_cache.h:20-28, :33-48, :79-84 |
Add a SentSweepState struct to LoadResult, SnapshotJob and replace() |
src/data/transaction_history_cache.cpp:412-426 (writeSnapshot) |
Emit sent_sweep only if 0 < A <= W <= job.tipHeight |
src/data/transaction_history_cache.cpp:351-370 (load) |
Parse with defaults (W=-1, empty backlog). Drop the object if it has the wrong type or W is above the stored tip_height |
src/data/transaction_history_store_policy.h:35-41 / .cpp:62-113 |
Add W/A/H_A to the slow signature. They move every block, so this means a write every 600 s and always on disconnect, lock and exit. Add the backlog to content (written within about 20 s). Otherwise an invalidation that only changes the watermark is skipped as "unchanged". Update both callers (app_network.cpp:2012-2014, :2041-2045). The epoch counter is never persisted, so it doesn't belong in a signature |
src/app_network.cpp:2066-2079 (storeTransactionHistoryCacheIfAvailable) |
Fill job->sentSweep from the history_sent_* and backlog members |
src/app_network.cpp:1998-2017 (loadTransactionHistoryCacheIfAvailable) |
Restore the members. ++history_sent_epoch_. Set history_sent_verify_pending_ = true. Apply any pending rescan action (2.3) |
src/app_network.cpp:2028 |
The store returns early when there are no rows. On a persistCache=true invalidation with no rows, call clearWallet() so a stale W can't survive on disk |
src/services/network_refresh_service.h:256-266, :299 |
New snapshot/result fields: anchorHeight/anchorHash in, the verify result and H_A out, and the band cursor |
src/services/network_refresh_service.cpp:1314-1345 |
Read the tip immediately before the call. After success, fetch getblockhash(A). Add the verify step (2.3) and the large-window path (2.5) |
src/app_network.cpp:2784-2792 |
Gate both the end-of-pass sweep and the mid-pass windowed sweep (allowMidPassSentSweep) on the pending verify. Extend the retry gate at :2792, which today applies only when W < 0, to large windows |
src/app_network.cpp:2843-2854 |
Count large-window failures toward the backoff; today only sentSweepFull failures count (:2849). Store the band cursor in App and reset it with history_sent_epoch_, like the watermark |
src/app.cpp:restoreOriginalWallet |
Call invalidateShieldedHistoryScanProgress(true) before onDisconnected, as rescanBlockchain and zap already do (app.cpp:6298, :6352) |
src/services/history_scan_policy.{h,cpp} |
Pure helpers: sentSweepAnchor(W), sentSweepNeedsLongCall(tip, W), sentSweepMargin(firstAfterLoad), nextSentSweepBand(...) |
2.3 Invalidation and safety rules
Verify at the bottom edge of the window, not at its tip.
- The check: before the first windowed sweep after a load, call
getblockhash(A)and compare it withH_A.- Match: any fork point lies above
A, so it is inside the window. Run the windowed sweep. - Mismatch: a reorg went deeper than the margin, or this is a different chain or datadir. Drop W and run the banded full sweep (2.5).
- Match: any fork point lies above
- Why not the tip: checking
getblockhash(W)would trigger a full sweep on an ordinary one-block reorg of the tip, even though the window already covers that reorg.
| Event | Action | Mechanism |
|---|---|---|
| First windowed sweep after a load | Anchor verify (above) | getblockhash: O(1), under cs_main |
Stored tip_height > current tip at load (replay after an unclean stop, older datadir) |
Today: load() deletes the whole snapshot (cache.cpp:338-344, clearWalletByHash at :341). Rows and W are lost, and a full sweep runs |
Keep as the default. Optional improvement: have load() keep the rows and sent_sweep, flag "ahead", and defer the sent sweep until tip >= A, then verify H_A. That requires changes in sentSweepIsFull (history_scan_policy.cpp:37: W > tip means full today), the gate at app_network.cpp:2789-2792, and the test at tests/test_phase4.cpp:15831 |
| Wallet switch, or an address added or imported while the GUI was closed | Nothing new | Different address set → different snapshot key → full sweep |
| GUI-initiated rescan, zap, import, runtime rescan | Already invalidate(true); with 2.2 it now reaches disk |
app.cpp:6298, :6352; app_network.cpp:4013, :7279, :7302 |
| New never-scanned z-address during a session | Already resets W (:2593-2597); the reset must be persisted |
Signature change in 2.2 |
| Disconnect, daemon restart, same-wallet reconnect | Keep today's in-memory reset | The disk copy is written first (:857) and reloaded after connect |
Rescan observed in the daemon log (the daemon's catch-up "Rescanning last N blocks (from block H)", or an external -rescan) |
Sticky pending action (see below) | The warmup-end invalidation at :2186-2195 runs before the snapshot loads, so today it can't reach a persisted W |
Wallet changed outside the GUI with the same address set (Console z_importviewingkey, external -rescan, same-seed restore) |
Optional tripwire: after the first windowed sweep returns R rows, run the banded full sweep if txcount − txcount_saved > |R| − k + slack. k is the rows of the saved sweep within the same 100 + E depth band the first window uses |
getwalletinfo.txcount = mapWallet.size() (dx:rpcwallet.cpp:2791). The GUI already fetches it on every connect (network_refresh_service.cpp:251-252, :295-298; app_network.cpp:2140-2143). Measured cost: ~8-20 ms warm, ~180 ms on the first call after a start |
| Daemon version change | Keep W. Reset the persisted backoff | Same filter semantics since 2020; v1.3.0 and the candidate both report 1.3.0.50 |
The sticky rescan signal. Why it's needed:
- During warmup no address list is fetched (
scan_policy.h:42-44,:64-68;app_network.cpp:2492), andstate_.clear()has emptied the rows (wallet_state.h:406-409). - So when
invalidate(true)runs atapp_network.cpp:2195, it finds an empty identity and returns (:2028,:2032). - The cache then loads afterwards (
:2608) and would restore the old W unchanged.
The fix:
- Whenever
rescan_confirmed_active_is set (app.cpp:1193,:1324,:1397), record a pending action that survivesresetTransactionHistoryCacheSession:- clamp to
H, parsed from "Rescanning last N blocks (from block H)" or "Rescanning from height H"; - full reset if
His unknown or near genesis.
- clamp to
loadTransactionHistoryCacheIfAvailableapplies it to the loaded W (W = min(W, H), andAfollows) and to the receive-scan heights, then clears it.
With daemon item D4 (wallet locator on clean shutdown, 3.2) shipped, the daemon's catch-up rescan line disappears from normal starts.
Reorg overlap.
- Window extent. Filter type 2 drops only txs deeper than N (
dx:rpcwallet.cpp:3740), andmaxDepth = tip − W + margin. So the window covers heights ≥W − margin + 1, plus the mempool (depth 0 always passes). - Margin.
MAX_REORG_LENGTHdefaults to 99 (dx:src/main.h:79), and a longer reorg shuts the node down (dx:main.cpp:4314-4333).- A margin of 100 therefore leaves 1 block of slack, which tip drift between reading the tip and making the call can use up.
- Use margin ≈ 120 in steady state (recommendation), and read the tip in the same worker job as the call.
- First sweep after a load. Use margin
120 + Ewith E ≈ 1000 blocks (~10 h).- This absorbs wallet-notifier lag at save time:
ThreadNotifyWalletsapplies blocks asynchronously (dx:src/validationinterface.cpp:89-262), and no RPC exposes the wallet's processed tip. - E is an inferred bound, not a measurement.
- This absorbs wallet-notifier lag at save time:
- Large
-maxreorg. A-maxreorgset above the margin is caught by the anchor check. - Conflicted txs. Conflicted txs (depth −1) are dropped by both the full and the windowed sweep (
dx:rpcwallet.cpp:3725), so nothing changes there.
Locked-wallet rule.
- The rule: never advance or persist W from a sweep dispatched or completed while the wallet is locked, and never load or store the snapshot while it is locked.
- What already protects this today:
- History scans don't run while locked (
src/services/scan_policy.h:48-51). - An encrypted wallet's snapshot opens only after unlock (
app_network.cpp:797-798,:1944-1962). - Locking keeps W and stores immediately (
:1851-1859).
- History scans don't run while locked (
- What breaks if it's ever violated: a locked daemon wallet returns empty
saplingReceivedandsaplingSends, becauseGetSaplingHistoryKeysneeds spending keys (dx:rpchushwallet.cpp:19-39).- The fields the main consumer reads (txid,
transparentSends) don't depend on the lock. locateSweepTxidreadssaplingSends, so it must never run on a locked wallet.
- The fields the main consumer reads (txid,
2.4 Backlog rule (required)
- The risk. Enrichment is capped at 25 per cycle, and the txids that miss the cap go to
history_enrich_backlog_(app_network.cpp:2843).- A pure z→z send leaves only a change note, which produces no receive row (
network_refresh_service.cpp:972). - So its txid exists only in a sweep result.
- If W is persisted while those txids sit in the backlog, the sends are lost for good once the window moves past them.
- A pure z→z send leaves only a change note, which produces no receive row (
- The rule: persist the backlog together with W.
- Rejected fallback: "persist
W = -1while the backlog is non-empty" isn't enough on its own. Enrichment retries are unbounded (network_refresh_service.cpp:1521), so one txid that keeps failing would stop W from ever being persisted. If that fallback is wanted as well, cap the retries per txid.
2.5 Timeouts, large windows, banded full sweep
Large windows.
- The windowed sweep uses
rpc.call, which has curl's 30 s timeout (src/rpc/rpc_client.cpp:223). Only full-sweep failures count toward the backoff (app_network.cpp:2846-2853). - So a watermark saved days ago produces a huge "windowed" call that times out on every block, while the daemon keeps working on each abandoned call.
- Rule: if
tip − W > 2400(about 1 day of 36 s blocks), usecallLongand count failures the same way as for a full sweep.
Banded full sweep.
- The call: replace the single 113 s (fixed daemon) or 424 s (v1.3.0) call with depth bands,
z_listsentbyaddress(z, minconf=a, 2, maxDepth=b).minconf(dx:rpcwallet.cpp:3732) and filter type 2 (:3740) are independent filters, so each call returns exactly the depth band[a, b]. - Order:
- Go newest band first; the first band uses
minconf=0so it includes the mempool. - Overlap bands by at least 10 blocks, plus the blocks that arrive between calls, and dedupe by txid.
- Size bands adaptively, aiming for about 5 s each.
- Go newest band first; the first band uses
- Scheduling: run one band per history cycle, using the epoch-guarded band cursor from 2.2. All bands in one job would tie up the shared RPC worker for about 113 s, exactly as today.
- Watermark: take
Wfrom the tip at the first band, and set it only after every band has succeeded. - Don't page with
nCount. The iteration order isnOrderPos, not height (:3709,:3746). - Limits:
- Banding shortens each
LOCK2hold but not the total CPU. - On v1.3.0 every call has a ~19.5 s floor, because every
CWalletTxis deep-copied (inference from the windowed measurement). There the bands needcallLong.
- Banding shortens each
Backoff persistence.
- Store the failure count and the relative delay (2.1), so a relaunch doesn't immediately re-run a sweep that keeps failing.
- Reset both when
daemon_idchanges. Otherwise a user who installs the fixed daemon could wait up to 6 h for a full sweep that would now succeed.
2.6 Safety net
- When: about every 7 days, run one banded full re-sweep.
- Conditions: only when synced, unlocked, idle (not on the Mining tab) and admitted by the governor.
- On success, update
last_full_*. On failure, back off. - Why: it catches what the guards can't see, such as a Console
z_importviewingkey. That case is also missed within a session today.
2.7 Edge cases
| Case | Handling |
|---|---|
Stored tip < current tip: load() doesn't check the hash (cache.cpp:338-344) |
The anchor verify covers the watermark. Optionally extend load() too |
Transparent send rows from appendSentTransparentOutputs aren't reproduced by windowed sweeps |
Probably dropped on the next clean cycle (network_refresh_service.cpp:1356-1368, :1528-1539) (inference). Negligible on DragonX, where consensus bans non-notary/burn t-outputs (dx:main.cpp:1606, :1633, :1657). Optional fix: carry over send rows that listtransactions didn't produce |
locateSweepTxid runs a full sweep under the 30 s timeout (app_network.cpp:6388) |
That times out on this wallet. Switch it to a windowed or banded call |
| Watch-only addresses aren't part of the snapshot key | Covered by the tripwire and the safety net |
| Switching between two wallets | pruneOtherWallets keeps one snapshot, so a switch still costs a full sweep (open question 4) |
| GUI crash mid-sweep | W advances only on sentSweepOk, and the write is atomic |
| Regtest or testnet | Different z-address prefixes, so a different snapshot key |
2.8 Test plan
Unit tests (tests/test_phase4.cpp):
- In
testTransactionHistoryCache(:9057):- every
sent_sweepfield round-trips; - a missing key reads as −1;
- a wrong type,
W > tip_heightorA > Wreads as −1; - unknown keys are ignored.
- every
- Signatures (~
:9244): a change to W, A orH_Achangesslow; a change to the backlog changescontent. - Policy (~
:15829,:16098-16213):- the anchor is
W − margin; - the first window after a load uses
120 + E; tip − W > 2400usescallLongand counts failures;- band planning: boundaries, overlap, dedupe, and cursor reset on an epoch change;
- an anchor mismatch means a full sweep;
- the pending rescan action clamps W;
daemon_idchanges reset the backoff.
- the anchor is
Integration tests (regtest or -testnode):
- Full sweep, quit, relaunch: the log shows only
[z,0,2,N]calls. invalidateblockone block above A while the GUI is closed: the windowed sweep runs and returns the right rows.invalidateblockbelow A: the verify fails and a banded full sweep runs.- More than 25 z→z sends, then quit before enrichment finishes: after relaunch every send appears.
- A daemon started with
-rescanwhile the GUI was closed: the sticky action clamps or resets W. restoreOriginalWallet: W is invalidated.- Locked encrypted wallet: W doesn't advance and the snapshot isn't loaded.
Mainnet copy: relaunch against the isolated owner copy. Expect no [z,0] call and a sent sweep well under 1 s.
2.9 Expected effect
| Scenario | Today | With Part 1 |
|---|---|---|
| Relaunch or reconnect, fixed daemon | 113 s full sweep under LOCK2, 7 s from the timeout |
getblockhash plus one windowed sweep over 120 + E + the closure gap. Steady-state windows measure ~128 ms; the first window after a load is larger (unmeasured) |
| Relaunch, v1.3.0 daemon | 424 s; never fits in 120 s, so it loops in backoff with RSS at 19.4 GB | ~19.5 s+ windowed once a W exists. Seeding the first W needs the banded sweep |
| Safety net | — | One banded full sweep a week, each LOCK2 hold about 5 s |
3. Part 2: daemon follow-up
Where the 113 s goes (owner wallet, full sweep)
| Component | Cost | Basis |
|---|---|---|
| Trial decryption of outputs that aren't wallet notes: ~172,800 outputs × 5 ivks ≈ 864k failed tries × ~0.13 ms | ~112 s (~99%) | Output count derived from tx sizes; per-try cost fitted to the full (112.7 s) and windowed (126 ms / 740 tries) measurements (estimate). Code: OutputDecryptions::Get (dx:rpchushwallet.cpp:87-100), loop at :303-346 |
DecryptSent esk-path decrypts |
0 on this wallet | No tx spends a wallet note, so the sends helper is gated off (:586) |
UniValue build + copy under LOCK2 |
~0.28 s | Synthetic benchmark on the repo's UniValue |
| Reply serialization (after the lock is released) | ~0.31-0.34 s | Same benchmark |
Where the foreign outputs come from
Sietch padding. z_sendmany pads its z-recipients up to -sietch-min-zouts (default 7, clamped to 3-50; dx:rpcwallet.cpp:5424-5434), with change on top.
- Recipient and dummy outputs take the sender's ovk: the from-address's
fvk.ovk, or the seed ovk for t-address sources (dx:asyncrpcoperation_sendmany.cpp:352-368, applied at:437). - Consolidation (
saplingconsolidation.cpp:244), sweep (sweep.cpp:299), shieldcoinbase (:239-254) and autoshield (:435) also add dummies with the sender's ovk. - So a sending wallet's
DecryptSentopens every one of its own dummies. That is the ~0.36 ms esk-path decrypt each, which is D2. - Every other wallet sees them as foreign outputs and tries them under every ivk, which is D1.
On this wallet most foreign outputs are not sietch dummies. About 158.6k of the ~174k sit in the 9,597 txs with 10 or more outputs, which are mostly multi-recipient payouts (inference from tx sizes). That doesn't change D1.
Items
| # | Item | Output | Gain (owner wallet) | Risk | GUI change |
|---|---|---|---|---|---|
| D1 | Per-output negative ivk cache, plus parallel pre-fill of the txs a call selects | Byte-identical | Warm 113 s → 0.4-1 s. First full sweep after a restart ~20-25 s (pre-fill at the measured 4.75× warm-up scaling). A windowed call stays ~100 ms (estimate) | Low-med | None |
| D2 | DecryptSent cache per (output, ovk) |
Byte-identical | 0 here; ~0.36 ms per send output on sending wallets | Low | None |
| D3 | getalldata balance loop |
Byte-identical | ~20-35 s → 10-30 ms (estimate) | Low | None (the GUI never calls it) |
| D4 | Write the wallet locator on clean shutdown | n/a | Start: −49-62 s (post-rescan locator write, inferred from the log gap) and −5-10 s of block rescan; the ~25 s witness-root pass moves to the first live block. Shutdown: +3-60 s (inferred) | Medium | Check the stop policy |
| D5 | JSON trims | Byte-identical | 0.2-0.4 s | Low | None |
| D6 | Sends-only returnType (6th parameter) |
Changes output; opt-in | 113 s → 0.1-0.3 s; 26.2 → ~4.1 MB (estimate) | Low | Probe + opt-in |
| D7 | Honour the address argument | Changes output | None (slower) | Breaks the GUI | Not recommended |
- D6 is the single largest win, but it needs a GUI release.
- D1-D5 are invisible to the GUI.
- With Part 1 shipped, full sweeps become rare, so D1 mostly helps older GUIs, other clients and the safety net.
3.1 D1: per-output decryption cache (byte-identical)
Shape
- A
mutable std::vector<uint64_t>onCWalletTx, with one mask pervShieldedOutputindex.- It is sized once, never serialized, and cleared in
Init(). - Precedents:
cachedPlaintext(dx:src/wallet/wallet.h:340-348) and the mutable caches at:472-490.
- It is sized once, never serialized, and cleared in
- Bit k means "history key k does not decrypt this output".
- An append-only ivk → index registry on
CWallet, undercs_wallet. It is resolved once per RPC intoSaplingHistoryKeys::SpendableAddress(dx:rpchushwallet.cpp:19-39).
Logic in OutputDecryptions::Get
- If the bit is set, return null without decrypting.
- After a failed
Decrypt, set the bit. - Never cache a success: wallet notes already carry
cachedPlaintext. - Keys with index ≥ 64 aren't cached.
Invariant: the result is a pure function of (txid, n, ivk). A v4 txid commits to every output byte, so:
- nothing ever goes stale;
- a newly added key has a clear bit and is still tried;
- locking, or losing keys, only means fewer keys are consulted;
- reorgs, deletions and reloads are harmless;
- output stays byte-identical.
Thread safety
- The mask is mutable behind a
const CWalletTx&, so the compiler won't enforce locking. Every mask read and write therefore happens on the RPC thread undercs_wallet, with anAssertLockHeldin the accessor. - Pre-fill workers follow
FillSaplingNoteCache's rule that workers must not lock, or even assert a lock (dx:wallet.cpp:5745-5746). They call onlydecrypt_with_noteon pre-resolved (output, ivk) pairs and write private masks, which are merged serially after the join. - Workers must not call
GetSaplingHistoryKeysorIsNullifierOfAddress, because both assert locks.
Fill
- Lazily, as the helpers run.
- In addition, pre-fill in parallel, but only the txs the call has already selected:
- In
z_listsentbyaddress, first collect thewtxpointers that pass the filters atdx:rpcwallet.cpp:3722-3741, honouringnCount. - Pre-fill their outputs, using the 128-item threshold and
-witnessbuildthreadsfromdx:wallet.cpp:5739-5797. - Then emit.
- In
- Pre-fill time is spent under
LOCK2. For a full sweep that's ~20-25 s once; a windowed call stays small. - Optionally, also record the misses
FindMySaplingNotesalready computes (:2282-2322). - A startup precompute is opt-in only: ~112 CPU-s, about 24 s of wall time per start (estimate).
Also benefits: getalldata's tx list (dx:rpcwallet.cpp:3254) and z_listreceivedaddress (:3555).
Tests
- Extend the verbatim oracle
dx:src/gtest/test_wallet_history.cpp: cold, warm and pre-filled runs must match byte for byte. - A key added after the mask was filled, which opens a foreign output, must appear in the output.
- More than 64 keys; locked and encrypted wallets;
-witnessbuildthreads=1vs 24; aDEBUG_LOCKORDERbuild. - The mainnet A/B harness:
cmpcold and warm replies against the captured 26,233,144-byte reply (/home/d/dragonx-ab/mainnet-ab/out/B/cap/023_z_listsentbyaddress_addr0_full.json).
D2. Cache DecryptSent results per (n, ovk index): the plaintext and address on success, or "no". That's about 650 B per entry. Key the seed ovk by value, so lock state still decides whether the cache is consulted. Use the same oracle tests as D1.
3.2 Smaller items
D3: the getalldata balance loop (dx:rpcwallet.cpp:3108-3151, under LOCK2 at :2998).
- Today it copies each
SaplingNoteDataby value, witnesses included (:3110). For every unspent note and every address it re-derives the spending key and trial-decrypts (:3117-3121). - Fix:
- build
SaplingHistoryKeysonce, and reuse it at:3233; - take note data by
const&; - use
OutputDecryptions(wtx, op.n).Get(a.ivk)overkeys.spendable, movingOutputDecryptionsout of its anonymous namespace (rpchushwallet.cpp:56-120) intorpchushwallet.h.
- build
- Keep two quirks for byte-identity, and fix them later as a separate change with a release note:
- The no-op
txType == 3;at:3124means locked notes are never counted as locked. When fixed, use a per-note local,noteType = (txType == 0 && IsLockedNote(op)) ? 3 : txType.txTypeis per tx (:3043), so assigning to it would mark every later note of the tx as locked. - Every address whose ivk opens the note is credited, because the
continuedoesn't exit the address loop.
- The no-op
- Don't switch to
GetFilteredNotes: that changes the output. - Test: an oracle gtest with a verbatim copy of the old loop (shared-ivk, locked-note, watch-only and encrypted cases).
D4: write the wallet locator on clean shutdown.
-
Evidence: the copy, taken from a clean Windows shutdown, was 12 blocks behind its chainstate. So every start rescans those blocks.
-
Cause:
FlushStateToDiskwrites the locator only ifDATABASE_WRITE_INTERVAL(3600 s,dx:main.h:145) has passed, even forFLUSH_STATE_ALWAYS(dx:main.cpp:3838-3841).Shutdown()calls it atdx:init.cpp:259.pwalletMain->Flush(true)at:280only flushes BDB.
-
Hook: in
Shutdown(), between:259and:280:LOCK2(cs_main, pwalletMain->cs_wallet); const CBlockIndex* tip = pwalletMain->GetWalletTip(); // new accessor: pindexWalletTip is private (wallet.h:988-994) if (tip && tip != lastStamped) pwalletMain->SetBestChainINTERNAL(walletdb, chainActive.GetLocator(tip)); -
Don't route it through
CWallet::SetBestChain. That swaps inpindexWalletTipwhen set but falls back to the passed locator when it's null (dx:wallet.cpp:829-834). The null case is exactly "stamp the tip before the init rescan ran", which would skip the unscanned blocks for good. -
The failure path needs the lock too. AppInit2's failure path calls
Shutdown()withoutjoin_all(dx:bitcoind.cpp:232-243), so the notifier can still be running. Reading the tip underLOCK2keeps that safe. -
Don't drop the interval check globally.
gettxoutsetinfoand init also flush with ALWAYS, and each would then rewrite ~2 GB undercs_main. -
Trade-off: one locator write rewrites every note-bearing
CWalletTx, ~2 GB here (SetBestChainINTERNAL,dx:wallet.h:939-986, WriteTx loop:947-962). The cost moves to shutdown rather than disappearing; see the D4 row in the table above. -
Ship it together with a parallel witness-root check at init.
-
Risk: the GUI's stop policy (
src/daemon/daemon_controller.h:360,evaluateStopWait) must allow about 16 s plus 3-60 s. Context: the 2026-09-30 Windows kill loop. -
Tests:
- Stop within the hour and restart: no "Rescanning last" line, and identical balances.
- Stop during the startup rescan: the locator does not advance.
D5: JSON trims.
- Use
static_cast<const CTransaction&>atdx:rpchushwallet.cpp:553. Today it copies 190.6 MB of tx data per full sweep. - Drop the
getValues/ reverse /push_backVcopy (dx:rpcwallet.cpp:3749-3755). Collect the selectedwtxpointers in the existing reverse loop, which keepsnCountpicking the newest txs, then buildretoldest to newest. The same pattern is atgetalldata:3259-3265.
Also found; each needs its own design.
- The hourly
SetBestChainrewrites ~2 GB whilecs_mainis held (3-47 s gaps in the live log, inferred from log gaps). FindMySaplingNotestries foreign outputs once per viewing key and again per address (dx:wallet.cpp:2282-2322). De-duplicating ivks would probably halve the per-block tries (inference).
3.3 Options that change RPC output (need a GUI opt-in)
D6: sends-only mode.
- The parameter: add an optional 6th parameter
returnTypetoz_listsentbyaddress, default 0 (unchanged output).- The code's meanings are 1 = spends, 2 = received, 3 = sends. The comment at
dx:rpchushwallet.cpp:561-566swaps 1 and 3; fix it in the same change.
- The code's meanings are 1 = spends, 2 = received, 3 = sends. The comment at
- With
3and no address filter:- the spends helper still runs (
:569), but its output isn't emitted; received, where all the trial decryption happens, is skipped (:576-581);sendsis emitted (:584-592), and every tx is still pushed (:594-598).- The GUI gets the same txids and the same
sends.transparentSends.
- the spends helper still runs (
- Daemon changes:
- Raise the check at
dx:rpcwallet.cpp:3606from> 5to> 6, keeping the== 3rejection. - Change
:3693toparams.size() >= 5. Today it readsnCountonly whensize() == 5, so every 6-parameter call would silently drop it. - Read
params[5], reject anything outside 0-3, and document it in the help text.dx:src/rpc/client.cpp:165already converts index 5.
- Raise the check at
- GUI side: send 6 parameters. An old daemon throws its help text for more than 5, so fall back to 5 parameters and remember that per connection.
- Alternative: a new RPC name, probed through "Method not found" the same way as
z_autoshieldstatus(app.h:1744-1747).
- Alternative: a new RPC name, probed through "Method not found" the same way as
- Owner decision: a variant that returns only txs with wallet spends would return 0 rows here, but it changes the
knownTxidsset.
D7: honour the address argument.
- The GUI passes
shieldedAddresses.front()(network_refresh_service.cpp:1319) and relies on the daemon using"*"(dx:rpcwallet.cpp:3743). Honouring the argument by default would silently shrink GUI sweeps to one address. - It isn't faster either: with a filter set,
fBool=truerunszsTxSendsToJSONfor every tx (:586). - Recommendation: document
"*"as a compatibility contract. If honouring the argument is ever wanted, put it behind a flag.
Additive and safe for the GUI: a wallet-tip / history fingerprint.
- Expose
pindexWalletTip, for example ingetwalletinfoor a newz_getwallethistorystate(maxheight)returning{count, txid-hash, wallet_tip}. - The GUI ignores unknown JSON fields.
- This would make Part 1's invalidation exact, replacing the E-margin guess and the
txcounttripwire.
4. Rollout and compatibility
| Step | Ships in | Works with | Notes |
|---|---|---|---|
| Part 1: watermark + anchor verify + backlog + sticky rescan + large-window path + banding + safety net | Next GUI release | Every daemon version; it uses only existing z_listsentbyaddress parameters, getblockhash, getwalletinfo and getinfo |
Old GUI with a new snapshot: the key is ignored. New GUI with an old snapshot: one full sweep, which is today's behaviour. On v1.3.0, seeding the first W needs the banded sweep |
| D1, D2, D3, D5 | Daemon v1.3.1 / v1.3.2 | Invisible to the GUI | Gate each on the byte-identical oracle and the mainnet cmp |
| D6 + GUI probe | Daemon first, then GUI | The GUI falls back on old daemons | Makes the safety net and any full sweep cheap |
| D4 | Together with a parallel witness-root check | Invisible apart from shutdown time | Confirm the GUI stop policy first |
5. Open questions
- How big is the backlog after the first full sweep on the owner's wallet? Unmeasured; it sizes the persisted
backlogfield. - What is the full-sweep time on the owner's 24-core Windows node? The 113 s was measured on Linux/WSL, 7 s inside the 120 s timeout.
- How long does the wallet notifier lag after a catch-up on this wallet? This sets E.
- Should the snapshot store keep more than one wallet, so that switching wallets also benefits?
- D6 shape: a 6th parameter or a new RPC? Should a "wallet-spends-only" variant exist?
- Does any client besides ObsidianDragon depend on
returnType 0output? This sets D1's priority once D6 exists. - Are the 5 ivks distinct? Assumed. The per-address note counts sum to exactly 16,206, which fits but doesn't prove it.
- Does the owner want the additive wallet-tip / fingerprint RPC, which makes Part 1's invalidation exact?