From f050291bacc7383fe6dc4a087290b7fc6225e791 Mon Sep 17 00:00:00 2001 From: Kanan M Date: Sun, 13 Sep 2026 16:37:19 +0400 Subject: [PATCH] fix: rediscover rotated vaults in one pass after mnemonic recovery libqc's `importWallet` persists an empty address list, so a recovered wallet only gets its index-0 vault back from the missing-default-chain branch. Every later vault (each `emptyVault` rotation mints the next index) had to be re-derived by the boot safety net, which minted exactly one index per `initWallet` and only re-checked on the next popup boot. A user who had rotated three times therefore saw an empty wallet right after recovery and needed several open/close cycles before the vault holding their funds showed up. Chain the safety-net repair: after minting an index, inspect the minted vault's status and keep advancing while it reports `vulnerable`, so the whole history is rediscovered during the recovery hydration. A fresh, never-used derivation reports `safe` and terminates the chain, so the non-recovery case is unchanged (one mint). A per-chain cap guards against a status provider that never reports `safe`. --- src/providers/safe-account-repair.test.ts | 143 ++++++++++++++++++++++ src/providers/safe-account-repair.ts | 116 +++++++++++++----- 2 files changed, 230 insertions(+), 29 deletions(-) diff --git a/src/providers/safe-account-repair.test.ts b/src/providers/safe-account-repair.test.ts index aff9424..40cb558 100644 --- a/src/providers/safe-account-repair.test.ts +++ b/src/providers/safe-account-repair.test.ts @@ -10,6 +10,7 @@ vi.mock('@project-eleven/libqc', () => ({ import { getDefaultChainGroupsToInspect, + maxSafeVaultRepairsPerChain, repairUnsafeDefaultAccounts, resolveSafeVaultRepairAction, type AccountStatus @@ -85,6 +86,65 @@ const createRepairVault = ({ createAccount }); +/** + * Mirrors a recovered wallet: `importWallet` persists no accounts, so every + * `createAccount(chainId, _, _, index)` call re-derives an index whose + * on-chain status is fixed by `statusByIndex`. The mock persists the minted + * account so subsequent `listAccounts` calls see it, exactly like libqc. + */ +const createRecoveredRepairVault = ({ + chainIdValue, + supportedChains, + statusByIndex, + statusErrorByIndex = {}, + initialIndices +}: { + chainIdValue: string; + supportedChains: ReadonlyArray<{ chainId: ChainId }>; + statusByIndex: ReadonlyArray; + statusErrorByIndex?: Readonly>; + initialIndices: ReadonlyArray; +}) => { + const accounts: TestAccount[] = initialIndices.map(index => + createAccount(chainIdValue, `index-${index}`) + ); + const indexByAccountId = new Map( + initialIndices.map(index => [`${chainIdValue}:index-${index}`, index]) + ); + + return { + listAccounts: vi.fn(async () => [...accounts]), + getSupportedChains: vi.fn(() => supportedChains), + getAccount: vi.fn(async (accountId: TestPersistedAccountId) => ({ + getStatus: vi.fn(async () => { + const index = indexByAccountId.get(accountId.toString()); + if (index === undefined) { + throw new Error(`Unknown account ${accountId.toString()}`); + } + const statusError = statusErrorByIndex[index]; + if (statusError !== undefined) { + throw statusError; + } + return statusByIndex[index] ?? 'safe'; + }) + })), + createAccount: vi.fn( + async ( + chainId: ChainId, + _forceNewAddress?: boolean, + _version?: unknown, + addressIndex?: number + ) => { + const index = addressIndex ?? accounts.length; + const account = createAccount(chainId.toString(), `index-${index}`); + accounts.push(account); + indexByAccountId.set(account.id.toString(), index); + return undefined; + } + ) + }; +}; + describe('resolveSafeVaultRepairAction', () => { it('skips when at least one account on the chain is already safe', () => { expect( @@ -321,4 +381,87 @@ describe('repairUnsafeDefaultAccounts', () => { kind: 'account-creation-failed' }); }); + + it('rediscovers every rotated vault of a recovered wallet in a single pass', async () => { + // Recovered wallet: index 0 restored by the missing-chain branch. On + // chain, indices 0-2 were each drained by an earlier rotation and index + // 3 holds the funds and has never signed. + const bitcoinChain = createChainId(bitcoinMainnetChainId); + const vault = createRecoveredRepairVault({ + chainIdValue: bitcoinMainnetChainId, + supportedChains: [ + { chainId: bitcoinChain }, + { chainId: createChainId(ethereumChainId) } + ], + statusByIndex: ['vulnerable', 'vulnerable', 'vulnerable', 'safe'], + initialIndices: [0] + }); + + await expect(repairUnsafeDefaultAccounts({ vault })).resolves.toBe(3); + expect(vault.createAccount.mock.calls.map(call => call[3])).toEqual([ + 1, 2, 3 + ]); + }); + + it('stops after the first repair when the minted vault is safe', async () => { + const bitcoinChain = createChainId(bitcoinMainnetChainId); + const vault = createRecoveredRepairVault({ + chainIdValue: bitcoinMainnetChainId, + supportedChains: [ + { chainId: bitcoinChain }, + { chainId: createChainId(ethereumChainId) } + ], + statusByIndex: ['vulnerable', 'safe'], + initialIndices: [0] + }); + + await expect(repairUnsafeDefaultAccounts({ vault })).resolves.toBe(1); + expect(vault.createAccount).toHaveBeenCalledTimes(1); + }); + + it('caps chained repairs per chain when every minted vault keeps reporting vulnerable', async () => { + const bitcoinChain = createChainId(bitcoinMainnetChainId); + const vault = createRecoveredRepairVault({ + chainIdValue: bitcoinMainnetChainId, + supportedChains: [ + { chainId: bitcoinChain }, + { chainId: createChainId(ethereumChainId) } + ], + statusByIndex: Array.from( + { length: maxSafeVaultRepairsPerChain + 5 }, + () => 'vulnerable' as const + ), + initialIndices: [0] + }); + + await expect(repairUnsafeDefaultAccounts({ vault })).resolves.toBe( + maxSafeVaultRepairsPerChain + ); + }); + + it('stops chaining and warns when the minted vault status cannot be inspected', async () => { + const bitcoinChain = createChainId(bitcoinMainnetChainId); + const onRepairWarning = vi.fn(); + const vault = createRecoveredRepairVault({ + chainIdValue: bitcoinMainnetChainId, + supportedChains: [ + { chainId: bitcoinChain }, + { chainId: createChainId(ethereumChainId) } + ], + statusByIndex: ['vulnerable'], + statusErrorByIndex: { 1: new Error('status unavailable') }, + initialIndices: [0] + }); + + await expect( + repairUnsafeDefaultAccounts({ vault, onRepairWarning }) + ).resolves.toBe(1); + expect(vault.createAccount).toHaveBeenCalledTimes(1); + expect(onRepairWarning).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: bitcoinChain, + kind: 'status-inspection-failed' + }) + ); + }); }); diff --git a/src/providers/safe-account-repair.ts b/src/providers/safe-account-repair.ts index cee4558..470de26 100644 --- a/src/providers/safe-account-repair.ts +++ b/src/providers/safe-account-repair.ts @@ -155,6 +155,22 @@ type RepairUnsafeDefaultAccountsInput = { }) => void; }; +/** + * Upper bound on consecutive safe-vault repairs per default chain within a + * single pass. libqc's `importWallet` persists no accounts, so a recovered + * wallet only gets its index-0 vault back from the missing-chain branch; + * every later vault (each `emptyVault` rotation mints the next index) has + * to be re-derived here. Chaining repairs until the freshly minted vault + * reports `safe` rediscovers them in one hydration instead of one index per + * popup boot. The cap keeps a pathological status provider from spinning. + */ +export const maxSafeVaultRepairsPerChain = 20; + +const getAccountIds = ( + accounts: ReadonlyArray +): ReadonlySet => + new Set(accounts.map(account => account.id.toString())); + export const repairUnsafeDefaultAccounts = async ({ vault, onRepairWarning @@ -166,16 +182,21 @@ export const repairUnsafeDefaultAccounts = async ({ }); let createdCount = 0; - for (const group of safetyNetGroups) { - const latestAccounts = await vault.listAccounts(); - const chainAccountStatusesResult = await attempt(() => + const inspectStatuses = (accounts: ReadonlyArray) => + attempt(() => Promise.all( - group.accountsOnChain.map(async account => { + accounts.map(async account => { const accountClient = await vault.getAccount(account.id); return accountClient.getStatus(); }) ) ); + + for (const group of safetyNetGroups) { + let latestAccounts = await vault.listAccounts(); + const chainAccountStatusesResult = await inspectStatuses( + group.accountsOnChain + ); if ('error' in chainAccountStatusesResult) { onRepairWarning?.({ chainId: group.chainId, @@ -185,33 +206,70 @@ export const repairUnsafeDefaultAccounts = async ({ continue; } - const repairAction = resolveSafeVaultRepairAction({ - chainAccountStatuses: chainAccountStatusesResult.data, - allAccounts: latestAccounts, - chainId: group.chainId - }); - if (repairAction.kind === 'skip') { - continue; - } - - const createResult = await attempt(() => - vault.createAccount( - group.chainId, - undefined, - undefined, - repairAction.addressIndex - ) - ); - if ('error' in createResult) { - onRepairWarning?.({ - chainId: group.chainId, - error: createResult.error, - kind: 'account-creation-failed' + let chainAccountStatuses: ReadonlyArray = + chainAccountStatusesResult.data; + for ( + let repairAttempt = 0; + repairAttempt < maxSafeVaultRepairsPerChain; + repairAttempt += 1 + ) { + const repairAction = resolveSafeVaultRepairAction({ + chainAccountStatuses, + allAccounts: latestAccounts, + chainId: group.chainId }); - continue; - } + if (repairAction.kind === 'skip') { + break; + } - createdCount += 1; + const createResult = await attempt(() => + vault.createAccount( + group.chainId, + undefined, + undefined, + repairAction.addressIndex + ) + ); + if ('error' in createResult) { + onRepairWarning?.({ + chainId: group.chainId, + error: createResult.error, + kind: 'account-creation-failed' + }); + break; + } + + createdCount += 1; + + // Inspect what was just minted. A brand-new derivation is `safe` and + // ends the chain; a recovered index that was already rotated on-chain + // is `vulnerable` and the loop advances to the next index. + const knownAccountIds = getAccountIds(latestAccounts); + latestAccounts = await vault.listAccounts(); + const mintedAccounts = latestAccounts.filter( + account => + account.chainId.toString() === group.chainId.toString() && + !knownAccountIds.has(account.id.toString()) + ); + if (mintedAccounts.length === 0) { + break; + } + + const mintedStatusesResult = await inspectStatuses(mintedAccounts); + if ('error' in mintedStatusesResult) { + onRepairWarning?.({ + chainId: group.chainId, + error: mintedStatusesResult.error, + kind: 'status-inspection-failed' + }); + break; + } + + chainAccountStatuses = [ + ...chainAccountStatuses, + ...mintedStatusesResult.data + ]; + } } return createdCount;