From 48fbf18ea34160694653686b334e428e94f777fd Mon Sep 17 00:00:00 2001 From: "zy0n.bear" Date: Sun, 10 May 2026 01:57:23 +0000 Subject: [PATCH] fix(wallet): preserve walletDetails when merkletree-history version is undefined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet-side merkletree-history-version key lives at [walletPrefix, 'merkleetree_history_version', chainId] — under the same namespace that clearDecryptedBalancesAllTXIDVersions clears via db.clearNamespace(walletPrefix). The engine's clearUTXOMerkletreeAndLoadedWalletsAllTXIDVersions chain calls into the wallet-clear, wiping the version key but never re-stamping it; the engine's own version key (in ChainSyncInfo namespace) survives. Result: every cold-sync followed by a refresh saw "wallet version undefined" in loadUTXOMerkletree, treated it as "stale schema, must clear", and wiped the freshly-rebuilt walletDetails — forcing decryptBalances to do a full leaf rescan from creationTreeHeight. A second cold-sync+refresh cycle finally landed the version key in a state where loadUTXOMerkletree skipped the clear path. Fix: split the two cases the version check was conflating — - undefined: cold install or post-adjacent-wipe; namespace is empty or already at current schema. Just stamp the version, no clear. - < CURRENT: real schema migration. Clear and re-stamp. Schema-bump behavior is preserved: a stored version older than CURRENT still triggers the clear+restamp. fullRedecryptBalancesAllTXIDVersions (driven by treeScannedHeights = [], not the version key) still rescans. Includes regression test that seeds walletDetails after a clear leaves the version key undefined and asserts loadUTXOMerkletree preserves the seeded heights. --- src/wallet/__tests__/railgun-wallet.test.ts | 60 +++++++++++++++++++++ src/wallet/abstract-wallet.ts | 30 ++++++++--- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/wallet/__tests__/railgun-wallet.test.ts b/src/wallet/__tests__/railgun-wallet.test.ts index 36fd26df..40b12ce4 100644 --- a/src/wallet/__tests__/railgun-wallet.test.ts +++ b/src/wallet/__tests__/railgun-wallet.test.ts @@ -230,6 +230,66 @@ describe('railgun-wallet', () => { }); }); + it('loadUTXOMerkletree preserves walletDetails when version key is undefined (post-clear case)', async () => { + // Regression for the redundant-rescan-after-refresh bug. + // + // Setup: simulate the post-cold-sync state where the wallet has scanned + // and persisted treeScannedHeights, but an adjacent clear path + // (engine.clearUTXOMerkletreeAndLoadedWalletsAllTXIDVersions, which + // clearNamespace()s the wallet/chain prefix) has wiped the wallet's + // version key without re-setting it. + // + // Pre-fix: next loadUTXOMerkletree would see undefined and call + // clearDecryptedBalancesAllTXIDVersions again, wiping the freshly + // persisted treeScannedHeights → forced full leaf rescan. + // + // Post-fix: undefined is handled as "nothing to migrate" and the + // version key is just re-stamped; walletDetails is preserved. + // beforeEach already called clearDecryptedBalancesAllTXIDVersions, so + // the version key is already undefined and walletDetails is empty — + // exactly the "post-adjacent-wipe" state the bug reproduces. + const versionAfterBeforeEach = await wallet.getUTXOMerkletreeHistoryVersion( + chain, + ); + expect(versionAfterBeforeEach).to.satisfy( + (v: unknown) => v === undefined || Number.isNaN(v), + ); + + // Seed walletDetails as if a previous sync persisted scanned heights. + // Re-fetch the map, set our entry, write it back via msgpack — same + // path decryptBalances uses internally to persist treeScannedHeights. + const map = await wallet.getWalletDetailsMap(chain); + map[txidVersion] = { + treeScannedHeights: [123], + creationTree: 0, + creationTreeHeight: 7, + }; + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const msgpackLite = require('msgpack-lite'); + await db.put( + wallet.getWalletDetailsPath(chain), + msgpackLite.encode(map), + ); + + // Pre-fix: loadUTXOMerkletree saw undefined and called + // clearDecryptedBalancesAllTXIDVersions, wiping walletDetails → + // forced full leaf rescan on next decryptBalances run. + // Post-fix: undefined is treated as "no migration", just re-stamps + // the version key. walletDetails is preserved. + await wallet.loadUTXOMerkletree(txidVersion, utxoMerkletree); + + const persisted = await wallet.getWalletDetails(txidVersion, chain); + expect(persisted.treeScannedHeights).to.deep.equal([123]); + expect(persisted.creationTree).to.equal(0); + expect(persisted.creationTreeHeight).to.equal(7); + + // Version key must be re-stamped so the NEXT loadUTXOMerkletree also + // skips the clear path. + const versionAfter = await wallet.getUTXOMerkletreeHistoryVersion(chain); + expect(versionAfter).to.be.a('number'); + expect(versionAfter).to.be.greaterThan(0); + }); + describe('createScannedDBCommitments — transact commitment hash verification', () => { // Genuinely-encrypted self-transfer note that decrypts cleanly for `wallet`. const encryptOwnTransactNote = async () => { diff --git a/src/wallet/abstract-wallet.ts b/src/wallet/abstract-wallet.ts index 2ec949b6..579d930c 100644 --- a/src/wallet/abstract-wallet.ts +++ b/src/wallet/abstract-wallet.ts @@ -222,19 +222,33 @@ abstract class AbstractWallet extends EventEmitter { txidVersion: TXIDVersion, utxoMerkletree: UTXOMerkletree, ): Promise { - // Remove balances if the UTXO merkletree is out of date for this wallet. const { chain } = utxoMerkletree; const utxoMerkletreeHistoryVersion = await this.getUTXOMerkletreeHistoryVersion(chain); - if ( - !isDefined(utxoMerkletreeHistoryVersion) || + + if (!isDefined(utxoMerkletreeHistoryVersion)) { + // Either a cold install (namespace already empty — no clear needed) or + // an adjacent path wiped our wallet namespace without preserving this + // version key (engine's clearUTXOMerkletreeAndLoadedWallets chain + // calls clearDecryptedBalancesAllTXIDVersions which clearNamespace()s + // the wallet/chain prefix this key lives under). Either way, no + // schema migration is needed — just write the marker so future schema + // bumps are detected. Skipping the clear preserves any walletDetails + // that were rebuilt after the adjacent wipe, avoiding an unnecessary + // full leaf rescan on the next boot. + await this.setUTXOMerkletreeHistoryVersion( + chain, + CURRENT_UTXO_MERKLETREE_HISTORY_VERSION, + ); + } else if ( utxoMerkletreeHistoryVersion < CURRENT_UTXO_MERKLETREE_HISTORY_VERSION ) { + // Real schema migration: stored version is older than what this code + // expects. Clear and re-stamp. await this.clearDecryptedBalancesAllTXIDVersions(chain); - await this.setUTXOMerkletreeHistoryVersion(chain, CURRENT_UTXO_MERKLETREE_HISTORY_VERSION); - - this.utxoMerkletrees.set(txidVersion, utxoMerkletree.chain, utxoMerkletree); - - return; + await this.setUTXOMerkletreeHistoryVersion( + chain, + CURRENT_UTXO_MERKLETREE_HISTORY_VERSION, + ); } this.utxoMerkletrees.set(txidVersion, utxoMerkletree.chain, utxoMerkletree);