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
31 changes: 31 additions & 0 deletions ui/contexts/hardware-wallets/__mocks__/webConnectionUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CameraPermissionState } from '../constants';
import {
HardwareWalletType,
HardwareConnectionPermissionState,
Expand All @@ -10,11 +11,15 @@ import {
// Mock functions
export const isWebHidAvailable = jest.fn();
export const isWebUsbAvailable = jest.fn();
export const isCameraAvailable = jest.fn();
export const checkWebHidPermission = jest.fn();
export const checkWebUsbPermission = jest.fn();
export const checkCameraPermissionState = jest.fn();
export const checkCameraPermission = jest.fn();
export const checkHardwareWalletPermission = jest.fn();
export const requestWebHidPermission = jest.fn();
export const requestWebUsbPermission = jest.fn();
export const requestCameraPermission = jest.fn();
export const requestHardwareWalletPermission = jest.fn();
export const getConnectedDevices = jest.fn();
export const subscribeToWebHidEvents = jest.fn();
Expand All @@ -24,6 +29,7 @@ export const subscribeToHardwareWalletEvents = jest.fn();
// Default mock implementations
isWebHidAvailable.mockReturnValue(true);
isWebUsbAvailable.mockReturnValue(true);
isCameraAvailable.mockReturnValue(true);
checkWebHidPermission.mockResolvedValue(
HardwareConnectionPermissionState.Granted,
);
Expand All @@ -37,18 +43,26 @@ checkHardwareWalletPermission.mockImplementation(
return Promise.resolve(HardwareConnectionPermissionState.Granted);
case HardwareWalletType.Trezor:
return Promise.resolve(HardwareConnectionPermissionState.Granted);
case HardwareWalletType.Qr:
return Promise.resolve(HardwareConnectionPermissionState.Granted);
default:
return Promise.resolve(HardwareConnectionPermissionState.Denied);
}
},
);
checkCameraPermissionState.mockResolvedValue(
HardwareConnectionPermissionState.Granted,
);
checkCameraPermission.mockResolvedValue(CameraPermissionState.Granted);
requestWebHidPermission.mockResolvedValue(true);
requestWebUsbPermission.mockResolvedValue(true);
requestCameraPermission.mockResolvedValue(true);
requestHardwareWalletPermission.mockImplementation(
(walletType: HardwareWalletType) => {
switch (walletType) {
case HardwareWalletType.Ledger:
case HardwareWalletType.Trezor:
case HardwareWalletType.Qr:
return Promise.resolve(true);
default:
return Promise.resolve(false);
Expand All @@ -64,6 +78,7 @@ subscribeToHardwareWalletEvents.mockReturnValue(jest.fn());
export const resetwebConnectionUtilsMocks = () => {
isWebHidAvailable.mockReturnValue(true);
isWebUsbAvailable.mockReturnValue(true);
isCameraAvailable.mockReturnValue(true);
checkWebHidPermission.mockResolvedValue(
HardwareConnectionPermissionState.Granted,
);
Expand All @@ -76,19 +91,26 @@ export const resetwebConnectionUtilsMocks = () => {
case HardwareWalletType.Ledger:
return Promise.resolve(HardwareConnectionPermissionState.Granted);
case HardwareWalletType.Trezor:
case HardwareWalletType.Qr:
return Promise.resolve(HardwareConnectionPermissionState.Granted);
default:
return Promise.resolve(HardwareConnectionPermissionState.Denied);
}
},
);
checkCameraPermissionState.mockResolvedValue(
HardwareConnectionPermissionState.Granted,
);
checkCameraPermission.mockResolvedValue(CameraPermissionState.Granted);
requestWebHidPermission.mockResolvedValue(true);
requestWebUsbPermission.mockResolvedValue(true);
requestCameraPermission.mockResolvedValue(true);
requestHardwareWalletPermission.mockImplementation(
(walletType: HardwareWalletType) => {
switch (walletType) {
case HardwareWalletType.Ledger:
case HardwareWalletType.Trezor:
case HardwareWalletType.Qr:
return Promise.resolve(true);
default:
return Promise.resolve(false);
Expand All @@ -114,11 +136,16 @@ export const mockPermissionsDenied = () => {
checkWebUsbPermission.mockResolvedValue(
HardwareConnectionPermissionState.Denied,
);
checkCameraPermissionState.mockResolvedValue(
HardwareConnectionPermissionState.Denied,
);
checkCameraPermission.mockResolvedValue(CameraPermissionState.Denied);
checkHardwareWalletPermission.mockResolvedValue(
HardwareConnectionPermissionState.Denied,
);
requestWebHidPermission.mockResolvedValue(false);
requestWebUsbPermission.mockResolvedValue(false);
requestCameraPermission.mockResolvedValue(false);
requestHardwareWalletPermission.mockResolvedValue(false);
};

Expand All @@ -130,6 +157,10 @@ export const mockPermissionsPrompt = () => {
checkWebUsbPermission.mockResolvedValue(
HardwareConnectionPermissionState.Prompt,
);
checkCameraPermissionState.mockResolvedValue(
HardwareConnectionPermissionState.Prompt,
);
checkCameraPermission.mockResolvedValue(CameraPermissionState.Prompt);
checkHardwareWalletPermission.mockResolvedValue(
HardwareConnectionPermissionState.Prompt,
);
Expand Down
135 changes: 135 additions & 0 deletions ui/contexts/hardware-wallets/adapters/QrAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { ErrorCode, HardwareWalletError } from '@metamask/hw-wallet-sdk';
import { CameraPermissionState } from '../constants';
import { DeviceEvent, type HardwareWalletAdapterOptions } from '../types';
import * as webConnectionUtils from '../webConnectionUtils';
import { QrAdapter } from './QrAdapter';

jest.mock('../webConnectionUtils', () => ({
...jest.requireActual('../webConnectionUtils'),
checkCameraPermission: jest.fn(),
}));

const mockCheckCameraPermission =
webConnectionUtils.checkCameraPermission as jest.MockedFunction<
typeof webConnectionUtils.checkCameraPermission
>;

describe('QrAdapter', () => {
let adapter: QrAdapter;
let mockOptions: HardwareWalletAdapterOptions;

const createMockOptions = (): HardwareWalletAdapterOptions => ({
onDisconnect: jest.fn(),
onAwaitingConfirmation: jest.fn(),
onDeviceLocked: jest.fn(),
onAppNotOpen: jest.fn(),
onDeviceEvent: jest.fn(),
});

beforeEach(() => {
jest.clearAllMocks();
mockOptions = createMockOptions();
adapter = new QrAdapter(mockOptions);
});

afterEach(() => {
jest.resetAllMocks();
adapter.destroy();
});

it('connect marks adapter as connected', async () => {
await adapter.connect();
expect(adapter.isConnected()).toBe(true);
});

it('disconnect emits disconnected event', async () => {
await adapter.connect();
await adapter.disconnect();

expect(adapter.isConnected()).toBe(false);
expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith({
event: DeviceEvent.Disconnected,
});
});

it('disconnect calls onDisconnect when onDeviceEvent throws', async () => {
await adapter.connect();
const handlerError = new Error('onDeviceEvent failed');
jest.mocked(mockOptions.onDeviceEvent).mockImplementation(() => {
throw handlerError;
});

await adapter.disconnect();

expect(mockOptions.onDisconnect).toHaveBeenCalledWith(handlerError);
});

it('ensureDeviceReady returns true when camera permission is granted', async () => {
mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Granted);
await expect(adapter.ensureDeviceReady()).resolves.toBe(true);
});

it('ensureDeviceReady does not call connect again when already connected', async () => {
mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Granted);
await adapter.connect();
const connectSpy = jest.spyOn(adapter, 'connect');

await expect(adapter.ensureDeviceReady()).resolves.toBe(true);

expect(connectSpy).not.toHaveBeenCalled();
connectSpy.mockRestore();
});

it('ensureDeviceReady throws PermissionCameraDenied when camera permission is denied', async () => {
mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Denied);

await expect(adapter.ensureDeviceReady()).rejects.toThrow(
HardwareWalletError,
);

expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith(
expect.objectContaining({
event: DeviceEvent.ConnectionFailed,
error: expect.objectContaining({
code: ErrorCode.PermissionCameraDenied,
}),
}),
);
});

it('ensureDeviceReady throws PermissionCameraPromptDismissed when camera permission is prompt', async () => {
mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Prompt);

await expect(adapter.ensureDeviceReady()).rejects.toThrow(
HardwareWalletError,
);

expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith(
expect.objectContaining({
event: DeviceEvent.ConnectionFailed,
error: expect.objectContaining({
code: ErrorCode.PermissionCameraPromptDismissed,
}),
}),
);
});

it('maps unexpected errors to hardware wallet errors and emits device event', async () => {
mockCheckCameraPermission.mockRejectedValue(
new Error('Unable to read camera permission'),
);

await expect(adapter.ensureDeviceReady()).rejects.toThrow(
HardwareWalletError,
);

expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith(
expect.objectContaining({
event: DeviceEvent.ConnectionFailed,
error: expect.objectContaining({
code: ErrorCode.Unknown,
}),
}),
);
});
});
120 changes: 120 additions & 0 deletions ui/contexts/hardware-wallets/adapters/QrAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { ErrorCode, type HardwareWalletError } from '@metamask/hw-wallet-sdk';
import { createHardwareWalletError, getDeviceEventForError } from '../errors';
import { toHardwareWalletError } from '../rpcErrorUtils';
import {
DeviceEvent,
HardwareWalletType,
type EnsureDeviceReadyOptions,
type HardwareWalletAdapter,
type HardwareWalletAdapterOptions,
} from '../types';
import { CameraPermissionState } from '../constants';
import { checkCameraPermission } from '../webConnectionUtils';

/**
* QR hardware wallet adapter.
*
* Readiness depends on camera availability and permission state for QR scanning.
*/
export class QrAdapter implements HardwareWalletAdapter {
private readonly options: HardwareWalletAdapterOptions;

private connected = false;

constructor(options: HardwareWalletAdapterOptions) {
this.options = options;
}

/**
* Marks the adapter as connected.
*/
async connect(): Promise<void> {
this.connected = true;
}

/**
* Clears connection state and notifies listeners that the QR flow is no longer active.
*/
async disconnect(): Promise<void> {
try {
this.connected = false;
this.options.onDeviceEvent({
event: DeviceEvent.Disconnected,
});
} catch (error) {
this.options.onDisconnect(error);
}
}

/**
* Whether the adapter considers the QR account flow active (after connection).
*/
isConnected(): boolean {
return this.connected;
}

/**
* Resets local connection state.
*/
destroy(): void {
this.connected = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: But I wonder if destroy should also call disconnect implicitly in-case the device is still connected (I know we're not doing that for the LedgerAdapter, but that might be something we should discuss internally cc @montelaidev)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not something we need to handle on this PR btw)

}

/**
* Emits a device event for the given error and returns a rejected promise with that error.
*
* @param hwError - Structured hardware wallet error to surface to the UI layer.
* @returns A promise that rejects with `hwError`.
*/
private failEnsureDeviceReady(hwError: HardwareWalletError): Promise<never> {
this.options.onDeviceEvent({
event: getDeviceEventForError(hwError.code),
error: hwError,
});
return Promise.reject(hwError);
}

/**
* Ensures camera permission state allows QR scanning (via Permissions API probe).
* Rejects with `HardwareWalletError` when permission is denied, still prompt, or the probe fails.
*
* @param _options - Reserved for parity with other hardware adapters; ignored for QR.
* @returns True when camera permission is granted.
*/
async ensureDeviceReady(
_options?: EnsureDeviceReadyOptions,
): Promise<boolean> {
if (!this.isConnected()) {
await this.connect();
}

let permissionState: PermissionState;
try {
permissionState = await checkCameraPermission();
} catch (error) {
return this.failEnsureDeviceReady(
toHardwareWalletError(error, HardwareWalletType.Qr),
);
}

if (permissionState === CameraPermissionState.Granted) {
return true;
}

if (permissionState === CameraPermissionState.Denied) {
return this.failEnsureDeviceReady(
createHardwareWalletError(
ErrorCode.PermissionCameraDenied,
HardwareWalletType.Qr,
),
);
}

return this.failEnsureDeviceReady(
createHardwareWalletError(
ErrorCode.PermissionCameraPromptDismissed,
HardwareWalletType.Qr,
),
);
}
}
9 changes: 9 additions & 0 deletions ui/contexts/hardware-wallets/adapters/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import { createAdapterForHardwareWalletType } from './factory';
import { LedgerAdapter } from './LedgerAdapter';
import { NonHardwareAdapter } from './NonHardwareAdapter';
import { QrAdapter } from './QrAdapter';

describe('createAdapterForHardwareWalletType', () => {
const mockOptions: HardwareWalletAdapterOptions = {
Expand All @@ -27,6 +28,14 @@ describe('createAdapterForHardwareWalletType', () => {
);
expect(adapter).toBeInstanceOf(LedgerAdapter);
});

it('creates QrAdapter for QR wallet type', () => {
const adapter = createAdapterForHardwareWalletType(
HardwareWalletType.Qr,
mockOptions,
);
expect(adapter).toBeInstanceOf(QrAdapter);
});
});

describe('non-hardware wallet accounts', () => {
Expand Down
Loading
Loading