diff --git a/src/views/forms/AccountManagementFormsNew/WithdrawForm/WithdrawButtonAndReceipt.tsx b/src/views/forms/AccountManagementFormsNew/WithdrawForm/WithdrawButtonAndReceipt.tsx new file mode 100644 index 0000000000..a7d49a464d --- /dev/null +++ b/src/views/forms/AccountManagementFormsNew/WithdrawForm/WithdrawButtonAndReceipt.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react'; + +import { Asset, RouteResponse } from '@skip-go/client'; +import { shallowEqual } from 'react-redux'; +import styled from 'styled-components'; +import tw from 'twin.macro'; +import { formatUnits } from 'viem'; + +import { ButtonAction, ButtonSize, ButtonType } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; +import { NumberSign, TOKEN_DECIMALS } from '@/constants/numbers'; + +import { ConnectionErrorType, useApiState } from '@/hooks/useApiState'; +import { useStringGetter } from '@/hooks/useStringGetter'; +import { useTokenConfigs } from '@/hooks/useTokenConfigs'; + +import { Button } from '@/components/Button'; +import { Details } from '@/components/Details'; +import { DiffOutput } from '@/components/DiffOutput'; +import { Output, OutputType } from '@/components/Output'; +import { Tag } from '@/components/Tag'; +import { WithReceipt } from '@/components/WithReceipt'; +import { WithTooltip } from '@/components/WithTooltip'; +import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton'; + +import { calculateCanAccountTrade } from '@/state/accountCalculators'; +import { getSubaccount } from '@/state/accountSelectors'; +import { useAppSelector } from '@/state/appTypes'; +import { getTransferInputs } from '@/state/inputsSelectors'; + +import { isTruthy } from '@/lib/isTruthy'; + +import { RouteWarningMessage } from '../RouteWarningMessage'; +import { SlippageEditor } from '../SlippageEditor'; + +type ElementProps = { + setSlippage: (slippage: number) => void; + + slippage: number; + withdrawToken?: Asset; + route?: RouteResponse; + + isDisabled?: boolean; + isLoading?: boolean; +}; + +export const WithdrawButtonAndReceipt = ({ + setSlippage, + + slippage, + withdrawToken, + route, + + isDisabled, + isLoading, +}: ElementProps) => { + const [isEditingSlippage, setIsEditingSlipapge] = useState(false); + const stringGetter = useStringGetter(); + + const { leverage } = useAppSelector(getSubaccount, shallowEqual) ?? {}; + // TODO: https://linear.app/dydx/issue/OTE-867/coinbase-withdrawals + const { exchange } = useAppSelector(getTransferInputs, shallowEqual) ?? {}; + + const canAccountTrade = useAppSelector(calculateCanAccountTrade, shallowEqual); + const { usdcLabel } = useTokenConfigs(); + const { connectionError } = useApiState(); + + const fees = Number(route?.usdAmountIn) - Number(route?.usdAmountOut); + const submitButtonReceipt = [ + { + key: 'expected-amount-received', + + label: ( + <$RowWithGap> + {stringGetter({ key: STRING_KEYS.EXPECTED_AMOUNT_RECEIVED })} + {withdrawToken && {withdrawToken?.symbol}} + + ), + value: ( + + ), + }, + withdrawToken && + !withdrawToken.symbol?.toLowerCase().includes('usd') && { + key: 'expected-amount-received-usd', + + label: ( + <$RowWithGap> + {stringGetter({ key: STRING_KEYS.EXPECTED_AMOUNT_RECEIVED })} + {withdrawToken && {usdcLabel}} + + ), + value: ( + + ), + }, + fees && { + key: 'bridge-fees', + label: ( + + {stringGetter({ key: STRING_KEYS.BRIDGE_FEE })} + + ), + value: , + }, + !exchange && { + key: 'slippage', + label: {stringGetter({ key: STRING_KEYS.MAX_SLIPPAGE })}, + value: ( + + ), + }, + { + key: 'estimated-route-duration', + label: {stringGetter({ key: STRING_KEYS.ESTIMATED_TIME })}, + value: + typeof route?.estimatedRouteDurationSeconds === 'number' ? ( + + ) : undefined, + }, + { + key: 'leverage', + label: {stringGetter({ key: STRING_KEYS.ACCOUNT_LEVERAGE })}, + value: ( + + ), + }, + ].filter(isTruthy); + + const [hasAcknowledged, setHasAcknowledged] = useState(false); + const requiresAcknowledgement = Boolean(route?.warning && !hasAcknowledged); + const isFormValid = + !isDisabled && + !isEditingSlippage && + connectionError !== ConnectionErrorType.CHAIN_DISRUPTION && + !requiresAcknowledgement; + + if (!canAccountTrade) { + return ( + <$WithReceipt slotReceipt={<$Details items={submitButtonReceipt} />}> + + + ); + } + + return ( + <$WithReceipt slotReceipt={<$Details items={submitButtonReceipt} />}> + + + + ); +}; +const $RowWithGap = tw.span`row gap-[0.5ch]`; + +const $WithReceipt = tw(WithReceipt)`[--withReceipt-backgroundColor:--color-layer-2]`; + +const $Details = styled(Details)` + --details-item-vertical-padding: 0.33rem; + padding: var(--form-input-paddingY) var(--form-input-paddingX); + font-size: 0.8125em; +`; diff --git a/src/views/forms/AccountManagementFormsNew/WithdrawForm/useValidation.tsx b/src/views/forms/AccountManagementFormsNew/WithdrawForm/useValidation.tsx new file mode 100644 index 0000000000..988266332a --- /dev/null +++ b/src/views/forms/AccountManagementFormsNew/WithdrawForm/useValidation.tsx @@ -0,0 +1,203 @@ +import { useMemo } from 'react'; + +import { exchange } from '@dydxprotocol/v4-abacus'; +import { Asset } from '@skip-go/client'; +import BigNumber from 'bignumber.js'; + +import { AlertType } from '@/constants/alerts'; +import { STRING_KEYS } from '@/constants/localization'; +import { + MAX_CCTP_TRANSFER_AMOUNT, + MIN_CCTP_TRANSFER_AMOUNT, + TOKEN_DECIMALS, +} from '@/constants/numbers'; + +import { useLocaleSeparators } from '@/hooks/useLocaleSeparators'; +import { useRestrictions } from '@/hooks/useRestrictions'; +import { useStringGetter } from '@/hooks/useStringGetter'; +import { useTokenConfigs } from '@/hooks/useTokenConfigs'; +import { useWithdrawalInfo } from '@/hooks/useWithdrawalInfo'; + +import { formatNumberOutput, OutputType } from '@/components/Output'; +import { Tag } from '@/components/Tag'; + +import { useAppSelector } from '@/state/appTypes'; +import { getSelectedLocale } from '@/state/localizationSelectors'; + +import { MustBigNumber } from '@/lib/numbers'; + +type UseValidationProps = { + isCctp: boolean; + debouncedAmountBN: BigNumber; + freeCollateralBN: BigNumber; + isValidDestinationAddress: boolean; + error?: string; + toAddress?: string; + toChainId?: string; + toToken?: Asset; +}; + +export const useValidation = ({ + error, + isCctp, + debouncedAmountBN, + toAddress, + isValidDestinationAddress, + toChainId, + toToken, + freeCollateralBN, +}: UseValidationProps) => { + const stringGetter = useStringGetter(); + const { sanctionedAddresses } = useRestrictions(); + const { decimal: decimalSeparator, group: groupSeparator } = useLocaleSeparators(); + const selectedLocale = useAppSelector(getSelectedLocale); + const { usdcLabel } = useTokenConfigs(); + const { usdcWithdrawalCapacity } = useWithdrawalInfo({ transferType: 'withdrawal' }); + + const { alertType, errorMessage } = useMemo(() => { + if (isCctp) { + if (debouncedAmountBN.gte(MAX_CCTP_TRANSFER_AMOUNT)) { + return { + errorMessage: stringGetter({ + key: STRING_KEYS.MAX_CCTP_TRANSFER_LIMIT_EXCEEDED, + params: { + MAX_CCTP_TRANSFER_AMOUNT, + }, + }), + }; + } + if ( + !debouncedAmountBN.isZero() && + MustBigNumber(debouncedAmountBN).lte(MIN_CCTP_TRANSFER_AMOUNT) + ) { + return { + errorMessage: stringGetter({ + key: STRING_KEYS.AMOUNT_MINIMUM_ERROR, + params: { + NUMBER: MIN_CCTP_TRANSFER_AMOUNT, + TOKEN: usdcLabel, + }, + }), + }; + } + } + if (error) { + return { + errorMessage: error, + }; + } + + if (!toAddress) { + return { + alertType: AlertType.Warning, + errorMessage: stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_ADDRESS }), + }; + } + + if (sanctionedAddresses.has(toAddress)) + return { + errorMessage: stringGetter({ + key: STRING_KEYS.TRANSFER_INVALID_DYDX_ADDRESS, + }), + }; + + if (!isValidDestinationAddress) { + return { + errorMessage: stringGetter({ + key: STRING_KEYS.ENTER_VALID_ADDRESS, + }), + }; + } + + // TODO: https://linear.app/dydx/issue/OTE-874/handle-skip-request-error-responses + // skip Client does not return error codes. work with skip on this + // if (route?.code) { + // const routeErrorMessageOverride = getRouteErrorMessageOverride(route?.code, route?.message); + // const routeErrorContext = { + // transferType: TransferType.Withdraw, + // errorMessage: routeErrorMessageOverride ?? undefined, + // amount: debouncedAmount, + // chainId: toChainId ?? undefined, + // assetAddress: toToken?.denom ?? undefined, + // assetSymbol: toToken?.symbol ?? undefined, + // assetName: toToken?.name ?? undefined, + // assetId: toToken?.toString() ?? undefined, + // }; + // track(AnalyticsEvents.RouteError(routeErrorContext)); + // dd.info('Route error received', routeErrorContext); + // return { + // errorMessage: routeErrorMessageOverride + // ? stringGetter({ + // key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + // params: { ERROR_MESSAGE: routeErrorMessageOverride }, + // }) + // : stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG }), + // }; + // } + + if (debouncedAmountBN) { + if (!toChainId && !exchange) { + return { + errorMessage: stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_CHAIN }), + }; + } + if (!toToken) { + return { + errorMessage: stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_ASSET }), + }; + } + } + + if (debouncedAmountBN.gt(MustBigNumber(freeCollateralBN))) { + return { + errorMessage: stringGetter({ key: STRING_KEYS.WITHDRAW_MORE_THAN_FREE }), + }; + } + + // Withdrawal Safety + if (usdcWithdrawalCapacity.gt(0) && debouncedAmountBN.gt(usdcWithdrawalCapacity)) { + return { + alertType: AlertType.Warning, + errorMessage: stringGetter({ + key: STRING_KEYS.WITHDRAWAL_LIMIT_OVER, + params: { + USDC_LIMIT: ( + + {formatNumberOutput(usdcWithdrawalCapacity, OutputType.Number, { + decimalSeparator, + groupSeparator, + selectedLocale, + fractionDigits: TOKEN_DECIMALS, + })} + {usdcLabel} + + ), + }, + }), + }; + } + return { + errorMessage: undefined, + }; + }, [ + isCctp, + error, + toAddress, + sanctionedAddresses, + stringGetter, + isValidDestinationAddress, + debouncedAmountBN, + freeCollateralBN, + usdcWithdrawalCapacity, + usdcLabel, + toChainId, + toToken, + decimalSeparator, + groupSeparator, + selectedLocale, + ]); + return { + alertType, + errorMessage, + }; +};