Skip to content

feat: store shielded notes so the balance can be read without the password - #170

Merged
pshenmic merged 10 commits into
developfrom
feat/shieldedCache
Sep 28, 2026
Merged

pshenmic merged 10 commits into
developfrom
feat/shieldedCache

Conversation

@LexxXell

@LexxXell LexxXell commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Every shielded read pages the whole note pool, trial-decrypts it with the wallet's viewing key and therefore needs the password. Unlocking the extension does not keep it: CHECK_PASSWORD only verifies, and each handler takes the password in its own payload. So the popup has to ask for it again before it can show anything shielded, and again after every reopen.

Changes

The wallet's shielded notes are now kept in storage, the way the desktop wallet keeps them in sqlite (shielded_notes + ShieldedNoteDAO), so the UI can render them with no password at all.

  • SYNC_SHIELDED_NOTES (password) — brings the stored notes up to date and saves addresses, recovered notes and how many pool notes have been trial-decrypted. Meant to be called once, right after unlocking. Covers every seedphrase wallet of both networks unless one is named, so switching networks afterwards needs no second password prompt; keystore wallets hold no seed and are skipped.
  • GET_SHIELDED_SYNC_STATE — what the last sync left: balance, spendable note count, addresses, notes, fetched, total, updatedAt and phase. No password and no network call. An account never synced answers empty with updatedAt: null instead of failing, and phase (idle / syncing / done / error) tells a sync still running apart from one that never happened — the backend outlives the popup, so a reopened popup sees syncing.
  • REFRESH_SHIELDED_NOTES (no password) — for a dashboard refresh button: re-checks the stored notes against the nullifier index, so a spend made elsewhere lowers the balance, and re-reads the pool size. Nothing is trial-decrypted, so notes added since the last sync raise total without being recovered; comparing it with fetched tells the UI a full sync would find more.
  • GET_SHIELDED_BALANCE is untouched and stays the live path.

How the sync stays cheap

  • Only the part of the pool that has not been trial-decrypted is scanned. Notes are appended to the pool in leaf order, so fetched doubles as the offset to resume from. recoverNotes numbers what it is handed from zero, so the offset is added back to keep each note's global leaf position.
  • Platform serves the pool in chunks the size of a full query and rejects a read that starts inside one, so a resumed scan rewinds to the chunk boundary below its offset. Over the wire that re-reads at most one chunk, whatever the pool size; nothing already scanned is trial-decrypted again. A sync that finds the pool unchanged reads nothing at all and only re-checks spent notes.
  • Each network has its own pool, read once per sync for all of that network's wallets, starting at the offset of the one furthest behind; each wallet trial-decrypts its own slice. The extension's own SDK serves the selected network; the other one gets a read-only SDK created once.
  • Only notes still believed unspent are re-checked against the nullifier index; a note once spent stays spent.
  • A record claiming more notes than the pool holds is not trusted and is rescanned from the start.
  • A per-wallet Web Lock keeps two syncs of one wallet from appending the same notes twice; a wallet whose sync fails is reported in its own entry and does not stop the others.

Storage keeps the notes in the clear (values and addresses included), same as the desktop wallet. No migration: the records live under new keys and are built lazily.

Layering follows the desktop's shape: ShieldedNotesRepository is pure storage (key shieldedNotes_<network>_<walletId>), ShieldedService exposes the domain primitives (read the pool, recover notes, refresh spent flags, derive addresses, prepare a spend, shape the sync state), and the handlers orchestrate them.

The shielded logic that lived in src/utils/index.ts moves into that service too — 234 lines: deriveShieldedAddresses, fetchAllShieldedNotes, getShieldedNullifierStatuses, recoveredNoteNullifier, sumUnspentShieldedValue, filterRecoveredNotesByAddress, loadUnspentShieldedNotes and prepareShieldedSpend. utils is for utilitarian helpers, not application logic; it ended up there because there was no shielded service when the first shielded PR landed. Seven handlers now take the service (getShieldedBalance, getShieldedAddresses, generateShieldedAddresses, estimateShieldedFee, sendShieldedTransfer, unshieldToAddress, withdrawShieldedToCore), and their tests spy on it instead of mocking the module. The pure fee formula (shieldedFee.ts) stays in utils.

Backend only — no UI changes.

Testing

  • tsc --noEmit, ts-standard, npm run build
  • New test/api/private/wallet/shieldedNotes.spec.ts (17 cases): first scan stores notes with their leaf positions; a second sync reads only what the pool gained; a resumed scan starts at a chunk boundary; an unchanged pool is not read at all; one pool read serves several wallets and starts at the furthest behind; notes spent since the last sync are marked and never re-queried; a shrunken pool triggers a full rescan; a failing wallet is isolated and keeps its stored state; reads need no password and touch no network; an unsynced account answers empty; payload validation; a keystore wallet is refused; both networks are covered; the phase is syncing mid-run and error on a failure; a refresh marks spent notes and raises total without a password or any trial-decryption; a wallet never synced is left alone by a refresh.
  • jest: 347/347
  • Live on testnet from the dev console: shielded 500 000 000 credits, the note landed in storage labelled with its address, balance went 500 000 000 → 1 000 000 000, delta sync 1.0 s, password-less read 2 ms and equal to the live GET_SHIELDED_BALANCE.

@LexxXell LexxXell changed the title feat: cache shielded addresses and notes so they can be read without the password feat: store shielded notes so the balance can be read without the password Sep 25, 2026
@pshenmic
pshenmic merged commit 25c94c9 into develop Sep 28, 2026
2 checks passed
@pshenmic
pshenmic deleted the feat/shieldedCache branch September 28, 2026 11:38
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.

2 participants