feat(web3): ready to withdraw toast - fixed async issues

This commit is contained in:
asiaznik
2023-06-21 13:59:26 +02:00
parent 357bc71a64
commit 2b0a91d435
12 changed files with 304 additions and 193 deletions
@@ -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;
@@ -27,6 +29,8 @@ export type Toast = {
signal?: 'close';
loader?: boolean;
hidden?: boolean;
// meta information
meta?: WithdrawalInfoMeta | undefined;
};
type ToastProps = Toast & {
@@ -43,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>}
@@ -73,8 +77,8 @@ export const ToastsContainer = ({
'flex-col-reverse': order === 'desc',
})}
>
{toasts &&
Object.values(toasts)
{validToasts.length > 0 &&
validToasts
.filter((t) => !t.hidden || showHidden)
.map((toast) => {
return (
@@ -93,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={() => {
@@ -50,6 +50,10 @@ type Actions = {
* 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;
@@ -102,6 +106,17 @@ 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';
}
});
},
}))
);
+1
View File
@@ -28,3 +28,4 @@ export * from './lib/web3-provider';
export * from './lib/withdrawal-approval-dialog';
export * from './lib/withdrawal-approval-status';
export * from './lib/default-web3-provider';
export * from './lib/use-withdraw-data-store';
@@ -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(
@@ -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(
+31 -54
View File
@@ -1,32 +1,7 @@
import { useBridgeContract } from './use-bridge-contract';
import { useCallback, useEffect } from 'react';
import { useCallback } from 'react';
import { localLoggerFactory } from '@vegaprotocol/logger';
import { create } from 'zustand';
/**
* 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(true);
const getDelay = useCallback(async () => {
const logger = localLoggerFactory({ application: 'web3' });
if (!contract) {
logger.error('get withdraw delay: no bridge contract');
return;
}
try {
logger.info('get withdraw delay', { contract });
const res = await contract?.default_withdraw_delay();
return res.toNumber();
} catch (err) {
logger.error('get withdraw delay', err);
}
}, [contract]);
return getDelay;
};
import { useWithdrawDataStore } from './use-withdraw-data-store';
/**
* The withdraw delay is a global value set on the contract bridge which may be
@@ -36,34 +11,36 @@ export const useGetWithdrawDelay = () => {
const MAX_AGE = 5 * 60 * 1000; // 5 minutes
type WithdrawDelayStore = {
delay: number | null;
ts: number;
setDelay: (delay: number) => void;
};
const useWithdrawDelayStore = create<WithdrawDelayStore>()((set) => ({
delay: null,
ts: 0,
setDelay: (delay) => set({ delay }),
}));
export const useWithdrawDelay = () => {
const getDelay = useGetWithdrawDelay();
/**
* 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 delay = useWithdrawDataStore((state) => state.delay);
const setDelay = useWithdrawDataStore((state) => state.setDelay);
const contract = useBridgeContract(true);
const logger = localLoggerFactory({ application: 'web3' });
const [delay, ts] = useWithdrawDelayStore((state) => [state.delay, state.ts]);
const setDelay = useWithdrawDelayStore((state) => state.setDelay);
useEffect(() => {
if (delay && Date.now() - ts <= MAX_AGE) return;
getDelay()
.then((d) => {
if (typeof d === 'number') {
logger.info(`retrieved withdraw delay: ${d} seconds`);
setDelay(d);
}
})
.catch((err) => logger.error('could not get the withdraw delay', err));
}, [delay, getDelay, logger, setDelay, ts]);
const getDelay = useCallback(async () => {
// return cached value if still valid
if (delay && Date.now() - delay.ts <= MAX_AGE) {
return delay.value;
}
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`);
setDelay(res.toNumber());
return res.toNumber() as number;
} catch (err) {
logger.error('could not get withdraw delay', err);
return undefined;
}
}, [contract, delay, logger, setDelay]);
return delay;
return getDelay;
};
@@ -1,44 +1,20 @@
import { useCallback, useEffect } from 'react';
import { useCallback } from 'react';
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';
import { create } from 'zustand';
import {
BUILTIN_ASSET_ADDRESS,
BUILTIN_ASSET_THRESHOLD,
useWithdrawDataStore,
} from './use-withdraw-data-store';
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
*/
export const useGetWithdrawThreshold = () => {
const contract = useBridgeContract(true);
const getThreshold = useCallback(
async (asset: Asset | undefined) => {
if (!contract || asset?.source.__typename !== 'ERC20') {
return new BigNumber(Infinity);
}
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]
);
return getThreshold;
};
/**
* 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
@@ -46,57 +22,55 @@ export const useGetWithdrawThreshold = () => {
*/
const MAX_AGE = 5 * 60 * 1000; // 5 minutes
const BUILTIN_ASSET_ADDRESS = 'builtin';
const BUILTIN_ASSET_THRESHOLD = new BigNumber(Infinity);
type WithdrawThresholdsStore = {
thresholds: Record<string, { value: BigNumber; ts: number }>;
setThreshold: (contractAddress: string, threshold: BigNumber) => void;
};
const useWithdrawThresholdsStore = create<WithdrawThresholdsStore>()((set) => ({
thresholds: {
[BUILTIN_ASSET_ADDRESS]: { value: BUILTIN_ASSET_THRESHOLD, ts: 0 },
},
setThreshold: (contractAddress, threshold) =>
set((state) => {
state.thresholds[contractAddress] = { value: threshold, ts: Date.now() };
return state;
}),
}));
const addr = (asset: Asset) =>
asset?.source.__typename === 'ERC20'
export const addr = (asset: Asset | undefined) =>
asset && asset.source.__typename === 'ERC20'
? asset.source.contractAddress
: BUILTIN_ASSET_ADDRESS;
export const useWithdrawThresholds = (assets: Asset[] | undefined) => {
const getThreshold = useGetWithdrawThreshold();
/**
* 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 logger = localLoggerFactory({ application: 'web3' });
const thresholds = useWithdrawThresholdsStore((state) => state.thresholds);
const setThreshold = useWithdrawThresholdsStore(
(state) => state.setThreshold
const contract = useBridgeContract(true);
const thresholds = useWithdrawDataStore((state) => state.thresholds);
const setThreshold = useWithdrawDataStore((state) => state.setThreshold);
const getThreshold = useCallback(
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;
}
},
[contract, logger, setThreshold, thresholds]
);
useEffect(() => {
if (!assets || assets.length === 0) return;
for (const asset of assets) {
const threshold = thresholds[addr(asset)];
if (threshold && Date.now() - threshold.ts <= MAX_AGE) {
return;
}
getThreshold(asset)
.then((t) => {
logger.info(
`retrieved withdraw threshold for ${addr(asset)}: ${t.toString()}`
);
setThreshold(addr(asset), t);
})
.catch((err) =>
logger.error('could not get the withdraw threshold', err)
);
}
}, [assets, getThreshold, logger, setThreshold, thresholds]);
return thresholds;
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,
};
};
@@ -0,0 +1,33 @@
import BigNumber from 'bignumber.js';
import { create } from 'zustand';
export const BUILTIN_ASSET_ADDRESS = 'builtin';
export const BUILTIN_ASSET_THRESHOLD = new BigNumber(Infinity);
type TimestampedThreshold = { value: BigNumber; ts: number };
type TimestampedDelay = { value: number | undefined; ts: number };
type WithdrawDataStore = {
thresholds: Record<string, TimestampedThreshold>;
delay: TimestampedDelay;
setThreshold: (contractAddress: string, threshold: BigNumber) => void;
setDelay: (delay: number) => void;
};
export const useWithdrawDataStore = create<WithdrawDataStore>()((set) => ({
thresholds: {
[BUILTIN_ASSET_ADDRESS]: { value: BUILTIN_ASSET_THRESHOLD, ts: 0 },
},
delay: {
value: undefined,
ts: 0,
},
setThreshold: (contractAddress, threshold) =>
set((state) => {
state.thresholds[contractAddress] = {
value: threshold,
ts: Date.now(),
};
return state;
}),
setDelay: (delay) => set({ delay: { value: delay, ts: Date.now() } }),
}));
@@ -5,21 +5,26 @@ import {
} from '@vegaprotocol/wallet';
import BigNumber from 'bignumber.js';
import type { Toast } from '@vegaprotocol/ui-toolkit';
import { CLOSE_AFTER } from '@vegaprotocol/ui-toolkit';
import { Button, Intent, Panel, ToastHeading } from '@vegaprotocol/ui-toolkit';
import { useToasts } from '@vegaprotocol/ui-toolkit';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect } from 'react';
import { t } from '@vegaprotocol/i18n';
import { formatNumber, toBigNum } from '@vegaprotocol/utils';
import { useNavigate } from 'react-router-dom';
import {
addr,
useEthWithdrawApprovalsStore,
useWithdrawDelay,
useWithdrawThresholds,
useGetWithdrawDelay,
useGetWithdrawThreshold,
useWithdrawDataStore,
} from '@vegaprotocol/web3';
import { withdrawalProvider } from './withdrawals-provider';
import type { WithdrawalFieldsFragment } from './__generated__/Withdrawal';
import uniqBy from 'lodash/uniqBy';
const TOAST_ID = `ready-to-withdraw`;
const CHECK_INTERVAL = 1000;
const ON_APP_START_TOAST_ID = `ready-to-withdraw`;
type UseReadyToWithdrawalToastsOptions = {
withdrawalsLink: string;
};
@@ -27,10 +32,15 @@ type UseReadyToWithdrawalToastsOptions = {
export const useReadyToWithdrawalToasts = ({
withdrawalsLink,
}: UseReadyToWithdrawalToastsOptions) => {
const [setToast, hasToast, updateToast] = useToasts((store) => [
const [setToast, hasToast, updateToast, removeToast] = useToasts((store) => [
store.setToast,
store.hasToast,
store.update,
store.remove,
]);
const [thresholds, delay] = useWithdrawDataStore((state) => [
state.thresholds,
state.delay,
]);
const { pubKey, isReadOnly } = useVegaWallet();
@@ -39,60 +49,125 @@ export const useReadyToWithdrawalToasts = ({
variables: { partyId: pubKey || '' },
skip: !pubKey || isReadOnly,
});
const delay = useWithdrawDelay(); // seconds
const incompleteWithdrawals = useMemo(
() => data?.filter((w) => !w.txHash),
[data]
);
const getDelay = useGetWithdrawDelay(); // seconds
const incompleteWithdrawals = data?.filter((w) => !w.txHash);
const assets = incompleteWithdrawals?.map((w) => w.asset);
const thresholds = useWithdrawThresholds(assets);
const readyToComplete = incompleteWithdrawals?.filter((w) => {
const address =
w.asset?.source.__typename === 'ERC20'
? w.asset.source.contractAddress
: 'builtin';
const threshold = thresholds[address];
if (threshold && delay) {
if (!new BigNumber(w.amount).isGreaterThan(threshold.value)) {
// there's no delay time for withdrawals below the threshold
return true;
}
const completeTimestamp =
new Date(w.createdTimestamp).getTime() + delay * 1000;
if (Date.now() >= completeTimestamp) {
// after delay
return true;
}
}
return false;
});
const assets = uniqBy(
incompleteWithdrawals?.map((w) => w.asset),
(a) => addr(a)
);
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)),
]);
}, [assets, getDelay, getThreshold]);
const onClose = useCallback(() => {
updateToast(TOAST_ID, { hidden: true });
updateToast(ON_APP_START_TOAST_ID, { hidden: true });
}, [updateToast]);
useEffect(() => {
if (!readyToComplete || readyToComplete?.length === 0) {
return;
}
const toast: Toast = {
id: TOAST_ID,
intent: Intent.Warning,
content:
readyToComplete.length === 1 ? (
<SingleReadyToWithdrawToastContent withdrawal={readyToComplete[0]} />
) : (
<MultipleReadyToWithdrawToastContent
count={readyToComplete.length}
withdrawalsLink={withdrawalsLink}
/>
),
onClose,
};
// set only once, unless removed
if (!hasToast(TOAST_ID)) setToast(toast);
}, [hasToast, onClose, readyToComplete, setToast, withdrawalsLink]);
checkWithdraws().then((retrieved) => {
if (!retrieved || delay.value === undefined || !incompleteWithdrawals) {
return;
}
const timestamped = incompleteWithdrawals.map((w) => {
let timestamp = undefined;
const threshold = thresholds[addr(w.asset)]; // { value: new BigNumber(0) };
if (threshold) {
timestamp = 0;
if (new BigNumber(w.amount).isGreaterThan(threshold.value)) {
const created = w.createdTimestamp;
timestamp =
new Date(created).getTime() + (delay.value as number) * 1000;
}
}
return {
data: w,
timestamp,
};
});
const delayed = timestamped?.filter(
(item) => item.timestamp != null && Date.now() < item.timestamp
);
const readyToComplete = timestamped?.filter(
(item) => item.timestamp != null && Date.now() >= item.timestamp
);
// set on app start toast if there are withdrawals ready to complete
if (readyToComplete && readyToComplete.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:
readyToComplete.length === 1 ? (
<SingleReadyToWithdrawToastContent
withdrawal={readyToComplete[0].data}
/>
) : (
<MultipleReadyToWithdrawToastContent
count={readyToComplete.length}
withdrawalsLink={withdrawalsLink}
/>
),
onClose,
};
setToast(appStartToast);
}
}
// set toast whenever a withdrawal delay is passed
let interval: NodeJS.Timer;
if (delayed && 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 });
removeToast(id);
},
// closeAfter: CLOSE_AFTER,
};
if (!hasToast(id)) setToast(toast);
}
}, CHECK_INTERVAL);
}
return () => {
clearInterval(interval);
};
});
}, [
checkWithdraws,
delay,
hasToast,
incompleteWithdrawals,
onClose,
removeToast,
setToast,
thresholds,
withdrawalsLink,
]);
};
const MultipleReadyToWithdrawToastContent = ({