Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ github-tools/
temp/
/temp/
.agent/
.cursor/hooks/state/

# Reassure performance testing
.reassure/
Expand Down
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,16 @@ bash .agents/skills/ab-testing-implementation/scripts/check-ab-testing-complianc
```

If no files are staged, the checker automatically falls back to changed working-tree files.

## Learned User Preferences

- Use `jest.mocked(<function>)` instead of casting with `(fn as jest.Mock)` when configuring mocks in tests
- In component tests, prefer `.toBeOnTheScreen()` over `.toBeTruthy()` on `queryBy*` results; assert mocks were called with expected arguments rather than just `toBeDefined()`
- When implementing from an attached plan, follow it as specified without editing the plan file; mark todos in_progress in order and complete all before stopping
- If a plan fails to generate, fix the issue and proceed with implementation rather than stopping
- After completing feature or bug work, create a new git branch, commit, and push when asked
- For PR code reviews, provide specific actionable comments on individual code lines rather than vague overviews

## Learned Workspace Facts

- On Android with targetSdk 35 and Android 15 edge-to-edge, use `SafeAreaView` from `react-native-safe-area-context` (not from `react-native`) or apply `marginTop` from insets to prevent headers overlapping the status bar
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
66 changes: 66 additions & 0 deletions app/components/Views/LedgerSelectAccount/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -803,5 +803,71 @@ 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(ACCOUNT_SELECTOR_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(ACCOUNT_SELECTOR_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(ACCOUNT_SELECTOR_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(ACCOUNT_SELECTOR_PREVIOUS_BUTTON));
});

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

expect(mockGetLedgerAccountsByOperation).not.toHaveBeenCalled();
});
});
});
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
76 changes: 76 additions & 0 deletions app/core/HardwareWallet/adapters/LedgerBluetoothAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,82 @@ 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" 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('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
32 changes: 26 additions & 6 deletions app/core/HardwareWallet/adapters/LedgerBluetoothAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ export class LedgerBluetoothAdapter implements HardwareWalletAdapter {
'[LedgerBluetoothAdapter] Failed to send open app command:',
openError,
);
await this.#closeTransport();
}
} else {
try {
Expand All @@ -526,6 +527,7 @@ export class LedgerBluetoothAdapter implements HardwareWalletAdapter {
'[LedgerBluetoothAdapter] Failed to close app:',
closeError,
);
await this.#closeTransport();
}
}
}
Expand Down Expand Up @@ -564,9 +566,10 @@ export class LedgerBluetoothAdapter implements HardwareWalletAdapter {
async #closeTransport(): Promise<void> {
const transport = this.#transport;
const deviceId = this.#deviceId;
if (transport) {
this.#transport = null;
try {
this.#transport = null;

try {
if (transport) {
if (deviceId) {
// TransportBLE.close() queues a delayed disconnect (5s timeout).
// Force an immediate BLE disconnection so in-flight signing is
Expand All @@ -575,9 +578,14 @@ export class LedgerBluetoothAdapter implements HardwareWalletAdapter {
} else {
await transport.close();
}
} catch {
// Ignore close errors — device may already be disconnected
} else if (deviceId) {
// Transport already cleared (e.g. by #handleDisconnect) but device
// ID still set — force BLE cleanup so the OS stack doesn't keep a
// stale connection that blocks the next TransportBLE.open() call.
await TransportBLE.disconnectDevice(deviceId);
}
} catch {
// Ignore close errors — device may already be disconnected
}
}

Expand Down Expand Up @@ -666,16 +674,28 @@ export class LedgerBluetoothAdapter implements HardwareWalletAdapter {
/**
* Check if error is a transient BLE error that can be retried
* (disconnects during app switch, pairing failures during reconnect, etc.)
*
* Checks error name first, then falls back to message-based detection
* for BLE errors that use generic Error names after a device power-cycle.
*/
#isTransientBleError(error: unknown): boolean {
if (!(error instanceof Error)) return false;

const transientBleErrorNames: readonly string[] = [
...DISCONNECT_ERROR_NAMES,
'PairingFailed',
'PeerRemovedPairing',
'BleError',
];
return transientBleErrorNames.includes(error.name);
if (transientBleErrorNames.includes(error.name)) return true;

const message = error.message?.toLowerCase() ?? '';
return (
message.includes('disconnected') ||
message.includes('connection lost') ||
message.includes('gatt') ||
message.includes('bluetooth')
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
Expand Down
20 changes: 20 additions & 0 deletions app/core/Ledger/Ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,26 @@ describe('Ledger core', () => {
getLedgerAccountsByOperation(PAGINATION_OPERATIONS.GET_FIRST_PAGE),
).rejects.toThrow('Unspecified error when connect Ledger Hardware,');
});

it('throws disconnect error for DisconnectedDevice', async () => {
const disconnectError = new Error('Disconnected during operation');
disconnectError.name = 'DisconnectedDevice';
ledgerKeyring.getFirstPage.mockRejectedValueOnce(disconnectError);

await expect(
getLedgerAccountsByOperation(PAGINATION_OPERATIONS.GET_FIRST_PAGE),
).rejects.toThrow('Your device got disconnected');
});

it('throws disconnect error for DisconnectedDeviceDuringOperation', async () => {
const disconnectError = new Error('Lost connection');
disconnectError.name = 'DisconnectedDeviceDuringOperation';
ledgerKeyring.getNextPage.mockRejectedValueOnce(disconnectError);

await expect(
getLedgerAccountsByOperation(PAGINATION_OPERATIONS.GET_NEXT_PAGE),
).rejects.toThrow('Your device got disconnected');
});
});

describe('ledgerSignTypedMessage', () => {
Expand Down
5 changes: 4 additions & 1 deletion app/core/Ledger/Ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import PAGINATION_OPERATIONS from '../../constants/pagination';
import { strings } from '../../../locales/i18n';
import { keyringTypeToName } from '@metamask/accounts-controller';
import { removeAccountsFromPermissions } from '../Permissions';
import { isEthAppNotOpenError } from './ledgerErrors';
import { isEthAppNotOpenError, isDisconnectError } from './ledgerErrors';

/**
* Perform an operation with the Ledger keyring.
Expand Down Expand Up @@ -192,6 +192,9 @@ export const getLedgerAccountsByOperation = async (
if (isEthAppNotOpenError(e)) {
throw new Error(strings('ledger.eth_app_not_open_message'));
}
if (isDisconnectError(e)) {
throw new Error(strings('ledger.ledger_disconnected'));
}
Comment thread
dawnseeker8 marked this conversation as resolved.
Outdated
throw new Error(strings('ledger.unspecified_error_during_connect'));
}
};
Expand Down
Loading