feat(web3): ready to withdraw toast (#4142)
Co-authored-by: Bartłomiej Głownia <bglownia@gmail.com>
This commit is contained in:
parent
187f1929fe
commit
484d7888cf
@ -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 (
|
||||
<span className="bg-vega-blue-450 text-white text-[10px] rounded p-[3px] pb-[2px] leading-none">
|
||||
{ready.length}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const Portfolio = () => {
|
||||
const { updateTitle } = usePageTitleStore((store) => ({
|
||||
@ -103,7 +116,11 @@ export const Portfolio = () => {
|
||||
<DepositsContainer />
|
||||
</VegaWalletContainer>
|
||||
</Tab>
|
||||
<Tab id="withdrawals" name={t('Withdrawals')}>
|
||||
<Tab
|
||||
id="withdrawals"
|
||||
name={t('Withdrawals')}
|
||||
indicator={<WithdrawalsIndicator />}
|
||||
>
|
||||
<WithdrawalsContainer />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
@ -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 (
|
||||
<VegaWalletContainer>
|
||||
@ -25,6 +27,8 @@ export const WithdrawalsContainer = () => {
|
||||
data-testid="withdrawals-history"
|
||||
rowData={data}
|
||||
overlayNoRowsTemplate={error ? error.message : t('No withdrawals')}
|
||||
ready={ready}
|
||||
delayed={delayed}
|
||||
/>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
|
@ -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')}
|
||||
>
|
||||
<Web3ProviderInternal connectors={connectors}>
|
||||
<Web3ProviderInternal
|
||||
connectors={connectors}
|
||||
defaultProvider={defaultProvider}
|
||||
>
|
||||
<>{children}</>
|
||||
</Web3ProviderInternal>
|
||||
</AsyncRenderer>
|
||||
|
@ -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 <ToastsContainer order="desc" toasts={toasts} />;
|
||||
|
@ -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}
|
||||
<span className={borderClass} />
|
||||
</TabsPrimitive.Trigger>
|
||||
@ -87,6 +89,7 @@ interface TabProps {
|
||||
children: ReactNode;
|
||||
id: string;
|
||||
name: string;
|
||||
indicator?: ReactNode;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
|
@ -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 & {
|
||||
|
@ -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<HTMLDivElement>();
|
||||
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 (
|
||||
<Portal
|
||||
ref={ref as Ref<HTMLDivElement>}
|
||||
@ -71,14 +77,16 @@ export const ToastsContainer = ({
|
||||
'flex-col-reverse': order === 'desc',
|
||||
})}
|
||||
>
|
||||
{toasts &&
|
||||
Object.values(toasts).map((toast) => {
|
||||
return (
|
||||
<li key={toast.id}>
|
||||
<Toast {...toast} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{validToasts.length > 0 &&
|
||||
validToasts
|
||||
.filter((t) => !t.hidden || showHidden)
|
||||
.map((toast) => {
|
||||
return (
|
||||
<li key={toast.id}>
|
||||
<Toast {...toast} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
title={t('Dismiss all toasts')}
|
||||
size="sm"
|
||||
@ -89,7 +97,7 @@ export const ToastsContainer = ({
|
||||
'opacity-0 group-hover:opacity-50 hover:!opacity-100',
|
||||
'text-sm text-black dark:text-white bg-white dark:bg-black hover:!bg-white hover:dark:!bg-black',
|
||||
{
|
||||
hidden: Object.keys(toasts).length === 0,
|
||||
hidden: validToasts.length === 0,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
|
@ -46,12 +46,20 @@ type Actions = {
|
||||
* Arbitrary removes all toasts
|
||||
*/
|
||||
removeAll: () => void;
|
||||
/**
|
||||
* Checks if a given toasts exists in the collection
|
||||
*/
|
||||
hasToast: (id: string) => boolean;
|
||||
/**
|
||||
* Closes toast by meta
|
||||
*/
|
||||
closeBy: (meta: Toast['meta']) => void;
|
||||
};
|
||||
|
||||
type ToastsStore = State & Actions;
|
||||
|
||||
export const useToasts = create<ToastsStore>()(
|
||||
immer((set) => ({
|
||||
immer((set, get) => ({
|
||||
toasts: {},
|
||||
count: 0,
|
||||
add: (toast) =>
|
||||
@ -97,6 +105,18 @@ export const useToasts = create<ToastsStore>()(
|
||||
}
|
||||
}),
|
||||
removeAll: () => set({ toasts: {}, count: 0 }),
|
||||
hasToast: (id) => get().toasts[id] != null,
|
||||
closeBy: (meta) => {
|
||||
if (!meta) return;
|
||||
set((state) => {
|
||||
const found = Object.values(state.toasts).find((t) =>
|
||||
isEqual(t.meta, meta)
|
||||
);
|
||||
if (found) {
|
||||
found.signal = 'close';
|
||||
}
|
||||
});
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
|
@ -27,3 +27,4 @@ export * from './lib/web3-connectors';
|
||||
export * from './lib/web3-provider';
|
||||
export * from './lib/withdrawal-approval-dialog';
|
||||
export * from './lib/withdrawal-approval-status';
|
||||
export * from './lib/default-web3-provider';
|
||||
|
@ -16,3 +16,6 @@ export const getChainName = (chainId: number | null | undefined) => {
|
||||
*/
|
||||
export const WALLETCONNECT_PROJECT_ID =
|
||||
process.env['NX_WALLETCONNECT_PROJECT_ID'] || '';
|
||||
|
||||
export const ETHEREUM_PROVIDER_URL =
|
||||
process.env['NX_ETHEREUM_PROVIDER_URL'] || '';
|
||||
|
19
libs/web3/src/lib/default-web3-provider.tsx
Normal file
19
libs/web3/src/lib/default-web3-provider.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import type { ethers } from 'ethers';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type DefaultWeb3ProviderContextShape = {
|
||||
provider?: ethers.providers.JsonRpcProvider;
|
||||
};
|
||||
export const DefaultWeb3ProviderContext = createContext<
|
||||
DefaultWeb3ProviderContextShape | undefined
|
||||
>(undefined);
|
||||
|
||||
export const useDefaultWeb3Provider = () => {
|
||||
const context = useContext(DefaultWeb3ProviderContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useDefaultWeb3Provider must be used within DefaultWeb3ProviderContext'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
@ -2,23 +2,40 @@ import { CollateralBridge } from '@vegaprotocol/smart-contracts';
|
||||
import { useWeb3React } from '@web3-react/core';
|
||||
import { useMemo } from 'react';
|
||||
import { useEthereumConfig } from './use-ethereum-config';
|
||||
import { useDefaultWeb3Provider } from './default-web3-provider';
|
||||
import { localLoggerFactory } from '@vegaprotocol/logger';
|
||||
|
||||
export const useBridgeContract = () => {
|
||||
const { provider } = useWeb3React();
|
||||
export const useBridgeContract = (allowDefaultProvider = false) => {
|
||||
const { provider: activeProvider } = useWeb3React();
|
||||
const { provider: defaultProvider } = useDefaultWeb3Provider();
|
||||
const { config } = useEthereumConfig();
|
||||
const logger = localLoggerFactory({ application: 'web3' });
|
||||
|
||||
const provider = useMemo(() => {
|
||||
if (!activeProvider && allowDefaultProvider) {
|
||||
logger.info('will use default web3 provider');
|
||||
return defaultProvider;
|
||||
}
|
||||
return activeProvider;
|
||||
}, [activeProvider, allowDefaultProvider, defaultProvider, logger]);
|
||||
|
||||
// this has to be memoized, otherwise it ticks like crazy
|
||||
const signer = useMemo(() => {
|
||||
return !activeProvider && allowDefaultProvider
|
||||
? undefined
|
||||
: activeProvider?.getSigner();
|
||||
}, [activeProvider, allowDefaultProvider]);
|
||||
|
||||
const contract = useMemo(() => {
|
||||
if (!provider || !config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const signer = provider.getSigner();
|
||||
|
||||
return new CollateralBridge(
|
||||
config.collateral_bridge_contract.address,
|
||||
signer || provider
|
||||
);
|
||||
}, [provider, config]);
|
||||
}, [provider, signer, config]);
|
||||
|
||||
return contract;
|
||||
};
|
||||
|
@ -4,7 +4,10 @@ import type { MultisigControl } from '@vegaprotocol/smart-contracts';
|
||||
import type { CollateralBridge } from '@vegaprotocol/smart-contracts';
|
||||
import type { Token } from '@vegaprotocol/smart-contracts';
|
||||
|
||||
import type { DepositBusEventFieldsFragment } from '@vegaprotocol/wallet';
|
||||
import type {
|
||||
DepositBusEventFieldsFragment,
|
||||
WithdrawalBusEventFieldsFragment,
|
||||
} from '@vegaprotocol/wallet';
|
||||
|
||||
import type { EthTxState } from './use-ethereum-transaction';
|
||||
import { EthTxStatus } from './use-ethereum-transaction';
|
||||
@ -27,6 +30,7 @@ export interface EthStoredTxState extends EthTxState {
|
||||
requiresConfirmation: boolean; // whether or not the tx needs external confirmation (IE from a subscription even)
|
||||
assetId?: string;
|
||||
deposit?: DepositBusEventFieldsFragment;
|
||||
withdrawal?: WithdrawalBusEventFieldsFragment;
|
||||
}
|
||||
|
||||
export interface EthTransactionStore {
|
||||
@ -37,7 +41,8 @@ export interface EthTransactionStore {
|
||||
args: string[],
|
||||
assetId?: string,
|
||||
requiredConfirmations?: number,
|
||||
requiresConfirmation?: boolean
|
||||
requiresConfirmation?: boolean,
|
||||
withdrawal?: EthStoredTxState['withdrawal']
|
||||
) => number;
|
||||
update: (
|
||||
id: EthStoredTxState['id'],
|
||||
@ -62,7 +67,8 @@ export const useEthTransactionStore = create<EthTransactionStore>()(
|
||||
args: string[] = [],
|
||||
assetId?: string,
|
||||
requiredConfirmations = 1,
|
||||
requiresConfirmation = false
|
||||
requiresConfirmation = false,
|
||||
withdrawal = undefined
|
||||
) => {
|
||||
const transactions = get().transactions;
|
||||
const now = new Date();
|
||||
@ -82,6 +88,7 @@ export const useEthTransactionStore = create<EthTransactionStore>()(
|
||||
requiredConfirmations,
|
||||
requiresConfirmation,
|
||||
assetId,
|
||||
withdrawal,
|
||||
};
|
||||
set({ transactions: transactions.concat(transaction) });
|
||||
return transaction.id;
|
||||
|
@ -162,9 +162,10 @@ const isFinal = (tx: EthStoredTxState) =>
|
||||
[EthTxStatus.Confirmed, EthTxStatus.Error].includes(tx.status);
|
||||
|
||||
export const useEthereumTransactionToasts = () => {
|
||||
const [setToast, removeToast] = useToasts((store) => [
|
||||
const [setToast, removeToast, closeToastBy] = useToasts((store) => [
|
||||
store.setToast,
|
||||
store.remove,
|
||||
store.closeBy,
|
||||
]);
|
||||
|
||||
const dismissTx = useEthTransactionStore((state) => state.dismiss);
|
||||
@ -173,8 +174,16 @@ export const useEthereumTransactionToasts = () => {
|
||||
(tx: EthStoredTxState) => () => {
|
||||
dismissTx(tx.id);
|
||||
removeToast(`eth-${tx.id}`);
|
||||
// closes related "Funds released" toast after successful withdrawal
|
||||
if (
|
||||
isWithdrawTransaction(tx) &&
|
||||
tx.status === EthTxStatus.Confirmed &&
|
||||
tx.withdrawal
|
||||
) {
|
||||
closeToastBy({ withdrawalId: tx.withdrawal.id });
|
||||
}
|
||||
},
|
||||
[dismissTx, removeToast]
|
||||
[closeToastBy, dismissTx, removeToast]
|
||||
);
|
||||
|
||||
const fromEthTransaction = useCallback(
|
||||
|
@ -298,14 +298,19 @@ describe('useEthWithdrawApprovalsManager', () => {
|
||||
mockEthTransactionStoreState.mockReturnValue({ create });
|
||||
render();
|
||||
await waitForNextTick();
|
||||
expect(create).toBeCalledWith({}, 'withdraw_asset', [
|
||||
erc20WithdrawalApproval.assetSource,
|
||||
erc20WithdrawalApproval.amount,
|
||||
erc20WithdrawalApproval.targetAddress,
|
||||
erc20WithdrawalApproval.creation,
|
||||
erc20WithdrawalApproval.nonce,
|
||||
erc20WithdrawalApproval.signatures,
|
||||
]);
|
||||
expect(create).toHaveBeenCalled();
|
||||
expect(create.mock.calls[0][0]).toEqual({});
|
||||
expect(create.mock.calls[0][1]).toEqual('withdraw_asset');
|
||||
expect(create.mock.calls[0][2]).toEqual(
|
||||
expect.arrayContaining([
|
||||
erc20WithdrawalApproval.assetSource,
|
||||
erc20WithdrawalApproval.amount,
|
||||
erc20WithdrawalApproval.targetAddress,
|
||||
erc20WithdrawalApproval.creation,
|
||||
erc20WithdrawalApproval.nonce,
|
||||
erc20WithdrawalApproval.signatures,
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('detect wrong chainId', () => {
|
||||
|
@ -91,7 +91,8 @@ export const useEthWithdrawApprovalsManager = () => {
|
||||
if (threshold && amount.isGreaterThan(threshold)) {
|
||||
const delaySecs = await getDelay();
|
||||
const completeTimestamp =
|
||||
new Date(withdrawal.createdTimestamp).getTime() + delaySecs * 1000;
|
||||
new Date(withdrawal.createdTimestamp).getTime() +
|
||||
(delaySecs as number) * 1000;
|
||||
const now = Date.now();
|
||||
if (now < completeTimestamp) {
|
||||
update(transaction.id, {
|
||||
@ -139,7 +140,11 @@ export const useEthWithdrawApprovalsManager = () => {
|
||||
approval.creation,
|
||||
approval.nonce,
|
||||
approval.signatures,
|
||||
]
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
transaction.withdrawal || undefined
|
||||
);
|
||||
})().catch((err) => {
|
||||
localLoggerFactory({ application: 'web3' }).error(
|
||||
|
@ -3,21 +3,44 @@ import { useCallback } from 'react';
|
||||
import { localLoggerFactory } from '@vegaprotocol/logger';
|
||||
|
||||
/**
|
||||
* Gets the delay in seconds thats required if the withdrawal amount is
|
||||
* over the withdrawal threshold (contract.get_withdraw_threshold)
|
||||
* The withdraw delay is a global value set on the contract bridge which may be
|
||||
* changed via a proposal therefore it can be cached for a set amount of time
|
||||
* (MAX_AGE) and re-retrieved when necessary.
|
||||
*/
|
||||
|
||||
const MAX_AGE = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
type TimestampedDelay = { value: number | undefined; ts: number };
|
||||
const DELAY: TimestampedDelay = { value: undefined, ts: 0 };
|
||||
|
||||
/**
|
||||
* Returns a function that gets the delay in seconds that's required if the
|
||||
* withdrawal amount is over the withdrawal threshold
|
||||
* (contract.get_withdraw_threshold)
|
||||
*/
|
||||
export const useGetWithdrawDelay = () => {
|
||||
const contract = useBridgeContract();
|
||||
const contract = useBridgeContract(true);
|
||||
const logger = localLoggerFactory({ application: 'web3' });
|
||||
|
||||
const getDelay = useCallback(async () => {
|
||||
const logger = localLoggerFactory({ application: 'web3' });
|
||||
try {
|
||||
logger.info('get withdraw delay', { contract: contract?.toString() });
|
||||
const res = await contract?.default_withdraw_delay();
|
||||
return res.toNumber();
|
||||
} catch (err) {
|
||||
logger.error('get withdraw delay', err);
|
||||
if (DELAY.value != null && Date.now() - DELAY.ts <= MAX_AGE) {
|
||||
return DELAY.value;
|
||||
}
|
||||
}, [contract]);
|
||||
if (!contract) {
|
||||
logger.info('could not get withdraw delay: no bridge contract');
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const res = await contract?.default_withdraw_delay();
|
||||
logger.info(`retrieved withdraw delay: ${res} seconds`);
|
||||
DELAY.value = res.toNumber();
|
||||
DELAY.ts = Date.now();
|
||||
return res.toNumber() as number;
|
||||
} catch (err) {
|
||||
logger.error('could not get withdraw delay', err);
|
||||
return undefined;
|
||||
}
|
||||
}, [contract, logger]);
|
||||
|
||||
return getDelay;
|
||||
};
|
||||
|
@ -3,33 +3,74 @@ import { useBridgeContract } from './use-bridge-contract';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import { addDecimal } from '@vegaprotocol/utils';
|
||||
import type { WithdrawalBusEventFieldsFragment } from '@vegaprotocol/wallet';
|
||||
import { localLoggerFactory } from '@vegaprotocol/logger';
|
||||
|
||||
type Asset = Pick<
|
||||
WithdrawalBusEventFieldsFragment['asset'],
|
||||
'source' | 'decimals'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Returns a function to get the threshold amount for a withdrawal. If a withdrawal amount
|
||||
* is greater than this value it will incur a delay before being able to be completed. The delay is set
|
||||
* on the smart contract and can be retrieved using contract.default_withdraw_delay
|
||||
* The withdraw threshold is a value set on the contract bridge per asset which
|
||||
* may be changed via a proposal therefore it can be cached for a set amount of
|
||||
* time (MAX_AGE) and re-retrieved when necessary.
|
||||
*/
|
||||
|
||||
const MAX_AGE = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export const BUILTIN_ASSET_ADDRESS = 'builtin';
|
||||
export const BUILTIN_ASSET_THRESHOLD = new BigNumber(Infinity);
|
||||
type TimestampedThreshold = { value: BigNumber; ts: number };
|
||||
const THRESHOLDS: Record<string, TimestampedThreshold> = {};
|
||||
const setThreshold = (address: string, value: BigNumber) =>
|
||||
(THRESHOLDS[address] = { value, ts: Date.now() });
|
||||
|
||||
export const addr = (asset: Asset | undefined) =>
|
||||
asset && asset.source.__typename === 'ERC20'
|
||||
? asset.source.contractAddress
|
||||
: BUILTIN_ASSET_ADDRESS;
|
||||
|
||||
/**
|
||||
* Returns a function to get the threshold amount for a withdrawal.
|
||||
* If a withdrawal amount is greater than this value it will incur a delay
|
||||
* before being able to be completed. The delay is set on the smart contract and
|
||||
* can be retrieved using contract.default_withdraw_delay
|
||||
*/
|
||||
export const useGetWithdrawThreshold = () => {
|
||||
const contract = useBridgeContract();
|
||||
const logger = localLoggerFactory({ application: 'web3' });
|
||||
const contract = useBridgeContract(true);
|
||||
|
||||
const getThreshold = useCallback(
|
||||
async (
|
||||
asset:
|
||||
| Pick<WithdrawalBusEventFieldsFragment['asset'], 'source' | 'decimals'>
|
||||
| undefined
|
||||
) => {
|
||||
if (!contract || asset?.source.__typename !== 'ERC20') {
|
||||
return new BigNumber(Infinity);
|
||||
async (asset: Asset | undefined) => {
|
||||
const contractAddress = addr(asset);
|
||||
// return cached value if still valid
|
||||
const thr = THRESHOLDS[contractAddress];
|
||||
if (thr && Date.now() - thr.ts <= MAX_AGE) {
|
||||
return thr.value;
|
||||
}
|
||||
if (!contract || !asset || contractAddress === BUILTIN_ASSET_ADDRESS) {
|
||||
setThreshold(BUILTIN_ASSET_ADDRESS, BUILTIN_ASSET_THRESHOLD);
|
||||
return BUILTIN_ASSET_THRESHOLD;
|
||||
}
|
||||
try {
|
||||
const res = await contract.get_withdraw_threshold(contractAddress);
|
||||
const value = new BigNumber(addDecimal(res.toString(), asset.decimals));
|
||||
const threshold = value.isEqualTo(0)
|
||||
? new BigNumber(Infinity)
|
||||
: value.minus(new BigNumber(addDecimal('1', asset.decimals)));
|
||||
logger.info(
|
||||
`retrieved withdraw threshold for ${addr(
|
||||
asset
|
||||
)}: ${threshold.toString()}`
|
||||
);
|
||||
setThreshold(contractAddress, threshold);
|
||||
return threshold;
|
||||
} catch (err) {
|
||||
logger.error('could not get the withdraw thresholds', err);
|
||||
return undefined;
|
||||
}
|
||||
const res = await contract.get_withdraw_threshold(
|
||||
asset.source.contractAddress
|
||||
);
|
||||
const value = new BigNumber(addDecimal(res.toString(), asset.decimals));
|
||||
const threshold = value.isEqualTo(0)
|
||||
? new BigNumber(Infinity)
|
||||
: value.minus(new BigNumber(addDecimal('1', asset.decimals)));
|
||||
return threshold;
|
||||
},
|
||||
[contract]
|
||||
[contract, logger]
|
||||
);
|
||||
|
||||
return getThreshold;
|
||||
|
@ -666,6 +666,12 @@ export const useVegaTransactionToasts = () => {
|
||||
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,
|
||||
@ -673,6 +679,7 @@ export const useVegaTransactionToasts = () => {
|
||||
loader: tx.status === VegaTxStatus.Pending,
|
||||
content,
|
||||
closeAfter,
|
||||
meta,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -9,6 +9,11 @@ import { initializeUrlConnector } from './url-connector';
|
||||
import { WALLETCONNECT_PROJECT_ID } from './constants';
|
||||
import { useWeb3ConnectStore } from './web3-connect-store';
|
||||
import { theme } from '@vegaprotocol/tailwindcss-config';
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
export const createDefaultProvider = (providerUrl: string, chainId: number) => {
|
||||
return new ethers.providers.JsonRpcProvider(providerUrl, chainId);
|
||||
};
|
||||
|
||||
export const initializeCoinbaseConnector = (providerUrl: string) =>
|
||||
initializeConnector<CoinbaseWallet>(
|
||||
|
@ -1,14 +1,21 @@
|
||||
import type { Web3ReactHooks } from '@web3-react/core';
|
||||
import { Web3ReactProvider } from '@web3-react/core';
|
||||
import type { Connector } from '@web3-react/types';
|
||||
import { useMemo } from 'react';
|
||||
import { Web3ReactProvider } from '@web3-react/core';
|
||||
import type { Web3ReactHooks } from '@web3-react/core';
|
||||
import type { Connector } from '@web3-react/types';
|
||||
import type { DefaultWeb3ProviderContextShape } from './default-web3-provider';
|
||||
import { DefaultWeb3ProviderContext } from './default-web3-provider';
|
||||
|
||||
interface Web3ProviderProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
connectors: [Connector, Web3ReactHooks][];
|
||||
defaultProvider?: DefaultWeb3ProviderContextShape['provider'];
|
||||
}
|
||||
|
||||
export const Web3Provider = ({ children, connectors }: Web3ProviderProps) => {
|
||||
export const Web3Provider = ({
|
||||
children,
|
||||
connectors,
|
||||
defaultProvider,
|
||||
}: Web3ProviderProps) => {
|
||||
/**
|
||||
* The connectors prop passed to Web3ReactProvider must be referentially static.
|
||||
* https://github.com/Uniswap/web3-react/blob/31742897f9fddb38e00e36c2516029d3df9a9c54/packages/core/src/provider.tsx#L66
|
||||
@ -20,8 +27,10 @@ export const Web3Provider = ({ children, connectors }: Web3ProviderProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Web3ReactProvider key={key} connectors={connectors}>
|
||||
{children}
|
||||
</Web3ReactProvider>
|
||||
<DefaultWeb3ProviderContext.Provider value={{ provider: defaultProvider }}>
|
||||
<Web3ReactProvider key={key} connectors={connectors}>
|
||||
{children}
|
||||
</Web3ReactProvider>
|
||||
</DefaultWeb3ProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
@ -11,3 +11,4 @@ export * from './lib/withdrawal-dialog';
|
||||
export * from './lib/withdrawal-feedback';
|
||||
export * from './lib/withdrawals-provider';
|
||||
export * from './lib/withdrawals-table';
|
||||
export * from './lib/use-ready-to-complete-withdrawals-toast';
|
||||
|
@ -0,0 +1,207 @@
|
||||
import * as Types from '@vegaprotocol/types';
|
||||
import type { WithdrawalFieldsFragment } from './__generated__/Withdrawal';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import * as web3 from '@vegaprotocol/web3';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useIncompleteWithdrawals } from './use-ready-to-complete-withdrawals-toast';
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
||||
|
||||
jest.mock('@vegaprotocol/web3');
|
||||
jest.mock('@vegaprotocol/wallet');
|
||||
|
||||
type Asset = WithdrawalFieldsFragment['asset'];
|
||||
type Withdrawal = WithdrawalFieldsFragment;
|
||||
|
||||
const NO_THRESHOLD_ASSET: Asset = {
|
||||
__typename: 'Asset',
|
||||
id: 'NO_THRESHOLD_ASSET',
|
||||
name: 'NO_THRESHOLD_ASSET',
|
||||
symbol: 'NTA',
|
||||
decimals: 1,
|
||||
status: Types.AssetStatus.STATUS_ENABLED,
|
||||
source: {
|
||||
__typename: 'ERC20',
|
||||
contractAddress: '0xnta',
|
||||
},
|
||||
};
|
||||
|
||||
const LOW_THRESHOLD_ASSET: Asset = {
|
||||
__typename: 'Asset',
|
||||
id: 'LOW_THRESHOLD_ASSET',
|
||||
name: 'LOW_THRESHOLD_ASSET',
|
||||
symbol: 'LTA',
|
||||
decimals: 1,
|
||||
status: Types.AssetStatus.STATUS_ENABLED,
|
||||
source: {
|
||||
__typename: 'ERC20',
|
||||
contractAddress: '0xlta',
|
||||
},
|
||||
};
|
||||
|
||||
const HIGH_THRESHOLD_ASSET: Asset = {
|
||||
__typename: 'Asset',
|
||||
id: 'HIGH_THRESHOLD_ASSET',
|
||||
name: 'HIGH_THRESHOLD_ASSET',
|
||||
symbol: 'HTA',
|
||||
decimals: 1,
|
||||
status: Types.AssetStatus.STATUS_ENABLED,
|
||||
source: {
|
||||
__typename: 'ERC20',
|
||||
contractAddress: '0xhta',
|
||||
},
|
||||
};
|
||||
|
||||
const NOW = 5000;
|
||||
const DELAY = { value: 1, ts: 5000 };
|
||||
const THRESHOLDS: Record<string, { value: BigNumber; ts: number }> = {
|
||||
builtin: { value: new BigNumber(Infinity), ts: 5000 },
|
||||
'0xnta': { value: new BigNumber(Infinity), ts: 5000 },
|
||||
'0xlta': { value: new BigNumber(10), ts: 5000 },
|
||||
'0xhta': { value: new BigNumber(1000), ts: 5000 },
|
||||
};
|
||||
|
||||
const ts = (ms: number) => new Date(ms).toISOString();
|
||||
|
||||
// ready
|
||||
const mockIncompleteW1: Withdrawal = {
|
||||
id: 'w1',
|
||||
status: Types.WithdrawalStatus.STATUS_FINALIZED,
|
||||
amount: '10000000000',
|
||||
createdTimestamp: ts(10),
|
||||
pendingOnForeignChain: true,
|
||||
withdrawnTimestamp: null,
|
||||
txHash: null,
|
||||
asset: NO_THRESHOLD_ASSET,
|
||||
};
|
||||
|
||||
// delay (10 + 1000 < 5000), ready
|
||||
const mockIncompleteW2: Withdrawal = {
|
||||
id: 'w2',
|
||||
status: Types.WithdrawalStatus.STATUS_FINALIZED,
|
||||
amount: '100',
|
||||
createdTimestamp: ts(10),
|
||||
pendingOnForeignChain: true,
|
||||
withdrawnTimestamp: null,
|
||||
txHash: null,
|
||||
asset: LOW_THRESHOLD_ASSET,
|
||||
};
|
||||
// delay (4500 + 1000 > 5000), below threshold, ready
|
||||
const mockIncompleteW3: Withdrawal = {
|
||||
id: 'w3',
|
||||
status: Types.WithdrawalStatus.STATUS_FINALIZED,
|
||||
amount: '1000',
|
||||
createdTimestamp: ts(4500),
|
||||
pendingOnForeignChain: true,
|
||||
withdrawnTimestamp: null,
|
||||
txHash: null,
|
||||
asset: HIGH_THRESHOLD_ASSET,
|
||||
};
|
||||
// delay (5000 + 1000 > 5000), delayed
|
||||
const mockIncompleteW4: Withdrawal = {
|
||||
id: 'w4',
|
||||
status: Types.WithdrawalStatus.STATUS_FINALIZED,
|
||||
amount: '10000',
|
||||
createdTimestamp: ts(5000),
|
||||
pendingOnForeignChain: true,
|
||||
withdrawnTimestamp: null,
|
||||
txHash: null,
|
||||
asset: HIGH_THRESHOLD_ASSET,
|
||||
};
|
||||
// delay (4001 + 1000 > 5000), delayed
|
||||
const mockIncompleteW5: Withdrawal = {
|
||||
id: 'w5',
|
||||
status: Types.WithdrawalStatus.STATUS_FINALIZED,
|
||||
amount: '100000',
|
||||
createdTimestamp: ts(4001),
|
||||
pendingOnForeignChain: true,
|
||||
withdrawnTimestamp: null,
|
||||
txHash: null,
|
||||
asset: HIGH_THRESHOLD_ASSET,
|
||||
};
|
||||
// completed
|
||||
const mockCompleteW1: Withdrawal = {
|
||||
id: 'cw1',
|
||||
status: Types.WithdrawalStatus.STATUS_FINALIZED,
|
||||
amount: '1000',
|
||||
createdTimestamp: ts(10),
|
||||
pendingOnForeignChain: false,
|
||||
withdrawnTimestamp: ts(11),
|
||||
txHash: '0xcompleted',
|
||||
asset: HIGH_THRESHOLD_ASSET,
|
||||
};
|
||||
|
||||
jest.mock('@vegaprotocol/data-provider', () => ({
|
||||
...jest.requireActual('@vegaprotocol/data-provider'),
|
||||
useDataProvider: () => ({
|
||||
data: [
|
||||
mockIncompleteW1,
|
||||
mockIncompleteW2,
|
||||
mockIncompleteW3,
|
||||
mockIncompleteW4,
|
||||
mockIncompleteW5,
|
||||
mockCompleteW1,
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useIncompleteWithdrawals', () => {
|
||||
let DATE_NOW: jest.SpyInstance<number, []>;
|
||||
beforeAll(() => {
|
||||
// mocks Date.now() to always return the same point in time.
|
||||
DATE_NOW = jest.spyOn(Date, 'now').mockImplementation(() => NOW);
|
||||
|
||||
(useVegaWallet as jest.Mock).mockReturnValue({
|
||||
pubKey: '0xpubkey',
|
||||
isReadOnly: false,
|
||||
});
|
||||
|
||||
(web3.useGetWithdrawThreshold as jest.Mock).mockImplementation(
|
||||
() => (asset: Asset) => {
|
||||
if (asset.source.__typename === 'ERC20') {
|
||||
return Promise.resolve(
|
||||
THRESHOLDS[asset.source.contractAddress].value
|
||||
);
|
||||
}
|
||||
return Promise.resolve(Infinity);
|
||||
}
|
||||
);
|
||||
(web3.useGetWithdrawDelay as jest.Mock).mockImplementation(() => () => {
|
||||
return Promise.resolve(DELAY.value);
|
||||
});
|
||||
(web3.addr as jest.Mock).mockImplementation((asset: Asset | undefined) =>
|
||||
asset && asset.source.__typename === 'ERC20'
|
||||
? asset.source.contractAddress
|
||||
: 'builtin'
|
||||
);
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
DATE_NOW.mockRestore();
|
||||
});
|
||||
|
||||
it('returns a collection of ready to complete withdrawals', async () => {
|
||||
const { result } = renderHook(() => useIncompleteWithdrawals(), {
|
||||
wrapper: MockedProvider,
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const { ready } = result.current;
|
||||
expect(ready).toHaveLength(3);
|
||||
expect(ready.map((w) => w.data.id)).toContain(mockIncompleteW1.id);
|
||||
expect(ready.map((w) => w.data.id)).toContain(mockIncompleteW2.id);
|
||||
expect(ready.map((w) => w.data.id)).toContain(mockIncompleteW3.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a collection of delayed withdrawals', async () => {
|
||||
const { result } = renderHook(() => useIncompleteWithdrawals(), {
|
||||
wrapper: MockedProvider,
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const { delayed } = result.current;
|
||||
expect(delayed).toHaveLength(2);
|
||||
expect(delayed.map((w) => w.data.id)).toContain(mockIncompleteW4.id);
|
||||
expect(delayed.map((w) => w.data.id)).toContain(mockIncompleteW5.id);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,277 @@
|
||||
import { useDataProvider } from '@vegaprotocol/data-provider';
|
||||
import {
|
||||
useVegaWallet,
|
||||
useWithdrawalApprovalQuery,
|
||||
} from '@vegaprotocol/wallet';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import type { Toast } from '@vegaprotocol/ui-toolkit';
|
||||
import { Button, Intent, Panel, ToastHeading } from '@vegaprotocol/ui-toolkit';
|
||||
import { useToasts } from '@vegaprotocol/ui-toolkit';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import { formatNumber, toBigNum } from '@vegaprotocol/utils';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
useEthWithdrawApprovalsStore,
|
||||
useGetWithdrawDelay,
|
||||
useGetWithdrawThreshold,
|
||||
} from '@vegaprotocol/web3';
|
||||
import { withdrawalProvider } from './withdrawals-provider';
|
||||
import type { WithdrawalFieldsFragment } from './__generated__/Withdrawal';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
|
||||
const CHECK_INTERVAL = 1000;
|
||||
const ON_APP_START_TOAST_ID = `ready-to-withdraw`;
|
||||
type UseReadyToWithdrawalToastsOptions = {
|
||||
withdrawalsLink: string;
|
||||
};
|
||||
|
||||
export type TimestampedWithdrawals = {
|
||||
data: WithdrawalFieldsFragment;
|
||||
timestamp: number | undefined;
|
||||
}[];
|
||||
|
||||
export const useIncompleteWithdrawals = () => {
|
||||
const [ready, setReady] = useState<TimestampedWithdrawals>([]);
|
||||
const [delayed, setDelayed] = useState<TimestampedWithdrawals>([]);
|
||||
const { pubKey, isReadOnly } = useVegaWallet();
|
||||
const { data } = useDataProvider({
|
||||
dataProvider: withdrawalProvider,
|
||||
variables: { partyId: pubKey || '' },
|
||||
skip: !pubKey || isReadOnly,
|
||||
});
|
||||
const getDelay = useGetWithdrawDelay(); // seconds
|
||||
const incompleteWithdrawals = useMemo(
|
||||
() => data?.filter((w) => !w.txHash),
|
||||
[data]
|
||||
);
|
||||
|
||||
const assets = useMemo(
|
||||
() =>
|
||||
uniqBy(
|
||||
incompleteWithdrawals?.map((w) => w.asset),
|
||||
(a) => a.id
|
||||
),
|
||||
[incompleteWithdrawals]
|
||||
);
|
||||
|
||||
const getThreshold = useGetWithdrawThreshold();
|
||||
|
||||
const checkWithdraws = useCallback(async () => {
|
||||
if (assets.length === 0) return;
|
||||
// trigger delay
|
||||
// trigger thresholds
|
||||
return await Promise.all([
|
||||
getDelay(),
|
||||
...assets.map((asset) => getThreshold(asset)),
|
||||
]).then(([delay, ...thresholds]) => ({
|
||||
delay,
|
||||
thresholds: assets.reduce<Record<string, BigNumber | undefined>>(
|
||||
(all, asset, index) =>
|
||||
Object.assign(all, { [asset.id]: thresholds[index] }),
|
||||
{}
|
||||
),
|
||||
}));
|
||||
}, [assets, getDelay, getThreshold]);
|
||||
|
||||
useEffect(() => {
|
||||
checkWithdraws().then((retrieved) => {
|
||||
if (
|
||||
!retrieved ||
|
||||
retrieved.delay === undefined ||
|
||||
!incompleteWithdrawals
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { thresholds, delay } = retrieved;
|
||||
const timestamped = incompleteWithdrawals.map((w) => {
|
||||
let timestamp = undefined;
|
||||
const threshold = thresholds[w.asset.id];
|
||||
if (threshold) {
|
||||
timestamp = 0;
|
||||
if (new BigNumber(w.amount).isGreaterThan(threshold)) {
|
||||
const created = w.createdTimestamp;
|
||||
timestamp = new Date(created).getTime() + (delay as number) * 1000;
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: w,
|
||||
timestamp,
|
||||
};
|
||||
});
|
||||
const delayed = timestamped?.filter(
|
||||
(item) => item.timestamp != null && Date.now() < item.timestamp
|
||||
);
|
||||
|
||||
const ready = timestamped?.filter(
|
||||
(item) => item.timestamp != null && Date.now() >= item.timestamp
|
||||
);
|
||||
|
||||
setReady(ready);
|
||||
setDelayed(delayed);
|
||||
});
|
||||
}, [checkWithdraws, incompleteWithdrawals]);
|
||||
|
||||
return { ready, delayed };
|
||||
};
|
||||
|
||||
export const useReadyToWithdrawalToasts = ({
|
||||
withdrawalsLink,
|
||||
}: UseReadyToWithdrawalToastsOptions) => {
|
||||
const [setToast, hasToast, updateToast, removeToast] = useToasts((store) => [
|
||||
store.setToast,
|
||||
store.hasToast,
|
||||
store.update,
|
||||
store.remove,
|
||||
]);
|
||||
|
||||
const { delayed, ready } = useIncompleteWithdrawals();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
// hides toast instead of removing is so it won't be re-added on rerender
|
||||
updateToast(ON_APP_START_TOAST_ID, { hidden: true });
|
||||
}, [updateToast]);
|
||||
|
||||
useEffect(() => {
|
||||
// set on app start toast if there are withdrawals ready to complete
|
||||
if (ready.length > 0) {
|
||||
// set only once, unless removed
|
||||
if (!hasToast(ON_APP_START_TOAST_ID)) {
|
||||
const appStartToast: Toast = {
|
||||
id: ON_APP_START_TOAST_ID,
|
||||
intent: Intent.Warning,
|
||||
content:
|
||||
ready.length === 1 ? (
|
||||
<SingleReadyToWithdrawToastContent withdrawal={ready[0].data} />
|
||||
) : (
|
||||
<MultipleReadyToWithdrawToastContent
|
||||
count={ready.length}
|
||||
withdrawalsLink={withdrawalsLink}
|
||||
/>
|
||||
),
|
||||
onClose,
|
||||
};
|
||||
setToast(appStartToast);
|
||||
}
|
||||
}
|
||||
|
||||
// set toast whenever a withdrawal delay is passed
|
||||
let interval: NodeJS.Timer;
|
||||
if (delayed.length > 0) {
|
||||
interval = setInterval(() => {
|
||||
const ready = delayed.filter(
|
||||
(item) => item.timestamp && Date.now() >= item.timestamp
|
||||
);
|
||||
for (const withdrawal of ready) {
|
||||
const id = `complete-withdrawal-${withdrawal.data.id}`;
|
||||
const toast: Toast = {
|
||||
id,
|
||||
intent: Intent.Warning,
|
||||
content: (
|
||||
<SingleReadyToWithdrawToastContent withdrawal={withdrawal.data} />
|
||||
),
|
||||
onClose: () => {
|
||||
updateToast(id, { hidden: true });
|
||||
},
|
||||
};
|
||||
if (!hasToast(id)) setToast(toast);
|
||||
}
|
||||
}, CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [
|
||||
delayed,
|
||||
hasToast,
|
||||
onClose,
|
||||
ready,
|
||||
removeToast,
|
||||
setToast,
|
||||
updateToast,
|
||||
withdrawalsLink,
|
||||
]);
|
||||
};
|
||||
|
||||
const MultipleReadyToWithdrawToastContent = ({
|
||||
count,
|
||||
withdrawalsLink,
|
||||
}: {
|
||||
count: number;
|
||||
withdrawalsLink?: string;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<ToastHeading>{t('Withdrawals ready')}</ToastHeading>
|
||||
<p>
|
||||
{t(
|
||||
'Complete these %s withdrawals to release your funds',
|
||||
count.toString()
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<Button
|
||||
data-testid="toast-view-withdrawals"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
withdrawalsLink ? navigate(withdrawalsLink) : undefined
|
||||
}
|
||||
>
|
||||
{t('View withdrawals')}
|
||||
</Button>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SingleReadyToWithdrawToastContent = ({
|
||||
withdrawal,
|
||||
}: {
|
||||
withdrawal: WithdrawalFieldsFragment;
|
||||
}) => {
|
||||
const { createEthWithdrawalApproval } = useEthWithdrawApprovalsStore(
|
||||
(state) => ({
|
||||
createEthWithdrawalApproval: state.create,
|
||||
})
|
||||
);
|
||||
|
||||
const { data: approval } = useWithdrawalApprovalQuery({
|
||||
variables: {
|
||||
withdrawalId: withdrawal.id,
|
||||
},
|
||||
});
|
||||
const completeButton = (
|
||||
<p className="mt-1">
|
||||
<Button
|
||||
data-testid="toast-complete-withdrawal"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
createEthWithdrawalApproval(
|
||||
withdrawal,
|
||||
approval?.erc20WithdrawalApproval
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('Complete withdrawal')}
|
||||
</Button>
|
||||
</p>
|
||||
);
|
||||
const amount = formatNumber(
|
||||
toBigNum(withdrawal.amount, withdrawal.asset.decimals),
|
||||
withdrawal.asset.decimals
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ToastHeading>{t('Withdrawal ready')}</ToastHeading>
|
||||
<p>{t('Complete the withdrawal to release your funds')}</p>
|
||||
<Panel>
|
||||
<strong>
|
||||
{t('Withdraw')} {amount} {withdrawal.asset.symbol}
|
||||
</strong>
|
||||
</Panel>
|
||||
{completeButton}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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<WithdrawalFieldsFragment>
|
||||
props: TypedDataAgGrid<WithdrawalFieldsFragment> & {
|
||||
ready?: TimestampedWithdrawals;
|
||||
delayed?: TimestampedWithdrawals;
|
||||
}
|
||||
) => {
|
||||
const gridRef = useRef<AgGridReact | null>(null);
|
||||
const createWithdrawApproval = useEthWithdrawApprovalsStore(
|
||||
@ -125,6 +131,7 @@ export const WithdrawalsTable = (
|
||||
<AgGridColumn
|
||||
headerName={t('Status')}
|
||||
field="status"
|
||||
cellRendererParams={{ ready: props.ready, delayed: props.delayed }}
|
||||
cellRenderer="StatusCell"
|
||||
/>
|
||||
<AgGridColumn
|
||||
@ -211,20 +218,74 @@ export const EtherscanLinkCell = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusCell = ({ data }: { data: WithdrawalFieldsFragment }) => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
if (data.pendingOnForeignChain || !data.txHash) {
|
||||
return <span>{t('Pending')}</span>;
|
||||
}
|
||||
if (data.status === Schema.WithdrawalStatus.STATUS_FINALIZED) {
|
||||
return <span>{t('Completed')}</span>;
|
||||
}
|
||||
if (data.status === Schema.WithdrawalStatus.STATUS_REJECTED) {
|
||||
return <span>{t('Rejected')}</span>;
|
||||
}
|
||||
return <span>{t('Failed')}</span>;
|
||||
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<string | undefined>(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 ? (
|
||||
<span
|
||||
className={classNames({
|
||||
'text-vega-blue-450': label === READY_TO_COMPLETE,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const RecipientCell = ({
|
||||
|
Loading…
Reference in New Issue
Block a user