feat: store shielded notes so the balance can be read without the password - #170
Merged
Merged
Conversation
LexxXell
force-pushed
the
feat/shieldedCache
branch
from
September 26, 2026 14:23
b9833e3 to
3b8aa51
Compare
pshenmic
approved these changes
Sep 28, 2026
…tes without a password
LexxXell
force-pushed
the
feat/shieldedCache
branch
from
September 28, 2026 11:26
3b8aa51 to
cb19e03
Compare
pshenmic
approved these changes
Sep 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_PASSWORDonly 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,updatedAtandphase. No password and no network call. An account never synced answers empty withupdatedAt: nullinstead of failing, andphase(idle/syncing/done/error) tells a sync still running apart from one that never happened — the backend outlives the popup, so a reopened popup seessyncing.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 raisetotalwithout being recovered; comparing it withfetchedtells the UI a full sync would find more.GET_SHIELDED_BALANCEis untouched and stays the live path.How the sync stays cheap
fetcheddoubles as the offset to resume from.recoverNotesnumbers what it is handed from zero, so the offset is added back to keep each note's global leaf position.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:
ShieldedNotesRepositoryis pure storage (keyshieldedNotes_<network>_<walletId>),ShieldedServiceexposes 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.tsmoves into that service too — 234 lines:deriveShieldedAddresses,fetchAllShieldedNotes,getShieldedNullifierStatuses,recoveredNoteNullifier,sumUnspentShieldedValue,filterRecoveredNotesByAddress,loadUnspentShieldedNotesandprepareShieldedSpend.utilsis 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 inutils.Backend only — no UI changes.
Testing
tsc --noEmit,ts-standard,npm run buildtest/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 issyncingmid-run anderroron a failure; a refresh marks spent notes and raisestotalwithout a password or any trial-decryption; a wallet never synced is left alone by a refresh.jest: 347/347GET_SHIELDED_BALANCE.