diff --git a/src/components/BackButton.tsx b/src/components/BackButton.tsx index 9c40b64..524fc69 100644 --- a/src/components/BackButton.tsx +++ b/src/components/BackButton.tsx @@ -7,7 +7,12 @@ type ElementProps = { onClick?: () => void; }; +type StyleProps = { + className?: string; +}; + export const BackButton = ({ + className, onClick = () => { // @ts-ignore const navigation = globalThis.navigation; @@ -21,8 +26,9 @@ export const BackButton = ({ navigation.navigate('/', { replace: true }); } }, -}: ElementProps) => ( +}: ElementProps & StyleProps) => ( - - - {onBack && } + {stacked ? ( + + {onBack && } - {slotIcon && {slotIcon}} - - {title && {title}} + {slotIcon} {!preventClose && withClose && ( - + )} - - {description && {description}} + {title && {title}} - {slotHeaderInner} - + {description && {description}} + + {slotHeaderInner} + + ) : ( + + + {onBack && } + + {slotIcon && {slotIcon}} + + {title && {title}} + + {!preventClose && withClose && ( + + + + )} + + + {description && {description}} + + {slotHeaderInner} + + )} {children} @@ -173,7 +196,11 @@ Styled.Overlay = styled(Overlay)` } `; -Styled.Container = styled(Content)<{ placement: DialogPlacement; $withAnimation?: boolean }>` +Styled.Container = styled(Content)<{ + placement: DialogPlacement; + $stacked?: boolean; + $withAnimation?: boolean; +}>` /* Params */ --dialog-inset: 1rem; --dialog-width: 30rem; @@ -353,6 +380,13 @@ Styled.Container = styled(Content)<{ placement: DialogPlacement; $withAnimation? bottom: 0; `, }[placement])} + + ${({ $stacked }) => + $stacked && + css` + justify-content: center; + text-align: center; + `} `; Styled.Header = styled.header<{ $withBorder: boolean }>` @@ -379,9 +413,21 @@ Styled.HeaderTopRow = styled.div` gap: var(--dialog-title-gap); `; -Styled.HeaderTopRow = styled.div` - ${layoutMixins.row} - gap: var(--dialog-title-gap); +Styled.StackedHeaderTopRow = styled.div<{ $withBorder: boolean }>` + ${layoutMixins.flexColumn} + align-items: center; + justify-content: center; + padding: var(--dialog-header-paddingTop) var(--dialog-header-paddingLeft) + var(--dialog-header-paddingBottom) var(--dialog-header-paddingRight); + border-top-left-radius: inherit; + border-top-right-radius: inherit; + + ${({ $withBorder }) => + $withBorder && + css` + ${layoutMixins.withOuterBorder}; + background: var(--dialog-backgroundColor); + `}; `; Styled.Content = styled.div` @@ -412,7 +458,7 @@ Styled.Icon = styled.div` line-height: 1; `; -Styled.Close = styled(Close)` +Styled.Close = styled(Close)<{ $absolute?: boolean }>` width: 0.7813rem; height: 0.7813rem; @@ -438,6 +484,14 @@ Styled.Close = styled(Close)` color: var(--color-text-2); } + ${({ $absolute }) => + $absolute && + css` + position: absolute; + right: var(--dialog-header-paddingRight); + top: var(--dialog-header-paddingTop); + `} + @media ${breakpoints.tablet} { width: 1rem; height: 1rem; @@ -445,6 +499,12 @@ Styled.Close = styled(Close)` } `; +Styled.BackButton = styled(BackButton)` + position: absolute; + left: var(--dialog-header-paddingLeft); + top: var(--dialog-header-paddingTop); +`; + Styled.Title = styled(Title)` flex: 1; diff --git a/src/constants/dialogs.ts b/src/constants/dialogs.ts index 6ed34ec..f282950 100644 --- a/src/constants/dialogs.ts +++ b/src/constants/dialogs.ts @@ -9,8 +9,11 @@ export enum DialogTypes { FillDetails = 'FillDetails', Help = 'Help', ExternalNavKeplr = 'ExternalNavKeplr', + ManageFunds = 'ManageFunds', MnemonicExport = 'MnemonicExport', MobileSignIn = 'MobileSignIn', + NewMarketAgreement = 'NewMarketAgreement', + NewMarketMessageDetails = 'NewMarketMessageDetails', Onboarding = 'Onboarding', OrderDetails = 'OrderDetails', Preferences = 'Preferences', @@ -20,9 +23,7 @@ export enum DialogTypes { Trade = 'Trade', Transfer = 'Transfer', Withdraw = 'Withdraw', - ManageFunds = 'ManageFunds', - NewMarketMessageDetails = 'NewMarketMessageDetails', - NewMarketAgreement = 'NewMarketAgreement', + WithdrawalGated = 'WithdrawalGated', } export enum TradeBoxDialogTypes { diff --git a/src/hooks/useURLConfigs.ts b/src/hooks/useURLConfigs.ts index 67cfc1d..121e168 100644 --- a/src/hooks/useURLConfigs.ts +++ b/src/hooks/useURLConfigs.ts @@ -24,6 +24,7 @@ export interface LinksConfigs { strideZoneApp?: string; accountExportLearnMore?: string; walletLearnMore?: string; + withdrawalGateLearnMore?: string; } export const useURLConfigs = (): LinksConfigs => { @@ -50,5 +51,6 @@ export const useURLConfigs = (): LinksConfigs => { strideZoneApp: linksConfigs.strideZoneApp || FALLBACK_URL, accountExportLearnMore: linksConfigs.accountExportLearnMore || FALLBACK_URL, walletLearnMore: linksConfigs.walletLearnMore || FALLBACK_URL, + withdrawalGateLearnMore: linksConfigs.withdrawalGateLearnMore || FALLBACK_URL, }; }; diff --git a/src/hooks/useWithdrawalInfo.ts b/src/hooks/useWithdrawalInfo.ts index 94ec219..a2395f2 100644 --- a/src/hooks/useWithdrawalInfo.ts +++ b/src/hooks/useWithdrawalInfo.ts @@ -1,13 +1,32 @@ +import { useEffect, useMemo } from 'react'; import { useQuery } from 'react-query'; +import BigNumber from 'bignumber.js'; +import { encodeJson } from '@dydxprotocol/v4-client-js'; +import { ByteArrayEncoding } from '@dydxprotocol/v4-client-js/build/src/lib/helpers'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; + +import { DialogTypes } from '@/constants/dialogs'; +import { isMainnet } from '@/constants/networks'; + +import { getApiState } from '@/state/appSelectors'; +import { closeDialog, openDialog } from '@/state/dialogs'; +import { getSelectedLocale } from '@/state/localizationSelectors'; + +import { formatRelativeTime } from '@/lib/dateTime'; +import { BIG_NUMBERS, MustBigNumber } from '@/lib/numbers'; import { useDydxClient } from './useDydxClient'; import { useTokenConfigs } from './useTokenConfigs'; -import { encodeJson } from '@dydxprotocol/v4-client-js'; -import { ByteArrayEncoding } from '@dydxprotocol/v4-client-js/build/src/lib/helpers'; -export const useWithdrawalInfo = () => { +const BLOCK_TIME = isMainnet ? 1_000 : 1_500; + +export const useWithdrawalInfo = ({ isTransfer }: { isTransfer?: boolean }) => { const { getWithdrawalAndTransferGatingStatus, getWithdrawalCapacityByDenom } = useDydxClient(); - const { usdcDenom } = useTokenConfigs(); + const { usdcDenom, usdcDecimals } = useTokenConfigs(); + const apiState = useSelector(getApiState, shallowEqual); + const { height } = apiState || {}; + const selectedLocale = useSelector(getSelectedLocale); + const dispatch = useDispatch(); const { data: usdcWithdawalCapacity } = useQuery({ queryKey: 'usdcWithdrawalCapacity', @@ -30,8 +49,62 @@ export const useWithdrawalInfo = () => { staleTime: 60_000, }); + const capacity = useMemo(() => { + const capacityList = usdcWithdawalCapacity?.limiterCapacityList; + if (!capacityList || capacityList.length < 2) { + return BIG_NUMBERS.ZERO; + } + + const [{ capacity: daily }, { capacity: weekly }] = capacityList; + const dailyBN = MustBigNumber(daily); + const weeklyBN = MustBigNumber(weekly); + return BigNumber.minimum(dailyBN, weeklyBN).div(10 ** usdcDecimals); + }, [usdcDecimals, usdcWithdawalCapacity]); + + const withdrawalAndTransferGatingStatusValue = useMemo(() => { + const { withdrawalsAndTransfersUnblockedAtBlock } = withdrawalAndTransferGatingStatus ?? {}; + if ( + height && + withdrawalsAndTransfersUnblockedAtBlock && + height < withdrawalsAndTransfersUnblockedAtBlock + ) { + return { + estimatedUnblockTime: formatRelativeTime( + Date.now() + (withdrawalsAndTransfersUnblockedAtBlock - height) * BLOCK_TIME, + { + locale: selectedLocale, + largestUnit: 'day', + } + ), + isGated: true, + }; + } + return { + estimatedUnblockTime: null, + isGated: false, + }; + }, [height, withdrawalAndTransferGatingStatus]); + + useEffect(() => { + if ( + withdrawalAndTransferGatingStatusValue.isGated && + withdrawalAndTransferGatingStatusValue.estimatedUnblockTime + ) { + dispatch(closeDialog()); + dispatch( + openDialog({ + type: DialogTypes.WithdrawalGated, + dialogProps: { + isTransfer, + estimatedUnblockTime: withdrawalAndTransferGatingStatusValue.estimatedUnblockTime, + }, + }) + ); + } + }, [withdrawalAndTransferGatingStatusValue.isGated]); + return { - usdcWithdawalCapacity, + usdcWithdawalCapacity: capacity, withdrawalAndTransferGatingStatus, }; }; diff --git a/src/layout/DialogManager.tsx b/src/layout/DialogManager.tsx index 4c5ec7e..0db0e5e 100644 --- a/src/layout/DialogManager.tsx +++ b/src/layout/DialogManager.tsx @@ -11,26 +11,27 @@ import { DepositDialog } from '@/views/dialogs/DepositDialog'; import { DisconnectDialog } from '@/views/dialogs/DisconnectDialog'; import { DisplaySettingsDialog } from '@/views/dialogs/DisplaySettingsDialog'; import { ExchangeOfflineDialog } from '@/views/dialogs/ExchangeOfflineDialog'; +import { ExternalNavStrideDialog } from '@/views/dialogs/ExternalNavStrideDialog'; import { HelpDialog } from '@/views/dialogs/HelpDialog'; import { ExternalLinkDialog } from '@/views/dialogs/ExternalLinkDialog'; import { ExternalNavKeplrDialog } from '@/views/dialogs/ExternalNavKeplrDialog'; +import { ManageFundsDialog } from '@/views/dialogs/ManageFundsDialog'; import { MnemonicExportDialog } from '@/views/dialogs/MnemonicExportDialog'; import { MobileSignInDialog } from '@/views/dialogs/MobileSignInDialog'; +import { NewMarketAgreementDialog } from '@/views/dialogs/NewMarketAgreementDialog'; +import { NewMarketMessageDetailsDialog } from '@/views/dialogs/NewMarketMessageDetailsDialog'; import { OnboardingDialog } from '@/views/dialogs/OnboardingDialog'; import { PreferencesDialog } from '@/views/dialogs/PreferencesDialog'; import { RateLimitDialog } from '@/views/dialogs/RateLimitDialog'; import { RestrictedGeoDialog } from '@/views/dialogs/RestrictedGeoDialog'; +import { RestrictedWalletDialog } from '@/views/dialogs/RestrictedWalletDialog'; import { TradeDialog } from '@/views/dialogs/TradeDialog'; import { TransferDialog } from '@/views/dialogs/TransferDialog'; -import { RestrictedWalletDialog } from '@/views/dialogs/RestrictedWalletDialog'; import { WithdrawDialog } from '@/views/dialogs/WithdrawDialog'; -import { ManageFundsDialog } from '@/views/dialogs/ManageFundsDialog'; +import { WithdrawalGateDialog } from '@/views/dialogs/WithdrawalGateDialog'; import { OrderDetailsDialog } from '@/views/dialogs/DetailsDialog/OrderDetailsDialog'; import { FillDetailsDialog } from '@/views/dialogs/DetailsDialog/FillDetailsDialog'; -import { NewMarketMessageDetailsDialog } from '@/views/dialogs/NewMarketMessageDetailsDialog'; -import { NewMarketAgreementDialog } from '@/views/dialogs/NewMarketAgreementDialog'; -import { ExternalNavStrideDialog } from '@/views/dialogs/ExternalNavStrideDialog'; export const DialogManager = () => { const dispatch = useDispatch(); @@ -72,6 +73,7 @@ export const DialogManager = () => { [DialogTypes.Trade]: , [DialogTypes.Transfer]: , [DialogTypes.Withdraw]: , + [DialogTypes.WithdrawalGated]: , [DialogTypes.ManageFunds]: , [DialogTypes.NewMarketMessageDetails]: , [DialogTypes.NewMarketAgreement]: , diff --git a/src/views/dialogs/WithdrawalGateDialog.tsx b/src/views/dialogs/WithdrawalGateDialog.tsx new file mode 100644 index 0000000..fc011be --- /dev/null +++ b/src/views/dialogs/WithdrawalGateDialog.tsx @@ -0,0 +1,100 @@ +import styled, { AnyStyledComponent } from 'styled-components'; + +import { STRING_KEYS } from '@/constants/localization'; +import { useStringGetter, useURLConfigs } from '@/hooks'; +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Dialog } from '@/components/Dialog'; +import { Icon, IconName } from '@/components/Icon'; +import { Button } from '@/components/Button'; +import { LinkOutIcon } from '@/icons'; +import { ButtonAction, ButtonType } from '@/constants/buttons'; + +type ElementProps = { + setIsOpen: (open: boolean) => void; + isTransfer?: boolean; + estimatedUnblockTime?: string | null; +}; + +export const WithdrawalGateDialog = ({ + setIsOpen, + isTransfer, + estimatedUnblockTime, +}: ElementProps) => { + const stringGetter = useStringGetter(); + const { withdrawalGateLearnMore } = useURLConfigs(); + + return ( + + + + } + slotFooter={ + + + + + } + > + + {stringGetter({ + key: STRING_KEYS.WITHDRAWALS_PAUSED_DESC, + params: { + ESTIMATED_DURATION: estimatedUnblockTime, + }, + })} + + + ); +}; + +const Styled: Record = {}; + +Styled.IconContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 72px; + height: 72px; + border-radius: 50%; + min-width: 72px; + min-height: 72px; + background-color: rgba(100, 100, 0, 0.1); +`; + +Styled.Icon = styled(Icon)` + color: var(--color-warning); + font-size: 2.5rem; + margin-bottom: 0.125rem; +`; + +Styled.Content = styled.div` + ${layoutMixins.column} + gap: 1rem; +`; + +Styled.ButtonRow = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +`; diff --git a/src/views/forms/AccountManagementForms/WithdrawForm.tsx b/src/views/forms/AccountManagementForms/WithdrawForm.tsx index 571dc59..7ba13d9 100644 --- a/src/views/forms/AccountManagementForms/WithdrawForm.tsx +++ b/src/views/forms/AccountManagementForms/WithdrawForm.tsx @@ -11,7 +11,12 @@ import { ButtonSize } from '@/constants/buttons'; import { STRING_KEYS } from '@/constants/localization'; import { ENVIRONMENT_CONFIG_MAP, isMainnet } from '@/constants/networks'; import { TransferNotificationTypes } from '@/constants/notifications'; -import { MAX_CCTP_TRANSFER_AMOUNT, MAX_PRICE_IMPACT, NumberSign } from '@/constants/numbers'; +import { + MAX_CCTP_TRANSFER_AMOUNT, + MAX_PRICE_IMPACT, + NumberSign, + TOKEN_DECIMALS, +} from '@/constants/numbers'; import { useAccounts, @@ -21,6 +26,7 @@ import { useSelectedNetwork, useStringGetter, useSubaccount, + useTokenConfigs, useWithdrawalInfo, } from '@/hooks'; import { useLocalNotifications } from '@/hooks/useLocalNotifications'; @@ -76,8 +82,8 @@ export const WithdrawForm = () => { const [withdrawAmount, setWithdrawAmount] = useState(''); const [slippage, setSlippage] = useState(isCctp ? 0 : 0.01); // 0.1% slippage const debouncedAmount = useDebounce(withdrawAmount, 500); - const { usdcWithdawalCapacity, withdrawalAndTransferGatingStatus } = useWithdrawalInfo(); - console.log({ usdcWithdawalCapacity, withdrawalAndTransferGatingStatus }); + const { usdcLabel } = useTokenConfigs(); + const { usdcWithdawalCapacity } = useWithdrawalInfo({ isTransfer: false }); const isValidAddress = toAddress && isAddress(toAddress); @@ -126,7 +132,7 @@ export const WithdrawForm = () => { setError(undefined); } } catch (error) { - setError(error.message); + // setError(error.message); } finally { setIsLoading(false); } @@ -290,55 +296,94 @@ export const WithdrawForm = () => { const { sanctionedAddresses } = useRestrictions(); - const errorMessage = useMemo(() => { + const { alertType, errorMessage } = useMemo(() => { if (error) { - return error; + return { + errorMessage: error, + }; } if (routeErrors) { - return routeErrorMessage - ? stringGetter({ - key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, - params: { ERROR_MESSAGE: routeErrorMessage }, - }) - : stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG }); + return { + errorMessage: routeErrorMessage + ? stringGetter({ + key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + params: { ERROR_MESSAGE: routeErrorMessage }, + }) + : stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG }), + }; } - if (!toAddress) return stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_ADDRESS }); + if (!toAddress) { + return { + alertType: AlertType.Warning, + errorMessage: stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_ADDRESS }), + }; + } if (sanctionedAddresses.has(toAddress)) - return stringGetter({ - key: STRING_KEYS.TRANSFER_INVALID_DYDX_ADDRESS, - }); + return { + errorMessage: stringGetter({ + key: STRING_KEYS.TRANSFER_INVALID_DYDX_ADDRESS, + }), + }; if (debouncedAmountBN) { if (!chainIdStr) { - return stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_CHAIN }); + return { + errorMessage: stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_CHAIN }), + }; } else if (!toToken) { - return stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_ASSET }); + return { + errorMessage: stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_ASSET }), + }; } } - if (MustBigNumber(debouncedAmountBN).gt(MustBigNumber(freeCollateralBN))) { - return stringGetter({ key: STRING_KEYS.WITHDRAW_MORE_THAN_FREE }); + if (debouncedAmountBN.gt(MustBigNumber(freeCollateralBN))) { + return { + errorMessage: stringGetter({ key: STRING_KEYS.WITHDRAW_MORE_THAN_FREE }), + }; } if (isCctp) { - if (MustBigNumber(debouncedAmountBN).gte(MAX_CCTP_TRANSFER_AMOUNT)) { - return stringGetter({ - key: STRING_KEYS.MAX_CCTP_TRANSFER_LIMIT_EXCEEDED, - params: { - MAX_CCTP_TRANSFER_AMOUNT: MAX_CCTP_TRANSFER_AMOUNT, - }, - }); + if (debouncedAmountBN.gte(MAX_CCTP_TRANSFER_AMOUNT)) { + return { + errorMessage: stringGetter({ + key: STRING_KEYS.MAX_CCTP_TRANSFER_LIMIT_EXCEEDED, + params: { + MAX_CCTP_TRANSFER_AMOUNT: MAX_CCTP_TRANSFER_AMOUNT, + }, + }), + }; } } if (isMainnet && MustBigNumber(summary?.aggregatePriceImpact).gte(MAX_PRICE_IMPACT)) { - return stringGetter({ key: STRING_KEYS.PRICE_IMPACT_TOO_HIGH }); + return { errorMessage: stringGetter({ key: STRING_KEYS.PRICE_IMPACT_TOO_HIGH }) }; } - return undefined; + // Withdrawal Safety + if (usdcWithdawalCapacity.gt(0) && debouncedAmountBN.gt(usdcWithdawalCapacity)) { + return { + alertType: AlertType.Warning, + errorMessage: stringGetter({ + key: STRING_KEYS.WITHDRAWAL_LIMIT_OVER, + params: { + USDC_LIMIT: ( + + {usdcWithdawalCapacity.toFormat(TOKEN_DECIMALS)} + {usdcLabel} + + ), + }, + }), + }; + } + + return { + errorMessage: undefined, + }; }, [ error, routeErrors, @@ -351,8 +396,7 @@ export const WithdrawForm = () => { sanctionedAddresses, stringGetter, summary, - withdrawalCapacity, - withdrawalTransferGateStatus, + usdcWithdawalCapacity, ]); const isDisabled = @@ -399,7 +443,11 @@ export const WithdrawForm = () => { } /> - {errorMessage && {errorMessage}} + {errorMessage && ( + + {errorMessage} + + )} { const Styled: Record = {}; +Styled.Tag = styled(Tag)` + margin-left: 0.5ch; +`; + Styled.DiffOutput = styled(DiffOutput)` --diffOutput-valueWithDiff-fontSize: 1em; `; @@ -435,6 +487,10 @@ Styled.DestinationRow = styled.div` gap: 1rem; `; +Styled.AlertMessage = styled(AlertMessage)` + display: inline; +`; + Styled.WithDetailsReceipt = styled(WithDetailsReceipt)` --withReceipt-backgroundColor: var(--color-layer-2); `; diff --git a/src/views/forms/TransferForm.tsx b/src/views/forms/TransferForm.tsx index 678e72e..6a3a01c 100644 --- a/src/views/forms/TransferForm.tsx +++ b/src/views/forms/TransferForm.tsx @@ -22,6 +22,7 @@ import { useStringGetter, useSubaccount, useTokenConfigs, + useWithdrawalInfo, } from '@/hooks'; import { formMixins } from '@/styles/formMixins'; @@ -65,6 +66,7 @@ export const TransferForm = ({ const { nativeTokenBalance, usdcBalance } = useAccountBalance(); const { selectedNetwork } = useSelectedNetwork(); const { tokensConfigs, usdcLabel, chainTokenLabel } = useTokenConfigs(); + useWithdrawalInfo({ isTransfer: true }); const { address: recipientAddress,