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