From 484d7888cfcbb8b4332f1856b849a8a158796fa1 Mon Sep 17 00:00:00 2001 From: Art Date: Mon, 26 Jun 2023 16:28:14 +0200 Subject: [PATCH] feat(web3): ready to withdraw toast (#4142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartłomiej Głownia --- .../client-pages/portfolio/portfolio.tsx | 19 +- .../portfolio/withdrawals-container.tsx | 4 + .../components/app-loader/web3-provider.tsx | 18 +- apps/trading/pages/toasts-manager.tsx | 5 + libs/ui-toolkit/src/components/tabs/tabs.tsx | 5 +- .../ui-toolkit/src/components/toast/toast.tsx | 5 + .../src/components/toast/toasts-container.tsx | 26 +- .../src/components/toast/use-toasts.ts | 22 +- libs/web3/src/index.ts | 1 + libs/web3/src/lib/constants.ts | 3 + libs/web3/src/lib/default-web3-provider.tsx | 19 ++ libs/web3/src/lib/use-bridge-contract.ts | 27 +- .../lib/use-ethereum-transaction-store.tsx | 13 +- .../lib/use-ethereum-transaction-toasts.tsx | 13 +- ...hereum-withdraw-approvals-manager.spec.tsx | 21 +- ...se-ethereum-withdraw-approvals-manager.tsx | 9 +- libs/web3/src/lib/use-get-withdraw-delay.ts | 45 ++- .../src/lib/use-get-withdraw-threshold.tsx | 81 +++-- .../src/lib/use-vega-transaction-toasts.tsx | 7 + libs/web3/src/lib/web3-connectors.ts | 5 + libs/web3/src/lib/web3-provider.tsx | 23 +- libs/withdraws/src/index.ts | 1 + ...eady-to-complete-withdrawals-toast.spec.ts | 207 +++++++++++++ ...se-ready-to-complete-withdrawals-toast.tsx | 277 ++++++++++++++++++ .../src/lib/use-verify-withdrawal.ts | 21 +- libs/withdraws/src/lib/use-withdraw-asset.tsx | 4 +- .../src/lib/withdraw-form-container.spec.tsx | 12 + libs/withdraws/src/lib/withdrawals-table.tsx | 93 +++++- 28 files changed, 887 insertions(+), 99 deletions(-) create mode 100644 libs/web3/src/lib/default-web3-provider.tsx create mode 100644 libs/withdraws/src/lib/use-ready-to-complete-withdrawals-toast.spec.ts create mode 100644 libs/withdraws/src/lib/use-ready-to-complete-withdrawals-toast.tsx diff --git a/apps/trading/client-pages/portfolio/portfolio.tsx b/apps/trading/client-pages/portfolio/portfolio.tsx index 7224ae6a1..2c14dd14a 100644 --- a/apps/trading/client-pages/portfolio/portfolio.tsx +++ b/apps/trading/client-pages/portfolio/portfolio.tsx @@ -23,6 +23,19 @@ import { ResizableGrid, ResizableGridPanel, } from '../../components/resizable-grid'; +import { useIncompleteWithdrawals } from '@vegaprotocol/withdraws'; + +const WithdrawalsIndicator = () => { + const { ready } = useIncompleteWithdrawals(); + if (!ready || ready.length === 0) { + return null; + } + return ( + + {ready.length} + + ); +}; export const Portfolio = () => { const { updateTitle } = usePageTitleStore((store) => ({ @@ -103,7 +116,11 @@ export const Portfolio = () => { - + } + > diff --git a/apps/trading/client-pages/portfolio/withdrawals-container.tsx b/apps/trading/client-pages/portfolio/withdrawals-container.tsx index 0809dd911..8f259b706 100644 --- a/apps/trading/client-pages/portfolio/withdrawals-container.tsx +++ b/apps/trading/client-pages/portfolio/withdrawals-container.tsx @@ -3,6 +3,7 @@ import { withdrawalProvider, useWithdrawalDialog, WithdrawalsTable, + useIncompleteWithdrawals, } from '@vegaprotocol/withdraws'; import { useVegaWallet } from '@vegaprotocol/wallet'; import { t } from '@vegaprotocol/i18n'; @@ -17,6 +18,7 @@ export const WithdrawalsContainer = () => { skip: !pubKey, }); const openWithdrawDialog = useWithdrawalDialog((state) => state.open); + const { ready, delayed } = useIncompleteWithdrawals(); return ( @@ -25,6 +27,8 @@ export const WithdrawalsContainer = () => { data-testid="withdrawals-history" rowData={data} overlayNoRowsTemplate={error ? error.message : t('No withdrawals')} + ready={ready} + delayed={delayed} /> {!isReadOnly && ( diff --git a/apps/trading/components/app-loader/web3-provider.tsx b/apps/trading/components/app-loader/web3-provider.tsx index 53dc5d239..aae923567 100644 --- a/apps/trading/components/app-loader/web3-provider.tsx +++ b/apps/trading/components/app-loader/web3-provider.tsx @@ -1,13 +1,16 @@ +import type { DefaultWeb3ProviderContextShape } from '@vegaprotocol/web3'; import { useEthereumConfig, createConnectors, Web3Provider as Web3ProviderInternal, useWeb3ConnectStore, + createDefaultProvider, } from '@vegaprotocol/web3'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { t } from '@vegaprotocol/i18n'; import { useEnvironment } from '@vegaprotocol/environment'; import type { ReactNode } from 'react'; +import { useState } from 'react'; import { useEffect } from 'react'; export const Web3Provider = ({ children }: { children: ReactNode }) => { @@ -17,10 +20,13 @@ export const Web3Provider = ({ children }: { children: ReactNode }) => { const connectors = useWeb3ConnectStore((store) => store.connectors); const initializeConnectors = useWeb3ConnectStore((store) => store.initialize); + const [defaultProvider, setDefaultProvider] = useState< + DefaultWeb3ProviderContextShape['provider'] | undefined + >(undefined); useEffect(() => { if (config?.chain_id) { - return initializeConnectors( + initializeConnectors( createConnectors( ETHEREUM_PROVIDER_URL, Number(config?.chain_id), @@ -29,6 +35,11 @@ export const Web3Provider = ({ children }: { children: ReactNode }) => { ), Number(config.chain_id) ); + const defaultProvider = createDefaultProvider( + ETHEREUM_PROVIDER_URL, + Number(config?.chain_id) + ); + setDefaultProvider(defaultProvider); } }, [ config?.chain_id, @@ -49,7 +60,10 @@ export const Web3Provider = ({ children }: { children: ReactNode }) => { }} noDataMessage={t('Could not fetch Ethereum configuration')} > - + <>{children} diff --git a/apps/trading/pages/toasts-manager.tsx b/apps/trading/pages/toasts-manager.tsx index 25f432ca1..4d06ca160 100644 --- a/apps/trading/pages/toasts-manager.tsx +++ b/apps/trading/pages/toasts-manager.tsx @@ -3,12 +3,17 @@ import { useUpdateNetworkParametersToasts } from '@vegaprotocol/proposals'; import { useVegaTransactionToasts } from '@vegaprotocol/web3'; import { useEthereumTransactionToasts } from '@vegaprotocol/web3'; import { useEthereumWithdrawApprovalsToasts } from '@vegaprotocol/web3'; +import { Routes } from './client-router'; +import { useReadyToWithdrawalToasts } from '@vegaprotocol/withdraws'; export const ToastsManager = () => { useUpdateNetworkParametersToasts(); useVegaTransactionToasts(); useEthereumTransactionToasts(); useEthereumWithdrawApprovalsToasts(); + useReadyToWithdrawalToasts({ + withdrawalsLink: `${Routes.PORTFOLIO}`, + }); const toasts = useToasts((store) => store.toasts); return ; diff --git a/libs/ui-toolkit/src/components/tabs/tabs.tsx b/libs/ui-toolkit/src/components/tabs/tabs.tsx index 1db47f19b..fd5ca0b13 100644 --- a/libs/ui-toolkit/src/components/tabs/tabs.tsx +++ b/libs/ui-toolkit/src/components/tabs/tabs.tsx @@ -45,7 +45,8 @@ export const Tabs = ({ 'cursor-default': isActive, 'text-neutral-400 hover:text-neutral-500 dark:hover:text-neutral-300': !isActive, - } + }, + 'flex items-center gap-2' ); const borderClass = classNames( 'absolute bottom-[-1px] left-0 w-full h-0 border-b', @@ -58,6 +59,7 @@ export const Tabs = ({ value={child.props.id} className={triggerClass} > + {child.props.indicator} {child.props.name} @@ -87,6 +89,7 @@ interface TabProps { children: ReactNode; id: string; name: string; + indicator?: ReactNode; hidden?: boolean; } diff --git a/libs/ui-toolkit/src/components/toast/toast.tsx b/libs/ui-toolkit/src/components/toast/toast.tsx index 3a5033282..16b8a9a3c 100644 --- a/libs/ui-toolkit/src/components/toast/toast.tsx +++ b/libs/ui-toolkit/src/components/toast/toast.tsx @@ -18,6 +18,8 @@ export type ToastContent = JSX.Element | undefined; type ToastState = 'initial' | 'showing' | 'expired'; +type WithdrawalInfoMeta = { withdrawalId: string | undefined }; + export type Toast = { id: string; intent: Intent; @@ -26,6 +28,9 @@ export type Toast = { onClose?: () => void; signal?: 'close'; loader?: boolean; + hidden?: boolean; + // meta information + meta?: WithdrawalInfoMeta | undefined; }; type ToastProps = Toast & { diff --git a/libs/ui-toolkit/src/components/toast/toasts-container.tsx b/libs/ui-toolkit/src/components/toast/toasts-container.tsx index bc4acd604..ca2e06818 100644 --- a/libs/ui-toolkit/src/components/toast/toasts-container.tsx +++ b/libs/ui-toolkit/src/components/toast/toasts-container.tsx @@ -13,11 +13,13 @@ import { Portal } from '@radix-ui/react-portal'; type ToastsContainerProps = { toasts: Toasts; order: 'asc' | 'desc'; + showHidden?: boolean; }; export const ToastsContainer = ({ toasts, order = 'asc', + showHidden = false, }: ToastsContainerProps) => { const ref = useRef(); const closeAll = useToasts((store) => store.closeAll); @@ -41,6 +43,10 @@ export const ToastsContainer = ({ }; }, [count, order, toasts]); + const validToasts = Object.values(toasts).filter( + (t) => !t.hidden || showHidden + ); + return ( } @@ -71,14 +77,16 @@ export const ToastsContainer = ({ 'flex-col-reverse': order === 'desc', })} > - {toasts && - Object.values(toasts).map((toast) => { - return ( -
  • - -
  • - ); - })} + {validToasts.length > 0 && + validToasts + .filter((t) => !t.hidden || showHidden) + .map((toast) => { + return ( +
  • + +
  • + ); + })} +

    + + ); +}; + +const SingleReadyToWithdrawToastContent = ({ + withdrawal, +}: { + withdrawal: WithdrawalFieldsFragment; +}) => { + const { createEthWithdrawalApproval } = useEthWithdrawApprovalsStore( + (state) => ({ + createEthWithdrawalApproval: state.create, + }) + ); + + const { data: approval } = useWithdrawalApprovalQuery({ + variables: { + withdrawalId: withdrawal.id, + }, + }); + const completeButton = ( +

    + +

    + ); + const amount = formatNumber( + toBigNum(withdrawal.amount, withdrawal.asset.decimals), + withdrawal.asset.decimals + ); + return ( + <> + {t('Withdrawal ready')} +

    {t('Complete the withdrawal to release your funds')}

    + + + {t('Withdraw')} {amount} {withdrawal.asset.symbol} + + + {completeButton} + + ); +}; diff --git a/libs/withdraws/src/lib/use-verify-withdrawal.ts b/libs/withdraws/src/lib/use-verify-withdrawal.ts index 252eea6b4..0f3e3bfca 100644 --- a/libs/withdraws/src/lib/use-verify-withdrawal.ts +++ b/libs/withdraws/src/lib/use-verify-withdrawal.ts @@ -82,16 +82,19 @@ export const useVerifyWithdrawal = () => { if (threshold && amount.isGreaterThan(threshold)) { const delaySecs = await getDelay(); - const completeTimestamp = - new Date(withdrawal.createdTimestamp).getTime() + delaySecs * 1000; + if (delaySecs != null) { + const completeTimestamp = + new Date(withdrawal.createdTimestamp).getTime() + + delaySecs * 1000; - if (Date.now() < completeTimestamp) { - setState({ - status: ApprovalStatus.Delayed, - threshold, - completeTimestamp, - }); - return false; + if (Date.now() < completeTimestamp) { + setState({ + status: ApprovalStatus.Delayed, + threshold, + completeTimestamp, + }); + return false; + } } } diff --git a/libs/withdraws/src/lib/use-withdraw-asset.tsx b/libs/withdraws/src/lib/use-withdraw-asset.tsx index 6d58a2621..4a635d661 100644 --- a/libs/withdraws/src/lib/use-withdraw-asset.tsx +++ b/libs/withdraws/src/lib/use-withdraw-asset.tsx @@ -62,8 +62,8 @@ export const useWithdrawAsset = ( try { logger.info('get withdraw asset data', { asset: asset?.id }); const result = await Promise.all([getThreshold(asset), getDelay()]); - threshold = result[0]; - delay = result[1]; + if (result[0] != null) threshold = result[0]; + if (result[1] != null) delay = result[1]; } catch (err) { logger.error('get withdraw asset data', err); } diff --git a/libs/withdraws/src/lib/withdraw-form-container.spec.tsx b/libs/withdraws/src/lib/withdraw-form-container.spec.tsx index 78ee7d7e4..2d59791a2 100644 --- a/libs/withdraws/src/lib/withdraw-form-container.spec.tsx +++ b/libs/withdraws/src/lib/withdraw-form-container.spec.tsx @@ -5,6 +5,11 @@ import { useAccountBalance } from '@vegaprotocol/accounts'; import { WithdrawFormContainer } from './withdraw-form-container'; import * as Types from '@vegaprotocol/types'; import { useWeb3React } from '@web3-react/core'; +import { + useGetWithdrawDelay, + useGetWithdrawThreshold, +} from '@vegaprotocol/web3'; +import BigNumber from 'bignumber.js'; let mockData: Account[] | null = null; jest.mock('@vegaprotocol/data-provider', () => ({ @@ -27,6 +32,7 @@ jest.mock('@vegaprotocol/network-parameters', () => { }), }; }); +jest.mock('@vegaprotocol/web3'); describe('WithdrawFormContainer', () => { const props = { @@ -110,6 +116,12 @@ describe('WithdrawFormContainer', () => { accountBalance: 0, accountDecimals: null, }); + (useGetWithdrawThreshold as jest.Mock).mockReturnValue(() => + Promise.resolve(new BigNumber(Infinity)) + ); + (useGetWithdrawDelay as jest.Mock).mockReturnValue(() => + Promise.resolve(60) + ); }); afterAll(() => { jest.resetAllMocks(); diff --git a/libs/withdraws/src/lib/withdrawals-table.tsx b/libs/withdraws/src/lib/withdrawals-table.tsx index 9a080e509..4c1336b6b 100644 --- a/libs/withdraws/src/lib/withdrawals-table.tsx +++ b/libs/withdraws/src/lib/withdrawals-table.tsx @@ -1,8 +1,9 @@ -import { useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { AgGridReact } from 'ag-grid-react'; import { AgGridColumn } from 'ag-grid-react'; import { addDecimalsFormatNumber, + convertToCountdownString, getDateTimeFormat, isNumeric, truncateByChars, @@ -32,9 +33,14 @@ import { useWithdrawalApprovalDialog, } from '@vegaprotocol/web3'; import * as Schema from '@vegaprotocol/types'; +import type { TimestampedWithdrawals } from './use-ready-to-complete-withdrawals-toast'; +import classNames from 'classnames'; export const WithdrawalsTable = ( - props: TypedDataAgGrid + props: TypedDataAgGrid & { + ready?: TimestampedWithdrawals; + delayed?: TimestampedWithdrawals; + } ) => { const gridRef = useRef(null); const createWithdrawApproval = useEthWithdrawApprovalsStore( @@ -125,6 +131,7 @@ export const WithdrawalsTable = ( { - if (!data) { - return null; - } - if (data.pendingOnForeignChain || !data.txHash) { - return {t('Pending')}; - } - if (data.status === Schema.WithdrawalStatus.STATUS_FINALIZED) { - return {t('Completed')}; - } - if (data.status === Schema.WithdrawalStatus.STATUS_REJECTED) { - return {t('Rejected')}; - } - return {t('Failed')}; +export const StatusCell = ({ + data, + ready, + delayed, +}: { + data: WithdrawalFieldsFragment; + ready?: TimestampedWithdrawals; + delayed?: TimestampedWithdrawals; +}) => { + const READY_TO_COMPLETE = t('Ready to complete'); + const DELAYED = (readyIn: string) => t('Delayed (ready in %s)', readyIn); + const PENDING = t('Pending'); + const COMPLETED = t('Completed'); + const REJECTED = t('Rejected'); + const FAILED = t('Failed'); + + const isPending = data.pendingOnForeignChain || !data.txHash; + const isReady = ready?.find((w) => w.data.id === data.id); + const isDelayed = delayed?.find((w) => w.data.id === data.id); + + const determineLabel = () => { + if (isPending) { + if (isReady) { + return READY_TO_COMPLETE; + } + return PENDING; + } + if (data.status === Schema.WithdrawalStatus.STATUS_FINALIZED) { + return COMPLETED; + } + if (data.status === Schema.WithdrawalStatus.STATUS_REJECTED) { + return REJECTED; + } + return FAILED; + }; + + const [label, setLabel] = useState(determineLabel()); + useEffect(() => { + // handle countdown for delayed withdrawals + let interval: NodeJS.Timer; + if (!data || !isDelayed || isDelayed.timestamp == null || !isPending) { + return; + } + + // eslint-disable-next-line prefer-const + interval = setInterval(() => { + if (isDelayed.timestamp == null) return; + const remaining = Date.now() - isDelayed.timestamp; + if (remaining < 0) { + setLabel(DELAYED(convertToCountdownString(remaining, '0:00:00:00'))); + } else { + setLabel(READY_TO_COMPLETE); + } + }, 1000); + return () => { + clearInterval(interval); + }; + }, [READY_TO_COMPLETE, data, delayed, isDelayed, isPending]); + + return data ? ( + + {label} + + ) : null; }; const RecipientCell = ({