Skip to content
Open
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
143 changes: 143 additions & 0 deletions src/providers/safe-account-repair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ vi.mock('@project-eleven/libqc', () => ({

import {
getDefaultChainGroupsToInspect,
maxSafeVaultRepairsPerChain,
repairUnsafeDefaultAccounts,
resolveSafeVaultRepairAction,
type AccountStatus
Expand Down Expand Up @@ -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<AccountStatus>;
statusErrorByIndex?: Readonly<Record<number, Error>>;
initialIndices: ReadonlyArray<number>;
}) => {
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(
Expand Down Expand Up @@ -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'
})
);
});
});
116 changes: 87 additions & 29 deletions src/providers/safe-account-repair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountIdentity>
): ReadonlySet<string> =>
new Set(accounts.map(account => account.id.toString()));

export const repairUnsafeDefaultAccounts = async ({
vault,
onRepairWarning
Expand All @@ -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<AccountIdentity>) =>
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,
Expand All @@ -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<AccountStatus> =
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;
Expand Down