diff --git a/src/hooks/useNotificationTypes.tsx b/src/hooks/useNotificationTypes.tsx index 82bd28e20b..fcb1739cbd 100644 --- a/src/hooks/useNotificationTypes.tsx +++ b/src/hooks/useNotificationTypes.tsx @@ -31,6 +31,7 @@ import { IndexerOrderSide, IndexerOrderType } from '@/types/indexer/indexerApiGe import { Icon, IconName } from '@/components/Icon'; import { Link } from '@/components/Link'; +import { LoadingSpinner } from '@/components/Loading/LoadingSpinner'; import { formatNumberOutput, Output, OutputType } from '@/components/Output'; import { FillWithNoOrderNotificationRow } from '@/views/Lists/Alerts/FillWithNoOrderNotificationRow'; import { OrderCancelNotificationRow } from '@/views/Lists/Alerts/OrderCancelNotificationRow'; @@ -1055,35 +1056,43 @@ export const notificationTypes: NotificationTypeConfig[] = [ useEffect(() => { spotTrades.forEach((trade) => { + const isPending = trade.status === 'pending'; const isSuccess = trade.status === 'success'; + trigger({ id: trade.id, displayData: { - slotTitleLeft: isSuccess ? ( + slotTitleLeft: isPending ? ( + + ) : isSuccess ? ( ) : ( ), - title: isSuccess - ? stringGetter({ key: STRING_KEYS.TRADE_SUCCESSFUL }) - : stringGetter({ key: STRING_KEYS.TRANSACTION_FAILED }), - body: isSuccess - ? stringGetter({ - key: STRING_KEYS.TRADE_SUCCESSFUL_DESCRIPTION, - params: { - PURCHASE_DIRECTION: - trade.side === SpotApiSide.BUY - ? stringGetter({ key: STRING_KEYS.PURCHASED }) - : stringGetter({ key: STRING_KEYS.SOLD }), - AMOUNT: trade.tokenAmount, - ASSET: trade.tokenSymbol, - SOL_AMOUNT: trade.solAmount, - }, - }) - : stringGetter({ key: STRING_KEYS.TRANSACTION_FAILED_RETRY }), + title: isPending + ? stringGetter({ key: STRING_KEYS.PENDING }) + : isSuccess + ? stringGetter({ key: STRING_KEYS.TRADE_SUCCESSFUL }) + : stringGetter({ key: STRING_KEYS.TRANSACTION_FAILED }), + body: isPending + ? `${trade.side === SpotApiSide.BUY ? stringGetter({ key: STRING_KEYS.BUY }) : stringGetter({ key: STRING_KEYS.SELL })} ${trade.tokenSymbol}` + : isSuccess + ? stringGetter({ + key: STRING_KEYS.TRADE_SUCCESSFUL_DESCRIPTION, + params: { + PURCHASE_DIRECTION: + trade.side === SpotApiSide.BUY + ? stringGetter({ key: STRING_KEYS.PURCHASED }) + : stringGetter({ key: STRING_KEYS.SOLD }), + AMOUNT: trade.tokenAmount, + ASSET: trade.tokenSymbol, + SOL_AMOUNT: trade.solAmount, + }, + }) + : stringGetter({ key: STRING_KEYS.TRANSACTION_FAILED_RETRY }), groupKey: NotificationType.SpotTrade, toastSensitivity: 'foreground', - toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS, + toastDuration: isPending ? Infinity : DEFAULT_TOAST_AUTO_CLOSE_MS, }, updateKey: [trade.status], }); diff --git a/src/hooks/useSpotForm.tsx b/src/hooks/useSpotForm.tsx index 7f7b00591b..c5977f7262 100644 --- a/src/hooks/useSpotForm.tsx +++ b/src/hooks/useSpotForm.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react'; import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; import { ErrorType, getHighestPriorityAlert } from '@/bonsai/lib/validationErrors'; import { BonsaiCore } from '@/bonsai/ontology'; +import { randomUUID } from 'crypto'; import { ComplianceStates } from '@/constants/compliance'; @@ -17,7 +18,7 @@ import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { getSelectedLocale } from '@/state/localizationSelectors'; import { spotFormActions } from '@/state/spotForm'; import { getSpotFormSummary } from '@/state/spotFormSelectors'; -import { addSpotTrade } from '@/state/spotTrades'; +import { addSpotTrade, updateSpotTrade } from '@/state/spotTrades'; import { SpotApiSide } from '@/clients/spotApi'; @@ -29,7 +30,7 @@ export function useSpotForm() { const dispatch = useAppDispatch(); const formSummary = useAppSelector(getSpotFormSummary); const { canDeriveSolanaWallet } = useAccounts(); - const { mutateAsync: submitTransactionMutation, isPending } = useSpotTransactionSubmit(); + const { mutateAsync: submitTransactionMutation } = useSpotTransactionSubmit(); const tokenMetadata = useAppSelector(BonsaiCore.spot.tokenMetadata.data); const { decimal: decimalSeparator, group: groupSeparator } = useLocaleSeparators(); const selectedLocale = useAppSelector(getSelectedLocale); @@ -50,9 +51,8 @@ export function useSpotForm() { canDeriveSolanaWallet && !hasErrors && formSummary.summary.payload != null && - !isPending && complianceState !== ComplianceStates.READ_ONLY, - [canDeriveSolanaWallet, complianceState, formSummary.summary.payload, hasErrors, isPending] + [canDeriveSolanaWallet, complianceState, formSummary.summary.payload, hasErrors] ); const actions = useMemo( @@ -91,25 +91,48 @@ export function useSpotForm() { ); const submitTransaction = useCallback(async () => { - try { - const result = await submitTransactionMutation(); - dispatch(spotFormActions.reset()); - - appQueryClient.invalidateQueries({ - queryKey: ['spot', 'portfolioTrades'], - exact: false, - }); + const side = formSummary.state.side === SpotSide.BUY ? SpotApiSide.BUY : SpotApiSide.SELL; + const tokenSymbol = tokenMetadata?.symbol ?? ''; + const tradeId = `spot-${Date.now()}-${side}-${tokenSymbol}-${formSummary.summary.payload?.pool ?? randomUUID()}`; + + dispatch( + addSpotTrade({ + trade: { + id: tradeId, + side, + tokenSymbol, + tokenAmount: '', + solAmount: '', + txHash: '', + status: 'pending', + createdAt: Date.now(), + }, + }) + ); + + // Pass payload as a mutation variable so it's captured at invocation time. + // React-Query v5's MutationObserver.setOptions() overwrites an in-flight + // mutation's mutationFn when pending, so closure-captured values are unsafe. + const mutationPromise = submitTransactionMutation({ + payload: formSummary.summary.payload!, + }); + + // Reset form — mutation has the payload as a variable, immune to re-render + dispatch(spotFormActions.reset()); + + appQueryClient.invalidateQueries({ + queryKey: ['spot', 'portfolioTrades'], + exact: false, + }); - const { landResponse } = result; - const isBuy = landResponse.side === SpotApiSide.BUY; - const tokenSymbol = tokenMetadata?.symbol ?? ''; + try { + const { landResponse } = await mutationPromise; const formattedTokenAmount = formatNumberOutput(landResponse.tokenChange, OutputType.Asset, { decimalSeparator, groupSeparator, selectedLocale, }); - const formattedSolAmount = formatNumberOutput(landResponse.solChange, OutputType.Asset, { decimalSeparator, groupSeparator, @@ -117,46 +140,35 @@ export function useSpotForm() { }); dispatch( - addSpotTrade({ + updateSpotTrade({ trade: { - id: `spot-${landResponse.txHash}`, - side: isBuy ? SpotApiSide.BUY : SpotApiSide.SELL, - tokenSymbol, + id: tradeId, tokenAmount: formattedTokenAmount, solAmount: formattedSolAmount, txHash: landResponse.txHash, status: 'success', - createdAt: Date.now(), }, }) ); - - return result; - } catch (error) { + } catch { dispatch( - addSpotTrade({ + updateSpotTrade({ trade: { - id: `spot-error-${Date.now()}`, - side: formSummary.state.side === SpotSide.BUY ? SpotApiSide.BUY : SpotApiSide.SELL, - tokenSymbol: tokenMetadata?.symbol ?? '', - tokenAmount: '', - solAmount: '', - txHash: '', + id: tradeId, status: 'error', - createdAt: Date.now(), }, }) ); - throw error; } }, [ + formSummary.state.side, + formSummary.summary.payload, + tokenMetadata?.symbol, dispatch, submitTransactionMutation, - tokenMetadata?.symbol, decimalSeparator, groupSeparator, selectedLocale, - formSummary.state.side, ]); return { @@ -168,7 +180,6 @@ export function useSpotForm() { hasErrors, primaryAlert, canSubmit, - isPending, handleInputTypeChange, submitTransaction, }; diff --git a/src/hooks/useSpotTransactionSubmit.ts b/src/hooks/useSpotTransactionSubmit.ts index 624bb495ae..8b0121a997 100644 --- a/src/hooks/useSpotTransactionSubmit.ts +++ b/src/hooks/useSpotTransactionSubmit.ts @@ -33,10 +33,9 @@ export const useSpotTransactionSubmit = () => { const { localSolanaKeypair, solanaAddress } = useAccounts(); const spotApiEndpoint = useEndpointsConfig().spotApi; - const makeTransaction = async () => { - const createPayload = formSummary.payload; + const makeTransaction = async ({ payload }: { payload: SpotApiCreateTransactionRequest }) => { // Should never happen - submit button should always be disabled when payload is not truthy - if (!createPayload) { + if (!payload) { logBonsaiError('spot/useSpotTransactionSubmit', 'No payload available', { formInputData, formState, @@ -51,19 +50,19 @@ export const useSpotTransactionSubmit = () => { 'validateWallet'; logBonsaiInfo('spot/useSpotTransactionSubmit', 'Crafting a transaction', { - createPayload, + payload, formInputData, formState, }); track( AnalyticsEvents.SpotTransactionSubmitStarted({ - side: createPayload.side, - tokenMint: createPayload.tokenMint, + side: payload.side, + tokenMint: payload.tokenMint, tokenSymbol: tokenMetadata?.symbol, - tradeRoute: createPayload.tradeRoute, + tradeRoute: payload.tradeRoute, estimatedUsdAmount: formSummary.amounts?.usd, inputType: - createPayload.side === SpotApiSide.BUY ? formState.buyInputType : formState.sellInputType, + payload.side === SpotApiSide.BUY ? formState.buyInputType : formState.sellInputType, }) ); @@ -73,7 +72,7 @@ export const useSpotTransactionSubmit = () => { } const requestWithAccount: SpotApiCreateTransactionRequest = { - ...createPayload, + ...payload, account: solanaAddress, }; @@ -98,7 +97,7 @@ export const useSpotTransactionSubmit = () => { const landTransactionTimer = startTimer(); const landResponse = await landSpotTransaction(spotApiEndpoint, { signedTransaction: signedTransactionBase58, - expectedTokenMint: createPayload.tokenMint, + expectedTokenMint: payload.tokenMint, landingMethod: createResponse.metadata.jupiterRequestId ? SpotApiLandingMethod.JUPITER : undefined, @@ -109,7 +108,7 @@ export const useSpotTransactionSubmit = () => { timingMs.totalMs = totalTimer.elapsed(); logBonsaiInfo('spot/useSpotTransactionSubmit', 'Successfuly landed a transaction', { - createPayload, + payload, formInputData, formState, createMetadata: createResponse.metadata, @@ -118,10 +117,10 @@ export const useSpotTransactionSubmit = () => { }); track( AnalyticsEvents.SpotTransactionSubmitSuccess({ - side: createPayload.side, - tokenMint: createPayload.tokenMint, + side: payload.side, + tokenMint: payload.tokenMint, tokenSymbol: tokenMetadata?.symbol, - tradeRoute: createPayload.tradeRoute, + tradeRoute: payload.tradeRoute, usdAmount: landResponse.metrics.boughtUsd + landResponse.metrics.soldUsd, solAmount: landResponse.solChange, timingMs, @@ -143,7 +142,7 @@ export const useSpotTransactionSubmit = () => { logBonsaiError('spot/useSpotTransactionSubmit', 'Transaction failure', { error, - createPayload, + payload, formInputData, formState, step, @@ -154,10 +153,10 @@ export const useSpotTransactionSubmit = () => { }); track( AnalyticsEvents.SpotTransactionSubmitError({ - side: createPayload.side, - tokenMint: createPayload.tokenMint, + side: payload.side, + tokenMint: payload.tokenMint, tokenSymbol: tokenMetadata?.symbol, - tradeRoute: createPayload.tradeRoute, + tradeRoute: payload.tradeRoute, estimatedUsdAmount: formSummary.amounts?.usd, step, errorName, diff --git a/src/pages/spot/SpotTradeForm.tsx b/src/pages/spot/SpotTradeForm.tsx index 1de6bb29c6..8cf5895a9c 100644 --- a/src/pages/spot/SpotTradeForm.tsx +++ b/src/pages/spot/SpotTradeForm.tsx @@ -93,7 +93,7 @@ export const SpotTradeForm = () => { return ( { form.actions.setSide(v as SpotSide); @@ -107,7 +107,6 @@ export const SpotTradeForm = () => { form.actions.setSize(formattedValue) } @@ -138,7 +137,6 @@ export const SpotTradeForm = () => { onSelect={(val) => form.actions.setSize(val)} onOptionsEdit={handleQuickOptionsChange} currentValue={form.state.size} - disabled={form.isPending} validation={validationConfig} {...currencyIndicator} /> @@ -158,7 +156,6 @@ export const SpotTradeForm = () => { onClick={form.submitTransaction} disabled={!form.canSubmit} state={{ - isLoading: form.isPending, isDisabled: !form.canSubmit, }} > diff --git a/src/state/spotTrades.ts b/src/state/spotTrades.ts index 30147a44a4..f1f1c0deb8 100644 --- a/src/state/spotTrades.ts +++ b/src/state/spotTrades.ts @@ -9,7 +9,7 @@ export type SpotTrade = { tokenAmount: string; solAmount: string; txHash: string; - status: 'success' | 'error'; + status: 'pending' | 'success' | 'error'; createdAt: number; }; @@ -29,7 +29,14 @@ export const spotTradesSlice = createSlice({ const { trade } = action.payload; state.trades.push(trade); }, + updateSpotTrade: ( + state, + action: PayloadAction<{ trade: Partial & { id: string } }> + ) => { + const { trade } = action.payload; + state.trades = state.trades.map((t) => (t.id === trade.id ? { ...t, ...trade } : t)); + }, }, }); -export const { addSpotTrade } = spotTradesSlice.actions; +export const { addSpotTrade, updateSpotTrade } = spotTradesSlice.actions;