Skip to content

Commit 3a61181

Browse files
authored
fix(ledger): Cp-11586 ledger lock state (#471)
1 parent dcaa96e commit 3a61181

File tree

2 files changed

+180
-0
lines changed

2 files changed

+180
-0
lines changed

packages/ui/src/contexts/LedgerProvider/LedgerProvider.test.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,12 @@ describe('src/contexts/LedgerProvider.tsx', () => {
537537

538538
await waitFor(() => {
539539
expect(getLedgerTransport).toHaveBeenCalled();
540+
});
541+
542+
// Clear the AppAvalanche mock to ignore heartbeat calls
543+
(AppAvalanche as unknown as jest.Mock).mockClear();
544+
545+
await waitFor(() => {
540546
expect(AppAvalanche).not.toHaveBeenCalled();
541547
});
542548

@@ -1181,4 +1187,117 @@ describe('src/contexts/LedgerProvider.tsx', () => {
11811187
});
11821188
});
11831189
});
1190+
1191+
describe('checkHeartbeat', () => {
1192+
beforeEach(() => {
1193+
// Clear any previous calls to refMock.send before each test
1194+
refMock.send.mockClear();
1195+
});
1196+
1197+
afterEach(() => {
1198+
jest.restoreAllMocks();
1199+
});
1200+
1201+
it('should not run heartbeat when transport is not available', async () => {
1202+
// Mock transportRef.current to be null
1203+
jest.spyOn(React, 'useRef').mockReturnValue({
1204+
current: null,
1205+
});
1206+
1207+
renderTestComponent();
1208+
1209+
// Fast-forward time to trigger heartbeat
1210+
jest.advanceTimersByTime(3000);
1211+
1212+
// Should not call transport.send when no transport
1213+
expect(refMock.send).not.toHaveBeenCalled();
1214+
expect(AppAvalanche).not.toHaveBeenCalled();
1215+
});
1216+
1217+
it('should run heartbeat when transport is available', async () => {
1218+
// Mock transportRef.current to be available
1219+
jest.spyOn(React, 'useRef').mockReturnValue({
1220+
current: refMock,
1221+
});
1222+
1223+
// Mock app state to exist so heartbeat runs
1224+
const mockApp = new AppAvalanche(refMock as any);
1225+
jest.spyOn(React, 'useState').mockImplementation(((initialValue: any) => {
1226+
if (initialValue === undefined) {
1227+
// This is the app state
1228+
return [mockApp, jest.fn()];
1229+
}
1230+
return [initialValue, jest.fn()];
1231+
}) as any);
1232+
1233+
renderTestComponent();
1234+
1235+
// Fast-forward time to trigger heartbeat
1236+
jest.advanceTimersByTime(3000);
1237+
1238+
// Should call transport.send when transport is available
1239+
expect(refMock.send).toHaveBeenCalled();
1240+
});
1241+
1242+
it('should run heartbeat when no app but transport is available', async () => {
1243+
// Mock transportRef.current to be available
1244+
jest.spyOn(React, 'useRef').mockReturnValue({
1245+
current: refMock,
1246+
});
1247+
1248+
// Mock app state to be undefined (no app)
1249+
jest.spyOn(React, 'useState').mockImplementation(((initialValue: any) => {
1250+
if (initialValue === undefined) {
1251+
// This is the app state - return undefined to simulate no app
1252+
return [undefined, jest.fn()];
1253+
}
1254+
return [initialValue, jest.fn()];
1255+
}) as any);
1256+
1257+
renderTestComponent();
1258+
1259+
// Fast-forward time to trigger heartbeat
1260+
jest.advanceTimersByTime(3000);
1261+
1262+
// The heartbeat should run (it will attempt to reinitialize the app)
1263+
// We can verify this by checking that the heartbeat mechanism is active
1264+
// The actual reinitialization will happen through initLedgerApp
1265+
expect(refMock.send).not.toHaveBeenCalled(); // No direct send call when no app
1266+
});
1267+
1268+
it('should detect device lock error codes correctly', () => {
1269+
// Test the error detection logic directly
1270+
const testCases = [
1271+
{ statusCode: 0x5515, shouldBeLock: true },
1272+
{ statusCode: 0x6700, shouldBeLock: true },
1273+
{ statusCode: 0x6b0c, shouldBeLock: true },
1274+
{ statusCode: 0x9001, shouldBeLock: false },
1275+
];
1276+
1277+
testCases.forEach(({ statusCode, shouldBeLock }) => {
1278+
const error = new Error('Test error') as any;
1279+
error.statusCode = statusCode;
1280+
const isLockError =
1281+
error?.statusCode === 0x5515 || // Device locked
1282+
error?.statusCode === 0x6700 || // Incorrect length
1283+
error?.statusCode === 0x6b0c; // Something went wrong
1284+
1285+
expect(isLockError).toBe(shouldBeLock);
1286+
});
1287+
});
1288+
1289+
it('should clean up interval on unmount', () => {
1290+
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
1291+
1292+
const { unmount } = renderTestComponent();
1293+
1294+
// Fast-forward to set up interval
1295+
jest.advanceTimersByTime(3000);
1296+
1297+
// Unmount component
1298+
unmount();
1299+
1300+
expect(clearIntervalSpy).toHaveBeenCalled();
1301+
});
1302+
});
11841303
});

packages/ui/src/contexts/LedgerProvider/LedgerProvider.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export enum LedgerAppType {
6868
export const REQUIRED_LEDGER_VERSION = '0.7.3';
6969
export const LEDGER_VERSION_WITH_EIP_712 = '0.8.0';
7070

71+
const LEDGER_ERROR_CODES = Object.freeze({
72+
DEVICE_LOCKED: 0x5515,
73+
INCORRECT_LENGTH: 0x6700,
74+
SOMETHING_WRONG: 0x6b0c,
75+
});
76+
7177
/**
7278
* Run this here since each new window will have a different id
7379
* this is used to track the transport and close on window close
@@ -495,6 +501,61 @@ export function LedgerContextProvider({ children }: PropsWithChildren) {
495501
setLedgerVersionWarningClosed(result);
496502
}, [request]);
497503

504+
// Ledger Stax getting locked when connected via USB needs to be detected and the transport needs to be cleared
505+
// Heartbeat mechanism is being used to detect device lock
506+
useEffect(() => {
507+
let isCheckingHeartbeat = false;
508+
509+
const checkHeartbeat = async () => {
510+
if (isCheckingHeartbeat || !transportRef.current) {
511+
return;
512+
}
513+
514+
isCheckingHeartbeat = true;
515+
516+
try {
517+
if (!app) {
518+
// No app instance - try to re-establish connection
519+
await initLedgerApp(transportRef.current);
520+
} else {
521+
// Send a simple GET_VERSION command which should always require device interaction
522+
await transportRef.current.send(
523+
0xe0, // CLA - Generic command class
524+
0x01, // INS - Get version instruction
525+
0x00, // P1
526+
0x00, // P2
527+
Buffer.alloc(0), // Data
528+
[0x9000], // Expected status code for success
529+
);
530+
}
531+
} catch (error: any) {
532+
// Check if this looks like a device lock error
533+
const isLockError = Object.values(LEDGER_ERROR_CODES).includes(
534+
error?.statusCode,
535+
);
536+
537+
if (isLockError && app) {
538+
// Device appears to be locked, clearing transport but keeping heartbeat running
539+
setApp(undefined);
540+
setAppType(LedgerAppType.UNKNOWN);
541+
}
542+
} finally {
543+
isCheckingHeartbeat = false;
544+
}
545+
};
546+
547+
// Check heartbeat every 3 seconds to detect if the device is locked (3 seconds might be too excessive, but it's a good starting point to avoid false positives)
548+
const heartbeatInterval = setInterval(checkHeartbeat, 3000);
549+
550+
checkHeartbeat();
551+
552+
return () => {
553+
if (heartbeatInterval) {
554+
clearInterval(heartbeatInterval);
555+
}
556+
};
557+
}, [app, initLedgerApp]);
558+
498559
return (
499560
<LedgerContext.Provider
500561
value={{

0 commit comments

Comments
 (0)