From c3457f64dc2c57ca22aca29e20aef1b5d7a74012 Mon Sep 17 00:00:00 2001 From: Rafe Colton Date: Mon, 19 Oct 2026 19:20:13 -0700 Subject: [PATCH 1/3] DO NOT MERGE: Add temporary wallet MFA debug logging Logs the raw native wallet results, MFA authorize/action lifecycle, and abort timing to server logs and Sentry, tagged [WalletDebug]. --- .../AddToWalletButton/index.native.tsx | 2 + .../MultifactorAuthenticationMainContext.tsx | 51 +++++ .../config/scenarios/AddToDigitalWallet.ts | 16 ++ src/libs/Wallet/index.ios.ts | 180 +++++++++++++++++- src/libs/Wallet/walletDebugLog.ts | 18 ++ 5 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 src/libs/Wallet/walletDebugLog.ts diff --git a/src/components/AddToWalletButton/index.native.tsx b/src/components/AddToWalletButton/index.native.tsx index 11119ff04d6e..8df33c3d6c07 100644 --- a/src/components/AddToWalletButton/index.native.tsx +++ b/src/components/AddToWalletButton/index.native.tsx @@ -9,6 +9,7 @@ import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {handleAddCardToWallet} from '@libs/Wallet/index'; +import {walletDebugLog} from '@libs/Wallet/walletDebugLog'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -32,6 +33,7 @@ function AddToWalletButton({card, cardHolderName, cardDescription, style}: AddTo const {isInWallet, isLoading: isCardLoading, isCardAvailable, isWalletAvailable} = useIsCardInWallet(card); const handleOnPress = () => { + walletDebugLog('Add to wallet pressed', false, {cardID: card.cardID, isUkEuCard: isUkEuExpensifyCard(card), isLoading, isCardLoading}); if (isUkEuExpensifyCard(card)) { executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, {card, cardHolderName, cardDescription}); return; diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index ec7bceaf11d9..75263f7013fd 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -18,6 +18,7 @@ import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers'; import {createLocalMFAError, createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; import type {MultifactorAuthenticationCallbackInput} from '@libs/MultifactorAuthentication/shared/types'; import Navigation from '@libs/Navigation/Navigation'; +import {walletDebugCapture, walletDebugLog} from '@libs/Wallet/walletDebugLog'; import {clearLocalMFAPublicKeyList, getDeviceBiometricsOnyxKey, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; import {processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; @@ -67,9 +68,12 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const state = useMultifactorAuthenticationState(); const {dispatch} = useMultifactorAuthenticationActions(); const actionControllerRef = useRef(undefined); + const debugProcessRunCountRef = useRef(0); + const debugAuthorizeCountRef = useRef(0); useEffect( () => () => { + walletDebugLog('MFA scenario-change cleanup', false, {hadActionController: !!actionControllerRef.current}); actionControllerRef.current?.abort(); actionControllerRef.current = undefined; }, @@ -193,6 +197,23 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent isFlowComplete, } = state; + debugProcessRunCountRef.current++; + walletDebugLog('MFA process run', false, { + debugProcessRunID: debugProcessRunCountRef.current, + hasScenario: !!scenario, + errorReason: error?.reason, + hasContinuableError: !!continuableError, + hasValidateCode: !!validateCode, + hasRegistrationChallenge: !!registrationChallenge, + hasAuthorizationChallenge: !!authorizationChallenge, + softPromptApproved, + isRegistrationComplete, + isAuthorizationComplete, + isFlowComplete, + isOffline, + hadActionController: !!actionControllerRef.current, + }); + // 0. Check if one of the early exit conditions applies: // - Flow is already complete, // - User is offline, @@ -215,6 +236,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } addMFABreadcrumb('Flow error', error, 'error'); + const flowErrorParams = {reason: error.reason, message: error.message, hadActionController: !!actionControllerRef.current}; + walletDebugLog('MFA flow error', true, flowErrorParams); + walletDebugCapture(`MFA flow error: ${error.reason}`, flowErrorParams); handleCallback(false); return; } @@ -335,11 +359,20 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } + debugAuthorizeCountRef.current++; + const debugAuthorizeID = debugAuthorizeCountRef.current; + walletDebugLog('MFA authorize start', false, {debugAuthorizeID, hadActionController: !!actionControllerRef.current}); await biometrics.authorize( { challenge: authorizationChallenge, }, async (result: AuthorizeResult) => { + walletDebugLog('MFA authorize result', false, { + debugAuthorizeID, + success: result.success, + reason: result.success ? undefined : result.error.reason, + authMethod: result.success ? result.authenticationMethod.code : undefined, + }); addMFABreadcrumb( 'Biometric authorization completed', result.success ? {success: true, authMethod: result.authenticationMethod.code} : result.error, @@ -368,6 +401,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Call backend with signed challenge const controller = new AbortController(); + walletDebugLog('MFA scenario action dispatch', false, {debugAuthorizeID, replacedActionController: !!actionControllerRef.current}); actionControllerRef.current = controller; const scenarioAPIResponse = await processScenarioAction( scenario.action, @@ -378,6 +412,12 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }, {signal: controller.signal}, ).catch((scenarioActionError: unknown) => { + walletDebugLog('MFA scenario action rejected', true, { + debugAuthorizeID, + isAborted: controller.signal.aborted, + isCurrentController: actionControllerRef.current === controller, + errorMessage: getErrorMessage(scenarioActionError), + }); if (controller.signal.aborted || actionControllerRef.current !== controller) { return undefined; } @@ -385,6 +425,15 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent throw scenarioActionError; }); + walletDebugLog('MFA scenario action returned', true, { + debugAuthorizeID, + hasResponse: !!scenarioAPIResponse, + success: scenarioAPIResponse?.success, + reason: scenarioAPIResponse?.success ? scenarioAPIResponse.reason : scenarioAPIResponse?.error.reason, + isAborted: controller.signal.aborted, + isCurrentController: actionControllerRef.current === controller, + }); + // A canceled action can finish after the next flow has already started. if (!scenarioAPIResponse || controller.signal.aborted || actionControllerRef.current !== controller) { return; @@ -493,6 +542,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // latest state and drops the duplicate). This check exists only as a perf short-circuit // so the second tap doesn't pay for an extra captureCredentialsState() native call // and Sentry breadcrumb on the happy path. + walletDebugLog('MFA executeScenario called', false, {scenario, isAlreadyRunning: !!state.scenario, hadActionController: !!actionControllerRef.current}); if (state.scenario) { return; } @@ -531,6 +581,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent * close the modal directly instead to avoid getting stuck. */ const cancel = useCallback(async () => { + walletDebugLog('MFA cancel', false, {hasScenario: !!state.scenario, hadActionController: !!actionControllerRef.current}); actionControllerRef.current?.abort(); if (!state.scenario || isOffline) { addMFABreadcrumb('Flow cancelled - closing directly', {hasScenario: !!state.scenario, isOffline}, 'warning'); diff --git a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts index e3ed008bbb00..9abad5ebce1c 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts @@ -5,6 +5,7 @@ import {getPaymentMethods} from '@libs/actions/PaymentMethods'; import getPlatform from '@libs/getPlatform'; import Navigation from '@libs/Navigation/Navigation'; import {handleAddCardToWallet} from '@libs/Wallet'; +import {walletDebugLog} from '@libs/Wallet/walletDebugLog'; import WalletProvisioningError from '@libs/Wallet/WalletProvisioningError'; import CONST from '@src/CONST'; @@ -20,11 +21,19 @@ type Payload = { export default { allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM], action: async ({card, cardHolderName, cardDescription, signedChallenge, authenticationMethod}, context) => { + walletDebugLog('scenario action start', false, { + cardID: card.cardID, + hasSignedChallenge: !!signedChallenge, + authenticationMethod, + hasContext: !!context, + isAborted: !!context?.signal.aborted, + }); try { const status = await handleAddCardToWallet(card, cardHolderName, cardDescription, { authentication: {signedChallenge, authenticationMethod}, signal: context?.signal, }); + walletDebugLog('scenario action got status', false, {cardID: card.cardID, status: String(status), statusType: typeof status, isAborted: !!context?.signal.aborted}); if (status === 'success' && !context?.signal.aborted) { return {httpStatusCode: 200, reason: undefined, message: undefined}; } @@ -37,6 +46,13 @@ export default { message: undefined, }; } catch (error) { + walletDebugLog('scenario action caught', false, { + cardID: card.cardID, + isProvisioningError: error instanceof WalletProvisioningError, + reason: error instanceof WalletProvisioningError ? error.response.reason : undefined, + errorType: error instanceof Error ? error.name : typeof error, + isAborted: !!context?.signal.aborted, + }); if (error instanceof WalletProvisioningError) { return error.response; } diff --git a/src/libs/Wallet/index.ios.ts b/src/libs/Wallet/index.ios.ts index 7213ee712e51..2a369e527470 100644 --- a/src/libs/Wallet/index.ios.ts +++ b/src/libs/Wallet/index.ios.ts @@ -4,12 +4,124 @@ import Log from '@libs/Log'; import CONST from '@src/CONST'; import type {Card} from '@src/types/onyx'; -import type {IOSCardData, TokenizationStatus} from '@expensify/react-native-wallet'; +import type {IOSAddPaymentPassData, IOSCardData, IOSEncryptPayload, TokenizationStatus} from '@expensify/react-native-wallet'; +import type {TurboModule} from 'react-native'; -import {addCardToAppleWallet, checkWalletAvailability, getCardStatusByIdentifier, getCardStatusBySuffix} from '@expensify/react-native-wallet'; +import {addCardToAppleWallet, addListener, checkWalletAvailability, getCardStatusByIdentifier, getCardStatusBySuffix} from '@expensify/react-native-wallet'; +import {AppState, TurboModuleRegistry} from 'react-native'; import type {AddCardToWalletOptions} from './types'; +import {walletDebugCapture, walletDebugLog} from './walletDebugLog'; +import WalletProvisioningError from './WalletProvisioningError'; + +// DEBUG ONLY: everything tagged [WalletDebug] is temporary instrumentation for an ad hoc build. +type DebugWalletModule = TurboModule & { + IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise; + IOSHandleAddPaymentPassResponse(payload: IOSEncryptPayload): Promise; +}; + +// Same cached native instance the wallet library uses, so the native call order stays identical. +const DebugNativeWallet = TurboModuleRegistry.get('RNWallet'); + +let debugCallCount = 0; +let hasDebugEventListener = false; + +function describeDebugNativeResult(result: unknown) { + if (result === null || result === undefined) { + return {isNull: true, resultType: String(result)}; + } + if (typeof result !== 'object') { + return {isNull: false, resultType: typeof result}; + } + const status = (result as {status?: unknown}).status; + return {isNull: false, resultType: 'object', keys: Object.keys(result).join(','), status: String(status), statusType: typeof status}; +} + +function describeDebugError(error: unknown) { + if (error instanceof WalletProvisioningError) { + return {errorType: 'WalletProvisioningError', reason: error.response.reason, httpStatusCode: error.response.httpStatusCode}; + } + if (error instanceof Error) { + return {errorType: error.name, code: String((error as Error & {code?: unknown}).code), message: error.message}; + } + return {errorType: typeof error}; +} + +function toDebugTokenizationStatus(stateID: number): TokenizationStatus { + switch (stateID) { + case -1: + return 'error'; + case 0: + return 'success'; + case 1: + return 'canceled'; + default: + throw new Error(`Unknown tokenization status: ${stateID}`); + } +} + +/** Mirrors addCardToAppleWallet from @expensify/react-native-wallet 0.1.22 line for line, plus logs of the raw native results. */ +async function debugAddCardToAppleWallet( + callID: number, + cardData: IOSCardData, + issuerCallback: (nonce: string, nonceSignature: string, certificates: string[]) => Promise, +): Promise { + const presentStartTime = Date.now(); + walletDebugLog('IOSPresentAddPaymentPassView call', false, {callID, appState: AppState.currentState, hasNativeModule: !!DebugNativeWallet}); + let passData: IOSAddPaymentPassData | undefined; + try { + passData = await DebugNativeWallet?.IOSPresentAddPaymentPassView(cardData); + } catch (error) { + walletDebugLog('IOSPresentAddPaymentPassView rejected', false, {callID, ms: Date.now() - presentStartTime, appState: AppState.currentState, ...describeDebugError(error)}); + throw error; + } + walletDebugLog('IOSPresentAddPaymentPassView resolved', false, { + callID, + ms: Date.now() - presentStartTime, + appState: AppState.currentState, + ...describeDebugNativeResult(passData), + }); + if (!passData || passData.status !== 0) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Matches the library exactly. + return toDebugTokenizationStatus(passData?.status || -1); + } + + let attempt = 0; + async function addPaymentPassToWallet(paymentPassData: IOSAddPaymentPassData): Promise { + attempt++; + const responseData = await issuerCallback(paymentPassData.nonce, paymentPassData.nonceSignature, paymentPassData.certificates); + const handleStartTime = Date.now(); + walletDebugLog('IOSHandleAddPaymentPassResponse call', false, {callID, attempt, appState: AppState.currentState}); + let response: IOSAddPaymentPassData | null | undefined; + try { + response = await DebugNativeWallet?.IOSHandleAddPaymentPassResponse(responseData); + } catch (error) { + walletDebugLog('IOSHandleAddPaymentPassResponse rejected', false, { + callID, + attempt, + ms: Date.now() - handleStartTime, + appState: AppState.currentState, + ...describeDebugError(error), + }); + throw error; + } + walletDebugLog('IOSHandleAddPaymentPassResponse resolved', false, { + callID, + attempt, + ms: Date.now() - handleStartTime, + appState: AppState.currentState, + ...describeDebugNativeResult(response), + }); + if (response) { + return addPaymentPassToWallet(response); + } + return 0; + } + const status = await addPaymentPassToWallet(passData); + return toDebugTokenizationStatus(status); +} + function checkIfWalletIsAvailable(): Promise { return checkWalletAvailability(); } @@ -22,18 +134,50 @@ async function handleAddCardToWallet(card: Card, cardHolderName: string, cardDes cardHolderName, } as IOSCardData; + debugCallCount++; + const callID = debugCallCount; + walletDebugLog('handleAddCardToWallet start', false, { + callID, + cardID: card.cardID, + hasAuthentication: !!authentication, + isAborted: !!signal?.aborted, + appState: AppState.currentState, + lastDigitsLength: card.lastFourPAN?.length ?? 0, + hasCardHolderName: !!cardHolderName, + hasCardDescription: !!cardDescription, + }); + if (!authentication) { return addCardToAppleWallet(data, (nonce, nonceSignature, certificates) => issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID)); } + if (!hasDebugEventListener) { + hasDebugEventListener = true; + addListener('onCardActivated', (event) => { + walletDebugLog('onCardActivated event', false, {latestCallID: debugCallCount, status: event?.status, appState: AppState.currentState}); + }); + } + signal?.addEventListener('abort', () => { + walletDebugLog('signal aborted', false, {callID, appState: AppState.currentState}); + }); + if (signal?.aborted) { + walletDebugLog('handleAddCardToWallet end', true, {callID, status: 'canceled', path: 'abortedBeforeStart'}); return 'canceled'; } let didRequestProvisioning = false; let provisioningFailure: unknown; try { - const status = await addCardToAppleWallet(data, async (nonce, nonceSignature, certificates) => { + const status = await debugAddCardToAppleWallet(callID, data, async (nonce, nonceSignature, certificates) => { + const issuerStartTime = Date.now(); + walletDebugLog('issuer callback start', false, { + callID, + didRequestProvisioning, + isAborted: !!signal?.aborted, + appState: AppState.currentState, + certificateCount: certificates?.length ?? 0, + }); try { // Apple's Try Again callback must not replay a consumed signed challenge. // Wallet 0.1.22 has no public abort API; rejection cannot dismiss its sheet. @@ -42,15 +186,35 @@ async function handleAddCardToWallet(card: Card, cardHolderName: string, cardDes } didRequestProvisioning = true; const payload = await issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID, authentication); + walletDebugLog('issuer callback payload', false, { + callID, + ms: Date.now() - issuerStartTime, + isAborted: !!signal?.aborted, + appState: AppState.currentState, + hasEncryptedPassData: !!payload?.encryptedPassData, + hasActivationData: !!payload?.activationData, + hasEphemeralPublicKey: !!payload?.ephemeralPublicKey, + }); if (signal?.aborted) { throw new Error('Wallet attempt canceled'); } return payload; } catch (error) { + walletDebugLog('issuer callback threw', false, {callID, ms: Date.now() - issuerStartTime, isFirstFailure: !provisioningFailure, ...describeDebugError(error)}); provisioningFailure ??= error; throw error; } }); + const endParams = { + callID, + status, + isAborted: !!signal?.aborted, + didRequestProvisioning, + hasProvisioningFailure: !!provisioningFailure, + appState: AppState.currentState, + }; + walletDebugLog('handleAddCardToWallet end', true, endParams); + walletDebugCapture(`handleAddCardToWallet end: ${status}`, endParams); if (provisioningFailure) { throw provisioningFailure; } @@ -59,6 +223,16 @@ async function handleAddCardToWallet(card: Card, cardHolderName: string, cardDes } return signal?.aborted ? 'canceled' : status; } catch (error) { + const threwParams = { + callID, + isAborted: !!signal?.aborted, + didRequestProvisioning, + appState: AppState.currentState, + ...describeDebugError(error), + ...(provisioningFailure ? {provisioningFailure: describeDebugError(provisioningFailure)} : {}), + }; + walletDebugLog('handleAddCardToWallet threw', true, threwParams); + walletDebugCapture('handleAddCardToWallet threw', threwParams); // A subsequent native rejection must not replace the original server failure. throw provisioningFailure ?? error; } diff --git a/src/libs/Wallet/walletDebugLog.ts b/src/libs/Wallet/walletDebugLog.ts new file mode 100644 index 000000000000..9b3a1e6d914b --- /dev/null +++ b/src/libs/Wallet/walletDebugLog.ts @@ -0,0 +1,18 @@ +// DEBUG ONLY: temporary instrumentation for an ad hoc build, sent to both server logs and Sentry. +import Log from '@libs/Log'; + +import * as Sentry from '@sentry/react-native'; + +type WalletDebugParams = Record; + +function walletDebugLog(message: string, shouldSendNow: boolean, params: WalletDebugParams) { + Log.info(`[WalletDebug] ${message}`, shouldSendNow, params); + Sentry.addBreadcrumb({category: 'wallet-debug', message: `[WalletDebug] ${message}`, level: 'info', data: params}); +} + +/** Creates a Sentry event so the full breadcrumb trail, including native iOS breadcrumbs, is uploaded. */ +function walletDebugCapture(message: string, params: WalletDebugParams) { + Sentry.captureMessage(`[WalletDebug] ${message}`, {level: 'warning', tags: {walletDebug: 'true'}, extra: params}); +} + +export {walletDebugLog, walletDebugCapture}; From 7922309ef6d352605a59ab50425c32f1b1247b67 Mon Sep 17 00:00:00 2001 From: Rafe Colton Date: Mon, 19 Oct 2026 21:34:01 -0700 Subject: [PATCH 2/3] DO NOT MERGE: Probe how the wallet action resumes after MFA --- .../MultifactorAuthenticationMainContext.tsx | 10 ++++--- .../config/scenarios/AddToDigitalWallet.ts | 26 ++++++++++++++++--- src/libs/Wallet/walletDebugLog.ts | 10 ++++++- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 75263f7013fd..2fbd00f56fb3 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -18,7 +18,7 @@ import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers'; import {createLocalMFAError, createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; import type {MultifactorAuthenticationCallbackInput} from '@libs/MultifactorAuthentication/shared/types'; import Navigation from '@libs/Navigation/Navigation'; -import {walletDebugCapture, walletDebugLog} from '@libs/Wallet/walletDebugLog'; +import {getWalletDebugStack, walletDebugCapture, walletDebugLog, walletDebugState} from '@libs/Wallet/walletDebugLog'; import {clearLocalMFAPublicKeyList, getDeviceBiometricsOnyxKey, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; import {processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing'; @@ -403,7 +403,8 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const controller = new AbortController(); walletDebugLog('MFA scenario action dispatch', false, {debugAuthorizeID, replacedActionController: !!actionControllerRef.current}); actionControllerRef.current = controller; - const scenarioAPIResponse = await processScenarioAction( + walletDebugState.isDispatchingAction = true; + const scenarioActionPromise = processScenarioAction( scenario.action, { signedChallenge: result.signedChallenge, @@ -411,7 +412,10 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent ...payload, }, {signal: controller.signal}, - ).catch((scenarioActionError: unknown) => { + ); + walletDebugState.isDispatchingAction = false; + walletDebugLog('MFA scenario action dispatch returned', false, {debugAuthorizeID, stack: getWalletDebugStack()}); + const scenarioAPIResponse = await scenarioActionPromise.catch((scenarioActionError: unknown) => { walletDebugLog('MFA scenario action rejected', true, { debugAuthorizeID, isAborted: controller.signal.aborted, diff --git a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts index 9abad5ebce1c..7fc1c755fce1 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts @@ -5,7 +5,7 @@ import {getPaymentMethods} from '@libs/actions/PaymentMethods'; import getPlatform from '@libs/getPlatform'; import Navigation from '@libs/Navigation/Navigation'; import {handleAddCardToWallet} from '@libs/Wallet'; -import {walletDebugLog} from '@libs/Wallet/walletDebugLog'; +import {getWalletDebugStack, walletDebugLog, walletDebugState} from '@libs/Wallet/walletDebugLog'; import WalletProvisioningError from '@libs/Wallet/WalletProvisioningError'; import CONST from '@src/CONST'; @@ -27,13 +27,33 @@ export default { authenticationMethod, hasContext: !!context, isAborted: !!context?.signal.aborted, + isDispatchingAction: walletDebugState.isDispatchingAction, }); try { - const status = await handleAddCardToWallet(card, cardHolderName, cardDescription, { + const walletPromise = handleAddCardToWallet(card, cardHolderName, cardDescription, { authentication: {signedChallenge, authenticationMethod}, signal: context?.signal, }); - walletDebugLog('scenario action got status', false, {cardID: card.cardID, status: String(status), statusType: typeof status, isAborted: !!context?.signal.aborted}); + walletDebugLog('handleAddCardToWallet returned', false, { + cardID: card.cardID, + valueType: typeof walletPromise, + isPromise: walletPromise instanceof Promise, + hasThen: typeof (walletPromise as Partial> | undefined)?.then === 'function', + isDispatchingAction: walletDebugState.isDispatchingAction, + }); + walletPromise.then( + (value) => walletDebugLog('handleAddCardToWallet promise fulfilled', false, {cardID: card.cardID, valueType: typeof value, value: String(value)}), + (error: unknown) => walletDebugLog('handleAddCardToWallet promise rejected', false, {cardID: card.cardID, errorType: error instanceof Error ? error.name : typeof error}), + ); + const status = await walletPromise; + walletDebugLog('scenario action got status', false, { + cardID: card.cardID, + status: String(status), + statusType: typeof status, + isAborted: !!context?.signal.aborted, + isDispatchingAction: walletDebugState.isDispatchingAction, + stack: getWalletDebugStack(), + }); if (status === 'success' && !context?.signal.aborted) { return {httpStatusCode: 200, reason: undefined, message: undefined}; } diff --git a/src/libs/Wallet/walletDebugLog.ts b/src/libs/Wallet/walletDebugLog.ts index 9b3a1e6d914b..46d9352916f1 100644 --- a/src/libs/Wallet/walletDebugLog.ts +++ b/src/libs/Wallet/walletDebugLog.ts @@ -5,6 +5,14 @@ import * as Sentry from '@sentry/react-native'; type WalletDebugParams = Record; +/** True only while the MFA context is synchronously inside its call to the scenario action. */ +const walletDebugState = {isDispatchingAction: false}; + +/** Short call stack, so frame depth shows whether a continuation ran synchronously or from a promise job. */ +function getWalletDebugStack() { + return new Error('stack probe').stack?.split('\n').slice(1, 16).join(' | '); +} + function walletDebugLog(message: string, shouldSendNow: boolean, params: WalletDebugParams) { Log.info(`[WalletDebug] ${message}`, shouldSendNow, params); Sentry.addBreadcrumb({category: 'wallet-debug', message: `[WalletDebug] ${message}`, level: 'info', data: params}); @@ -15,4 +23,4 @@ function walletDebugCapture(message: string, params: WalletDebugParams) { Sentry.captureMessage(`[WalletDebug] ${message}`, {level: 'warning', tags: {walletDebug: 'true'}, extra: params}); } -export {walletDebugLog, walletDebugCapture}; +export {walletDebugLog, walletDebugCapture, walletDebugState, getWalletDebugStack}; From 25e95450cff199df7ee1e880e696db8e7988ae62 Mon Sep 17 00:00:00 2001 From: Rafe Colton Date: Mon, 19 Oct 2026 22:11:02 -0700 Subject: [PATCH 3/3] Destructure wallet action params in the body to work around a Hermes V1 await bug --- .../config/scenarios/AddToDigitalWallet.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts index 7fc1c755fce1..2d3db3df214c 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts @@ -20,7 +20,9 @@ type Payload = { export default { allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM], - action: async ({card, cardHolderName, cardDescription, signedChallenge, authenticationMethod}, context) => { + // Hermes V1 does not suspend at await inside async arrows with destructured parameters, so destructure in the body. + action: async (params, context) => { + const {card, cardHolderName, cardDescription, signedChallenge, authenticationMethod} = params; walletDebugLog('scenario action start', false, { cardID: card.cardID, hasSignedChallenge: !!signedChallenge,