diff --git a/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.test.ts b/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.test.ts index 71824d17bbb..45bec553b30 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.test.ts @@ -1,5 +1,5 @@ import { renderHook, act } from '@testing-library/react-native'; -import { AppState, AppStateStatus } from 'react-native'; +import { AppState, AppStateStatus, Linking } from 'react-native'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import Logger from '../../../../util/Logger'; @@ -55,15 +55,19 @@ describe('useMusdConversionStaleApprovalCleanup', () => { const mockGoBack = jest.mocked(NavigationService.navigation.goBack); let appStateHandler: ((nextAppState: AppStateStatus) => void) | undefined; - let removeSubscriptionMock: jest.Mock; + let linkingUrlHandler: ((event: { url: string }) => void) | undefined; + let removeAppStateSubscription: jest.Mock; + let removeLinkingSubscription: jest.Mock; beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); mockSelectUnapprovedMusdConversions.mockReturnValue([]); - removeSubscriptionMock = jest.fn(); + removeAppStateSubscription = jest.fn(); + removeLinkingSubscription = jest.fn(); appStateHandler = undefined; + linkingUrlHandler = undefined; (AppState as unknown as { currentState: AppStateStatus }).currentState = 'active'; @@ -73,10 +77,17 @@ describe('useMusdConversionStaleApprovalCleanup', () => { .mockImplementation((_, handler) => { appStateHandler = handler as (nextAppState: AppStateStatus) => void; return { - remove: removeSubscriptionMock, + remove: removeAppStateSubscription, } as unknown as ReturnType; }); + jest.spyOn(Linking, 'addEventListener').mockImplementation((_, handler) => { + linkingUrlHandler = handler as (event: { url: string }) => void; + return { + remove: removeLinkingSubscription, + } as unknown as ReturnType; + }); + mockGetCurrentRoute.mockReturnValue(undefined as never); mockUseSelector.mockImplementation((selector) => { @@ -92,167 +103,365 @@ describe('useMusdConversionStaleApprovalCleanup', () => { jest.restoreAllMocks(); }); - it('registers app state listener and removes it on unmount', () => { - const { unmount } = renderHook(() => - useMusdConversionStaleApprovalCleanup(), - ); + const simulateDeeplinkWhileBackgrounded = () => { + linkingUrlHandler?.({ url: 'metamask://some-deeplink' }); + }; + + describe('listener lifecycle', () => { + it('registers AppState and Linking listeners and removes both on unmount', () => { + const { unmount } = renderHook(() => + useMusdConversionStaleApprovalCleanup(), + ); + + expect(AppState.addEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + expect(Linking.addEventListener).toHaveBeenCalledWith( + 'url', + expect.any(Function), + ); + + unmount(); + + expect(removeAppStateSubscription).toHaveBeenCalledTimes(1); + expect(removeLinkingSubscription).toHaveBeenCalledTimes(1); + }); + }); - expect(AppState.addEventListener).toHaveBeenCalledWith( - 'change', - expect.any(Function), - ); + describe('standard stale cleanup (not on confirmation screen)', () => { + it('rejects stale pending approvals when app returns to active from background', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); - unmount(); + renderHook(() => useMusdConversionStaleApprovalCleanup()); - expect(removeSubscriptionMock).toHaveBeenCalledTimes(1); - }); + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); - it('rejects stale pending approvals when app returns to active', () => { - mockSelectUnapprovedMusdConversions.mockReturnValue([ - { id: 'tx-1' } as never, - ]); + expect(mockLoggerLog).toHaveBeenCalledWith( + '[mUSD Conversion] Rejecting stale pending approvals on foreground', + { + count: 1, + transactionIds: ['tx-1'], + }, + ); + expect(mockRejectPendingApproval).toHaveBeenCalledTimes(1); + expect(mockRejectPendingApproval).toHaveBeenCalledWith( + 'tx-1', + expect.objectContaining({ + data: expect.objectContaining({ + cause: 'useMusdConversionStaleApprovalCleanup', + transactionId: 'tx-1', + }), + }), + { + ignoreMissing: true, + logErrors: false, + }, + ); + }); - renderHook(() => useMusdConversionStaleApprovalCleanup()); + it('does not reject approvals on inactive to active transition', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); - act(() => { - appStateHandler?.('background'); - appStateHandler?.('active'); + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('inactive'); + appStateHandler?.('active'); + }); + + expect(mockLoggerLog).not.toHaveBeenCalled(); + expect(mockRejectPendingApproval).not.toHaveBeenCalled(); }); - expect(mockLoggerLog).toHaveBeenCalledWith( - '[mUSD Conversion] Rejecting stale pending approvals on foreground', - { - count: 1, - transactionIds: ['tx-1'], - }, - ); - expect(mockRejectPendingApproval).toHaveBeenCalledTimes(1); - expect(mockRejectPendingApproval).toHaveBeenCalledWith( - 'tx-1', - expect.objectContaining({ - data: expect.objectContaining({ - cause: 'useMusdConversionStaleApprovalCleanup', - transactionId: 'tx-1', - }), - }), - { - ignoreMissing: true, - logErrors: false, - }, - ); - }); + it('does not reject approvals when there are no stale pending approvals', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([]); - it('does not reject approvals on inactive to active transition', () => { - mockSelectUnapprovedMusdConversions.mockReturnValue([ - { id: 'tx-1' } as never, - ]); + renderHook(() => useMusdConversionStaleApprovalCleanup()); - renderHook(() => useMusdConversionStaleApprovalCleanup()); + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); - act(() => { - appStateHandler?.('inactive'); - appStateHandler?.('active'); + expect(mockLoggerLog).not.toHaveBeenCalled(); + expect(mockRejectPendingApproval).not.toHaveBeenCalled(); }); - expect(mockLoggerLog).not.toHaveBeenCalled(); - expect(mockRejectPendingApproval).not.toHaveBeenCalled(); + it('uses latest pending approvals after rerender', () => { + mockSelectUnapprovedMusdConversions + .mockReturnValueOnce([]) + .mockReturnValue([{ id: 'tx-latest' } as never]); + + const { rerender } = renderHook(() => + useMusdConversionStaleApprovalCleanup(), + ); + + rerender({}); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + expect(mockRejectPendingApproval).toHaveBeenCalledWith( + 'tx-latest', + expect.any(Object), + { + ignoreMissing: true, + logErrors: false, + }, + ); + }); }); - it('does not reject approvals when there are no stale pending approvals', () => { - mockSelectUnapprovedMusdConversions.mockReturnValue([]); + describe('navigation after rejection', () => { + it('navigates back when confirmation screen is focused after rejecting stale approvals', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('background'); + simulateDeeplinkWhileBackgrounded(); + appStateHandler?.('active'); + }); - renderHook(() => useMusdConversionStaleApprovalCleanup()); + jest.runAllTimers(); - act(() => { - appStateHandler?.('background'); - appStateHandler?.('active'); + expect(mockGoBack).toHaveBeenCalledTimes(1); }); - expect(mockLoggerLog).not.toHaveBeenCalled(); - expect(mockRejectPendingApproval).not.toHaveBeenCalled(); - }); + it('does not navigate back when a different screen is focused after rejection', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: 'SomeOtherScreen', + } as never); - it('uses latest pending approvals after rerender', () => { - mockSelectUnapprovedMusdConversions - .mockReturnValueOnce([]) - .mockReturnValue([{ id: 'tx-latest' } as never]); + renderHook(() => useMusdConversionStaleApprovalCleanup()); - const { rerender } = renderHook(() => - useMusdConversionStaleApprovalCleanup(), - ); + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); - rerender({}); + jest.runAllTimers(); - act(() => { - appStateHandler?.('background'); - appStateHandler?.('active'); + expect(mockGoBack).not.toHaveBeenCalled(); }); - expect(mockRejectPendingApproval).toHaveBeenCalledWith( - 'tx-latest', - expect.any(Object), - { - ignoreMissing: true, - logErrors: false, - }, - ); + it('does not navigate back when no stale approvals exist', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + jest.runAllTimers(); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); }); - it('navigates back when confirmation screen is focused after rejecting stale approvals', () => { - mockSelectUnapprovedMusdConversions.mockReturnValue([ - { id: 'tx-1' } as never, - ]); - mockGetCurrentRoute.mockReturnValue({ - name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, - } as never); + describe('screen-aware guard (on confirmation screen without deeplink)', () => { + it('skips cleanup when on REDESIGNED_CONFIRMATIONS and no deeplink received', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); - renderHook(() => useMusdConversionStaleApprovalCleanup()); + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); - act(() => { - appStateHandler?.('background'); - appStateHandler?.('active'); + expect(mockLoggerLog).toHaveBeenCalledWith( + '[mUSD Conversion] Skipping stale approval cleanup — user is in active confirmation flow', + ); + expect(mockRejectPendingApproval).not.toHaveBeenCalled(); }); - jest.runAllTimers(); + it('skips cleanup when on TOOLTIP_MODAL and no deeplink received', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.SHEET.TOOLTIP_MODAL, + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); - expect(mockGoBack).toHaveBeenCalledTimes(1); + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + expect(mockLoggerLog).toHaveBeenCalledWith( + '[mUSD Conversion] Skipping stale approval cleanup — user is in active confirmation flow', + ); + expect(mockRejectPendingApproval).not.toHaveBeenCalled(); + }); }); - it('does not navigate back when a different screen is focused', () => { - mockSelectUnapprovedMusdConversions.mockReturnValue([ - { id: 'tx-1' } as never, - ]); - mockGetCurrentRoute.mockReturnValue({ - name: 'SomeOtherScreen', - } as never); + describe('deeplink detection (on confirmation screen with deeplink)', () => { + it('rejects approvals when on confirmation screen and deeplink received while backgrounded', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('background'); + simulateDeeplinkWhileBackgrounded(); + appStateHandler?.('active'); + }); - renderHook(() => useMusdConversionStaleApprovalCleanup()); + expect(mockLoggerLog).toHaveBeenCalledWith( + '[mUSD Conversion] Incoming deeplink detected while on confirmation screen — rejecting stale approvals', + ); + expect(mockRejectPendingApproval).toHaveBeenCalledTimes(1); + expect(mockRejectPendingApproval).toHaveBeenCalledWith( + 'tx-1', + expect.objectContaining({ + data: expect.objectContaining({ + cause: 'useMusdConversionStaleApprovalCleanup', + transactionId: 'tx-1', + }), + }), + { + ignoreMissing: true, + logErrors: false, + }, + ); + }); + + it('rejects approvals when on tooltip modal and deeplink received while backgrounded', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.SHEET.TOOLTIP_MODAL, + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); - act(() => { - appStateHandler?.('background'); - appStateHandler?.('active'); + act(() => { + appStateHandler?.('background'); + simulateDeeplinkWhileBackgrounded(); + appStateHandler?.('active'); + }); + + expect(mockLoggerLog).toHaveBeenCalledWith( + '[mUSD Conversion] Incoming deeplink detected while on confirmation screen — rejecting stale approvals', + ); + expect(mockRejectPendingApproval).toHaveBeenCalledTimes(1); }); - jest.runAllTimers(); + it('resets the deeplink flag after processing so subsequent returns from browser are not affected', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + } as never); - expect(mockGoBack).not.toHaveBeenCalled(); - }); + renderHook(() => useMusdConversionStaleApprovalCleanup()); - it('does not navigate back when no stale approvals exist', () => { - mockSelectUnapprovedMusdConversions.mockReturnValue([]); - mockGetCurrentRoute.mockReturnValue({ - name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, - } as never); + act(() => { + appStateHandler?.('background'); + simulateDeeplinkWhileBackgrounded(); + appStateHandler?.('active'); + }); + + expect(mockRejectPendingApproval).toHaveBeenCalledTimes(1); + mockRejectPendingApproval.mockClear(); + mockLoggerLog.mockClear(); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + expect(mockLoggerLog).toHaveBeenCalledWith( + '[mUSD Conversion] Skipping stale approval cleanup — user is in active confirmation flow', + ); + expect(mockRejectPendingApproval).not.toHaveBeenCalled(); + }); - renderHook(() => useMusdConversionStaleApprovalCleanup()); + it('does not set deeplink flag when Linking url event fires while app is not backgrounded', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + } as never); - act(() => { - appStateHandler?.('background'); - appStateHandler?.('active'); + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + linkingUrlHandler?.({ url: 'metamask://some-deeplink' }); + }); + + act(() => { + appStateHandler?.('background'); + appStateHandler?.('active'); + }); + + expect(mockLoggerLog).toHaveBeenCalledWith( + '[mUSD Conversion] Skipping stale approval cleanup — user is in active confirmation flow', + ); + expect(mockRejectPendingApproval).not.toHaveBeenCalled(); }); - jest.runAllTimers(); + it('resets deeplink flag when app enters background', () => { + mockSelectUnapprovedMusdConversions.mockReturnValue([ + { id: 'tx-1' } as never, + ]); + mockGetCurrentRoute.mockReturnValue({ + name: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + } as never); + + renderHook(() => useMusdConversionStaleApprovalCleanup()); + + act(() => { + appStateHandler?.('background'); + simulateDeeplinkWhileBackgrounded(); + appStateHandler?.('background'); + appStateHandler?.('active'); + }); - expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockLoggerLog).toHaveBeenCalledWith( + '[mUSD Conversion] Skipping stale approval cleanup — user is in active confirmation flow', + ); + expect(mockRejectPendingApproval).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.ts b/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.ts index 0d82761ce0b..35d33c86652 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionStaleApprovalCleanup.ts @@ -1,6 +1,6 @@ import { providerErrors } from '@metamask/rpc-errors'; import { useEffect, useMemo } from 'react'; -import { AppState, AppStateStatus } from 'react-native'; +import { AppState, AppStateStatus, Linking } from 'react-native'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import Logger from '../../../../util/Logger'; @@ -8,13 +8,80 @@ import NavigationService from '../../../../core/NavigationService'; import Routes from '../../../../constants/navigation/Routes'; import { selectUnapprovedMusdConversions } from '../selectors/musdConversionStatus'; +/** + * Routes that indicate the user is actively in the mUSD confirmation flow. + * When the focused route is one of these, a background/foreground cycle is + * most likely caused by a transient external link (e.g. "terms apply") and + * the pending approval should not be rejected. + */ +const ACTIVE_CONFIRMATION_FLOW_ROUTES = new Set([ + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + Routes.SHEET.TOOLTIP_MODAL, +]); + +const isRouteInActiveConfirmationFlow = (): boolean => { + const currentRoute = NavigationService.navigation.getCurrentRoute(); + return ACTIVE_CONFIRMATION_FLOW_ROUTES.has(currentRoute?.name ?? ''); +}; + +const rejectStaleApprovals = (transactionIds: string[]) => { + Logger.log( + '[mUSD Conversion] Rejecting stale pending approvals on foreground', + { + count: transactionIds.length, + transactionIds, + }, + ); + + for (const transactionId of transactionIds) { + Engine.rejectPendingApproval( + transactionId, + providerErrors.userRejectedRequest({ + message: + 'Automatically rejected stale mUSD conversion pending approval on app foreground', + data: { + cause: 'useMusdConversionStaleApprovalCleanup', + transactionId, + }, + }), + { + ignoreMissing: true, + logErrors: false, + }, + ); + } + + // Pop the orphaned confirmation screen on the next frame so React + // finishes processing the approval-rejection state updates first. + requestAnimationFrame(() => { + const orphanedRoute = NavigationService.navigation.getCurrentRoute(); + if ( + orphanedRoute?.name === + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS + ) { + NavigationService.navigation.goBack(); + } + }); +}; + /** * Rejects stale mUSD conversion pending approvals on app foreground. * * If the app backgrounds while a mUSD conversion is pending approval, some * flows can stay disabled because the pending approval remains unresolved. * This hook rejects those stale unapproved mUSD approvals when the app returns - * to active state. + * to active state, with the following safeguards: + * + * If the user is NOT on a confirmation screen, approvals are rejected + * immediately (standard stale cleanup). + * + * If the user IS on a confirmation screen (or a modal on top of it such as + * a tooltip), the hook checks whether the foreground was triggered by an + * incoming deeplink. It listens for Linking 'url' events, which fire when + * the app receives a URL from an external source (deeplink) but do NOT fire + * when the user returns from Linking.openURL() (e.g. tapping "terms apply"). + * If a URL event was received while backgrounded, the approval is rejected. + * Otherwise the approval is preserved. */ export const useMusdConversionStaleApprovalCleanup = () => { const pendingUnapprovedMusdConversions = useSelector( @@ -31,8 +98,26 @@ export const useMusdConversionStaleApprovalCleanup = () => { useEffect(() => { let previousAppState = AppState.currentState; + let deeplinkReceivedWhileBackgrounded = false; + + // Linking 'url' fires when the app receives a URL from an external + // source (deeplink, universal link). It does NOT fire when the user + // returns from Linking.openURL() (opening the browser). + const handleIncomingUrl = () => { + if (previousAppState === 'background') { + deeplinkReceivedWhileBackgrounded = true; + } + }; + + const urlSubscription = Linking.addEventListener('url', handleIncomingUrl); const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState === 'background') { + deeplinkReceivedWhileBackgrounded = false; + previousAppState = nextAppState; + return; + } + // Only treat a true background return as stale approval cleanup signal. // iOS transient system overlays (Notification/Control Center) can emit // active -> inactive -> active and should not clear pending approvals. @@ -49,45 +134,24 @@ export const useMusdConversionStaleApprovalCleanup = () => { return; } - Logger.log( - '[mUSD Conversion] Rejecting stale pending approvals on foreground', - { - count: pendingMusdUnapprovedTransactionIds.length, - transactionIds: pendingMusdUnapprovedTransactionIds, - }, - ); + previousAppState = nextAppState; - for (const transactionId of pendingMusdUnapprovedTransactionIds) { - Engine.rejectPendingApproval( - transactionId, - providerErrors.userRejectedRequest({ - message: - 'Automatically rejected stale mUSD conversion pending approval on app foreground', - data: { - cause: 'useMusdConversionStaleApprovalCleanup', - transactionId, - }, - }), - { - ignoreMissing: true, - logErrors: false, - }, + if (isRouteInActiveConfirmationFlow()) { + if (!deeplinkReceivedWhileBackgrounded) { + Logger.log( + '[mUSD Conversion] Skipping stale approval cleanup — user is in active confirmation flow', + ); + deeplinkReceivedWhileBackgrounded = false; + return; + } + + Logger.log( + '[mUSD Conversion] Incoming deeplink detected while on confirmation screen — rejecting stale approvals', ); } - previousAppState = nextAppState; - - // Pop the orphaned confirmation screen on the next frame so React - // finishes processing the approval-rejection state updates first. - requestAnimationFrame(() => { - const currentRoute = NavigationService.navigation.getCurrentRoute(); - if ( - currentRoute?.name === - Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS - ) { - NavigationService.navigation.goBack(); - } - }); + deeplinkReceivedWhileBackgrounded = false; + rejectStaleApprovals(pendingMusdUnapprovedTransactionIds); }; const appStateListener = AppState.addEventListener( @@ -97,6 +161,7 @@ export const useMusdConversionStaleApprovalCleanup = () => { return () => { appStateListener.remove(); + urlSubscription.remove(); }; }, [pendingMusdUnapprovedTransactionIds]); }; diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 9b204acc7e0..39026559732 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -68,7 +68,6 @@ import { parsePolymarketPositions, previewOrder, priceValid, - refreshBalanceAllowance, submitClobOrder, } from './utils'; @@ -109,7 +108,6 @@ jest.mock('./utils', () => { priceValid: jest.fn(), createApiKey: jest.fn(), submitClobOrder: jest.fn(), - refreshBalanceAllowance: jest.fn(), getMarketPositions: jest.fn(), getBalance: jest.fn(), previewOrder: jest.fn(), @@ -212,7 +210,6 @@ const mockParsePolymarketPositions = parsePolymarketPositions as jest.Mock; const mockPriceValid = priceValid as jest.Mock; const mockCreateApiKey = createApiKey as jest.Mock; const mockSubmitClobOrder = submitClobOrder as jest.Mock; -const mockRefreshBalanceAllowance = refreshBalanceAllowance as jest.Mock; const mockEncodeClaim = encodeClaim as jest.Mock; const mockComputeProxyAddress = computeProxyAddress as jest.Mock; const mockCreatePermit2FeeAuthorization = @@ -1576,97 +1573,6 @@ describe('PolymarketProvider', () => { }); }); - describe('placeOrder balance/allowance refresh workaround', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockRefreshBalanceAllowance.mockResolvedValue(undefined); - }); - - it('calls refreshBalanceAllowance with COLLATERAL before submitting a BUY order', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - expect(mockRefreshBalanceAllowance).toHaveBeenCalledWith({ - address: mockSigner.address, - apiKey: expect.objectContaining({ apiKey: 'test-api-key' }), - side: Side.BUY, - outcomeTokenId: preview.outcomeTokenId, - }); - }); - - it('calls refreshBalanceAllowance with CONDITIONAL before submitting a SELL order', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.SELL }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - expect(mockRefreshBalanceAllowance).toHaveBeenCalledWith({ - address: mockSigner.address, - apiKey: expect.objectContaining({ apiKey: 'test-api-key' }), - side: Side.SELL, - outcomeTokenId: preview.outcomeTokenId, - }); - }); - - it('calls refreshBalanceAllowance before submitClobOrder', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const callOrder: string[] = []; - mockRefreshBalanceAllowance.mockImplementation(async () => { - callOrder.push('refresh'); - }); - mockSubmitClobOrder.mockImplementation(async () => { - callOrder.push('submit'); - return { - success: true, - response: { - success: true, - makingAmount: '1000000', - orderID: 'order-123', - status: 'success', - takingAmount: '0', - transactionsHashes: [], - }, - error: undefined, - }; - }); - const preview = createMockOrderPreview({ side: Side.BUY }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - expect(callOrder).toEqual(['refresh', 'submit']); - }); - - it('proceeds with order submission when refreshBalanceAllowance fails', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - mockRefreshBalanceAllowance.mockRejectedValue( - new Error('Network timeout'), - ); - const preview = createMockOrderPreview({ side: Side.BUY }); - - // Act - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - // Assert - order still submitted despite refresh failure - expect(mockSubmitClobOrder).toHaveBeenCalled(); - expect(result.success).toBe(true); - }); - }); - describe('placeOrder with Safe fee authorization', () => { it('computes Safe address before creating order', async () => { const { provider, mockSigner } = setupPlaceOrderTest(); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index d20e9dbc6b6..9ea907de825 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -101,7 +101,6 @@ import { parsePolymarketEvents, parsePolymarketPositions, previewOrder, - refreshBalanceAllowance, roundOrderAmount, submitClobOrder, } from './utils'; @@ -1317,23 +1316,6 @@ export class PolymarketProvider implements PredictProvider { apiKey: signerApiKey, }); - // TEMPORARY WORKAROUND: Refresh balance/allowance on Polymarket's CLOB - // before submitting the order. See refreshBalanceAllowance docs for details. - try { - await refreshBalanceAllowance({ - address: signer.address, - apiKey: signerApiKey, - side, - outcomeTokenId, - }); - } catch (refreshError) { - // Best-effort — don't block order submission if the refresh fails - DevLogger.log( - 'PolymarketProvider: Pre-order balance/allowance refresh failed', - refreshError, - ); - } - const { success, response, error } = await submitClobOrder({ headers, clobOrder, diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index e85607d6f0b..311a40ec4e7 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -60,7 +60,6 @@ import { parsePolymarketPositions, parsePolymarketActivity, priceValid, - refreshBalanceAllowance, submitClobOrder, decimalPlaces, roundNormal, @@ -702,100 +701,6 @@ describe('polymarket utils', () => { }); }); - describe('refreshBalanceAllowance', () => { - beforeEach(() => { - mockFetch.mockReset(); - (global.crypto as any).createHmac.mockReturnValue({ - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('mock-digest-base64'), - }); - }); - - it('sends COLLATERAL asset_type for BUY orders', async () => { - mockFetch.mockResolvedValue({ ok: true }); - - await refreshBalanceAllowance({ - address: mockAddress, - apiKey: mockApiKey, - side: Side.BUY, - outcomeTokenId: 'token-123', - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/balance-allowance/update?'), - expect.objectContaining({ method: 'GET' }), - ); - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain('asset_type=COLLATERAL'); - expect(calledUrl).toContain('signature_type=2'); - expect(calledUrl).not.toContain('token_id='); - }); - - it('sends CONDITIONAL asset_type with token_id for SELL orders', async () => { - mockFetch.mockResolvedValue({ ok: true }); - - await refreshBalanceAllowance({ - address: mockAddress, - apiKey: mockApiKey, - side: Side.SELL, - outcomeTokenId: 'token-456', - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain('asset_type=CONDITIONAL'); - expect(calledUrl).toContain('token_id=token-456'); - expect(calledUrl).toContain('signature_type=2'); - }); - - it('calls CLOB_ENDPOINT with L2 auth headers', async () => { - mockFetch.mockResolvedValue({ ok: true }); - - await refreshBalanceAllowance({ - address: mockAddress, - apiKey: mockApiKey, - side: Side.BUY, - outcomeTokenId: 'token-123', - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toMatch(/^https:\/\/clob\.polymarket\.com/); - const calledOptions = mockFetch.mock.calls[0][1] as RequestInit; - expect(calledOptions.headers).toEqual( - expect.objectContaining({ - POLY_ADDRESS: mockAddress, - }), - ); - }); - - it('does not throw when response is not ok', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 500 }); - - await expect( - refreshBalanceAllowance({ - address: mockAddress, - apiKey: mockApiKey, - side: Side.BUY, - outcomeTokenId: 'token-123', - }), - ).resolves.toBeUndefined(); - }); - - it('uses custom signatureType when provided', async () => { - mockFetch.mockResolvedValue({ ok: true }); - - await refreshBalanceAllowance({ - address: mockAddress, - apiKey: mockApiKey, - side: Side.BUY, - outcomeTokenId: 'token-123', - signatureType: SignatureType.EOA, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain('signature_type=0'); - }); - }); - describe('submitClobOrder', () => { const mockHeaders: ClobHeaders = { POLY_ADDRESS: mockAddress, diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index d3ca1f890e2..a103e93011f 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -61,7 +61,6 @@ import { PolymarketApiMarket, PolymarketApiTeam, PolymarketPosition, - SignatureType, TickSize, OrderBook, } from './types'; @@ -188,76 +187,6 @@ export const getL2Headers = async ({ return headers; }; -/** - * TEMPORARY WORKAROUND for Polymarket infrastructure issue. - * - * Polymarket's CLOB infrastructure intermittently returns 400 errors with - * "not enough balance / allowance" when placing orders at high request rates. - * Calling this endpoint before each order refreshes the balance/allowance state - * on their end and prevents most of these spurious failures. - * - * For BUY orders: refreshes COLLATERAL (USDC) balance/allowance. - * For SELL orders: refreshes CONDITIONAL token balance/allowance. - * - * TODO: Remove this workaround once Polymarket resolves the underlying - * infrastructure issue. Track removal in a follow-up ticket. - */ -export const refreshBalanceAllowance = async ({ - address, - apiKey, - side, - outcomeTokenId, - signatureType = SignatureType.POLY_GNOSIS_SAFE, -}: { - address: string; - apiKey: ApiKeyCreds; - side: Side; - outcomeTokenId: string; - signatureType?: SignatureType; -}): Promise => { - const { CLOB_ENDPOINT } = getPolymarketEndpoints(); - - const queryParams = new URLSearchParams({ - signature_type: String(signatureType), - }); - - if (side === Side.BUY) { - queryParams.set('asset_type', 'COLLATERAL'); - } else { - queryParams.set('asset_type', 'CONDITIONAL'); - queryParams.set('token_id', outcomeTokenId); - } - - const requestPath = `/balance-allowance/update`; - - const headers = await getL2Headers({ - l2HeaderArgs: { - method: 'GET', - requestPath, - }, - address, - apiKey, - }); - - const response = await fetch( - `${CLOB_ENDPOINT}${requestPath}?${queryParams.toString()}`, - { - method: 'GET', - headers, - }, - ); - - if (!response.ok) { - DevLogger.log( - 'refreshBalanceAllowance: Pre-order balance/allowance refresh failed', - { - status: response.status, - side, - }, - ); - } -}; - export const deriveApiKey = async ({ address }: { address: string }) => { const { CLOB_ENDPOINT } = getPolymarketEndpoints(); const headers = await getL1Headers({ address });