From 7bf3669113a1114dc80f8122b2f56bc0c9f058aa Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:36:05 +0000 Subject: [PATCH] Fix: iOS passkey cancellations logged as exceptions --- hooks/useUser.ts | 3 +- lib/__tests__/execute.test.ts | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/execute.test.ts diff --git a/hooks/useUser.ts b/hooks/useUser.ts index 6106f2e0..ae74bd56 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -26,6 +26,7 @@ import { } from '@/lib/api'; import { getAttributionChannel } from '@/lib/attribution'; import { EXPO_PUBLIC_TURNKEY_ORGANIZATION_ID, USER } from '@/lib/config'; +import { isWebAuthnUserCancelledError } from '@/lib/execute'; import { useIntercom } from '@/lib/intercom'; import { destroyOnramper } from '@/lib/onramper'; import { pimlicoClient } from '@/lib/pimlico'; @@ -431,7 +432,7 @@ const useUser = (): UseUserReturn => { ? 'User not found, please sign up' : error?.message || 'Network request timed out'; - if (error?.name === 'NotAllowedError') { + if (error?.name === 'NotAllowedError' || isWebAuthnUserCancelledError(error)) { errorMessage = 'User cancelled login'; Sentry.captureMessage(errorMessage, { level: 'warning', diff --git a/lib/__tests__/execute.test.ts b/lib/__tests__/execute.test.ts new file mode 100644 index 00000000..b9504af8 --- /dev/null +++ b/lib/__tests__/execute.test.ts @@ -0,0 +1,59 @@ +/// + +// execute.ts imports several native / blockchain modules that are not needed for +// isWebAuthnUserCancelledError. Mock them so Jest can load the module without +// native binaries or ESM-only packages. +import { isWebAuthnUserCancelledError } from '@/lib/execute'; + +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), + captureMessage: jest.fn(), +})); +jest.mock('permissionless', () => ({})); +jest.mock('permissionless/actions', () => ({})); +jest.mock('viem', () => ({})); +jest.mock('viem/account-abstraction', () => ({ entryPoint07Address: '0x' })); +jest.mock('@/lib/wagmi', () => ({ publicClient: jest.fn() })); + +/** + * Covers the iOS passkey cancellation bug: on iOS (Expo/React Native) a + * dismissed passkey prompt surfaces as `{ name: 'UserCancelled', message: + * 'The user cancelled the request.' }` rather than the web-standard + * `NotAllowedError`. The helper must recognise both so callers can treat them + * as benign warnings instead of exceptions. + */ +describe('isWebAuthnUserCancelledError', () => { + it('matches the web-standard NotAllowedError name via message', () => { + expect(isWebAuthnUserCancelledError({ name: 'NotAllowedError', message: 'not allowed' })).toBe( + true, + ); + }); + + it('matches the iOS UserCancelled message', () => { + expect( + isWebAuthnUserCancelledError({ + name: 'UserCancelled', + message: 'The user cancelled the request.', + }), + ).toBe(true); + }); + + it('matches other user-rejection message variants', () => { + expect(isWebAuthnUserCancelledError({ message: 'The user denied the prompt.' })).toBe(true); + expect(isWebAuthnUserCancelledError({ message: 'User rejected the credential.' })).toBe(true); + expect(isWebAuthnUserCancelledError({ message: 'Aborted by the user' })).toBe(true); + expect( + isWebAuthnUserCancelledError({ message: 'Operation either timed out or was not allowed' }), + ).toBe(true); + expect(isWebAuthnUserCancelledError({ message: 'Failed to sign the transaction' })).toBe(true); + }); + + it('does not match unrelated errors', () => { + expect(isWebAuthnUserCancelledError({ name: 'NetworkError', message: 'fetch failed' })).toBe( + false, + ); + expect(isWebAuthnUserCancelledError(null)).toBe(false); + expect(isWebAuthnUserCancelledError(undefined)).toBe(false); + expect(isWebAuthnUserCancelledError({})).toBe(false); + }); +});