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);