Skip to content

Commit 51fcdfa

Browse files
committed
Add QR adapter
1 parent e4fa9e7 commit 51fcdfa

10 files changed

Lines changed: 510 additions & 0 deletions

File tree

ui/contexts/hardware-wallets/__mocks__/webConnectionUtils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CameraPermissionState } from '../constants';
12
import {
23
HardwareWalletType,
34
HardwareConnectionPermissionState,
@@ -10,11 +11,15 @@ import {
1011
// Mock functions
1112
export const isWebHidAvailable = jest.fn();
1213
export const isWebUsbAvailable = jest.fn();
14+
export const isCameraAvailable = jest.fn();
1315
export const checkWebHidPermission = jest.fn();
1416
export const checkWebUsbPermission = jest.fn();
17+
export const checkCameraPermissionState = jest.fn();
18+
export const checkCameraPermission = jest.fn();
1519
export const checkHardwareWalletPermission = jest.fn();
1620
export const requestWebHidPermission = jest.fn();
1721
export const requestWebUsbPermission = jest.fn();
22+
export const requestCameraPermission = jest.fn();
1823
export const requestHardwareWalletPermission = jest.fn();
1924
export const getConnectedDevices = jest.fn();
2025
export const subscribeToWebHidEvents = jest.fn();
@@ -24,6 +29,7 @@ export const subscribeToHardwareWalletEvents = jest.fn();
2429
// Default mock implementations
2530
isWebHidAvailable.mockReturnValue(true);
2631
isWebUsbAvailable.mockReturnValue(true);
32+
isCameraAvailable.mockReturnValue(true);
2733
checkWebHidPermission.mockResolvedValue(
2834
HardwareConnectionPermissionState.Granted,
2935
);
@@ -37,18 +43,26 @@ checkHardwareWalletPermission.mockImplementation(
3743
return Promise.resolve(HardwareConnectionPermissionState.Granted);
3844
case HardwareWalletType.Trezor:
3945
return Promise.resolve(HardwareConnectionPermissionState.Granted);
46+
case HardwareWalletType.Qr:
47+
return Promise.resolve(HardwareConnectionPermissionState.Granted);
4048
default:
4149
return Promise.resolve(HardwareConnectionPermissionState.Denied);
4250
}
4351
},
4452
);
53+
checkCameraPermissionState.mockResolvedValue(
54+
HardwareConnectionPermissionState.Granted,
55+
);
56+
checkCameraPermission.mockResolvedValue(CameraPermissionState.Granted);
4557
requestWebHidPermission.mockResolvedValue(true);
4658
requestWebUsbPermission.mockResolvedValue(true);
59+
requestCameraPermission.mockResolvedValue(true);
4760
requestHardwareWalletPermission.mockImplementation(
4861
(walletType: HardwareWalletType) => {
4962
switch (walletType) {
5063
case HardwareWalletType.Ledger:
5164
case HardwareWalletType.Trezor:
65+
case HardwareWalletType.Qr:
5266
return Promise.resolve(true);
5367
default:
5468
return Promise.resolve(false);
@@ -64,6 +78,7 @@ subscribeToHardwareWalletEvents.mockReturnValue(jest.fn());
6478
export const resetwebConnectionUtilsMocks = () => {
6579
isWebHidAvailable.mockReturnValue(true);
6680
isWebUsbAvailable.mockReturnValue(true);
81+
isCameraAvailable.mockReturnValue(true);
6782
checkWebHidPermission.mockResolvedValue(
6883
HardwareConnectionPermissionState.Granted,
6984
);
@@ -76,19 +91,26 @@ export const resetwebConnectionUtilsMocks = () => {
7691
case HardwareWalletType.Ledger:
7792
return Promise.resolve(HardwareConnectionPermissionState.Granted);
7893
case HardwareWalletType.Trezor:
94+
case HardwareWalletType.Qr:
7995
return Promise.resolve(HardwareConnectionPermissionState.Granted);
8096
default:
8197
return Promise.resolve(HardwareConnectionPermissionState.Denied);
8298
}
8399
},
84100
);
101+
checkCameraPermissionState.mockResolvedValue(
102+
HardwareConnectionPermissionState.Granted,
103+
);
104+
checkCameraPermission.mockResolvedValue(CameraPermissionState.Granted);
85105
requestWebHidPermission.mockResolvedValue(true);
86106
requestWebUsbPermission.mockResolvedValue(true);
107+
requestCameraPermission.mockResolvedValue(true);
87108
requestHardwareWalletPermission.mockImplementation(
88109
(walletType: HardwareWalletType) => {
89110
switch (walletType) {
90111
case HardwareWalletType.Ledger:
91112
case HardwareWalletType.Trezor:
113+
case HardwareWalletType.Qr:
92114
return Promise.resolve(true);
93115
default:
94116
return Promise.resolve(false);
@@ -114,11 +136,16 @@ export const mockPermissionsDenied = () => {
114136
checkWebUsbPermission.mockResolvedValue(
115137
HardwareConnectionPermissionState.Denied,
116138
);
139+
checkCameraPermissionState.mockResolvedValue(
140+
HardwareConnectionPermissionState.Denied,
141+
);
142+
checkCameraPermission.mockResolvedValue(CameraPermissionState.Denied);
117143
checkHardwareWalletPermission.mockResolvedValue(
118144
HardwareConnectionPermissionState.Denied,
119145
);
120146
requestWebHidPermission.mockResolvedValue(false);
121147
requestWebUsbPermission.mockResolvedValue(false);
148+
requestCameraPermission.mockResolvedValue(false);
122149
requestHardwareWalletPermission.mockResolvedValue(false);
123150
};
124151

@@ -130,6 +157,10 @@ export const mockPermissionsPrompt = () => {
130157
checkWebUsbPermission.mockResolvedValue(
131158
HardwareConnectionPermissionState.Prompt,
132159
);
160+
checkCameraPermissionState.mockResolvedValue(
161+
HardwareConnectionPermissionState.Prompt,
162+
);
163+
checkCameraPermission.mockResolvedValue(CameraPermissionState.Prompt);
133164
checkHardwareWalletPermission.mockResolvedValue(
134165
HardwareConnectionPermissionState.Prompt,
135166
);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
Category,
3+
ErrorCode,
4+
HardwareWalletError,
5+
Severity,
6+
} from '@metamask/hw-wallet-sdk';
7+
import { CameraPermissionState } from '../constants';
8+
import { DeviceEvent, type HardwareWalletAdapterOptions } from '../types';
9+
import * as webConnectionUtils from '../webConnectionUtils';
10+
import { QrAdapter } from './QrAdapter';
11+
12+
jest.mock('../webConnectionUtils', () => ({
13+
...jest.requireActual('../webConnectionUtils'),
14+
checkCameraPermission: jest.fn(),
15+
}));
16+
17+
const mockCheckCameraPermission =
18+
webConnectionUtils.checkCameraPermission as jest.MockedFunction<
19+
typeof webConnectionUtils.checkCameraPermission
20+
>;
21+
22+
describe('QrAdapter', () => {
23+
let adapter: QrAdapter;
24+
let mockOptions: HardwareWalletAdapterOptions;
25+
26+
const createMockOptions = (): HardwareWalletAdapterOptions => ({
27+
onDisconnect: jest.fn(),
28+
onAwaitingConfirmation: jest.fn(),
29+
onDeviceLocked: jest.fn(),
30+
onAppNotOpen: jest.fn(),
31+
onDeviceEvent: jest.fn(),
32+
});
33+
34+
beforeEach(() => {
35+
jest.clearAllMocks();
36+
mockOptions = createMockOptions();
37+
adapter = new QrAdapter(mockOptions);
38+
});
39+
40+
afterEach(() => {
41+
jest.resetAllMocks();
42+
adapter.destroy();
43+
});
44+
45+
it('connect marks adapter as connected', async () => {
46+
await adapter.connect();
47+
expect(adapter.isConnected()).toBe(true);
48+
});
49+
50+
it('disconnect emits disconnected event', async () => {
51+
await adapter.connect();
52+
await adapter.disconnect();
53+
54+
expect(adapter.isConnected()).toBe(false);
55+
expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith({
56+
event: DeviceEvent.Disconnected,
57+
});
58+
});
59+
60+
it('ensureDeviceReady returns true when camera permission is granted', async () => {
61+
mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Granted);
62+
await expect(adapter.ensureDeviceReady()).resolves.toBe(true);
63+
});
64+
65+
it('ensureDeviceReady throws PermissionCameraDenied when camera permission is denied', async () => {
66+
mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Denied);
67+
68+
await expect(adapter.ensureDeviceReady()).rejects.toThrow(
69+
HardwareWalletError,
70+
);
71+
72+
expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith(
73+
expect.objectContaining({
74+
event: DeviceEvent.ConnectionFailed,
75+
error: expect.objectContaining({
76+
code: ErrorCode.PermissionCameraDenied,
77+
}),
78+
}),
79+
);
80+
});
81+
82+
it('ensureDeviceReady throws PermissionCameraPromptDismissed when camera permission is prompt', async () => {
83+
mockCheckCameraPermission.mockResolvedValue(CameraPermissionState.Prompt);
84+
85+
await expect(adapter.ensureDeviceReady()).rejects.toThrow(
86+
HardwareWalletError,
87+
);
88+
89+
expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith(
90+
expect.objectContaining({
91+
event: DeviceEvent.ConnectionFailed,
92+
error: expect.objectContaining({
93+
code: ErrorCode.PermissionCameraPromptDismissed,
94+
}),
95+
}),
96+
);
97+
});
98+
99+
it('maps unexpected errors to hardware wallet errors and emits device event', async () => {
100+
const unknownError = new HardwareWalletError('Unknown error', {
101+
code: ErrorCode.Unknown,
102+
severity: Severity.Err,
103+
category: Category.Unknown,
104+
userMessage: 'Unknown',
105+
});
106+
mockCheckCameraPermission.mockRejectedValue(unknownError);
107+
108+
await expect(adapter.ensureDeviceReady()).rejects.toThrow(
109+
HardwareWalletError,
110+
);
111+
112+
expect(mockOptions.onDeviceEvent).toHaveBeenCalledWith(
113+
expect.objectContaining({
114+
event: DeviceEvent.ConnectionFailed,
115+
error: expect.any(HardwareWalletError),
116+
}),
117+
);
118+
});
119+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { ErrorCode, type HardwareWalletError } from '@metamask/hw-wallet-sdk';
2+
import { createHardwareWalletError, getDeviceEventForError } from '../errors';
3+
import { toHardwareWalletError } from '../rpcErrorUtils';
4+
import {
5+
DeviceEvent,
6+
HardwareWalletType,
7+
type EnsureDeviceReadyOptions,
8+
type HardwareWalletAdapter,
9+
type HardwareWalletAdapterOptions,
10+
} from '../types';
11+
import { CameraPermissionState } from '../constants';
12+
import { checkCameraPermission } from '../webConnectionUtils';
13+
14+
/**
15+
* QR hardware wallet adapter.
16+
*
17+
* Readiness depends on camera availability and permission state for QR scanning.
18+
*/
19+
export class QrAdapter implements HardwareWalletAdapter {
20+
private options: HardwareWalletAdapterOptions;
21+
22+
private connected = false;
23+
24+
constructor(options: HardwareWalletAdapterOptions) {
25+
this.options = options;
26+
}
27+
28+
/**
29+
* Marks the adapter as connected.
30+
*/
31+
async connect(): Promise<void> {
32+
this.connected = true;
33+
}
34+
35+
/**
36+
* Clears connection state and notifies listeners that the QR flow is no longer active.
37+
*/
38+
async disconnect(): Promise<void> {
39+
try {
40+
this.connected = false;
41+
this.options.onDeviceEvent({
42+
event: DeviceEvent.Disconnected,
43+
});
44+
} catch (error) {
45+
this.options.onDisconnect(error);
46+
}
47+
}
48+
49+
/**
50+
* Whether the adapter considers the QR account flow active (after connection).
51+
*/
52+
isConnected(): boolean {
53+
return this.connected;
54+
}
55+
56+
/**
57+
* Resets local connection state.
58+
*/
59+
destroy(): void {
60+
this.connected = false;
61+
}
62+
63+
/**
64+
* Emits a device event for the given error and returns a rejected promise with that error.
65+
*
66+
* @param hwError - Structured hardware wallet error to surface to the UI layer.
67+
* @returns A promise that rejects with `hwError`.
68+
*/
69+
private failEnsureDeviceReady(hwError: HardwareWalletError): Promise<never> {
70+
this.options.onDeviceEvent({
71+
event: getDeviceEventForError(hwError.code),
72+
error: hwError,
73+
});
74+
return Promise.reject(hwError);
75+
}
76+
77+
/**
78+
* Ensures camera permission state allows QR scanning (via Permissions API probe).
79+
* Rejects with `HardwareWalletError` when permission is denied, still prompt, or the probe fails.
80+
*
81+
* @param _options - Reserved for parity with other hardware adapters; ignored for QR.
82+
* @returns True when camera permission is granted.
83+
*/
84+
async ensureDeviceReady(
85+
_options?: EnsureDeviceReadyOptions,
86+
): Promise<boolean> {
87+
if (!this.isConnected()) {
88+
await this.connect();
89+
}
90+
91+
let permissionState: PermissionState;
92+
try {
93+
permissionState = await checkCameraPermission();
94+
} catch (error) {
95+
return this.failEnsureDeviceReady(
96+
toHardwareWalletError(error, HardwareWalletType.Qr),
97+
);
98+
}
99+
100+
if (permissionState === CameraPermissionState.Granted) {
101+
return true;
102+
}
103+
104+
if (permissionState === CameraPermissionState.Denied) {
105+
return this.failEnsureDeviceReady(
106+
createHardwareWalletError(
107+
ErrorCode.PermissionCameraDenied,
108+
HardwareWalletType.Qr,
109+
),
110+
);
111+
}
112+
113+
return this.failEnsureDeviceReady(
114+
createHardwareWalletError(
115+
ErrorCode.PermissionCameraPromptDismissed,
116+
HardwareWalletType.Qr,
117+
),
118+
);
119+
}
120+
}

ui/contexts/hardware-wallets/adapters/factory.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
import { createAdapterForHardwareWalletType } from './factory';
66
import { LedgerAdapter } from './LedgerAdapter';
77
import { NonHardwareAdapter } from './NonHardwareAdapter';
8+
import { QrAdapter } from './QrAdapter';
89

910
describe('createAdapterForHardwareWalletType', () => {
1011
const mockOptions: HardwareWalletAdapterOptions = {
@@ -27,6 +28,14 @@ describe('createAdapterForHardwareWalletType', () => {
2728
);
2829
expect(adapter).toBeInstanceOf(LedgerAdapter);
2930
});
31+
32+
it('creates QrAdapter for QR wallet type', () => {
33+
const adapter = createAdapterForHardwareWalletType(
34+
HardwareWalletType.Qr,
35+
mockOptions,
36+
);
37+
expect(adapter).toBeInstanceOf(QrAdapter);
38+
});
3039
});
3140

3241
describe('non-hardware wallet accounts', () => {

ui/contexts/hardware-wallets/adapters/factory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from '../types';
66
import { LedgerAdapter } from './LedgerAdapter';
77
import { NonHardwareAdapter } from './NonHardwareAdapter';
8+
import { QrAdapter } from './QrAdapter';
89

910
/**
1011
* Creates an adapter for the given hardware wallet type.
@@ -20,6 +21,8 @@ export function createAdapterForHardwareWalletType(
2021
switch (walletType) {
2122
case HardwareWalletType.Ledger:
2223
return new LedgerAdapter(adapterOptions);
24+
case HardwareWalletType.Qr:
25+
return new QrAdapter(adapterOptions);
2326
default:
2427
return new NonHardwareAdapter(adapterOptions);
2528
}

0 commit comments

Comments
 (0)