Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
10 changes: 9 additions & 1 deletion app/components/UI/OptinMetrics/OptinMetrics.navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,15 @@ describe('OptinMetrics — interest questionnaire navigation branching', () => {
await waitFor(() => {
expect(Logger.error).toHaveBeenCalledWith(
expect.objectContaining({ message: 'provisioning failed' }),
'OptinMetrics: provisionFromMetadata failed',
expect.objectContaining({
tags: expect.objectContaining({
feature: 'qr-sync',
surface: 'import',
operation: 'provision_from_metadata',
source: 'OptinMetrics',
syncFlow: 'new_user',
}),
}),
);
});
});
Expand Down
27 changes: 17 additions & 10 deletions app/components/Views/AddDeviceToWallet/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ import { showAddDeviceVerificationSheet } from '../../../core/QrSync/showAddDevi
import { useAddDeviceResetToInstructionsListener } from '../../../core/QrSync/useAddDeviceResetToInstructionsListener';
import { useIsQrTabSwitcherOpen } from '../../../core/QrSync/useIsQrTabSwitcherOpen';
import { useQrSyncImportNavigation } from '../../../core/QrSync/useQrSyncImportNavigation';
import {
QrSyncOperations,
QrSyncSurfaces,
QrSyncTelemetrySources,
reportQrSyncFailure,
} from '../../../core/QrSync/qrSyncTelemetry';
import type { AppNavigationProp } from '../../../core/NavigationService/types';
import Logger from '../../../util/Logger';
import {
selectQrSyncError,
selectQrSyncIsBusy,
Expand Down Expand Up @@ -119,10 +124,11 @@ const AddDeviceToWallet = () => {
const scannedQrPayload = content ?? data.content ?? '';

submitQrPayload(scannedQrPayload).catch((err: unknown) => {
Logger.error(
err as Error,
'AddDeviceToWallet: failed to submit scanned QR payload',
);
reportQrSyncFailure(err, {
surface: QrSyncSurfaces.SCANNER,
operation: QrSyncOperations.SUBMIT_SCANNED_PAYLOAD,
source: QrSyncTelemetrySources.ADD_DEVICE_ON_SCAN_SUCCESS,
});
});
},
[submitQrPayload],
Expand Down Expand Up @@ -150,11 +156,12 @@ const AddDeviceToWallet = () => {
}, [manualQrPayload, submitQrPayload]);

const triggerManualQrSubmit = useCallback(() => {
handleManualQrSubmit().catch((error: unknown) => {
Logger.error(
error as Error,
'AddDeviceToWallet: failed to submit manual QR payload',
);
handleManualQrSubmit().catch((submitError: unknown) => {
reportQrSyncFailure(submitError, {
surface: QrSyncSurfaces.SCANNER,
operation: QrSyncOperations.SUBMIT_MANUAL_PAYLOAD,
source: QrSyncTelemetrySources.ADD_DEVICE_MANUAL_SUBMIT,
});
});
}, [handleManualQrSubmit]);

Expand Down
18 changes: 18 additions & 0 deletions app/components/Views/QRScanner/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ import AddDeviceScannerRecovery, {
AddDeviceScannerPermissionDenied,
} from './AddDeviceScannerRecovery';
import { EXTENSION_ACCOUNT_SYNC_CONNECTION_FAILED_EVENT } from '../../../core/ExtensionAccountSync/types';
import {
QrSyncOperations,
QrSyncSurfaces,
QrSyncTelemetrySources,
reportQrSyncFailure,
} from '../../../core/QrSync/qrSyncTelemetry';
import { QrSyncErrorCodes } from '../../../core/QrSync/types';

const sleep = (ms: number) =>
new Promise<void>((resolve) => {
Expand Down Expand Up @@ -258,6 +265,17 @@ const QRScanner = ({
if (classification !== 'valid') {
shouldReadBarCodeRef.current = false;
setIsCameraActive(false);
if (classification === 'invalid') {
Comment thread
grvgoel81 marked this conversation as resolved.
reportQrSyncFailure(
new Error('Add-device QR scan classified as invalid'),
{
surface: QrSyncSurfaces.SCANNER,
operation: QrSyncOperations.CLASSIFY_SCAN_CONTENT,
errorCode: QrSyncErrorCodes.INVALID_PAYLOAD,
source: QrSyncTelemetrySources.QR_SCANNER_ADD_DEVICE,
},
);
}
trackEvent(
createEventBuilder(MetaMetricsEvents.QR_SCANNED)
.addProperties({
Expand Down
2 changes: 2 additions & 0 deletions app/core/QrSync/QrSyncController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
QrSyncPhases,
QrSyncProvisioningStatuses,
QrSyncSecretTypes,
QrSyncSyncFlows,
} from './constants';
import {
QR_SYNC_CONTROLLER_NAME,
Expand Down Expand Up @@ -239,6 +240,7 @@ describe('QrSyncController', () => {
expect(walletClient.client.sendResponse).toHaveBeenCalledTimes(1);
expect(controller.state.phase).toBe(QrSyncPhases.AWAITING_SYNC_READY);
expect(controller.state.connectionStatus).toBe('connected');
expect(controller.state.syncFlow).toBe(QrSyncSyncFlows.EXISTING_USER);
});

it('throws when the scan payload cannot be parsed', async () => {
Expand Down
133 changes: 91 additions & 42 deletions app/core/QrSync/QrSyncController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,17 @@ import {
QrSyncPhases,
QrSyncProvisioningStatuses,
QrSyncSecretTypes,
QrSyncSyncFlows,
RELAY_URL,
} from './constants';
import { routeIncomingQrSyncMessage } from './services/qr-sync-message-router';
import Logger from '../../util/Logger';
import {
addQrSyncPhaseBreadcrumb,
QrSyncOperations,
QrSyncSurfaces,
QrSyncTelemetrySources,
reportQrSyncFailure,
} from './qrSyncTelemetry';

const metadata: StateMetadata<QrSyncControllerState> = {
phase: {
Expand All @@ -48,6 +55,12 @@ const metadata: StateMetadata<QrSyncControllerState> = {
includeInStateLogs: true,
usedInUi: true,
},
syncFlow: {
persist: false,
includeInDebugSnapshot: true,
includeInStateLogs: true,
usedInUi: false,
},
otp: {
persist: false,
includeInDebugSnapshot: false,
Expand Down Expand Up @@ -83,6 +96,7 @@ const metadata: StateMetadata<QrSyncControllerState> = {
export const defaultQrSyncControllerState: QrSyncControllerState = {
phase: QrSyncPhases.IDLE,
connectionStatus: 'disconnected',
syncFlow: null,
pendingSecretImports: null,
provisioningMetadata: null,
provisioningStatus: null,
Expand Down Expand Up @@ -154,6 +168,12 @@ export class QrSyncController extends BaseController<
// Destroy any existing session before starting a new one.
await this.destroySession();
this.clearControllerState();
// Capture sync flow once from local onboarding status at session start.
this.update((state) => {
state.syncFlow = this.getIsOnboardingCompleted()
? QrSyncSyncFlows.EXISTING_USER
: QrSyncSyncFlows.NEW_USER;
});
this.transitionTo(QrSyncPhases.INITIALIZING);

try {
Expand Down Expand Up @@ -221,10 +241,12 @@ export class QrSyncController extends BaseController<
try {
this.finalizeSecretImport();
} catch (error) {
Logger.error(
Comment thread
grvgoel81 marked this conversation as resolved.
error as Error,
'QrSyncController.importRemainingSecrets finalize',
);
reportQrSyncFailure(error, {
surface: QrSyncSurfaces.IMPORT,
operation: QrSyncOperations.IMPORT_REMAINING_SECRETS_FINALIZE,
phase: this.state.phase,
source: QrSyncTelemetrySources.CONTROLLER_IMPORT_REMAINING,
});
}
}

Expand Down Expand Up @@ -389,40 +411,37 @@ export class QrSyncController extends BaseController<

private readonly handleSessionServiceEvent = (event: QrSyncServiceEvent) => {
switch (event.type) {
case QrSyncActionTypes.OTP_DISPLAY_GRANT: {
this.update((state) => {
state.phase = QrSyncPhases.DISPLAYING_OTP;
state.otp = event.data;
state.error = null;
case QrSyncActionTypes.OTP_DISPLAY_GRANT:
this.transitionTo(QrSyncPhases.DISPLAYING_OTP, {
patch: (state) => {
state.otp = event.data;
state.error = null;
},
});
break;
}
case QrSyncActionTypes.SYNC_READY: {
this.update((state) => {
state.phase = QrSyncPhases.REVIEWING_IMPORT;
state.error = null;
case QrSyncActionTypes.SYNC_READY:
this.transitionTo(QrSyncPhases.REVIEWING_IMPORT, {
patch: (state) => {
state.error = null;
},
});
break;
}
case QrSyncActionTypes.SYNC_COMPLETED: {
this.update((state) => {
state.phase = QrSyncPhases.COMPLETED;
state.otp = null;
state.error = null;
case QrSyncActionTypes.SYNC_COMPLETED:
this.transitionTo(QrSyncPhases.COMPLETED, {
patch: (state) => {
state.otp = null;
state.error = null;
},
});
this.destroySession().catch(() => undefined);
break;
}
case QrSyncActionTypes.SYNC_CANCEL: {
case QrSyncActionTypes.SYNC_CANCEL:
this.clearControllerState();
this.destroySession().catch(() => undefined);
break;
}
case QrSyncActionTypes.SYNC_ERROR: {
case QrSyncActionTypes.SYNC_ERROR:
this.terminateWithError(event.data);
break;
}

default:
// no-op
}
Expand All @@ -438,9 +457,10 @@ export class QrSyncController extends BaseController<
},
});

this.update((state) => {
state.otp = null;
state.phase = QrSyncPhases.AWAITING_SYNC_READY;
this.transitionTo(QrSyncPhases.AWAITING_SYNC_READY, {
patch: (state) => {
state.otp = null;
},
});
}

Expand All @@ -450,10 +470,11 @@ export class QrSyncController extends BaseController<
version: QrSyncMessageVersion.V1,
});

this.update((state) => {
state.phase = QrSyncPhases.COMPLETED;
state.otp = null;
state.error = null;
this.transitionTo(QrSyncPhases.COMPLETED, {
patch: (state) => {
state.otp = null;
state.error = null;
},
});
await this.destroySession();
}
Expand Down Expand Up @@ -482,10 +503,12 @@ export class QrSyncController extends BaseController<
entropySource: primaryEntropySource,
});
} catch (error) {
Logger.error(
error as Error,
'QrSyncController.enrichPrimaryProvisioningEntry',
);
reportQrSyncFailure(error, {
surface: QrSyncSurfaces.IMPORT,
operation: QrSyncOperations.ENRICH_PRIMARY_PROVISIONING_ENTRY,
phase: this.state.phase,
source: QrSyncTelemetrySources.CONTROLLER_ENRICH_PRIMARY,
});
}
}

Expand Down Expand Up @@ -517,9 +540,24 @@ export class QrSyncController extends BaseController<
await this.client.sendResponse(message);
}

private transitionTo(phase: QrSyncPhase): void {
private transitionTo(
phase: QrSyncPhase,
options?: {
errorCode?: QrSyncErrorCode;
patch?: (state: QrSyncControllerState) => void;
},
): void {
const phaseFrom = this.state.phase;
if (phaseFrom !== phase) {
addQrSyncPhaseBreadcrumb({
phaseFrom,
phaseTo: phase,
errorCode: options?.errorCode,
});
}
this.update((state) => {
state.phase = phase;
options?.patch?.(state);
});
}

Expand Down Expand Up @@ -557,9 +595,20 @@ export class QrSyncController extends BaseController<
await this.destroySession();

if (wireType === QrSyncActionTypes.SYNC_ERROR && error) {
this.update((state) => {
state.phase = QrSyncPhases.FAILED;
state.error = error;
const phaseFrom = this.state.phase;
reportQrSyncFailure(new Error(error.message), {
surface: QrSyncSurfaces.SESSION,
operation: QrSyncOperations.TERMINATE_WITH_ERROR,
errorCode: error.code,
phase: phaseFrom,
source: QrSyncTelemetrySources.CONTROLLER,
...(this.state.syncFlow ? { syncFlow: this.state.syncFlow } : {}),
});
this.transitionTo(QrSyncPhases.FAILED, {
errorCode: error.code,
patch: (state) => {
state.error = error;
},
});
return;
}
Expand Down
13 changes: 13 additions & 0 deletions app/core/QrSync/completeExistingUserQrSyncImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { showImportFailedSheet } from '../../components/Views/AddDeviceToWallet/
import Engine from '../Engine';
import type { AppNavigationProp } from '../NavigationService/types';
import { isDuplicateMnemonicError } from './duplicateMnemonicError';
import {
QrSyncOperations,
QrSyncSurfaces,
QrSyncSyncFlows,
QrSyncTelemetrySources,
reportQrSyncFailure,
} from './qrSyncTelemetry';

export {
DUPLICATE_MNEMONIC_ERROR_MESSAGES,
Expand Down Expand Up @@ -37,6 +44,12 @@ const runExistingUserQrSyncImport = async (
return;
}

reportQrSyncFailure(error, {
surface: QrSyncSurfaces.IMPORT,
operation: QrSyncOperations.EXISTING_USER_MNEMONIC_IMPORT,
source: QrSyncTelemetrySources.COMPLETE_EXISTING_USER_IMPORT,
syncFlow: QrSyncSyncFlows.EXISTING_USER,
});
navigation.navigate(Routes.WALLET_VIEW);
showImportFailedSheet(navigation);
}
Expand Down
9 changes: 9 additions & 0 deletions app/core/QrSync/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export const QrSyncPhases = {
FAILED: 'failed',
} as const;

/** Distinguishes new-user vs existing-user QR sync receive paths. */
export const QrSyncSyncFlows = {
NEW_USER: 'new_user',
EXISTING_USER: 'existing_user',
} as const;

export type QrSyncSyncFlow =
(typeof QrSyncSyncFlows)[keyof typeof QrSyncSyncFlows];

/**
* Persisted provisioning pipeline status for QR sync vault import.
*
Expand Down
2 changes: 2 additions & 0 deletions app/core/QrSync/controller-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
QrSyncOtpDisplay,
QrSyncPhase,
} from './types';
import type { QrSyncSyncFlow } from './constants';
import { QrSyncProvisioningServiceImportSecretsToVaultAction } from './services/qr-sync-provisioning-service';

/** Runtime IDs written to persisted metadata after vault import (Phase B). */
Expand All @@ -29,6 +30,7 @@ export const QR_SYNC_CONTROLLER_NAME = 'QrSyncController';
export type QrSyncControllerState = {
phase: QrSyncPhase;
connectionStatus: QrSyncConnectionStatus;
syncFlow: QrSyncSyncFlow | null;
/** Ephemeral secrets until password import. Never persisted. */
pendingSecretImports: QrSyncSecretImportEntry[] | null;
/** Persisted provisioning plan (no secret material). */
Expand Down
Loading
Loading