Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions src/hooks/useNotificationTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ? (
<LoadingSpinner tw="text-color-accent [--spinner-width:0.9375rem]" />
) : isSuccess ? (
<Icon iconName={IconName.CheckCircle} tw="text-color-success" />
) : (
<Icon iconName={IconName.Warning} tw="text-color-error" />
),
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],
});
Expand Down
83 changes: 47 additions & 36 deletions src/hooks/useSpotForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';

Expand All @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -91,72 +91,84 @@ 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,
selectedLocale,
});

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 {
Expand All @@ -168,7 +180,6 @@ export function useSpotForm() {
hasErrors,
primaryAlert,
canSubmit,
isPending,
handleInputTypeChange,
submitTransaction,
};
Expand Down
35 changes: 17 additions & 18 deletions src/hooks/useSpotTransactionSubmit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
})
);

Expand All @@ -73,7 +72,7 @@ export const useSpotTransactionSubmit = () => {
}

const requestWithAccount: SpotApiCreateTransactionRequest = {
...createPayload,
...payload,
account: solanaAddress,
};

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -143,7 +142,7 @@ export const useSpotTransactionSubmit = () => {

logBonsaiError('spot/useSpotTransactionSubmit', 'Transaction failure', {
error,
createPayload,
payload,
formInputData,
formState,
step,
Expand All @@ -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,
Expand Down
5 changes: 1 addition & 4 deletions src/pages/spot/SpotTradeForm.tsx
Copy link
Collaborator

@jaredvu jaredvu Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did we remove disabled and loading states for faster sequential submission?

I think this is generally a good idea.
We should allow the backend to validate and give the user the ability to execute as many trades in succession as they want here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yee, exactly- after some testing it appeared they were largely unnecessary since the backend validates the submission and we want the frontend to be more optimistic here

Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const SpotTradeForm = () => {
return (
<SpotTabs
tw="p-1"
disabled={form.isPending || form.inputData.walletStatus !== SpotWalletStatus.Connected}
disabled={form.inputData.walletStatus !== SpotWalletStatus.Connected}
value={form.state.side}
onValueChange={(v) => {
form.actions.setSide(v as SpotSide);
Expand All @@ -107,7 +107,6 @@ export const SpotTradeForm = () => {
<SpotFormInput
ref={inputRef}
value={form.state.size}
disabled={form.isPending}
onInput={({ formattedValue }: { formattedValue: string }) =>
form.actions.setSize(formattedValue)
}
Expand Down Expand Up @@ -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}
/>
Expand All @@ -158,7 +156,6 @@ export const SpotTradeForm = () => {
onClick={form.submitTransaction}
disabled={!form.canSubmit}
state={{
isLoading: form.isPending,
isDisabled: !form.canSubmit,
}}
>
Expand Down
11 changes: 9 additions & 2 deletions src/state/spotTrades.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type SpotTrade = {
tokenAmount: string;
solAmount: string;
txHash: string;
status: 'success' | 'error';
status: 'pending' | 'success' | 'error';
createdAt: number;
};

Expand All @@ -29,7 +29,14 @@ export const spotTradesSlice = createSlice({
const { trade } = action.payload;
state.trades.push(trade);
},
updateSpotTrade: (
state,
action: PayloadAction<{ trade: Partial<SpotTrade> & { 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;
Loading