Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/wallet/__tests__/railgun-wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
30 changes: 22 additions & 8 deletions src/wallet/abstract-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,19 +222,33 @@ abstract class AbstractWallet extends EventEmitter {
txidVersion: TXIDVersion,
utxoMerkletree: UTXOMerkletree,
): Promise<void> {
// 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);
Expand Down
Loading