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;