import { useCallback } from 'react'; import first from 'lodash/first'; import compact from 'lodash/compact'; import type { BatchMarketInstructionSubmissionBody, OrderAmendment, OrderSubmission, StopOrdersSubmission, StopOrderSetup, } from '@vegaprotocol/wallet'; import type { OrderTxUpdateFieldsFragment, WithdrawalBusEventFieldsFragment, } from './__generated__/TransactionResult'; import type { VegaStoredTxState } from './use-vega-transaction-store'; import { isTransferTransaction, isBatchMarketInstructionsTransaction, ClientErrors, useReconnectVegaWallet, WalletError, isOrderAmendmentTransaction, isOrderCancellationTransaction, isOrderSubmissionTransaction, isWithdrawTransaction, isStopOrdersSubmissionTransaction, isStopOrdersCancellationTransaction, isReferralRelatedTransaction, } from '@vegaprotocol/wallet'; import { useVegaTransactionStore } from './use-vega-transaction-store'; import { VegaTxStatus } from './types'; import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit'; import { ToastHeading } from '@vegaprotocol/ui-toolkit'; import { Panel } from '@vegaprotocol/ui-toolkit'; import { CLOSE_AFTER } from '@vegaprotocol/ui-toolkit'; import { useToasts } from '@vegaprotocol/ui-toolkit'; import { Button, ExternalLink, Intent } from '@vegaprotocol/ui-toolkit'; import { addDecimalsFormatNumber, formatNumber, toBigNum, truncateByChars, formatTrigger, } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; import { useAssetsMapProvider } from '@vegaprotocol/assets'; import { useEthWithdrawApprovalsStore } from './use-ethereum-withdraw-approvals-store'; import { DApp, EXPLORER_TX, useLinks } from '@vegaprotocol/environment'; import { useOrderByIdQuery, useStopOrderByIdQuery, } from './__generated__/Orders'; import { getAsset, useMarketsMapProvider } from '@vegaprotocol/markets'; import type { Market } from '@vegaprotocol/markets'; import type { Side } from '@vegaprotocol/types'; import { OrderStatusMapping } from '@vegaprotocol/types'; import { Size } from '@vegaprotocol/datagrid'; import { useWithdrawalApprovalDialog } from './withdrawal-approval-dialog'; import * as Schema from '@vegaprotocol/types'; export const getRejectionReason = ( order: OrderTxUpdateFieldsFragment ): string | null => { switch (order.status) { case Schema.OrderStatus.STATUS_STOPPED: return t( `Your ${ Schema.OrderTimeInForceMapping[order.timeInForce] } order was not filled and it has been stopped` ); default: return order.rejectionReason ? t(Schema.OrderRejectionReasonMapping[order.rejectionReason]) : ''; } }; export const getOrderToastTitle = ( status?: Schema.OrderStatus ): string | undefined => { if (!status) { return; } switch (status) { case Schema.OrderStatus.STATUS_ACTIVE: return t('Order submitted'); case Schema.OrderStatus.STATUS_FILLED: return t('Order filled'); case Schema.OrderStatus.STATUS_PARTIALLY_FILLED: return t('Order partially filled'); case Schema.OrderStatus.STATUS_PARKED: return t('Order parked'); case Schema.OrderStatus.STATUS_STOPPED: return t('Order stopped'); case Schema.OrderStatus.STATUS_CANCELLED: return t('Order cancelled'); case Schema.OrderStatus.STATUS_EXPIRED: return t('Order expired'); case Schema.OrderStatus.STATUS_REJECTED: return t('Order rejected'); default: return t('Submission failed'); } }; export const getOrderToastIntent = ( status?: Schema.OrderStatus ): Intent | undefined => { if (!status) { return; } switch (status) { case Schema.OrderStatus.STATUS_PARKED: case Schema.OrderStatus.STATUS_EXPIRED: case Schema.OrderStatus.STATUS_PARTIALLY_FILLED: case Schema.OrderStatus.STATUS_STOPPED: return Intent.Warning; case Schema.OrderStatus.STATUS_REJECTED: return Intent.Danger; case Schema.OrderStatus.STATUS_FILLED: case Schema.OrderStatus.STATUS_ACTIVE: case Schema.OrderStatus.STATUS_CANCELLED: return Intent.Success; default: return; } }; const intentMap: { [s in VegaTxStatus]: Intent } = { Default: Intent.Primary, Requested: Intent.Warning, Pending: Intent.Warning, Error: Intent.Danger, Complete: Intent.Success, }; const isClosePositionTransaction = (tx: VegaStoredTxState) => { if (isBatchMarketInstructionsTransaction(tx.body)) { const amendments = tx.body.batchMarketInstructions.amendments && tx.body.batchMarketInstructions.amendments?.length > 0; const cancellation = tx.body.batchMarketInstructions.cancellations?.length === 1 && tx.body.batchMarketInstructions.cancellations[0].orderId === '' && tx.body.batchMarketInstructions.cancellations[0]; const submission = cancellation && tx.body.batchMarketInstructions.submissions?.length === 1 && tx.body.batchMarketInstructions.submissions[0].marketId === cancellation.marketId; return !amendments && cancellation && submission; } return false; }; const isTransactionTypeSupported = (tx: VegaStoredTxState) => { const withdraw = isWithdrawTransaction(tx.body); const submitOrder = isOrderSubmissionTransaction(tx.body); const cancelOrder = isOrderCancellationTransaction(tx.body); const submitStopOrder = isStopOrdersSubmissionTransaction(tx.body); const cancelStopOrder = isStopOrdersCancellationTransaction(tx.body); const editOrder = isOrderAmendmentTransaction(tx.body); const batchMarketInstructions = isBatchMarketInstructionsTransaction(tx.body); const transfer = isTransferTransaction(tx.body); const referral = isReferralRelatedTransaction(tx.body); return ( withdraw || submitOrder || cancelOrder || submitStopOrder || cancelStopOrder || editOrder || batchMarketInstructions || transfer || referral ); }; type SizeAtPriceProps = { side: Side; size: string; price: string | undefined; meta: { positionDecimalPlaces: number; decimalPlaces: number; asset: string }; }; const SizeAtPrice = ({ side, size, price, meta }: SizeAtPriceProps) => { return ( <> {' '} {price && price !== '0' && meta.decimalPlaces ? `@ ${addDecimalsFormatNumber(price, meta.decimalPlaces)} ${ meta.asset }` : `@ ~ ${meta.asset}`} ); }; const SubmitOrderDetails = ({ data, order, }: { data: OrderSubmission; order?: OrderTxUpdateFieldsFragment; }) => { const { data: markets } = useMarketsMapProvider(); const market = markets?.[order?.marketId || '']; if (!market) return null; const price = order ? order.price : data.price; const size = order ? order.size : data.size; const side = order ? order.side : data.side; return (

{order ? t( `Submit order - ${OrderStatusMapping[order.status].toLowerCase()}` ) : t('Submit order')}

{market?.tradableInstrument.instrument.code}

); }; const SubmitStopOrderSetup = ({ stopOrderSetup, triggerDirection, market, }: { stopOrderSetup: StopOrderSetup; triggerDirection: Schema.StopOrderTriggerDirection; market: Market; }) => { if (!market || !stopOrderSetup) return null; const { price, size, side } = stopOrderSetup.orderSubmission; let trigger: Schema.StopOrderTrigger | null = null; if (stopOrderSetup.price) { trigger = { price: stopOrderSetup.price, __typename: 'StopOrderPrice' }; } else if (stopOrderSetup.trailingPercentOffset) { trigger = { trailingPercentOffset: stopOrderSetup.trailingPercentOffset, __typename: 'StopOrderTrailingPercentOffset', }; } return (


{trigger && formatTrigger( { triggerDirection, trigger, }, market.decimalPlaces, '' )}

); }; const SubmitStopOrderDetails = ({ data }: { data: StopOrdersSubmission }) => { const { data: markets } = useMarketsMapProvider(); const marketId = data.fallsBelow?.orderSubmission.marketId || data.risesAbove?.orderSubmission.marketId; const market = marketId && markets?.[marketId]; if (!market) { return null; } return (

{t('Submit stop order')}

{market?.tradableInstrument.instrument.code}

{data.fallsBelow && ( )} {data.risesAbove && ( )}
); }; const EditOrderDetails = ({ data, order, }: { data: OrderAmendment; order?: OrderTxUpdateFieldsFragment; }) => { const { data: orderById } = useOrderByIdQuery({ variables: { orderId: data.orderId }, fetchPolicy: 'no-cache', }); const { data: markets } = useMarketsMapProvider(); const originalOrder = order || orderById?.orderByID; const marketId = order?.marketId || orderById?.orderByID.market.id; const market = markets?.[marketId || '']; if (!originalOrder || !market) return null; const original = ( ); const edited = ( ); return (

{order ? t(`Edit order - ${OrderStatusMapping[order.status].toLowerCase()}`) : t('Edit order')}

{market?.tradableInstrument.instrument.code}

{original}

{edited}

); }; const CancelOrderDetails = ({ orderId, order, }: { orderId: string; order?: OrderTxUpdateFieldsFragment; }) => { const { data: orderById } = useOrderByIdQuery({ variables: { orderId }, }); const { data: markets } = useMarketsMapProvider(); const originalOrder = orderById?.orderByID; if (!originalOrder) return null; const market = markets?.[originalOrder.market.id]; if (!market) return null; const original = ( ); return (

{order ? t( `Cancel order - ${OrderStatusMapping[order.status].toLowerCase()}` ) : t('Cancel order')}

{market?.tradableInstrument.instrument.code}

{original}

); }; const CancelStopOrderDetails = ({ stopOrderId }: { stopOrderId: string }) => { const { data: orderById } = useStopOrderByIdQuery({ variables: { stopOrderId }, }); const { data: markets } = useMarketsMapProvider(); const originalOrder = orderById?.stopOrder; if (!originalOrder) return null; const market = markets?.[originalOrder.marketId]; if (!market) return null; const original = ( <>
{formatTrigger(originalOrder, market.decimalPlaces, '')} ); return (

{t('Cancel stop order')}

{market?.tradableInstrument.instrument.code}

{original}

); }; export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => { const { data: assets } = useAssetsMapProvider(); const { data: markets } = useMarketsMapProvider(); if (isWithdrawTransaction(tx.body)) { const transactionDetails = tx.body; const asset = assets?.[transactionDetails.withdrawSubmission.asset]; if (asset) { const num = formatNumber( toBigNum(transactionDetails.withdrawSubmission.amount, asset.decimals), asset.decimals ); return ( {t('Withdraw')} {num} {asset.symbol} ); } } if (isOrderSubmissionTransaction(tx.body)) { return ( ); } if (isStopOrdersSubmissionTransaction(tx.body)) { return ; } if (isOrderCancellationTransaction(tx.body)) { // CANCEL if ( tx.body.orderCancellation.orderId && tx.body.orderCancellation.marketId ) { return ( ); } // CANCEL ALL (from Trading) if (tx.body.orderCancellation.marketId) { const marketName = markets?.[tx.body.orderCancellation.marketId]?.tradableInstrument .instrument.code; if (marketName) { return ( {t('Cancel all orders for')} {marketName} ); } } // CANCEL ALL (from Portfolio) return {t('Cancel all orders')}; } if (isStopOrdersCancellationTransaction(tx.body)) { // CANCEL if ( tx.body.stopOrdersCancellation.stopOrderId && tx.body.stopOrdersCancellation.marketId ) { return ( ); } // CANCEL ALL for market if (tx.body.stopOrdersCancellation.marketId) { const marketName = markets?.[tx.body.stopOrdersCancellation.marketId]?.tradableInstrument .instrument.code; if (marketName) { return ( {t('Cancel all stop orders for')} {marketName} ); } } // CANCEL ALL return {t('Cancel all stop orders')}; } if (isOrderAmendmentTransaction(tx.body)) { return ( ); } if (isClosePositionTransaction(tx)) { const transaction = tx.body as BatchMarketInstructionSubmissionBody; const marketId = first( transaction.batchMarketInstructions.cancellations )?.marketId; const market = markets?.[marketId || '']; if (market) { return ( {t('Close position for')}{' '} {market.tradableInstrument.instrument.code} ); } } if (isBatchMarketInstructionsTransaction(tx.body)) { return {t('Batch market instruction')}; } if (isTransferTransaction(tx.body)) { const { amount, to, asset } = tx.body.transfer; const transferAsset = assets?.[asset]; // only render if we have an asset to avoid unformatted amounts showing if (transferAsset) { const value = addDecimalsFormatNumber(amount, transferAsset.decimals); return (

{t('Transfer')}

{t('To')} {truncateByChars(to)}

{value} {transferAsset.symbol}

); } } return null; }; type VegaTxToastContentProps = { tx: VegaStoredTxState }; const VegaTxRequestedToastContent = ({ tx }: VegaTxToastContentProps) => ( <> {t('Action required')}

{t( 'Please go to your Vega wallet application and approve or reject the transaction.' )}

); const VegaTxPendingToastContentProps = ({ tx }: VegaTxToastContentProps) => { const explorerLink = useLinks(DApp.Explorer); return ( <> {t('Awaiting confirmation')}

{t('Please wait for your transaction to be confirmed')}

{tx.txHash && (

{t('View in block explorer')}

)} ); }; const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => { const { createEthWithdrawalApproval } = useEthWithdrawApprovalsStore( (state) => ({ createEthWithdrawalApproval: state.create, }) ); const explorerLink = useLinks(DApp.Explorer); if (isWithdrawTransaction(tx.body)) { const completeWithdrawalButton = tx.withdrawal && (

); const dialogTrigger = ( // It has to stay as due to the word breaking issue // eslint-disable-next-line jsx-a11y/anchor-is-valid { e.preventDefault(); if (tx.withdrawal?.id) { useWithdrawalApprovalDialog.getState().open(tx.withdrawal?.id); } }} > {t('save your withdrawal details')} ); return ( <> {t('Funds unlocked')}

{t('Your funds have been unlocked for withdrawal.')}

{tx.txHash && ( {t('View in block explorer')} )} {/* TODO: Delay message - This withdrawal is subject to a delay. Come back in 5 days to complete the withdrawal. */}

{t('You can')} {dialogTrigger} {t('for extra security.')}

{completeWithdrawalButton} ); } if (tx.order && tx.order.rejectionReason) { const rejectionReason = getRejectionReason(tx.order); return ( <> {getOrderToastTitle(tx.order.status)} {rejectionReason ? (

{t('Your order has been %s because: %s', [ tx.order.status === Schema.OrderStatus.STATUS_STOPPED ? 'stopped' : 'rejected', rejectionReason, ])}

) : (

{t('Your order has been %s.', [ tx.order.status === Schema.OrderStatus.STATUS_STOPPED ? 'stopped' : 'rejected', ])}

)} {tx.txHash && (

{t('View in block explorer')}

)} ); } if (isOrderSubmissionTransaction(tx.body) && tx.order?.rejectionReason) { return (

{getOrderToastTitle(tx.order.status)}

{t('Your order was rejected.')}

{tx.txHash && (

{t('View in block explorer')}

)}
); } if (isTransferTransaction(tx.body)) { return (

{t('Transfer complete')}

{t('Your transaction has been confirmed ')}

{tx.txHash && (

{t('View in block explorer')}

)}
); } return ( <> {tx.order?.status ? getOrderToastTitle(tx.order.status) : t('Confirmed')}

{t('Your transaction has been confirmed ')}

{tx.txHash && (

{t('View in block explorer')}

)} ); }; const VegaTxErrorToastContent = ({ tx }: VegaTxToastContentProps) => { let label = t('Error occurred'); let errorMessage = tx.error instanceof WalletError ? `${tx.error.title}: ${tx.error.data}` : tx.error?.message; const reconnectVegaWallet = useReconnectVegaWallet(); const orderRejection = tx.order && getRejectionReason(tx.order); const walletNoConnectionCodes = [ ClientErrors.NO_SERVICE.code, ClientErrors.NO_CLIENT.code, ]; const walletError = tx.error instanceof WalletError && walletNoConnectionCodes.includes(tx.error.code); if (orderRejection) { label = getOrderToastTitle(tx.order?.status) || t('Order rejected'); errorMessage = t('Your order has been rejected because: %s', [ orderRejection || tx.order?.rejectionReason || ' ', ]); } if (walletError) { label = t('Wallet disconnected'); errorMessage = t('The connection to your Vega Wallet has been lost.'); } return ( <> {label}

{errorMessage}

{walletError && ( )} ); }; const isFinal = (tx: VegaStoredTxState) => [VegaTxStatus.Error, VegaTxStatus.Complete].includes(tx.status); export const useVegaTransactionToasts = () => { const [setToast, removeToast] = useToasts((store) => [ store.setToast, store.remove, ]); const [dismissTx, deleteTx] = useVegaTransactionStore((state) => [ state.dismiss, state.delete, ]); const onClose = useCallback( (tx: VegaStoredTxState) => () => { const safeToDelete = isFinal(tx); if (safeToDelete) { deleteTx(tx.id); } else { dismissTx(tx.id); } removeToast(`vega-${tx.id}`); }, [deleteTx, dismissTx, removeToast] ); const fromVegaTransaction = (tx: VegaStoredTxState): Toast => { const { intent, content } = getVegaTransactionContentIntent(tx); const closeAfter = isFinal(tx) && !isWithdrawTransaction(tx.body) ? CLOSE_AFTER : undefined; // marks "Funds unlocked" toast so it can be found in eth toasts const meta = isFinal(tx) && isWithdrawTransaction(tx.body) ? { withdrawalId: tx.withdrawal?.id } : undefined; return { id: `vega-${tx.id}`, intent, onClose: onClose(tx), loader: tx.status === VegaTxStatus.Pending, content, closeAfter, meta, }; }; useVegaTransactionStore.subscribe( (state) => compact( state.transactions.filter( (tx) => tx?.dialogOpen && isTransactionTypeSupported(tx) ) ), (txs) => { txs.forEach((tx) => setToast(fromVegaTransaction(tx))); } ); }; export const getVegaTransactionContentIntent = (tx: VegaStoredTxState) => { let content: ToastContent; if (tx.status === VegaTxStatus.Requested) { content = ; } if (tx.status === VegaTxStatus.Pending) { content = ; } if (tx.status === VegaTxStatus.Complete) { content = ; } if (tx.status === VegaTxStatus.Error) { content = ; } // Transaction can be successful but the order can be rejected by the network const intentForRejectedOrder = tx.order && !isOrderAmendmentTransaction(tx.body) && getOrderToastIntent(tx.order.status); // Although the transaction is completed on the vega network the whole // withdrawal process is not - funds are only released at this point const intentForCompletedWithdrawal = tx.status === VegaTxStatus.Complete && isWithdrawTransaction(tx.body) && Intent.Warning; const intent = intentForRejectedOrder || intentForCompletedWithdrawal || intentMap[tx.status]; return { intent, content }; };