Skip to content
Open
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
4 changes: 2 additions & 2 deletions hooks/useWirexThreeDs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down Expand Up @@ -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;
}

Expand Down
25 changes: 2 additions & 23 deletions lib/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 (
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions lib/utils/__tests__/passkey-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading