Skip to content
Merged
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
119 changes: 119 additions & 0 deletions packages/ui/src/contexts/LedgerProvider/LedgerProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,12 @@ describe('src/contexts/LedgerProvider.tsx', () => {

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

// Clear the AppAvalanche mock to ignore heartbeat calls
(AppAvalanche as unknown as jest.Mock).mockClear();

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

Expand Down Expand Up @@ -1181,4 +1187,117 @@ describe('src/contexts/LedgerProvider.tsx', () => {
});
});
});

describe('checkHeartbeat', () => {
beforeEach(() => {
// Clear any previous calls to refMock.send before each test
refMock.send.mockClear();
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should not run heartbeat when transport is not available', async () => {
// Mock transportRef.current to be null
jest.spyOn(React, 'useRef').mockReturnValue({
current: null,
});

renderTestComponent();

// Fast-forward time to trigger heartbeat
jest.advanceTimersByTime(3000);

// Should not call transport.send when no transport
expect(refMock.send).not.toHaveBeenCalled();
expect(AppAvalanche).not.toHaveBeenCalled();
});

it('should run heartbeat when transport is available', async () => {
// Mock transportRef.current to be available
jest.spyOn(React, 'useRef').mockReturnValue({
current: refMock,
});

// Mock app state to exist so heartbeat runs
const mockApp = new AppAvalanche(refMock as any);
jest.spyOn(React, 'useState').mockImplementation(((initialValue: any) => {
if (initialValue === undefined) {
// This is the app state
return [mockApp, jest.fn()];
}
return [initialValue, jest.fn()];
}) as any);

renderTestComponent();

// Fast-forward time to trigger heartbeat
jest.advanceTimersByTime(3000);

// Should call transport.send when transport is available
expect(refMock.send).toHaveBeenCalled();
});

it('should run heartbeat when no app but transport is available', async () => {
// Mock transportRef.current to be available
jest.spyOn(React, 'useRef').mockReturnValue({
current: refMock,
});

// Mock app state to be undefined (no app)
jest.spyOn(React, 'useState').mockImplementation(((initialValue: any) => {
if (initialValue === undefined) {
// This is the app state - return undefined to simulate no app
return [undefined, jest.fn()];
}
return [initialValue, jest.fn()];
}) as any);

renderTestComponent();

// Fast-forward time to trigger heartbeat
jest.advanceTimersByTime(3000);

// The heartbeat should run (it will attempt to reinitialize the app)
// We can verify this by checking that the heartbeat mechanism is active
// The actual reinitialization will happen through initLedgerApp
expect(refMock.send).not.toHaveBeenCalled(); // No direct send call when no app
});

it('should detect device lock error codes correctly', () => {
// Test the error detection logic directly
const testCases = [
{ statusCode: 0x5515, shouldBeLock: true },
{ statusCode: 0x6700, shouldBeLock: true },
{ statusCode: 0x6b0c, shouldBeLock: true },
{ statusCode: 0x9001, shouldBeLock: false },
];

testCases.forEach(({ statusCode, shouldBeLock }) => {
const error = new Error('Test error') as any;
error.statusCode = statusCode;
const isLockError =
error?.statusCode === 0x5515 || // Device locked
error?.statusCode === 0x6700 || // Incorrect length
error?.statusCode === 0x6b0c; // Something went wrong

expect(isLockError).toBe(shouldBeLock);
});
});

it('should clean up interval on unmount', () => {
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');

const { unmount } = renderTestComponent();

// Fast-forward to set up interval
jest.advanceTimersByTime(3000);

// Unmount component
unmount();

expect(clearIntervalSpy).toHaveBeenCalled();
});
});
});
61 changes: 61 additions & 0 deletions packages/ui/src/contexts/LedgerProvider/LedgerProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export enum LedgerAppType {
export const REQUIRED_LEDGER_VERSION = '0.7.3';
export const LEDGER_VERSION_WITH_EIP_712 = '0.8.0';

const LEDGER_ERROR_CODES = Object.freeze({
DEVICE_LOCKED: 0x5515,
INCORRECT_LENGTH: 0x6700,
SOMETHING_WRONG: 0x6b0c,
});

/**
* Run this here since each new window will have a different id
* this is used to track the transport and close on window close
Expand Down Expand Up @@ -495,6 +501,61 @@ export function LedgerContextProvider({ children }: PropsWithChildren) {
setLedgerVersionWarningClosed(result);
}, [request]);

// Ledger Stax getting locked when connected via USB needs to be detected and the transport needs to be cleared
// Heartbeat mechanism is being used to detect device lock
useEffect(() => {
let isCheckingHeartbeat = false;

const checkHeartbeat = async () => {
if (isCheckingHeartbeat || !transportRef.current) {
return;
}

isCheckingHeartbeat = true;

try {
if (!app) {
// No app instance - try to re-establish connection
await initLedgerApp(transportRef.current);
} else {
// Send a simple GET_VERSION command which should always require device interaction
await transportRef.current.send(
0xe0, // CLA - Generic command class
0x01, // INS - Get version instruction
0x00, // P1
0x00, // P2
Buffer.alloc(0), // Data
[0x9000], // Expected status code for success
);
}
} catch (error: any) {
// Check if this looks like a device lock error
const isLockError = Object.values(LEDGER_ERROR_CODES).includes(
error?.statusCode,
);

if (isLockError && app) {
// Device appears to be locked, clearing transport but keeping heartbeat running
setApp(undefined);
setAppType(LedgerAppType.UNKNOWN);
}
} finally {
isCheckingHeartbeat = false;
}
};

// 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)
const heartbeatInterval = setInterval(checkHeartbeat, 3000);

checkHeartbeat();

return () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
};
}, [app, initLedgerApp]);

return (
<LedgerContext.Provider
value={{
Expand Down
Loading