Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
8a1b934
fix: improve Ledger error handling for disconnect, retry, and paginat…
dawnseeker8 Apr 7, 2026
b066887
chore: update .gitignore to exclude cursor hooks state directory
dawnseeker8 Apr 7, 2026
14a22cb
chore: add learned preferences and workspace facts to AGENTS.md
dawnseeker8 Apr 8, 2026
4f30f91
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 Apr 9, 2026
2ad9d44
fix: narrow bluetooth pattern in #isTransientBleError to avoid miscla…
cursoragent Apr 9, 2026
60ab66a
fix: narrow overly broad 'bluetooth' pattern in #isTransientBleError
cursoragent Apr 9, 2026
5109ebd
[skip ci] Bump version number to 4433
metamaskbot Apr 10, 2026
6b7e079
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 Apr 13, 2026
65379cd
fix(hardware-wallet): improve ledger reconnect error handling
dawnseeker8 Apr 14, 2026
aa3af58
[skip ci] Bump version number to 4481
metamaskbot Apr 14, 2026
18ccb31
[skip ci] Bump version number to 4482
metamaskbot Apr 14, 2026
f6a2188
[skip ci] Bump version number to 4506
metamaskbot Apr 15, 2026
acbcd1c
[skip ci] Bump version number to 4540
metamaskbot Apr 17, 2026
0ecc6ac
[skip ci] Bump version number to 4541
metamaskbot Apr 17, 2026
2f4b979
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 Apr 20, 2026
b740ee8
chore(agents): remove outdated user preferences and workspace facts s…
dawnseeker8 Apr 20, 2026
ba9759f
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 Apr 20, 2026
4542c4d
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 Apr 28, 2026
633631a
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 Apr 28, 2026
a0492a5
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 5, 2026
912cfa2
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 5, 2026
1d255af
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 5, 2026
c298c3d
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 7, 2026
331b67e
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 7, 2026
59d9c7c
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 13, 2026
0da240d
Refactor test identifiers in LedgerSelectAccount tests and update moc…
dawnseeker8 May 13, 2026
a005650
Enhance Ledger tests to handle device readiness errors and modal beha…
dawnseeker8 May 13, 2026
6b4b136
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 13, 2026
20c3dae
Refactor Ledger test mocks for error handling
dawnseeker8 May 13, 2026
3c8a7c2
Update app/core/HardwareWallet/hooks/useDeviceConnectionFlow.test.ts
dawnseeker8 May 13, 2026
13899d2
Update app/core/Ledger/Ledger.ts
dawnseeker8 May 13, 2026
856a74d
Refactor error handling in getLedgerAccountsByOperation
dawnseeker8 May 13, 2026
2d5fc37
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 13, 2026
6c003e3
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 13, 2026
2f9e7f2
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 14, 2026
ab7fa8d
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 14, 2026
59c1544
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 14, 2026
e6d641c
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 14, 2026
5e139f5
Merge branch 'main' into fix/ledger-error-handling-28272
dawnseeker8 May 14, 2026
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
124 changes: 124 additions & 0 deletions app/components/Views/LedgerSelectAccount/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -818,5 +818,129 @@ describe('LedgerSelectAccount', () => {
expect(queryByText('Network error')).toBeOnTheScreen();
});
});

it('calls ensureDeviceReady before nextPage pagination', async () => {
const { getByTestId } = await renderAndWaitForAccounts();

mockEnsureDeviceReady.mockClear();
mockGetLedgerAccountsByOperation.mockClear();
mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts);

await act(async () => {
fireEvent.press(getByTestId(AccountSelectorSelectorsIDs.NEXT_BUTTON));
});

await waitFor(() => {
expect(mockEnsureDeviceReady).toHaveBeenCalledWith('test-device-id');
});
});

it('calls ensureDeviceReady before prevPage pagination', async () => {
const { getByTestId } = await renderAndWaitForAccounts();

mockEnsureDeviceReady.mockClear();
mockGetLedgerAccountsByOperation.mockClear();
mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts);

await act(async () => {
fireEvent.press(
getByTestId(AccountSelectorSelectorsIDs.PREVIOUS_BUTTON),
);
});

await waitFor(() => {
expect(mockEnsureDeviceReady).toHaveBeenCalledWith('test-device-id');
});
});

it('skips pagination when ensureDeviceReady returns false on nextPage', async () => {
const { getByTestId } = await renderAndWaitForAccounts();

mockEnsureDeviceReady.mockResolvedValue(false);
mockGetLedgerAccountsByOperation.mockClear();

await act(async () => {
fireEvent.press(getByTestId(AccountSelectorSelectorsIDs.NEXT_BUTTON));
});

await waitFor(() => {
expect(mockEnsureDeviceReady).toHaveBeenCalled();
});

expect(mockGetLedgerAccountsByOperation).not.toHaveBeenCalled();
});

it('skips pagination when ensureDeviceReady returns false on prevPage', async () => {
const { getByTestId } = await renderAndWaitForAccounts();

mockEnsureDeviceReady.mockResolvedValue(false);
mockGetLedgerAccountsByOperation.mockClear();

await act(async () => {
fireEvent.press(
getByTestId(AccountSelectorSelectorsIDs.PREVIOUS_BUTTON),
);
});

await waitFor(() => {
expect(mockEnsureDeviceReady).toHaveBeenCalled();
});

expect(mockGetLedgerAccountsByOperation).not.toHaveBeenCalled();
});

it('shows inline error when ensureDeviceReady throws during nextPage without blocking modal', async () => {
mockEnsureDeviceReady
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('Device readiness check failed'));

const { getByTestId, queryByText } = await renderAndWaitForAccounts();

await act(async () => {
fireEvent.press(getByTestId(AccountSelectorSelectorsIDs.NEXT_BUTTON));
});

await waitFor(() => {
expect(queryByText('Device readiness check failed')).toBeOnTheScreen();
});
expect(queryByText('Please wait')).not.toBeOnTheScreen();
});

it('shows inline error when ensureDeviceReady throws during prevPage without blocking modal', async () => {
mockEnsureDeviceReady
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('Bluetooth adapter unavailable'));

const { getByTestId, queryByText } = await renderAndWaitForAccounts();

await act(async () => {
fireEvent.press(
getByTestId(AccountSelectorSelectorsIDs.PREVIOUS_BUTTON),
);
});

await waitFor(() => {
expect(queryByText('Bluetooth adapter unavailable')).toBeOnTheScreen();
});
expect(queryByText('Please wait')).not.toBeOnTheScreen();
});

it('does not show blocking modal when ensureDeviceReady returns false on nextPage', async () => {
mockEnsureDeviceReady
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);

const { getByTestId, queryByText } = await renderAndWaitForAccounts();

await act(async () => {
fireEvent.press(getByTestId(AccountSelectorSelectorsIDs.NEXT_BUTTON));
});

await waitFor(() => {
expect(mockEnsureDeviceReady).toHaveBeenCalled();
});

expect(queryByText('Please wait')).not.toBeOnTheScreen();
});
});
});
26 changes: 20 additions & 6 deletions app/components/Views/LedgerSelectAccount/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,32 +222,46 @@ const LedgerSelectAccount = () => {
}, [selectedOption]);

const nextPage = useCallback(async () => {
showLoadingModal();
let modalShown = false;
try {
const isReady = await ensureDeviceReady(deviceId);
if (!isReady) return;

showLoadingModal();
modalShown = true;
const _accounts = await getLedgerAccountsByOperation(
PAGINATION_OPERATIONS.GET_NEXT_PAGE,
);
setAccounts(_accounts);
} catch (e) {
setErrorMsg((e as Error).message);
} finally {
setBlockingModalVisible(false);
if (modalShown) {
setBlockingModalVisible(false);
}
}
}, []);
}, [ensureDeviceReady, deviceId]);

const prevPage = useCallback(async () => {
showLoadingModal();
let modalShown = false;
try {
const isReady = await ensureDeviceReady(deviceId);
if (!isReady) return;

showLoadingModal();
modalShown = true;
const _accounts = await getLedgerAccountsByOperation(
PAGINATION_OPERATIONS.GET_PREVIOUS_PAGE,
);
setAccounts(_accounts);
} catch (e) {
setErrorMsg((e as Error).message);
} finally {
setBlockingModalVisible(false);
if (modalShown) {
setBlockingModalVisible(false);
}
}
}, []);
}, [ensureDeviceReady, deviceId]);

const updateNewLegacyAccountsLabel = useCallback(async () => {
if (LEDGER_LEGACY_PATH === (await getHDPath())) {
Expand Down
190 changes: 190 additions & 0 deletions app/core/HardwareWallet/adapters/LedgerBluetoothAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,27 @@ describe('LedgerBluetoothAdapter', () => {
});

describe('disconnect', () => {
it('forces disconnectDevice when BLE disconnect already cleared transport but deviceId remains', async () => {
await adapter.connect('device-123');
mockedTransportBLE.disconnectDevice.mockClear();

const disconnectHandler = mockTransportInstance.on.mock.calls.find(
(call: [string, () => void]) => call[0] === 'disconnect',
)?.[1];
expect(disconnectHandler).toBeDefined();
disconnectHandler?.();

expect(adapter.isConnected()).toBe(false);
expect(adapter.getConnectedDeviceId()).toBe('device-123');

await adapter.disconnect();

expect(mockedTransportBLE.disconnectDevice).toHaveBeenCalledWith(
'device-123',
);
expect(adapter.getConnectedDeviceId()).toBeNull();
});

it('closes transport and resets state', async () => {
await adapter.connect('device-123');
await adapter.disconnect();
Expand Down Expand Up @@ -625,6 +646,21 @@ describe('LedgerBluetoothAdapter', () => {
},
);

it('retries when address verification fails with message-based transient GATT error', async () => {
jest.mocked(connectLedgerHardware).mockResolvedValue('Ethereum');
const gattError = new Error('GATT server disconnected');
gattError.name = 'Error';
mockGetAddress
.mockRejectedValueOnce(gattError)
.mockResolvedValueOnce({ address: '0x1234' });

const result = await adapter.ensureDeviceReady('device-123');

expect(result).toBe(true);
expect(mockGetAddress).toHaveBeenCalledTimes(2);
expect(connectLedgerHardware).toHaveBeenCalledTimes(2);
});

it('emits DeviceLocked when getAddress fails with Locked device message', async () => {
jest.mocked(connectLedgerHardware).mockResolvedValue('Ethereum');
mockGetAddress.mockRejectedValueOnce(
Expand Down Expand Up @@ -687,6 +723,160 @@ describe('LedgerBluetoothAdapter', () => {

jest.useRealTimers();
});

it('retries when error message contains "disconnected" even with generic Error name', async () => {
const genericBleError = new Error('Device disconnected during operation');
genericBleError.name = 'Error';
jest
.mocked(connectLedgerHardware)
.mockRejectedValueOnce(genericBleError)
.mockResolvedValueOnce('Ethereum');
mockGetAddress.mockResolvedValue({ address: '0x1234' });

const result = await adapter.ensureDeviceReady('device-123');

expect(result).toBe(true);
expect(connectLedgerHardware).toHaveBeenCalledTimes(2);
});

it('retries when error message contains "bluetooth connection" even with generic Error name', async () => {
const genericBleError = new Error('Bluetooth connection failed');
genericBleError.name = 'Error';
jest
.mocked(connectLedgerHardware)
.mockRejectedValueOnce(genericBleError)
.mockResolvedValueOnce('Ethereum');
mockGetAddress.mockResolvedValue({ address: '0x1234' });

const result = await adapter.ensureDeviceReady('device-123');

expect(result).toBe(true);
expect(connectLedgerHardware).toHaveBeenCalledTimes(2);
});

it('retries when error message contains "bluetooth transfer" even with generic Error name', async () => {
const genericBleError = new Error('Bluetooth transfer interrupted');
genericBleError.name = 'Error';
jest
.mocked(connectLedgerHardware)
.mockRejectedValueOnce(genericBleError)
.mockResolvedValueOnce('Ethereum');
mockGetAddress.mockResolvedValue({ address: '0x1234' });

const result = await adapter.ensureDeviceReady('device-123');

expect(result).toBe(true);
expect(connectLedgerHardware).toHaveBeenCalledTimes(2);
});

it('retries when error message contains "connection lost" even with generic Error name', async () => {
const genericBleError = new Error('The connection lost unexpectedly');
genericBleError.name = 'Error';
jest
.mocked(connectLedgerHardware)
.mockRejectedValueOnce(genericBleError)
.mockResolvedValueOnce('Ethereum');
mockGetAddress.mockResolvedValue({ address: '0x1234' });

const result = await adapter.ensureDeviceReady('device-123');

expect(result).toBe(true);
expect(connectLedgerHardware).toHaveBeenCalledTimes(2);
});

it('retries when error message contains "gatt" even with generic Error name', async () => {
const genericBleError = new Error('GATT operation failed');
genericBleError.name = 'Error';
jest
.mocked(connectLedgerHardware)
.mockRejectedValueOnce(genericBleError)
.mockResolvedValueOnce('Ethereum');
mockGetAddress.mockResolvedValue({ address: '0x1234' });

const result = await adapter.ensureDeviceReady('device-123');

expect(result).toBe(true);
expect(connectLedgerHardware).toHaveBeenCalledTimes(2);
});

it('retries when error message contains "ble error" even with generic Error name', async () => {
const genericBleError = new Error('Native BLE error on channel');
genericBleError.name = 'Error';
jest
.mocked(connectLedgerHardware)
.mockRejectedValueOnce(genericBleError)
.mockResolvedValueOnce('Ethereum');
mockGetAddress.mockResolvedValue({ address: '0x1234' });

const result = await adapter.ensureDeviceReady('device-123');

expect(result).toBe(true);
expect(connectLedgerHardware).toHaveBeenCalledTimes(2);
});

it.each([
'Bluetooth is off',
'Bluetooth not supported',
'Not authorized to use Bluetooth',
'Bluetooth permission denied',
'Bluetooth scan failed',
])(
'does not retry for non-transient bluetooth error: "%s"',
async (errorMessage) => {
const nonTransientError = new Error(errorMessage);
nonTransientError.name = 'Error';
jest.mocked(connectLedgerHardware).mockRejectedValue(nonTransientError);

await expect(adapter.ensureDeviceReady('device-123')).rejects.toThrow();
expect(connectLedgerHardware).toHaveBeenCalledTimes(1);
},
);

it('forces BLE cleanup when transport is null but deviceId exists during retry', async () => {
const disconnectError = new Error('Disconnected');
disconnectError.name = 'DisconnectedDevice';
jest
.mocked(connectLedgerHardware)
.mockRejectedValueOnce(disconnectError)
.mockResolvedValueOnce('Ethereum');
mockGetAddress.mockResolvedValue({ address: '0x1234' });

await adapter.ensureDeviceReady('device-123');

expect(mockedTransportBLE.disconnectDevice).toHaveBeenCalled();
});

it('closes transport when openEthereumAppOnLedger fails', async () => {
jest.mocked(connectLedgerHardware).mockResolvedValue('BOLOS');
const { openEthereumAppOnLedger } = jest.requireMock(
'../../Ledger/Ledger',
) as { openEthereumAppOnLedger: jest.Mock };
openEthereumAppOnLedger.mockRejectedValueOnce(
new Error('User cancelled'),
);

await adapter.ensureDeviceReady('device-123');

expect(mockedTransportBLE.disconnectDevice).toHaveBeenCalledWith(
'device-123',
);
});

it('closes transport when closeRunningAppOnLedger fails', async () => {
jest.mocked(connectLedgerHardware).mockResolvedValue('Bitcoin');
const { closeRunningAppOnLedger } = jest.requireMock(
'../../Ledger/Ledger',
) as { closeRunningAppOnLedger: jest.Mock };
closeRunningAppOnLedger.mockRejectedValueOnce(
new Error('User cancelled'),
);

await adapter.ensureDeviceReady('device-123');

expect(mockedTransportBLE.disconnectDevice).toHaveBeenCalledWith(
'device-123',
);
});
});

describe('reset', () => {
Expand Down
Loading
Loading