chore(trading): move confirmApproval to common thunk

This commit is contained in:
vytick
2026-02-17 23:19:02 +04:00
parent 0df80bf56f
commit a2eab7a52e
5 changed files with 560 additions and 107 deletions

View File

@@ -24,18 +24,14 @@ import {
type TradingTransactionExchange,
cryptoIdToNetwork,
exchangeThunks,
getUnusedAddressFromAccount,
invityAPI,
isSendingEvmNativeToken,
selectTradingComposedTransactionInfo,
selectTradingExchange,
selectTradingExchangeAccountKey,
selectTradingExchangeInfo,
selectTradingExchangeReceiveAccountKey,
selectTradingIsSlip24Allowed,
selectTradingTrades,
selectTradingVerifiedAddress,
tradingActions,
tradingExchangeActions,
tradingThunks,
} from '@suite-common/trading';
@@ -154,9 +150,6 @@ export const useTradingExchangeForm = ({
[trades, transactionId],
);
const sendAccountKey = useSelector(selectTradingExchangeAccountKey);
const receiveAccountKey = useSelector(selectTradingExchangeReceiveAccountKey);
const { defaultCurrency, defaultValues } = useTradingExchangeFormDefaultValues();
const { draft, saveDraft, removeDraft } =
@@ -536,99 +529,15 @@ export const useTradingExchangeForm = ({
if (!commonFunctions) return undefined;
const { processResponseData } = commonFunctions;
const { address: refundAddress } = getUnusedAddressFromAccount(account);
if (!trade) {
trade = selectedQuote;
}
if (!quotesRequest || !trade || !refundAddress || !trade.quoteId || !receiveAddress) {
return undefined;
}
trade = { ...trade, receiveAddress };
if (!trade.fromAddress) {
trade = { ...trade, fromAddress: refundAddress };
}
dispatch(tradingExchangeActions.saveTransactionId(undefined));
const response = await invityAPI.doExchangeTrade({
trade,
receiveAddress,
refundAddress,
extraField,
returnUrl: undefined,
});
if (!response) {
dispatch(
notificationsActions.addToast({
type: 'error',
error: 'No response from the server',
}),
);
return undefined;
}
if (
response.error ||
!response.status ||
!response.orderId ||
response.status === 'ERROR'
) {
dispatch(
notificationsActions.addToast({
type: 'error',
error: response.error || 'Error response from the server',
}),
);
dispatch(tradingExchangeActions.saveSelectedQuote(response));
return response;
}
if (response.status === 'APPROVAL_REQ' || response.status === 'APPROVAL_PENDING') {
dispatch(tradingExchangeActions.saveSelectedQuote(response));
return response;
}
if (response.status === 'SIGN_DATA') {
dispatch(tradingExchangeActions.saveSelectedQuote(response));
dispatch(tradingExchangeActions.setFormStep('SIGN_DATA'));
return response;
}
if (response.status === 'CONFIRM') {
dispatch(tradingExchangeActions.saveSelectedQuote(response));
dispatch(tradingExchangeActions.setFormStep('SEND_TRANSACTION'));
return response;
}
dispatch(
tradingActions.saveTrade({
tradeType: 'exchange',
date: new Date().toISOString(),
key: response.orderId,
data: response,
sendAccountKey,
receiveAccountKey,
return await dispatch(
exchangeThunks.confirmApprovalThunk({
receiveAddress,
account,
extraField,
trade,
processResponseData,
}),
);
dispatch(tradingExchangeActions.saveTransactionId(response.orderId));
if (response.tradeForm?.form) {
processResponseData(response);
}
return response;
).unwrap();
};
const watchApproval = async ({ refreshCount }: { refreshCount: number }) => {

View File

@@ -0,0 +1,413 @@
import { combineReducers } from '@reduxjs/toolkit';
import { CryptoId, ExchangeTrade } from 'invity-api';
import { configureMockStore, extraDependenciesCommonMock } from '@suite-common/test-utils';
import { Account } from '@suite-common/wallet-types';
import { exchangeThunks } from '../';
import { MIN_MAX_QUOTES_OK } from '../../../__fixtures__/exchangeUtils';
import { accountBtc } from '../../../__fixtures__/utils';
import { invityAPI } from '../../../invityAPI';
import { TradingExchangeState } from '../../../reducers/exchangeReducer';
import { initialState } from '../../../reducers/tradingCommonReducer';
import { prepareTradingReducer } from '../../../reducers/tradingReducer';
import { getUnusedAddressFromAccount } from '../../../utils';
import type { LogErrorThunkProps } from '../../common/logErrorThunk';
const tradingReducer = prepareTradingReducer(extraDependenciesCommonMock);
jest.mock('../../common/logErrorThunk', () => ({
logErrorThunk: (props: LogErrorThunkProps) => ({
type: 'mockedLogErrorThunk',
payload: props,
}),
}));
jest.mock('../../../invityAPI');
describe('confirmApprovalThunk', () => {
afterEach(() => {
jest.clearAllMocks();
});
invityAPI.setInvityServersEnvironment = () => {};
invityAPI.createInvityAPIKey = () => {};
const getMocks = (initialExchangeState?: Partial<TradingExchangeState>) => {
const quoteNotTyped = MIN_MAX_QUOTES_OK[0];
const quote = {
...quoteNotTyped,
send: quoteNotTyped.send as CryptoId,
receive: quoteNotTyped.receive as CryptoId,
};
const store = configureMockStore({
extra: {},
reducer: combineReducers({
wallet: combineReducers({
trading: tradingReducer,
}),
}),
preloadedState: {
wallet: {
trading: {
...initialState,
exchange: {
...initialState.exchange,
selectedQuote: MIN_MAX_QUOTES_OK[1],
quotesRequest: {
send: quote.send,
receive: quote.receive,
sendStringAmount: quote.sendStringAmount,
dex: 'enable',
},
...(initialExchangeState ?? {}),
},
},
},
},
});
const mockProcessResponseData = jest.fn();
const account = accountBtc as Account;
const trade = {
...quote,
quoteId: 'quoteId',
fromAddress: 'fromAddress',
};
return {
store,
receiveAddress: 'receiveAddress',
account,
trade,
mockProcessResponseData,
};
};
const dispatchThunk = (
store: ReturnType<typeof getMocks>['store'],
props: Parameters<typeof exchangeThunks.confirmApprovalThunk>[0],
) => store.dispatch(exchangeThunks.confirmApprovalThunk(props)).unwrap();
const getExchangeState = (store: ReturnType<typeof getMocks>['store']) =>
store.getState().wallet.trading.exchange;
const getTradingState = (store: ReturnType<typeof getMocks>['store']) =>
store.getState().wallet.trading;
const findLogErrorAction = (store: ReturnType<typeof getMocks>['store']) =>
store.getActions().find(action => action.type === 'mockedLogErrorThunk');
describe('guard clauses', () => {
it.each([
[
'quotesRequest is undefined',
{ stateOverride: { quotesRequest: undefined }, tradeOverride: undefined },
],
[
'trade and selectedQuote are both undefined',
{ stateOverride: { selectedQuote: undefined }, tradeOverride: undefined },
],
[
'trade.quoteId is undefined (falls back to selectedQuote without quoteId)',
{ stateOverride: undefined, tradeOverride: undefined },
],
])('should return undefined when %s', async (_, { stateOverride, tradeOverride }) => {
const { store, receiveAddress, account, mockProcessResponseData } = getMocks(
stateOverride ?? {},
);
const response = await dispatchThunk(store, {
receiveAddress,
account,
trade: tradeOverride,
processResponseData: mockProcessResponseData,
});
expect(response).toBeUndefined();
});
it('should return undefined when refundAddress is undefined', async () => {
const { store, receiveAddress, account, mockProcessResponseData } = getMocks();
const response = await dispatchThunk(store, {
receiveAddress,
account: { ...account, addresses: undefined } as Account,
processResponseData: mockProcessResponseData,
});
expect(response).toBeUndefined();
});
});
describe('API call', () => {
it('should use refundAddress as fromAddress when trade.fromAddress is undefined', async () => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
const { address: refundAddress } = getUnusedAddressFromAccount(account);
const doExchangeTradeSpy = jest.fn().mockResolvedValue({
...trade,
status: 'SUCCESS',
orderId: 'orderId',
} as ExchangeTrade);
invityAPI.doExchangeTrade = doExchangeTradeSpy;
await dispatchThunk(store, {
receiveAddress,
account,
trade: { ...trade, fromAddress: undefined },
processResponseData: mockProcessResponseData,
});
expect(doExchangeTradeSpy).toHaveBeenCalledWith(
expect.objectContaining({
trade: expect.objectContaining({ fromAddress: refundAddress }),
}),
);
});
it('should keep original fromAddress when trade.fromAddress is defined', async () => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
const doExchangeTradeSpy = jest.fn().mockResolvedValue({
...trade,
status: 'SUCCESS',
orderId: 'orderId',
} as ExchangeTrade);
invityAPI.doExchangeTrade = doExchangeTradeSpy;
await dispatchThunk(store, {
receiveAddress,
account,
trade,
processResponseData: mockProcessResponseData,
});
expect(doExchangeTradeSpy).toHaveBeenCalledWith(
expect.objectContaining({
trade: expect.objectContaining({ fromAddress: 'fromAddress' }),
}),
);
});
});
describe('when API returns no response', () => {
it('should log error and return undefined', async () => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
invityAPI.doExchangeTrade = () =>
Promise.resolve(undefined as unknown as ExchangeTrade);
const response = await dispatchThunk(store, {
receiveAddress,
account,
trade,
processResponseData: mockProcessResponseData,
});
expect(findLogErrorAction(store)?.payload).toEqual({
errorMessage: 'No response from the server',
tradingType: 'exchange',
});
expect(response).toBeUndefined();
});
});
describe('when API returns error response', () => {
it.each([
['response.error is defined', { error: 'Server error' }, 'Server error'],
[
'response.status is undefined',
{ status: undefined },
'Error response from the server',
],
[
'response.orderId is undefined',
{ orderId: undefined },
'Error response from the server',
],
['response.status is ERROR', { status: 'ERROR' }, 'Error response from the server'],
])('should log error and save quote when %s', async (_, mockResponse, expectedMessage) => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
const tradeResponse = { ...trade, ...mockResponse } as ExchangeTrade;
invityAPI.doExchangeTrade = () => Promise.resolve(tradeResponse);
const response = await dispatchThunk(store, {
receiveAddress,
account,
trade,
processResponseData: mockProcessResponseData,
});
expect(findLogErrorAction(store)?.payload).toEqual({
tradingType: 'exchange',
errorMessage: expectedMessage,
});
expect(getExchangeState(store).selectedQuote).toEqual(tradeResponse);
expect(response).toEqual(tradeResponse);
});
});
describe('when API returns approval status', () => {
it.each([
['APPROVAL_REQ', { status: 'APPROVAL_REQ', orderId: 'orderId' }],
['APPROVAL_PENDING', { status: 'APPROVAL_PENDING', orderId: 'orderId' }],
])('should save quote without changing formStep for %s', async (_, mockResponse) => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
const tradeResponse = { ...trade, ...mockResponse } as ExchangeTrade;
invityAPI.doExchangeTrade = () => Promise.resolve(tradeResponse);
const response = await dispatchThunk(store, {
receiveAddress,
account,
trade,
processResponseData: mockProcessResponseData,
});
expect(getExchangeState(store).selectedQuote).toEqual(tradeResponse);
expect(getExchangeState(store).formStep).not.toBe('SIGN_DATA');
expect(getExchangeState(store).formStep).not.toBe('SEND_TRANSACTION');
expect(response).toEqual(tradeResponse);
});
});
it('should save quote and set formStep to SIGN_DATA when response status is SIGN_DATA', async () => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
const tradeResponse = {
...trade,
status: 'SIGN_DATA',
orderId: 'orderId',
} as ExchangeTrade;
invityAPI.doExchangeTrade = () => Promise.resolve(tradeResponse);
const response = await dispatchThunk(store, {
receiveAddress,
account,
trade,
processResponseData: mockProcessResponseData,
});
expect(getExchangeState(store).selectedQuote).toEqual(tradeResponse);
expect(getExchangeState(store).formStep).toBe('SIGN_DATA');
expect(response).toEqual(tradeResponse);
});
it('should save quote and set formStep to SEND_TRANSACTION when response status is CONFIRM', async () => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
const tradeResponse = {
...trade,
status: 'CONFIRM',
orderId: 'orderId',
} as ExchangeTrade;
invityAPI.doExchangeTrade = () => Promise.resolve(tradeResponse);
const response = await dispatchThunk(store, {
receiveAddress,
account,
trade,
processResponseData: mockProcessResponseData,
});
expect(getExchangeState(store).selectedQuote).toEqual(tradeResponse);
expect(getExchangeState(store).formStep).toBe('SEND_TRANSACTION');
expect(response).toEqual(tradeResponse);
});
describe('when API returns terminal status (default branch)', () => {
it('should save trade and set transactionId', async () => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
const dateString = new Date().toISOString();
jest.spyOn(Date.prototype, 'toISOString').mockImplementation(() => dateString);
const tradeResponse = {
...trade,
status: 'SUCCESS',
orderId: 'orderId',
} as ExchangeTrade;
invityAPI.doExchangeTrade = () => Promise.resolve(tradeResponse);
const response = await dispatchThunk(store, {
receiveAddress,
account,
trade,
processResponseData: mockProcessResponseData,
});
const { exchange, trades } = getTradingState(store);
expect(exchange.transactionId).toBe('orderId');
expect(trades[0]).toEqual({
tradeType: 'exchange',
date: dateString,
data: tradeResponse,
key: 'orderId',
});
expect(response).toEqual(tradeResponse);
});
it('should call processResponseData when tradeForm is present', async () => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
jest.spyOn(Date.prototype, 'toISOString').mockImplementation(() => 'mock-date');
const tradeResponse = {
...trade,
status: 'CONFIRMING',
orderId: 'orderId',
isDex: true,
tradeForm: {
form: {
formMethod: 'GET' as const,
formAction: 'action',
formTarget: '_blank' as const,
fields: { key: 'string' },
},
},
} as ExchangeTrade;
invityAPI.doExchangeTrade = () => Promise.resolve(tradeResponse);
await dispatchThunk(store, {
receiveAddress,
account,
trade,
processResponseData: mockProcessResponseData,
});
expect(mockProcessResponseData).toHaveBeenCalledWith(tradeResponse);
});
it('should not call processResponseData when tradeForm is not present', async () => {
const { store, receiveAddress, account, trade, mockProcessResponseData } = getMocks();
jest.spyOn(Date.prototype, 'toISOString').mockImplementation(() => 'mock-date');
const tradeResponse = {
...trade,
status: 'SUCCESS',
orderId: 'orderId',
} as ExchangeTrade;
invityAPI.doExchangeTrade = () => Promise.resolve(tradeResponse);
await dispatchThunk(store, {
receiveAddress,
account,
trade,
processResponseData: mockProcessResponseData,
});
expect(mockProcessResponseData).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,136 @@
import { ExchangeTrade } from 'invity-api';
import { createThunk } from '@suite-common/redux-utils';
import { Account } from '@suite-common/wallet-types';
import { TRADING_EXCHANGE_THUNK_PREFIX } from '../../constants';
import { invityAPI } from '../../invityAPI';
import { tradingExchangeActions } from '../../reducers/exchangeReducer';
import { tradingActions } from '../../reducers/tradingCommonReducer';
import {
selectTradingExchangeAccountKey,
selectTradingExchangeQuotesRequest,
selectTradingExchangeReceiveAccountKey,
selectTradingExchangeSelectedQuote,
} from '../../selectors/tradingSelectors';
import { getUnusedAddressFromAccount } from '../../utils';
import { logErrorThunk } from '../common/logErrorThunk';
export type ConfirmApprovalThunkProps = {
receiveAddress: string;
account: Account;
extraField?: string;
trade?: ExchangeTrade;
processResponseData: (response: ExchangeTrade) => void;
};
export const confirmApprovalThunk = createThunk(
`${TRADING_EXCHANGE_THUNK_PREFIX}/confirmApproval`,
async (
{
trade,
receiveAddress,
account,
extraField,
processResponseData,
}: ConfirmApprovalThunkProps,
{ dispatch, getState },
) => {
const selectedQuote = selectTradingExchangeSelectedQuote(getState());
const quotesRequest = selectTradingExchangeQuotesRequest(getState());
const sendAccountKey = selectTradingExchangeAccountKey(getState());
const receiveAccountKey = selectTradingExchangeReceiveAccountKey(getState());
const { address: refundAddress } = getUnusedAddressFromAccount(account);
if (!trade) {
trade = selectedQuote;
}
if (!quotesRequest || !trade || !refundAddress || !trade.quoteId || !receiveAddress) {
return undefined;
}
trade = { ...trade, receiveAddress };
if (!trade.fromAddress) {
trade = { ...trade, fromAddress: refundAddress };
}
dispatch(tradingExchangeActions.saveTransactionId(undefined));
const response = await invityAPI.doExchangeTrade({
trade,
receiveAddress,
refundAddress,
extraField,
returnUrl: undefined,
});
if (!response) {
dispatch(
logErrorThunk({
errorMessage: 'No response from the server',
tradingType: 'exchange',
}),
);
return undefined;
}
if (
response.error ||
!response.status ||
!response.orderId ||
response.status === 'ERROR'
) {
dispatch(
logErrorThunk({
errorMessage: response.error || 'Error response from the server',
tradingType: 'exchange',
}),
);
dispatch(tradingExchangeActions.saveSelectedQuote(response));
return response;
}
if (response.status === 'APPROVAL_REQ' || response.status === 'APPROVAL_PENDING') {
dispatch(tradingExchangeActions.saveSelectedQuote(response));
return response;
}
if (response.status === 'SIGN_DATA') {
dispatch(tradingExchangeActions.saveSelectedQuote(response));
dispatch(tradingExchangeActions.setFormStep('SIGN_DATA'));
return response;
}
if (response.status === 'CONFIRM') {
dispatch(tradingExchangeActions.saveSelectedQuote(response));
dispatch(tradingExchangeActions.setFormStep('SEND_TRANSACTION'));
return response;
}
dispatch(
tradingActions.saveTrade({
tradeType: 'exchange',
date: new Date().toISOString(),
key: response.orderId,
data: response,
sendAccountKey,
receiveAccountKey,
}),
);
dispatch(tradingExchangeActions.saveTransactionId(response.orderId));
if (response.tradeForm?.form) {
processResponseData(response);
}
return response;
},
);

View File

@@ -119,14 +119,7 @@ export const confirmExchangeTradeThunk = createThunk(
return response;
}
if (response.status === 'CONFIRM' && !response.isDex) {
dispatch(tradingExchangeActions.saveSelectedQuote(response));
dispatch(tradingExchangeActions.setFormStep('SEND_TRANSACTION'));
return response;
}
if (response.status === 'CONFIRM' && response.isDex) {
if (response.status === 'CONFIRM') {
dispatch(tradingExchangeActions.saveSelectedQuote(response));
dispatch(tradingExchangeActions.setFormStep('SEND_TRANSACTION'));

View File

@@ -1,3 +1,4 @@
import { confirmApprovalThunk } from './confirmApprovalThunk';
import { confirmExchangeTradeThunk } from './confirmExchangeTradeThunk';
import { handleExchangeRequestThunk } from './handleExchangeRequestThunk';
import { loadExchangeInfoThunk } from './loadExchangeInfoThunk';
@@ -11,6 +12,7 @@ export const exchangeThunks = {
handleRequestThunk: handleExchangeRequestThunk,
selectQuoteThunk: selectExchangeQuoteThunk,
confirmTradeThunk: confirmExchangeTradeThunk,
confirmApprovalThunk,
signDataAndConfirmThunk,
sendDexTransactionThunk,
sendTransactionThunk,