Skip to content

Mobile: instant transaction history — RAM row cache, lock-free row taps, nav fixes#9

Open
Dirtybird99 wants to merge 6 commits into
moralpriest:mainfrom
Dirtybird99:history-instant
Open

Mobile: instant transaction history — RAM row cache, lock-free row taps, nav fixes#9
Dirtybird99 wants to merge 6 commits into
moralpriest:mainfrom
Dirtybird99:history-instant

Conversation

@Dirtybird99

Copy link
Copy Markdown

Summary

Transaction history on mobile took seconds to appear (and sometimes stalled outright), despite earlier optimization of the row-formatting path. This PR makes History paint instantly on every revisit (and, after a short warm-up, on first visit too), makes tapping a row open the detail view without ever touching the wallet lock, and removes wasted layout work on the mobile back path. No behavior of the underlying wallet sync is changed; derohe is untouched.

Root cause

Profiling the formatting layer was a dead end — by the time of this PR it built 20k rows in <1ms. Tracing into derohe (walletapi) showed:

  • All wallet entries already live fully decoded in RAM (account.EntriesNative[scid], kept sorted). Show_Transfers is a cheap O(n) post-filter — milliseconds even on a phone.
  • But Show_Transfers/Get_Payments_TXID take the wallet's exclusive RWMutex (wallet.go:267), and Save_Wallet holds the same lock while it json.Marshals and AES-encrypts the entire account — including the full history — every sync cycle (wallet_memory.go:268-305). On a mobile CPU with a long history, any history fetch randomly blocks for seconds behind that save, and it gets worse as history grows.
  • Engram amplified this: every visit to History re-ran the full fetch (menu auto-select), and every row tap ran two more full locked scans — one of which fed a label that was never added to any container (invisible dead work).

The lock lives in a dependency, so the fix is to stop making the user wait on it.

What changed (commit by commit)

  • 69c0312 — RAM row cache, instant paint, background refresh, login pre-warm. New history_cache.go: formatted rows cached per wallet address + view (Normal/Coinbase/Messages). Revisits paint synchronously from cache with Results: N (updating), while the same full fetch as before runs underneath and swaps in only if anything changed (slices.Equal skip → no flicker, scroll preserved). The Normal view is pre-warmed ~3s after login so the first visit is instant too. Cache is cleared on wallet close (privacy) and an address mismatch can never serve another wallet's rows. Cold path (no cache) is byte-for-byte the previous behavior. Unit tests incl. a -race concurrency test. (Also folds in the previously uncommitted streaming groundwork this builds on: extracted row builders + streamed reveal + gate tests.)
  • 2b709eb — stop scanning the wallet on every row tap. Deletes the dead invisible-label scan; layoutHistoryDetail(txid, *rpc.Entry) now receives the entry from a TXID-indexed copy of the rows on screen (nil → old scan as fallback, only reachable before the first fetch lands). Tap→detail no longer touches the wallet mutex.
  • 1748786 — mobile back button no longer builds every layout twice (discarded layoutDashboard()/layoutMain() builds, which also double-registered their listeners).
  • becfe72 — drop layoutTransition where it never paints. Both SetContent calls run synchronously in one event handler, so the transition GIF was decoded + animated and never shown (and the back path left it animating unseen). Removed on the History round trip and back handler only; layoutTransition itself is untouched.
  • 9637707 — generation counter for in-flight loads. Switching views while a fetch is stalled behind a save can no longer let the stale fetch repaint the new view's rows or count (latent flaw in the original async load).
  • 4e47dd6 — adversarial-review fixes. (1) crash: the load goroutine re-dereferenced the global engram.Disk after the fetch; signing out mid-fetch nils it → panic in a bare goroutine kills the app. The wallet is now snapshotted once and threaded through (same pattern as warmHistoryCache). (2) privacy: the cache can no longer be repopulated after closeWallet() cleared it (wallet-identity re-check before put). (3) parity: multi-payload transactions repeat a TXID; the index now keeps the first entry, matching the old scan's selection.

Verification

  • go build ., go vet ., go test . green after every commit; go test -race -run HistoryCache . green (go1.26, fyne v2.6.2).
  • Existing differential gate tests (row format, money/date, stream invariance, headless list smoke) untouched and passing.
  • A multi-agent adversarial review pass over the branch; all confirmed findings fixed in 4e47dd6.

On-device checklist (what reviewers should try)

  1. Login → open History within ~3s → cold path: "Scanning..." + progressive reveal (unchanged).
  2. Login → wait >5s → open History: instant paint, Results: N (updating)Results: N.
  3. History → dashboard → History: instant, no "Scanning..." flash.
  4. Switch Normal/Coinbase/Messages rapidly, incl. mid-fetch: no cross-view rows, no stale counts.
  5. Tap a row mid-sync: detail opens instantly.
  6. Sign out while History is refreshing: no crash.
  7. Close wallet A, open wallet B: B never shows A's rows.
  8. Mobile back button from History: single rebuild, instant repaint.

Known limitations / accepted trade-offs

  • A cold fetch can still stall behind the save lock (that's derohe's locking design); it is just never user-visible anymore because rows are already painted.
  • The detail view renders the entry as of the fetch that produced the visible rows (consistent with what the list shows) rather than a tap-time re-scan.
  • The pre-existing derohe race (sync's InsertReplace writes EntriesNative without the lock readers take) is unchanged — this PR neither fixes nor worsens it.
  • Millisecond-scale window where a login pre-warm can cache rows for a wallet closed at that exact instant; address-keying prevents any cross-wallet display.

🤖 Generated with Claude Code

Dirtybird99 and others added 6 commits July 2, 2026 08:13
…-warm

The wallet's Show_Transfers takes the account's exclusive lock, which
Save_Wallet holds while marshalling and encrypting the entire history
every sync cycle — so the history fetch intermittently stalls for
seconds on mobile. Stop making the user wait on it: cache the formatted
rows (per wallet address, per view) in RAM, paint them synchronously on
revisit, and swap in a fresh full fetch underneath only when something
actually changed. Warm the Normal view a few seconds after login so
even the first visit paints instantly. Cache is dropped on wallet
close/switch (privacy) and an address mismatch can never serve another
wallet's rows.

Also lands the previously uncommitted history groundwork this builds
on: the extracted row builders/streamed reveal (history_format.go), the
async load() in layoutHistory, gate tests, and sandbox/ ignore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A row tap ran two full wallet scans under the exclusive account lock:
one feeding a label that was never added to any container (dead since
the detail overlay replaced the inline panel), and one inside
layoutHistoryDetail to re-find an entry the list already had. Delete
the dead scan and pass the entry into the detail view via a
TXID-indexed copy of the rows on screen; the by-TXID fallback scan
remains only for taps that land before the first fetch completes.
Tap-to-detail no longer touches the wallet mutex, so it can no longer
stall behind a wallet save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The back handler built layoutDashboard()/layoutMain() into
session.LastDomain, immediately overwrote it with the current window
content, then built the same layout again for SetContent. The first
build was pure discarded work — and each dashboard build starts its
own listeners, so it also double-registered those. Drop the discarded
assignment (BUG_NOTES DEROFDN#13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every nav site swaps SetContent(layoutTransition()) and then the real
screen synchronously inside the same event handler; Fyne paints only
after the handler returns, so the transition frame is never shown. On
the History round trip and the mobile back handler that build was pure
overhead (GIF re-decode + animation start per navigation) — and the
back handler never removed its overlay, leaving the GIF animating
unseen afterwards. Remove it from these paths only; layoutTransition
itself stays (shared with showLoadingOverlay).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
If the user switches views while a fetch is still stalled behind a
wallet save, the older goroutine could finish later and repaint the
previous view's rows and count under the new view's heading (a latent
flaw in the original async load too). A per-screen generation counter,
bumped on every load and checked inside each fyne.Do, makes superseded
fetches drop their UI updates; their result is still cached, since the
cache is keyed per view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review of the branch surfaced three defects:

- Crash: load()'s goroutine re-dereferenced the global engram.Disk
  after fetch() returned. Signing out while a fetch sat behind the
  wallet's save lock nils the global, and the goroutine then panicked —
  an unrecovered panic in a bare goroutine kills the app. The wallet is
  now snapshotted once on the UI thread and threaded through fetch, so
  the goroutine never reads the global (same pattern as
  warmHistoryCache).
- Privacy: the unconditional histCache.put could repopulate the cache
  seconds after closeWallet() cleared it. The put is now guarded by a
  wallet-identity re-check.
- Parity: multi-payload transactions repeat a TXID; the byTXID map kept
  the last entry while the old tap-time scan returned the first match.
  The map now keeps the first, matching the old detail view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant