From 4c95a097f9d1cb8fb7ee4630381e8c82dfd7b949 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:38:10 +0000 Subject: [PATCH] fix(execute): misclassify Turnkey API errors as user cancellations --- hooks/useWirexThreeDs.ts | 4 +-- lib/execute.ts | 25 ++----------------- .../__tests__/passkey-credentials.test.ts | 6 +++++ 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/hooks/useWirexThreeDs.ts b/hooks/useWirexThreeDs.ts index 9e4c833a8..34f3daab6 100644 --- a/hooks/useWirexThreeDs.ts +++ b/hooks/useWirexThreeDs.ts @@ -8,8 +8,8 @@ import { declineWirexThreeDsRequest, getWirexThreeDsRequests, } from '@/lib/api'; -import { isWebAuthnUserCancelledError } from '@/lib/execute'; import { CardProvider, WirexThreeDsDecisionOutcome, WirexThreeDsRequest } from '@/lib/types'; +import { isPasskeyPromptError } from '@/lib/utils/passkey'; export const WIREX_THREE_DS_QUERY_KEY = ['wirexThreeDsRequests']; @@ -82,7 +82,7 @@ export function useWirexThreeDs(options?: { pollMs?: number }) { try { signature = await signMessage(messageTemplate.replace(NONCE_PLACEHOLDER, String(nonce))); } catch (error) { - if (isWebAuthnUserCancelledError(error)) return THREE_DS_CANCELLED; + if (isPasskeyPromptError(error)) return THREE_DS_CANCELLED; throw error; } diff --git a/lib/execute.ts b/lib/execute.ts index 2d7500759..ada431c11 100644 --- a/lib/execute.ts +++ b/lib/execute.ts @@ -4,6 +4,7 @@ import { getAccountNonce } from 'permissionless/actions'; import { Chain } from 'viem'; import { entryPoint07Address } from 'viem/account-abstraction'; +import { isPasskeyPromptError } from '@/lib/utils/passkey'; import { publicClient } from '@/lib/wagmi'; export const USER_CANCELLED_TRANSACTION = Symbol('USER_CANCELLED_TRANSACTION'); @@ -16,28 +17,6 @@ export type TransactionResult = } | typeof USER_CANCELLED_TRANSACTION; -/** - * Whether a signing failure is the user dismissing the passkey prompt rather than - * something going wrong. WebAuthn reports a cancel and a timeout through the same - * `NotAllowedError`, and each platform words it differently, so this matches on - * the message. - * - * Exported because anything that raises a passkey prompt needs the distinction — - * a cancel is a decision, not an error to show. - */ -export const isWebAuthnUserCancelledError = (error: any): boolean => { - const message = error?.message?.toLowerCase() || ''; - return ( - message.includes('failed to sign') || - message.includes('operation either timed out or was not allowed') || - message.includes('user cancelled') || - message.includes('user denied') || - message.includes('user rejected') || - message.includes('aborted by the user') || - message.includes('not allowed') - ); -}; - const isUserOperationError = (error: any): boolean => { const message = error?.message?.toLowerCase() || ''; return ( @@ -165,7 +144,7 @@ export const executeTransactions = async ( return { transaction, userOpHash, transactionHash: transaction.transactionHash }; } catch (error: any) { - if (isWebAuthnUserCancelledError(error)) { + if (isPasskeyPromptError(error)) { Sentry.addBreadcrumb({ message: 'User cancelled transaction', category: 'transaction', diff --git a/lib/utils/__tests__/passkey-credentials.test.ts b/lib/utils/__tests__/passkey-credentials.test.ts index 31c9f5a67..1d9cdfc39 100644 --- a/lib/utils/__tests__/passkey-credentials.test.ts +++ b/lib/utils/__tests__/passkey-credentials.test.ts @@ -74,6 +74,12 @@ describe('isPasskeyPromptError', () => { ['a bundler rejection', { message: 'UserOperation reverted during simulation' }], ['a network failure', { name: 'TypeError', message: 'Network request failed' }], ['a gas estimation failure', { message: 'Failed to get gas price' }], + // Regression: Turnkey API errors begin with "Failed to sign:" but are server-side + // failures, not user dismissals. isWebAuthnUserCancelledError previously matched + // these because of a broad message.includes('failed to sign') check, causing them + // to be silently swallowed as USER_CANCELLED_TRANSACTION (SOLID-D5). + ['a Turnkey stale timestamp error', { message: 'Failed to sign: Turnkey error 3: activity timestamp is not current' }], + ['a Turnkey generic signing failure', { message: 'Failed to sign: Turnkey error 7: unauthorized' }], ])('leaves %s alone', (_label, error) => { // Dropping the pin here would be noise: the prompt already succeeded. expect(isPasskeyPromptError(error)).toBe(false);