From 5ea81cbcffdbdc32fcea13cf80e95163f79cdbe6 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:34:57 +0000 Subject: [PATCH] fix(withdraw): ignore user-cancelled passkey signing errors --- .../useWithdrawRainCollateral.test.tsx | 138 ++++++++++++++++++ hooks/useWithdrawRainCollateral.ts | 7 +- 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 hooks/__tests__/useWithdrawRainCollateral.test.tsx diff --git a/hooks/__tests__/useWithdrawRainCollateral.test.tsx b/hooks/__tests__/useWithdrawRainCollateral.test.tsx new file mode 100644 index 000000000..b0bd98359 --- /dev/null +++ b/hooks/__tests__/useWithdrawRainCollateral.test.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); +import * as Sentry from '@sentry/react-native'; + +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), + captureException: jest.fn(), +})); + +jest.mock('@/lib/api', () => ({ + withdrawCardCollateral: jest.fn().mockResolvedValue({ + chainId: 1, + collateralProxy: '0xproxy', + assetAddress: '0xasset', + amount: '1000000', + recipient: '0xrecipient', + expiresAt: '9999999999', + executorPublisherSalt: '0xsalt', + executorPublisherSig: '0xsig', + coordinatorAddress: '0xcoordinator', + }), +})); + +jest.mock('@/lib/wagmi', () => ({ + publicClient: jest.fn().mockReturnValue({ + readContract: jest.fn(), + getCode: jest.fn().mockResolvedValue('0x1234'), + }), + getChain: jest.fn().mockReturnValue({ id: 1, name: 'Ethereum' }), +})); + +jest.mock('viem/actions', () => ({ + readContract: jest.fn().mockResolvedValue(BigInt(0)), +})); + +jest.mock('viem', () => ({ + encodeFunctionData: jest.fn().mockReturnValue('0xcalldata'), + toHex: jest.fn().mockReturnValue('0xrandomsalt'), +})); + +const mockSignTypedData = jest.fn(); +const mockSafeAA = jest.fn().mockResolvedValue({ + account: { + signTypedData: mockSignTypedData, + }, + sendUserOperation: jest.fn(), + waitForUserOperationReceipt: jest.fn(), +}); + +jest.mock('@/hooks/useUser', () => ({ + __esModule: true, + default: () => ({ + user: { + safeAddress: '0xsafe', + suborgId: 'suborg-1', + signWith: 'passkey', + userId: 'user-1', + }, + safeAA: mockSafeAA, + }), +})); + +jest.mock('@/hooks/useActivityActions', () => ({ + useActivityActions: () => ({ + trackTransaction: (_params: unknown, execute: (onHash: () => void) => Promise) => + execute(jest.fn()), + }), +})); + +jest.mock('@/lib/execute', () => ({ + executeTransactions: jest.fn().mockResolvedValue('0xtxhash'), + isWebAuthnUserCancelledError: jest + .fn() + .mockImplementation((err: any) => err?.message?.includes('user cancelled')), + USER_CANCELLED_TRANSACTION: Symbol('cancel'), +})); + +import useWithdrawRainCollateral from '../useWithdrawRainCollateral'; +import { Status } from '@/lib/types'; + +function TestComponent({ onResult }: { onResult: (hook: any) => void }) { + const hook = useWithdrawRainCollateral(); + onResult(hook); + return null; +} + +describe('useWithdrawRainCollateral', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not call Sentry.captureException when user cancels the passkey prompt during signTypedData', async () => { + const cancelError = new Error('Failed to sign: The user cancelled the request.'); + + mockSignTypedData.mockRejectedValueOnce(cancelError); + + let hookRef: any; + await act(async () => { + create( (hookRef = hook)} />); + }); + + await act(async () => { + try { + await hookRef.withdrawCollateral({ amount: '10', recipientAddress: '0xrecipient' }); + } catch { + // may not throw for user cancellations + } + }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(hookRef.status).toBe(Status.IDLE); + }); + + it('calls Sentry.captureException for non-cancellation errors during signTypedData', async () => { + const networkError = new Error('Network failure'); + mockSignTypedData.mockRejectedValueOnce(networkError); + + let hookRef: any; + await act(async () => { + create( (hookRef = hook)} />); + }); + + await act(async () => { + try { + await hookRef.withdrawCollateral({ amount: '10', recipientAddress: '0xrecipient' }); + } catch { + // expected + } + }); + + expect(Sentry.captureException).toHaveBeenCalledWith( + networkError, + expect.objectContaining({ tags: { operation: 'withdraw_rain_collateral' } }), + ); + expect(hookRef.status).toBe(Status.ERROR); + }); +}); diff --git a/hooks/useWithdrawRainCollateral.ts b/hooks/useWithdrawRainCollateral.ts index db9c5268b..7c09cc44a 100644 --- a/hooks/useWithdrawRainCollateral.ts +++ b/hooks/useWithdrawRainCollateral.ts @@ -5,7 +5,7 @@ import * as Sentry from '@sentry/react-native'; import { withdrawCardCollateral } from '@/lib/api'; import { Status, TransactionType, WithdrawCollateralSignatureResponse } from '@/lib/types'; -import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { executeTransactions, isWebAuthnUserCancelledError, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; import { publicClient, getChain } from '@/lib/wagmi'; import useUser from './useUser'; import { useActivityActions } from '@/hooks/useActivityActions'; @@ -223,6 +223,11 @@ const useWithdrawRainCollateral = (): WithdrawRainCollateralResult => { } catch (err) { console.error('Rain collateral withdrawal error:', err); + if (isWebAuthnUserCancelledError(err)) { + setStatus(Status.IDLE); + return; + } + Sentry.captureException(err, { tags: { operation: 'withdraw_rain_collateral' }, extra: {