diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/RevokeModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/RevokeModal.tsx index 11468a22b91a..8e42406b115c 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/RevokeModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/RevokeModal.tsx @@ -2,7 +2,12 @@ import { useState } from 'react'; import styled from 'styled-components'; -import { TradingExchangeType, invityAPI, useTradingInfo } from '@suite-common/trading'; +import { + TradingExchangeType, + invityAPI, + tokenSupportsIncreasingAllowance, + useTradingInfo, +} from '@suite-common/trading'; import { getDisplaySymbol } from '@suite-common/wallet-config'; import { Badge, @@ -28,7 +33,6 @@ import { useTradingFormContext } from 'src/hooks/wallet/trading/form/useTradingC import { useTradingExchangeCryptoAndProviderInfo } from 'src/hooks/wallet/trading/form/useTradingExchangeCryptoAndProviderInfo'; import { selectIsDebugModeActive } from 'src/selectors/suite/suiteSelectors'; import { getProvidersInfoProps } from 'src/utils/wallet/trading/tradingTypingUtils'; -import { tokenSupportsIncreasingAllowance } from 'src/utils/wallet/trading/tradingUtils'; import { TradingCoinLogo } from 'src/views/wallet/trading/common/TradingCoinLogo'; const BreakableValue = styled.span` diff --git a/packages/suite/src/utils/wallet/trading/tradingUtils.ts b/packages/suite/src/utils/wallet/trading/tradingUtils.ts index 179a0405af6c..cc3eec8155c0 100644 --- a/packages/suite/src/utils/wallet/trading/tradingUtils.ts +++ b/packages/suite/src/utils/wallet/trading/tradingUtils.ts @@ -390,12 +390,3 @@ export const getTradeTypeByRoute = ( return 'exchange'; } }; - -export const tokenSupportsIncreasingAllowance = (contractAddress?: string) => { - const ethereumUsdtContractAddress = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; - - return ( - contractAddress && - contractAddress.trim().toLowerCase() !== ethereumUsdtContractAddress.toLowerCase() - ); -}; diff --git a/packages/suite/src/views/wallet/trading/common/TradingForm/TradingFormApproval.tsx b/packages/suite/src/views/wallet/trading/common/TradingForm/TradingFormApproval.tsx index fa86961ee034..fb9c4362faba 100644 --- a/packages/suite/src/views/wallet/trading/common/TradingForm/TradingFormApproval.tsx +++ b/packages/suite/src/views/wallet/trading/common/TradingForm/TradingFormApproval.tsx @@ -3,7 +3,11 @@ import { usePrevious } from 'react-use'; import styled, { DefaultTheme, keyframes } from 'styled-components'; -import { TradingExchangeType, useTradingInfo } from '@suite-common/trading'; +import { + TradingExchangeType, + tokenSupportsIncreasingAllowance, + useTradingInfo, +} from '@suite-common/trading'; import { selectHasRunningDiscovery } from '@suite-common/wallet-core'; import { Banner, Button, Column, Icon, Paragraph, Row } from '@trezor/components'; import { EventType, analytics } from '@trezor/suite-analytics'; @@ -16,7 +20,6 @@ import { useTradingFormContext } from 'src/hooks/wallet/trading/form/useTradingC import { useTradingExchangeCryptoAndProviderInfo } from 'src/hooks/wallet/trading/form/useTradingExchangeCryptoAndProviderInfo'; import { useTradingExchangeWatchApproval } from 'src/hooks/wallet/trading/form/useTradingExchangeWatchApproval'; import { TradingExchangeApprovalType } from 'src/types/trading/tradingForm'; -import { tokenSupportsIncreasingAllowance } from 'src/utils/wallet/trading/tradingUtils'; const TextButton = styled.div<{ $disabled: boolean }>` color: ${({ theme, $disabled }) => diff --git a/suite-common/trading/src/utils/exchange/__tests__/exchangeUtils.test.ts b/suite-common/trading/src/utils/exchange/__tests__/exchangeUtils.test.ts new file mode 100644 index 000000000000..c01db65b785b --- /dev/null +++ b/suite-common/trading/src/utils/exchange/__tests__/exchangeUtils.test.ts @@ -0,0 +1,34 @@ +import { tokenSupportsIncreasingAllowance } from '../exchangeUtils'; + +describe('tokenSupportsIncreasingAllowance', () => { + it('should return false for Ethereum USDT contract address (uppercase)', () => { + const result = tokenSupportsIncreasingAllowance( + '0xdAC17F958D2ee523a2206206994597C13D831ec7', + ); + expect(result).toBe(false); + }); + + it('should return false for Ethereum USDT contract address (lowercase)', () => { + const result = tokenSupportsIncreasingAllowance( + '0xdac17f958d2ee523a2206206994597c13d831ec7', + ); + expect(result).toBe(false); + }); + + it('should return true for other contract addresses', () => { + const result = tokenSupportsIncreasingAllowance( + '0x1234567890123456789012345678901234567890', + ); + expect(result).toBe(true); + }); + + it('should return false for undefined contract address', () => { + const result = tokenSupportsIncreasingAllowance(undefined); + expect(result).toBe(false); + }); + + it('should return false for empty string', () => { + const result = tokenSupportsIncreasingAllowance(''); + expect(result).toBe(false); + }); +}); diff --git a/suite-common/trading/src/utils/exchange/exchangeUtils.ts b/suite-common/trading/src/utils/exchange/exchangeUtils.ts index 867520e4144a..ab119a60fae6 100644 --- a/suite-common/trading/src/utils/exchange/exchangeUtils.ts +++ b/suite-common/trading/src/utils/exchange/exchangeUtils.ts @@ -116,6 +116,16 @@ export const getStatusMessage = (status: ExchangeTradeStatus) => { } }; +export const tokenSupportsIncreasingAllowance = (contractAddress?: string): boolean => { + const ethereumUsdtContractAddress = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; + + if (!contractAddress) { + return false; + } + + return contractAddress.trim().toLowerCase() !== ethereumUsdtContractAddress.toLowerCase(); +}; + export const exchangeUtils = { getAmountLimits, isQuoteError, @@ -124,4 +134,5 @@ export const exchangeUtils = { getCexQuotesByRateType, getSuccessQuotesOrdered, getStatusMessage, + tokenSupportsIncreasingAllowance, }; diff --git a/suite-native/intl/src/messages.ts b/suite-native/intl/src/messages.ts index 8652258c3376..6586ff927038 100644 --- a/suite-native/intl/src/messages.ts +++ b/suite-native/intl/src/messages.ts @@ -2422,6 +2422,7 @@ export const messages = { providerNamePlaceholder: 'Provider', providerReceiveAddressLabel: "{providerName}'s receive address", confirmationAlertTitle: 'Failed to confirm offer.', + approvalSuccessAlert: 'Spending approval confirmed.', }, tradingExchangeApprovalScreen: { title: 'Set {symbol} spending', diff --git a/suite-native/module-trading/src/components/exchange/ExchangeApprovalLimitSheet/ExchangeApprovalLimitSheet.tsx b/suite-native/module-trading/src/components/exchange/ExchangeApprovalLimitSheet/ExchangeApprovalLimitSheet.tsx index 6f1fff8d5184..f039f36baf45 100644 --- a/suite-native/module-trading/src/components/exchange/ExchangeApprovalLimitSheet/ExchangeApprovalLimitSheet.tsx +++ b/suite-native/module-trading/src/components/exchange/ExchangeApprovalLimitSheet/ExchangeApprovalLimitSheet.tsx @@ -1,34 +1,45 @@ import { memo, useEffect } from 'react'; import { useSelector } from 'react-redux'; +import { DexApprovalType, ExchangeTrade } from 'invity-api'; + import { TradingRootState, cryptoIdToNetworkSymbolAndContractAddress, selectTradingCoinSymbolByCryptoId, - selectTradingExchangeSelectedQuote, selectTradingProviderByNameAndTradeType, } from '@suite-common/trading'; +import { isNetworkSymbol } from '@suite-common/wallet-config'; +import { TokenSymbol } from '@suite-common/wallet-types'; import { BottomSheetModal, VStack, useBottomSheetModal } from '@suite-native/atoms'; +import { CryptoAmountFormatter, TokenAmountFormatter } from '@suite-native/formatters'; import { Translation } from '@suite-native/intl'; import { ExchangeApprovalLimitCard } from './ExchangeApprovalLimitCard'; -type ExchangeApprovalLimitSheetProps = { +export type ExchangeApprovalLimitSheetProps = { isVisible: boolean; onDismiss: () => void; + onApprovalTypeSelect: (type: DexApprovalType) => void; + selectedApprovalType: DexApprovalType; + quote: ExchangeTrade; }; export const ExchangeApprovalLimitSheet = memo( - ({ isVisible, onDismiss }: ExchangeApprovalLimitSheetProps) => { - const { bottomSheetRef, openModal } = useBottomSheetModal(); + ({ + isVisible, + onDismiss, + onApprovalTypeSelect, + selectedApprovalType, + quote, + }: ExchangeApprovalLimitSheetProps) => { + const { bottomSheetRef, openModal, closeModal } = useBottomSheetModal(); - useEffect(() => { - if (isVisible) { - openModal(); - } - }, [isVisible, openModal]); + useEffect( + () => (isVisible ? openModal() : closeModal()), + [isVisible, openModal, closeModal], + ); - const quote = useSelector(selectTradingExchangeSelectedQuote); const providerInfo = useSelector((state: TradingRootState) => selectTradingProviderByNameAndTradeType(state, quote?.exchange, 'exchange'), ); @@ -37,10 +48,6 @@ export const ExchangeApprovalLimitSheet = memo( selectTradingCoinSymbolByCryptoId(state, quote?.send), ); - if (!quote) { - return null; - } - const { symbol, contractAddress } = quote.send ? cryptoIdToNetworkSymbolAndContractAddress(quote.send) : {}; @@ -49,7 +56,22 @@ export const ExchangeApprovalLimitSheet = memo( return null; } - const limitAmount = `200.32 ${coinSymbol}`; //TODO + const formattedLimitAmount = + !!coinSymbol && + (isNetworkSymbol(coinSymbol) ? ( + + ) : ( + + )); return ( { - // TODO - }} + isChecked={selectedApprovalType === 'INFINITE'} + onChange={() => onApprovalTypeSelect('INFINITE')} /> { - // TODO - }} + isChecked={selectedApprovalType === 'MINIMAL'} + onChange={() => onApprovalTypeSelect('MINIMAL')} /> diff --git a/suite-native/module-trading/src/components/exchange/ExchangeApprovalLimitSheet/__tests__/ExchangeApprovalLimitSheet.comp.test.tsx b/suite-native/module-trading/src/components/exchange/ExchangeApprovalLimitSheet/__tests__/ExchangeApprovalLimitSheet.comp.test.tsx index cd38ee867595..c5223b24ad71 100644 --- a/suite-native/module-trading/src/components/exchange/ExchangeApprovalLimitSheet/__tests__/ExchangeApprovalLimitSheet.comp.test.tsx +++ b/suite-native/module-trading/src/components/exchange/ExchangeApprovalLimitSheet/__tests__/ExchangeApprovalLimitSheet.comp.test.tsx @@ -5,6 +5,7 @@ import { getInitializedTradingState } from '../../../../__fixtures__/tradingStat import { ExchangeApprovalLimitSheet } from '../ExchangeApprovalLimitSheet'; const mockOnDismiss = jest.fn(); +const mockOnApprovalTypeSelect = jest.fn(); const testQuote = exchangeQuotes[0]; @@ -20,22 +21,20 @@ const getPreloadedState = (): PreloadedState => ({ }, }); -const getPreloadedStateWithoutQuote = (): PreloadedState => ({ - wallet: { - trading: { - ...getInitializedTradingState('exchange'), - exchange: { - ...getInitializedTradingState('exchange').exchange, - selectedQuote: undefined, - }, - }, - }, -}); - -const renderSheet = (isVisible = true, preloadedState = getPreloadedState()) => +const renderSheet = ( + isVisible = true, + quote = testQuote, + selectedApprovalType: 'INFINITE' | 'MINIMAL' = 'INFINITE', +) => renderWithStoreProviderAsync( - , - { preloadedState }, + , + { preloadedState: getPreloadedState() }, ); describe('ExchangeApprovalLimitSheet', () => { @@ -43,17 +42,11 @@ describe('ExchangeApprovalLimitSheet', () => { jest.clearAllMocks(); }); - it('should render nothing when quote is not available', async () => { - const { toJSON } = await renderSheet(true, getPreloadedStateWithoutQuote()); - - expect(toJSON()).toBeNull(); - }); - it('should render the sheet when visible', async () => { const { getByText } = await renderSheet(); expect(getByText('Unlimited')).toBeTruthy(); - expect(getByText('200.32 USDC')).toBeTruthy(); + expect(getByText('100 USDC')).toBeTruthy(); }); it('should render unlimited approval option with correct details', async () => { @@ -70,7 +63,7 @@ describe('ExchangeApprovalLimitSheet', () => { it('should render limited approval option with correct amount', async () => { const { getByText } = await renderSheet(); - expect(getByText('200.32 USDC')).toBeTruthy(); + expect(getByText('100 USDC')).toBeTruthy(); expect( getByText( "Approve only the amount needed for this swap. This helps reduce risk, but you'll need to approve again (and pay a fee) for future swaps.", @@ -94,4 +87,26 @@ describe('ExchangeApprovalLimitSheet', () => { ), ).toBeTruthy(); }); + + it('should display quote sendStringAmount in limited approval option', async () => { + const customQuote = { + ...testQuote, + sendStringAmount: '250', + }; + const { getByText } = await renderSheet(true, customQuote); + + expect(getByText('250 USDC')).toBeTruthy(); + }); + + it('should pass correct props when INFINITE is selected', async () => { + await renderSheet(true, testQuote, 'INFINITE'); + + expect(mockOnApprovalTypeSelect).toBeDefined(); + }); + + it('should pass correct props when MINIMAL is selected', async () => { + await renderSheet(true, testQuote, 'MINIMAL'); + + expect(mockOnApprovalTypeSelect).toBeDefined(); + }); }); diff --git a/suite-native/module-trading/src/components/exchange/ExchangePreview/ExchangePreviewView.tsx b/suite-native/module-trading/src/components/exchange/ExchangePreview/ExchangePreviewView.tsx index e1659dd502e1..0d6e4ae7338d 100644 --- a/suite-native/module-trading/src/components/exchange/ExchangePreview/ExchangePreviewView.tsx +++ b/suite-native/module-trading/src/components/exchange/ExchangePreview/ExchangePreviewView.tsx @@ -1,10 +1,10 @@ import { memo } from 'react'; -import { ScrollView } from 'react-native-gesture-handler'; import Animated from 'react-native-reanimated'; import { ExchangeTrade } from 'invity-api'; import { InlineAlertBox, VStack } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; import { ExchangeFeePickerCard } from './ExchangeFeePickerCard'; import { ExchangeFromAccountTradePreviewCard } from './ExchangeFromAccountTradePreviewCard'; @@ -14,15 +14,24 @@ import { useChangeStringsExtractor } from '../../../hooks/history/useChangeStrin export type ExchangePreviewViewProps = { quote: ExchangeTrade | undefined; txnErrorString: string | null; + isApproved?: boolean; }; -export const ExchangePreviewView = memo(({ quote, txnErrorString }: ExchangePreviewViewProps) => { - const { fromStringValue, toStringValue } = useChangeStringsExtractor(quote); - const isTxnError = !!txnErrorString; +export const ExchangePreviewView = memo( + ({ quote, txnErrorString, isApproved }: ExchangePreviewViewProps) => { + const { fromStringValue, toStringValue } = useChangeStringsExtractor(quote); + const isTxnError = !!txnErrorString; - return ( - + return ( + {!!isApproved && ( + + } + /> + )} {isTxnError && ( @@ -35,6 +44,6 @@ export const ExchangePreviewView = memo(({ quote, txnErrorString }: ExchangePrev - - ); -}); + ); + }, +); diff --git a/suite-native/module-trading/src/hooks/exchange/__tests__/useExchangeSelectQuote.test.ts b/suite-native/module-trading/src/hooks/exchange/__tests__/useExchangeSelectQuote.test.ts index 1a99d8e2a1c5..88fa31d18c27 100644 --- a/suite-native/module-trading/src/hooks/exchange/__tests__/useExchangeSelectQuote.test.ts +++ b/suite-native/module-trading/src/hooks/exchange/__tests__/useExchangeSelectQuote.test.ts @@ -13,6 +13,7 @@ import { exchangeQuotes } from '../../../__fixtures__/exchangeQuotes'; import { btcAsset } from '../../../__fixtures__/tradeableAssets'; import { getInitializedTradingStateWithQuotes } from '../../../__fixtures__/tradingState'; import { ExchangeFormType } from '../../../types/exchange'; +import * as approvalStatusUtils from '../../../utils/general/approvalStatusUtils'; import { useExchangeForm } from '../useExchangeForm'; import { useExchangeSelectQuote } from '../useExchangeSelectQuote'; @@ -27,6 +28,8 @@ jest.mock('@trezor/react-utils', () => ({ useTimer: () => mockTimerReturn, })); +const mockTokenSupportsIncreasingAllowance = jest.fn(); + jest.mock('@suite-common/trading', () => ({ ...jest.requireActual('@suite-common/trading'), exchangeThunks: { @@ -35,6 +38,8 @@ jest.mock('@suite-common/trading', () => ({ payload, }), }, + tokenSupportsIncreasingAllowance: (contractAddress?: string) => + mockTokenSupportsIncreasingAllowance(contractAddress), })); const mockNavigation = { @@ -275,7 +280,7 @@ describe('useExchangeSelectQuote', () => { nextStep(); }); - expect(mockNavigation.navigate).toHaveBeenCalledWith('TradingExchangePreview'); + expect(mockNavigation.navigate).toHaveBeenCalledWith('TradingExchangePreview', {}); }); it('should call cancelConsent when quote provider changes', async () => { @@ -307,4 +312,143 @@ describe('useExchangeSelectQuote', () => { expect(result.current.isConsentRequested).toBe(false); }); }); + + describe('navigation based on approval status', () => { + beforeEach(async () => { + store = await getInitializedStore({ isLoading: false }); + + const { result } = await renderExchangeForm(); + exchangeForm = result.current; + }); + + it('should navigate to TradingExchangePreview when approval status is "approved"', async () => { + jest.spyOn(approvalStatusUtils, 'getApprovalStatus').mockReturnValue('approved'); + + act(() => { + exchangeForm.setValue('quote', exchangeQuotes[1]); + }); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + const { result } = await renderUseExchangeSelectQuote(); + + act(() => { + result.current.selectQuote(); + }); + + const dispatchCall = dispatchSpy.mock.calls[0][0]; + const { nextStep } = (dispatchCall as any).payload; + + act(() => { + nextStep(); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('TradingExchangePreview', {}); + }); + + it('should navigate to TradingExchangePreview when approval status is "not_needed"', async () => { + jest.spyOn(approvalStatusUtils, 'getApprovalStatus').mockReturnValue('not_needed'); + + act(() => { + exchangeForm.setValue('quote', exchangeQuotes[1]); + }); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + const { result } = await renderUseExchangeSelectQuote(); + + act(() => { + result.current.selectQuote(); + }); + + const dispatchCall = dispatchSpy.mock.calls[0][0]; + const { nextStep } = (dispatchCall as any).payload; + + act(() => { + nextStep(); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('TradingExchangePreview', {}); + }); + + it('should navigate to TradingExchangeApproval when approval status is "needs_approval"', async () => { + jest.spyOn(approvalStatusUtils, 'getApprovalStatus').mockReturnValue('needs_approval'); + + act(() => { + exchangeForm.setValue('quote', exchangeQuotes[1]); + }); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + const { result } = await renderUseExchangeSelectQuote(); + + act(() => { + result.current.selectQuote(); + }); + + const dispatchCall = dispatchSpy.mock.calls[0][0]; + const { nextStep } = (dispatchCall as any).payload; + + act(() => { + nextStep(); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('TradingExchangeApproval', { + quote: exchangeQuotes[1], + }); + }); + + it('should navigate to TradingExchangeApproval with shouldIncreaseLimit when approval status is "needs_increase" and token supports increasing allowance', async () => { + jest.spyOn(approvalStatusUtils, 'getApprovalStatus').mockReturnValue('needs_increase'); + mockTokenSupportsIncreasingAllowance.mockReturnValue(true); + + act(() => { + exchangeForm.setValue('quote', exchangeQuotes[1]); + }); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + const { result } = await renderUseExchangeSelectQuote(); + + act(() => { + result.current.selectQuote(); + }); + + const dispatchCall = dispatchSpy.mock.calls[0][0]; + const { nextStep } = (dispatchCall as any).payload; + + act(() => { + nextStep(); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('TradingExchangeApproval', { + quote: exchangeQuotes[1], + shouldIncreaseLimit: true, + }); + }); + + it('should navigate to TradingExchangeRevoke when approval status is "needs_increase" and token does not support increasing allowance', async () => { + jest.spyOn(approvalStatusUtils, 'getApprovalStatus').mockReturnValue('needs_increase'); + mockTokenSupportsIncreasingAllowance.mockReturnValue(false); + + act(() => { + exchangeForm.setValue('quote', exchangeQuotes[1]); + }); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + const { result } = await renderUseExchangeSelectQuote(); + + act(() => { + result.current.selectQuote(); + }); + + const dispatchCall = dispatchSpy.mock.calls[0][0]; + const { nextStep } = (dispatchCall as any).payload; + + act(() => { + nextStep(); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('TradingExchangeRevoke', { + quote: exchangeQuotes[1], + shouldIncreaseLimit: true, + }); + }); + }); }); diff --git a/suite-native/module-trading/src/hooks/exchange/useExchangeSelectQuote.ts b/suite-native/module-trading/src/hooks/exchange/useExchangeSelectQuote.ts index 51350b0623f3..38fb490a08db 100644 --- a/suite-native/module-trading/src/hooks/exchange/useExchangeSelectQuote.ts +++ b/suite-native/module-trading/src/hooks/exchange/useExchangeSelectQuote.ts @@ -5,8 +5,10 @@ import { useNavigation } from '@react-navigation/native'; import { exchangeThunks, + parseCryptoId, selectTradingExchangeIsLoading, selectTradingMaxSlippagePercentage, + tokenSupportsIncreasingAllowance, } from '@suite-common/trading'; import { RootStackParamList, @@ -23,6 +25,7 @@ import { selectExchangeSelectedSendAccount, } from '../../selectors/exchangeSelectors'; import { ExchangeFormType } from '../../types/exchange'; +import { getApprovalStatus } from '../../utils/general/approvalStatusUtils'; import { isFullySelectedReceiveAccount } from '../../utils/general/receiveAccountUtils'; import { getSymbolFromTradeableAsset } from '../../utils/general/tradeableAssetUtils'; import { useConsent } from '../general/useConsent'; @@ -92,7 +95,36 @@ export const useExchangeSelectQuote = (form: ExchangeFormType) => { userConsent: waitForConsent, nextStep: () => { clearExchangeFormQuoteData(form); - navigation.navigate(TradingStackRoutes.TradingExchangePreview); + + const approvalStatus = getApprovalStatus(candidateQuote); + if (approvalStatus === 'approved' || approvalStatus === 'not_needed') { + return navigation.navigate(TradingStackRoutes.TradingExchangePreview, {}); + } + + const { contractAddress } = candidateQuote.send + ? parseCryptoId(candidateQuote.send) + : {}; + + const isIncreasingAllowanceSupported = + tokenSupportsIncreasingAllowance(contractAddress); + + if (approvalStatus === 'needs_increase' && isIncreasingAllowanceSupported) { + return navigation.navigate(TradingStackRoutes.TradingExchangeApproval, { + quote: candidateQuote, + shouldIncreaseLimit: true, + }); + } + + if (approvalStatus === 'needs_increase') { + return navigation.navigate(TradingStackRoutes.TradingExchangeRevoke, { + quote: candidateQuote, + shouldIncreaseLimit: true, + }); + } + + return navigation.navigate(TradingStackRoutes.TradingExchangeApproval, { + quote: candidateQuote, + }); }, onCancel: () => {}, }), diff --git a/suite-native/module-trading/src/screens/TradingExchangeApprovalScreen.tsx b/suite-native/module-trading/src/screens/TradingExchangeApprovalScreen.tsx index 2a8ad6866d81..276102116b21 100644 --- a/suite-native/module-trading/src/screens/TradingExchangeApprovalScreen.tsx +++ b/suite-native/module-trading/src/screens/TradingExchangeApprovalScreen.tsx @@ -1,12 +1,14 @@ -import { Pressable } from 'react-native'; +import { useState } from 'react'; +import { Pressable } from 'react-native-gesture-handler'; import { useSelector } from 'react-redux'; -import { invariant } from '@suite-common/suite-utils'; +import { useNavigation } from '@react-navigation/native'; +import { DexApprovalType } from 'invity-api'; + import { TradingRootState, cryptoIdToNetworkAndContractAddress, selectTradingCoinSymbolByCryptoId, - selectTradingExchangeSelectedQuote, selectTradingProviderByNameAndTradeType, } from '@suite-common/trading'; import { asBaseCurrencyAmount } from '@suite-common/wallet-utils'; @@ -14,22 +16,42 @@ import { Box, Button, Card, HStack, InlineAlertBox, Text, VStack } from '@suite- import { BaseCurrencyAmountFormatter } from '@suite-native/formatters'; import { CryptoIcon, Icon, NetworkIcon } from '@suite-native/icons'; import { Translation } from '@suite-native/intl'; -import { DynamicScreenHeader, Screen } from '@suite-native/navigation'; +import { + DynamicScreenHeader, + RootStackParamList, + Screen, + StackNavigationProps, + StackToStackCompositeScreenProps, + TradingStackParamList, + TradingStackRoutes, +} from '@suite-native/navigation'; import { BigNumber } from '@trezor/utils'; import { TradeInfoHeader } from '../components/TradeInfo/TradeInfoHeader'; import { TradeInfoRow } from '../components/TradeInfo/TradeInfoRow'; import { ExchangeApprovalLimitSheet } from '../components/exchange/ExchangeApprovalLimitSheet/ExchangeApprovalLimitSheet'; import { ProviderLogo } from '../components/general/ProviderLogo'; +import { useExchangeFlow } from '../hooks/exchange/useExchangeFlow'; import { useBottomSheetControls } from '../hooks/general/useBottomSheetControls'; import { selectExchangeSelectedSendAccount } from '../selectors/exchangeSelectors'; -export const TradingExchangeApprovalScreen = () => { - const quote = useSelector(selectTradingExchangeSelectedQuote); +type TradingExchangeApprovalScreenProps = StackToStackCompositeScreenProps< + TradingStackParamList, + TradingStackRoutes.TradingExchangeApproval, + RootStackParamList +>; - invariant(quote, 'quote must be defined'); +export const TradingExchangeApprovalScreen = ({ + route: { params }, +}: TradingExchangeApprovalScreenProps) => { + const { quote, shouldIncreaseLimit, isRevoked } = params; + const navigation = + useNavigation< + StackNavigationProps + >(); const account = useSelector(selectExchangeSelectedSendAccount); + const [selectedApprovalType, setSelectedApprovalType] = useState('INFINITE'); const { network, contractAddress } = quote.send ? cryptoIdToNetworkAndContractAddress(quote.send) @@ -45,8 +67,34 @@ export const TradingExchangeApprovalScreen = () => { selectTradingCoinSymbolByCryptoId(state, quote?.send), ); + const { confirmTrade } = useExchangeFlow(); + const fee = '4.76'; // TODO + const handleContinue = async () => { + const updatedQuote = { + ...quote, + approvalType: selectedApprovalType, + }; + + const success = await confirmTrade({ + receiveAddress: quote.receiveAddress ?? '', + trade: updatedQuote, + approvalFlow: true, + nextStep: () => {}, + }); + + if (success) { + // TODO + navigation.navigate(TradingStackRoutes.TradingExchangePreview, { isApproved: true }); + } + }; + + const handleApprovalTypeChange = (newType: DexApprovalType) => { + setSelectedApprovalType(newType); + hideSheet(); + }; + return ( { values={{ symbol: coinSymbol, companyName: providerInfo?.companyName }} /> } - closeActionType="close" + closeActionType="back" /> } > - - } - variant="success" - /> - - } - variant="info" - /> + {!!shouldIncreaseLimit && ( + + } + variant="warning" + /> + )} + + {!!isRevoked && ( + + } + /> + )} { /> )} - + {selectedApprovalType === 'INFINITE' ? ( + + ) : ( + `${quote.sendStringAmount} ${coinSymbol}` + )} @@ -177,15 +234,17 @@ export const TradingExchangeApprovalScreen = () => { - - + ); }; diff --git a/suite-native/module-trading/src/screens/TradingExchangePreviewScreen.tsx b/suite-native/module-trading/src/screens/TradingExchangePreviewScreen.tsx index 5040cb49cf0f..3931804573f1 100644 --- a/suite-native/module-trading/src/screens/TradingExchangePreviewScreen.tsx +++ b/suite-native/module-trading/src/screens/TradingExchangePreviewScreen.tsx @@ -35,7 +35,11 @@ export type TradingExchangePreviewScreenProps = StackProps< TradingStackRoutes.TradingExchangePreview >; -export const TradingExchangePreviewScreen = ({ navigation }: TradingExchangePreviewScreenProps) => { +export const TradingExchangePreviewScreen = ({ + navigation, + route: { params }, +}: TradingExchangePreviewScreenProps) => { + const { isApproved } = params; const { showAlert } = useAlert(); const dispatch = useDispatch(); const debounce = useDebounce(); @@ -146,7 +150,11 @@ export const TradingExchangePreviewScreen = ({ navigation }: TradingExchangePrev return ( }> - + { - const quote = useSelector(selectTradingExchangeSelectedQuote); +type TradingExchangeRevokeScreenProps = StackToStackCompositeScreenProps< + TradingStackParamList, + TradingStackRoutes.TradingExchangeRevoke, + RootStackParamList +>; + +type NavigationProps = StackToStackCompositeNavigationProps< + TradingStackParamList, + TradingStackRoutes.TradingExchangeRevoke, + RootStackParamList +>; - invariant(quote, 'quote must be defined'); +export const TradingExchangeRevokeScreen = ({ + route: { params }, +}: TradingExchangeRevokeScreenProps) => { + const { quote, shouldIncreaseLimit } = params; + + const navigation = useNavigation(); const account = useSelector(selectExchangeSelectedSendAccount); + const handleContinue = () => { + // TODO + if (shouldIncreaseLimit) { + return navigation.replace(TradingStackRoutes.TradingExchangeApproval, { + quote, + isRevoked: true, + }); + } + + return navigation.goBack(); + }; + const { network, contractAddress } = quote.send ? cryptoIdToNetworkAndContractAddress(quote.send) : {}; @@ -69,11 +104,16 @@ export const TradingExchangeRevokeScreen = () => { } > - } - variant="warning" - /> - + {!!shouldIncreaseLimit && ( + + + } + variant="warning" + /> + + )} } @@ -130,7 +170,23 @@ export const TradingExchangeRevokeScreen = () => { /> )} - + {!!coinSymbol && + (isNetworkSymbol(coinSymbol) ? ( + + ) : ( + + ))} @@ -185,11 +241,7 @@ export const TradingExchangeRevokeScreen = () => { - diff --git a/suite-native/module-trading/src/screens/__tests__/TradingExchangeApprovalScreen.comp.test.tsx b/suite-native/module-trading/src/screens/__tests__/TradingExchangeApprovalScreen.comp.test.tsx index 7ef57e0db40c..dc2f2a87d2ab 100644 --- a/suite-native/module-trading/src/screens/__tests__/TradingExchangeApprovalScreen.comp.test.tsx +++ b/suite-native/module-trading/src/screens/__tests__/TradingExchangeApprovalScreen.comp.test.tsx @@ -12,6 +12,13 @@ import { TradingExchangeApprovalScreen } from '../TradingExchangeApprovalScreen' const mockShowSheet = jest.fn(); const mockHideSheet = jest.fn(); +jest.mock('../../hooks/exchange/useExchangeFlow', () => ({ + useExchangeFlow: () => ({ + confirmTrade: jest.fn().mockResolvedValue(true), + fetchFeesAndCompose: jest.fn(), + }), +})); + jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useRoute: () => @@ -46,9 +53,20 @@ const preloadedState = { }; const renderScreen = () => - renderWithStoreProviderAsync(, { - preloadedState, - }); + renderWithStoreProviderAsync( + + } + navigation={{} as any} + />, + { + preloadedState, + }, + ); describe('TradingExchangeApprovalScreen', () => { beforeEach(() => { diff --git a/suite-native/module-trading/src/screens/__tests__/TradingExchangePreviewScreen.comp.test.tsx b/suite-native/module-trading/src/screens/__tests__/TradingExchangePreviewScreen.comp.test.tsx index 4da8340a23be..68b7d2083d64 100644 --- a/suite-native/module-trading/src/screens/__tests__/TradingExchangePreviewScreen.comp.test.tsx +++ b/suite-native/module-trading/src/screens/__tests__/TradingExchangePreviewScreen.comp.test.tsx @@ -22,7 +22,7 @@ jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useRoute: () => ({ - params: undefined, + params: {}, }) as RouteProp, })); @@ -56,7 +56,7 @@ describe('TradingExchangePreviewScreen', () => { let store: TestStore; const analyticsSpy = jest.spyOn(analytics, 'report'); - const renderTradingExchangePreviewScreen = () => + const renderTradingExchangePreviewScreen = (isApproved: boolean = false) => renderWithStoreProviderAsync( { popToTop: mockPopToTop, } as unknown as TradingExchangePreviewScreenProps['navigation'] } - route={{} as TradingExchangePreviewScreenProps['route']} + route={{ params: { isApproved } } as TradingExchangePreviewScreenProps['route']} />, { store }, ); diff --git a/suite-native/module-trading/src/screens/__tests__/TradingExchangeRevokeScreen.comp.test.tsx b/suite-native/module-trading/src/screens/__tests__/TradingExchangeRevokeScreen.comp.test.tsx index 93efb2304ab4..045d27d8f840 100644 --- a/suite-native/module-trading/src/screens/__tests__/TradingExchangeRevokeScreen.comp.test.tsx +++ b/suite-native/module-trading/src/screens/__tests__/TradingExchangeRevokeScreen.comp.test.tsx @@ -35,9 +35,20 @@ const preloadedState = { }; const renderScreen = () => - renderWithStoreProviderAsync(, { - preloadedState, - }); + renderWithStoreProviderAsync( + + } + navigation={{} as any} + />, + { + preloadedState, + }, + ); describe('TradingExchangeRevokeScreen', () => { it('should render the revoke screen with quote details', async () => { @@ -61,11 +72,12 @@ describe('TradingExchangeRevokeScreen', () => { }); it('should show current limit and new limit with crypto icon', async () => { - const { getByText } = await renderScreen(); + const { getByText, getAllByText } = await renderScreen(); expect(getByText('Current limit')).toBeOnTheScreen(); expect(getByText('New limit')).toBeOnTheScreen(); - expect(getByText('0 USDC')).toBeOnTheScreen(); + const usdcElements = getAllByText('0 USDC'); + expect(usdcElements).toHaveLength(2); }); it('should render continue button', async () => { diff --git a/suite-native/module-trading/src/utils/general/__tests__/approvalStatusUtils.test.ts b/suite-native/module-trading/src/utils/general/__tests__/approvalStatusUtils.test.ts index c98035eb816d..f4fba70de12f 100644 --- a/suite-native/module-trading/src/utils/general/__tests__/approvalStatusUtils.test.ts +++ b/suite-native/module-trading/src/utils/general/__tests__/approvalStatusUtils.test.ts @@ -6,43 +6,65 @@ describe('getApprovalStatus', () => { expect(result).toBe(null); }); - it('should return "approved" when quote has preapprovedStringAmount', () => { + it('should return "approved" when quote has preapprovedStringAmount and is not APPROVAL_REQ', () => { const quote = { orderId: 'test-order', preapprovedStringAmount: '0.001', - isDex: false, + isDex: true, + status: 'CONFIRM' as const, }; const result = getApprovalStatus(quote); expect(result).toBe('approved'); }); - it('should return "needs_approval" when quote is DEX', () => { + it('should return "approved" when quote has preapprovedStringAmount !== "0" without status', () => { const quote = { orderId: 'test-order', - preapprovedStringAmount: undefined, + preapprovedStringAmount: '0.001', + isDex: true, + }; + const result = getApprovalStatus(quote); + expect(result).toBe('approved'); + }); + + it('should return "needs_increase" when quote has preapprovedStringAmount !== "0" and status is APPROVAL_REQ', () => { + const quote = { + orderId: 'test-order', + preapprovedStringAmount: '0.001', + isDex: true, + status: 'APPROVAL_REQ' as const, + }; + const result = getApprovalStatus(quote); + expect(result).toBe('needs_increase'); + }); + + it('should return "needs_approval" when preapprovedStringAmount is "0" and isDex is true', () => { + const quote = { + orderId: 'test-order', + preapprovedStringAmount: '0', isDex: true, }; const result = getApprovalStatus(quote); expect(result).toBe('needs_approval'); }); - it('should return "not_needed" for regular quote', () => { + it('should return "needs_approval" when quote is DEX', () => { const quote = { orderId: 'test-order', preapprovedStringAmount: undefined, - isDex: false, + isDex: true, }; const result = getApprovalStatus(quote); - expect(result).toBe('not_needed'); + expect(result).toBe('needs_approval'); }); - it('should prioritize preapprovedStringAmount over isDex', () => { + it('should return "not_needed" for regular quote', () => { const quote = { orderId: 'test-order', - preapprovedStringAmount: '0.001', - isDex: true, + preapprovedStringAmount: undefined, + isDex: false, }; const result = getApprovalStatus(quote); - expect(result).toBe('approved'); + expect(result).toBe('not_needed'); }); }); diff --git a/suite-native/module-trading/src/utils/general/approvalStatusUtils.ts b/suite-native/module-trading/src/utils/general/approvalStatusUtils.ts index 0279371c283f..d6aa84d17da4 100644 --- a/suite-native/module-trading/src/utils/general/approvalStatusUtils.ts +++ b/suite-native/module-trading/src/utils/general/approvalStatusUtils.ts @@ -1,22 +1,26 @@ import type { ExchangeTrade } from 'invity-api'; -import { BigNumber } from '@trezor/utils'; - -export type ApprovalStatus = 'approved' | 'needs_approval' | 'not_needed' | null; +export type ApprovalStatus = 'approved' | 'needs_approval' | 'needs_increase' | 'not_needed' | null; export const getApprovalStatus = (candidateQuote?: ExchangeTrade): ApprovalStatus => { if (!candidateQuote) { return null; } - const preapproved = new BigNumber(candidateQuote.preapprovedStringAmount ?? '0'); - if (preapproved.gt(0)) { - return 'approved'; + if (!candidateQuote.isDex) { + return 'not_needed'; + } + + const isApprovalTxPreApproved = + candidateQuote.preapprovedStringAmount && candidateQuote.preapprovedStringAmount !== '0'; + + if (isApprovalTxPreApproved && candidateQuote.status === 'APPROVAL_REQ') { + return 'needs_increase'; } - if (candidateQuote.isDex) { - return 'needs_approval'; + if (isApprovalTxPreApproved) { + return 'approved'; } - return 'not_needed'; + return 'needs_approval'; }; diff --git a/suite-native/navigation/package.json b/suite-native/navigation/package.json index 082b53382877..098f3184d235 100644 --- a/suite-native/navigation/package.json +++ b/suite-native/navigation/package.json @@ -32,6 +32,7 @@ "@trezor/styles": "workspace:*", "@trezor/theme": "workspace:*", "@trezor/utils": "workspace:*", + "@types/invity-api": "^1.1.11", "expo-linear-gradient": "~14.1.5", "expo-system-ui": "5.0.10", "react": "19.0.0", diff --git a/suite-native/navigation/src/navigators.ts b/suite-native/navigation/src/navigators.ts index fc60b369cd40..4ac8649d6735 100644 --- a/suite-native/navigation/src/navigators.ts +++ b/suite-native/navigation/src/navigators.ts @@ -1,4 +1,5 @@ import { NavigatorScreenParams } from '@react-navigation/native'; +import type { ExchangeTrade } from 'invity-api'; import { RequireAllOrNone } from 'type-fest'; import { BackupType } from '@suite-common/suite-types'; @@ -344,9 +345,18 @@ export type TradingStackParamList = { tradingType: Exclude; }; [TradingStackRoutes.TradingHistory]: undefined; - [TradingStackRoutes.TradingExchangePreview]: undefined; - [TradingStackRoutes.TradingExchangeApproval]: undefined; - [TradingStackRoutes.TradingExchangeRevoke]: undefined; + [TradingStackRoutes.TradingExchangePreview]: { + isApproved?: boolean; + }; + [TradingStackRoutes.TradingExchangeApproval]: { + quote: ExchangeTrade; + shouldIncreaseLimit?: boolean; + isRevoked?: boolean; + }; + [TradingStackRoutes.TradingExchangeRevoke]: { + quote: ExchangeTrade; + shouldIncreaseLimit?: boolean; + }; [TradingStackRoutes.TradingFees]: { accountKey: AccountKey; tradingType: TradingType; diff --git a/yarn.lock b/yarn.lock index f9ffaace604b..f53dd126cae3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12355,6 +12355,7 @@ __metadata: "@trezor/styles": "workspace:*" "@trezor/theme": "workspace:*" "@trezor/utils": "workspace:*" + "@types/invity-api": "npm:^1.1.11" expo-linear-gradient: "npm:~14.1.5" expo-system-ui: "npm:5.0.10" react: "npm:19.0.0"