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
28 changes: 28 additions & 0 deletions app/core/HardwareWallet/HardwareWalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const HardwareWalletProvider: React.FC<HardwareWalletProviderProps> = ({

const awaitingConfirmationRejectRef = useRef<(() => void) | null>(null);
const operationTypeRef = useRef<'transaction' | 'message' | null>(null);
const qrScanRetryHandlerRef = useRef<(() => void) | null>(null);

const [analyticsFlow, setAnalyticsFlow] = useState(
HardwareWalletAnalyticsFlow.Connection,
Expand Down Expand Up @@ -136,6 +137,10 @@ export const HardwareWalletProvider: React.FC<HardwareWalletProviderProps> = ({
[handleError],
);

const setQrScanRetryHandler = useCallback((handler: (() => void) | null) => {
qrScanRetryHandlerRef.current = handler;
}, []);

const showAwaitingConfirmation = useCallback(
(operationType: 'transaction' | 'message', onReject?: () => void) => {
DevLogger.log(
Expand Down Expand Up @@ -187,6 +192,26 @@ export const HardwareWalletProvider: React.FC<HardwareWalletProviderProps> = ({
}
await retryEnsureDeviceReady();
}, [handleCloseFlow, retryEnsureDeviceReady]);

const handleRetryQrScan = useCallback(() => {
if (operationTypeRef.current !== null) {
updateConnectionState({
status: ConnectionStatus.AwaitingConfirmation,
deviceId: deviceId ?? 'unknown',
operationType: operationTypeRef.current,
});
return;
}

const retryQrScan = qrScanRetryHandlerRef.current;
updateConnectionState({ status: ConnectionStatus.Disconnected });
if (!retryQrScan) {
return;
}

retryQrScan();
}, [deviceId, updateConnectionState]);

const handleAwaitingConfirmationCancel = useCallback(() => {
DevLogger.log('[HardwareWallet] handleAwaitingConfirmationCancel');
const onReject = awaitingConfirmationRejectRef.current;
Expand Down Expand Up @@ -248,6 +273,7 @@ export const HardwareWalletProvider: React.FC<HardwareWalletProviderProps> = ({
setTargetWalletType: setters.setTargetWalletType,
setPendingOperationAddress,
showHardwareWalletError,
setQrScanRetryHandler,
showAwaitingConfirmation,
hideAwaitingConfirmation,
qr: qrSigningValue,
Expand All @@ -261,6 +287,7 @@ export const HardwareWalletProvider: React.FC<HardwareWalletProviderProps> = ({
setters.setTargetWalletType,
setPendingOperationAddress,
showHardwareWalletError,
setQrScanRetryHandler,
showAwaitingConfirmation,
hideAwaitingConfirmation,
qrSigningValue,
Expand All @@ -282,6 +309,7 @@ export const HardwareWalletProvider: React.FC<HardwareWalletProviderProps> = ({
onAwaitingConfirmationCancel={handleAwaitingConfirmationCancel}
onConnectionSuccess={handleBottomSheetConnectionSuccess}
onCTAClicked={trackCTAClicked}
onRetryQrScan={handleRetryQrScan}
/>
</HardwareWalletContext.Provider>
);
Expand Down
7 changes: 3 additions & 4 deletions app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,11 @@ describe('QRWalletAdapter', () => {
).rejects.toThrow('Adapter has been destroyed');
});

it('returns true and emits AppOpened (QR wallets are always ready)', async () => {
it('returns true when camera permission is granted (QR wallets are always ready)', async () => {
const result = await adapter.ensureDeviceReady('qr-account-address');

expect(result).toBe(true);
expect(onDeviceEvent).toHaveBeenCalledWith({
event: DeviceEvent.AppOpened,
});
expect(onDeviceEvent).not.toHaveBeenCalled();
});

it('stores device ID', async () => {
Expand All @@ -150,6 +148,7 @@ describe('QRWalletAdapter', () => {
const result = await adapter.ensureDeviceReady('qr-account-address');

expect(result).toBe(false);
expect(mockRequestCameraPermission).not.toHaveBeenCalled();
expect(adapter.getConnectedDeviceId()).toBeNull();
expect(adapter.isConnected()).toBe(false);
expect(onDeviceEvent).toHaveBeenCalledWith({
Expand Down
10 changes: 0 additions & 10 deletions app/core/HardwareWallet/adapters/QRWalletAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,6 @@ export class QRWalletAdapter implements HardwareWalletAdapter {

DevLogger.log('[QRWalletAdapter] Device is ready');

// For QR wallets, we consider the "app" to always be open
// since there's no app concept like on Ledger
this.#emitEvent({
event: DeviceEvent.AppOpened,
});

return true;
}

Expand Down Expand Up @@ -271,12 +265,8 @@ export class QRWalletAdapter implements HardwareWalletAdapter {
if (newStatus === 'granted') {
return true;
}

this.#emitCameraPermissionDenied();
return false;
}

// status === 'denied' - emit error event
this.#emitCameraPermissionDenied();
return false;
} catch (error) {
Expand Down
104 changes: 104 additions & 0 deletions app/core/HardwareWallet/analytics/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
getAnalyticsDeviceType,
getErrorDetails,
getAnalyticsFlowFromApproval,
getQrHardwareScanErrorAnalyticsProperties,
} from './helpers';
import { createQRHardwareScanError, QRHardwareScanErrorType } from '../errors';
import { QrScanRequestType } from '@metamask/eth-qr-keyring';

describe('analytics helpers', () => {
describe('getAnalyticsErrorType', () => {
Expand Down Expand Up @@ -362,4 +365,105 @@ describe('analytics helpers', () => {
);
});
});

describe('getQrHardwareScanErrorAnalyticsProperties', () => {
it('returns empty object for non-ErrorState', () => {
const state: HardwareWalletConnectionState = {
status: ConnectionStatus.Disconnected,
};

expect(getQrHardwareScanErrorAnalyticsProperties(state)).toEqual({});
});

it('returns empty object for ErrorState with non-QR error', () => {
const error = new HardwareWalletError('Test', {
code: ErrorCode.Unknown,
severity: Severity.Err,
category: Category.Connection,
userMessage: 'Test',
});

const state: HardwareWalletConnectionState = {
status: ConnectionStatus.ErrorState,
error,
};

expect(getQrHardwareScanErrorAnalyticsProperties(state)).toEqual({});
});

it('returns QR scan properties for non-UR QR scanned error', () => {
const qrError = createQRHardwareScanError({
errorType: QRHardwareScanErrorType.NonURQrScanned,
purpose: QrScanRequestType.SIGN,
isUrFormat: false,
});

const state: HardwareWalletConnectionState = {
status: ConnectionStatus.ErrorState,
error: qrError,
};

const result = getQrHardwareScanErrorAnalyticsProperties(state);
expect(result).toEqual({
error_category: 'non_ur_qr_scanned',
is_ur_format: false,
});
});

it('returns received_ur_type for wrong UR type error', () => {
const qrError = createQRHardwareScanError({
errorType: QRHardwareScanErrorType.WrongURType,
purpose: QrScanRequestType.SIGN,
receivedUrType: 'crypto-account',
isUrFormat: true,
});

const state: HardwareWalletConnectionState = {
status: ConnectionStatus.ErrorState,
error: qrError,
};

const result = getQrHardwareScanErrorAnalyticsProperties(state);
expect(result).toEqual({
error_category: 'wrong_ur_type',
is_ur_format: true,
received_ur_type: 'crypto-account',
});
});

it('returns empty string for received_ur_type when not provided', () => {
const qrError = createQRHardwareScanError({
errorType: QRHardwareScanErrorType.WrongURType,
purpose: QrScanRequestType.PAIR,
isUrFormat: true,
});

const state: HardwareWalletConnectionState = {
status: ConnectionStatus.ErrorState,
error: qrError,
};

const result = getQrHardwareScanErrorAnalyticsProperties(state);
expect(result.received_ur_type).toBe('');
});

it('returns QR scan properties for UR decode error', () => {
const qrError = createQRHardwareScanError({
errorType: QRHardwareScanErrorType.URDecodeError,
purpose: QrScanRequestType.SIGN,
isUrFormat: true,
});

const state: HardwareWalletConnectionState = {
status: ConnectionStatus.ErrorState,
error: qrError,
};

const result = getQrHardwareScanErrorAnalyticsProperties(state);
expect(result).toEqual({
error_category: 'ur_decode_error',
is_ur_format: true,
});
});
});
});
29 changes: 29 additions & 0 deletions app/core/HardwareWallet/analytics/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HardwareWalletConnectionState,
ConnectionStatus,
} from '@metamask/hw-wallet-sdk';
import { isQRHardwareScanError, QRHardwareScanErrorType } from '../errors';
import { ApprovalType } from '@metamask/controller-utils';
import { TransactionType } from '@metamask/transaction-controller';

Expand Down Expand Up @@ -186,3 +187,31 @@ export function getErrorDetails(
}
return { error_code: '', error_message: '' };
}

/**
* Segment/MetaMetrics properties for QR hardware camera scan failures
* (`Hardware Wallet Connection Failed` / recovery UI), when the connection
* {@link ConnectionStatus.ErrorState} error is a {@link isQRHardwareScanError}.
*/
export function getQrHardwareScanErrorAnalyticsProperties(
connectionState: HardwareWalletConnectionState,
): Record<string, string | boolean> {
if (connectionState.status !== ConnectionStatus.ErrorState) {
return {};
}
const { error } = connectionState;
if (!isQRHardwareScanError(error)) {
return {};
}
const metadata = error.metadata;
const payload: Record<string, string | boolean> = {
error_category: metadata.qrHardwareScanErrorType,
is_ur_format: metadata.isUrFormat,
};
if (
metadata.qrHardwareScanErrorType === QRHardwareScanErrorType.WrongURType
) {
payload.received_ur_type = metadata.receivedUrType ?? '';
}
return payload;
}
1 change: 1 addition & 0 deletions app/core/HardwareWallet/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
getErrorTypeFromConnectionState,
getAnalyticsDeviceType,
getErrorDetails,
getQrHardwareScanErrorAnalyticsProperties,
} from './helpers';

export { useHardwareWalletAnalytics } from './useHardwareWalletAnalytics';
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
HardwareWalletAnalyticsFlow,
} from './helpers';
import { MetaMetricsEvents } from '../../Analytics';
import { createQRHardwareScanError, QRHardwareScanErrorType } from '../errors';
import { QrScanRequestType } from '@metamask/eth-qr-keyring';

const mockTrackEvent = jest.fn();
const mockBuild = jest.fn().mockReturnValue({ name: 'built-event' });
Expand Down Expand Up @@ -86,6 +88,67 @@ describe('useHardwareWalletAnalytics', () => {
expect(mockTrackEvent).toHaveBeenCalled();
});

it('includes QR scan analytics when error is a QR hardware scan failure', () => {
const { rerender } = renderHook(
(props) => useHardwareWalletAnalytics(props),
{ initialProps: defaultOptions },
);

const qrError = createQRHardwareScanError({
errorType: QRHardwareScanErrorType.NonURQrScanned,
purpose: QrScanRequestType.PAIR,
isUrFormat: false,
});

rerender({
...defaultOptions,
walletType: HardwareWalletType.Qr,
connectionState: {
status: ConnectionStatus.ErrorState,
error: qrError,
},
});

expect(mockAddProperties).toHaveBeenCalledWith(
expect.objectContaining({
error_type: HardwareWalletAnalyticsErrorType.GenericError,
error_category: 'non_ur_qr_scanned',
is_ur_format: false,
}),
);
});

it('includes received_ur_type for wrong UR type QR scan errors', () => {
const { rerender } = renderHook(
(props) => useHardwareWalletAnalytics(props),
{ initialProps: defaultOptions },
);

const qrError = createQRHardwareScanError({
errorType: QRHardwareScanErrorType.WrongURType,
purpose: QrScanRequestType.SIGN,
receivedUrType: 'eth-signature',
isUrFormat: true,
});

rerender({
...defaultOptions,
walletType: HardwareWalletType.Qr,
connectionState: {
status: ConnectionStatus.ErrorState,
error: qrError,
},
});

expect(mockAddProperties).toHaveBeenCalledWith(
expect.objectContaining({
error_category: 'wrong_ur_type',
is_ur_format: true,
received_ur_type: 'eth-signature',
}),
);
});

it('fires when transitioning to AwaitingApp', () => {
const { rerender } = renderHook(
(props) => useHardwareWalletAnalytics(props),
Expand Down
Loading
Loading