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 = () => {
- {
- // TODO
- }}
- >
+
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"