feat: transaction store and toasts (#2382)

* feat: add eth and vega transaction stores

feat: replace useStoredEthereumTransaction with useEthTransactionManager

feat: add event bus subsciption to vega transaction store

feat: handle order cancellation

feat: rename Deposit, Order and Withdraw status field to be unique

Revert "feat: rename Deposit, Order and Withdraw status field to be unique"

This reverts commit f0b314d53fb3ada6fbebaba4fd1e5af6f38beaed.

feat: split transaction update subscription

feat: handle order and deposit transaction

feat: handle withdrawal creation through transaction store

feat: handle withdraw approval

feat: handle panding withdrawls, add createdAt

feat: handle transaction toast/dialog dismissal

feat: add use vega transaction store tests

feat: add use vega transaction store tests

feat: add use vega transaction menager tests

feat: add use vega transaction menager tests

feat: add use vega transaction updater tests

feat: improve use vega transaction updater tests

feat: add use eth transaction store

feat: add use eth withdraw approvals store

feat: add use eth transaction updater tests

fixed tests

* feat: toasts

feat: toasts

feat: toasts

* feat: add use eth withdraw approval manager tests

* feat: add use eth transaction manager tests

* feat: add use eth transaction manager tests

* feat: add useEthWithdrawApprovalsManager tests

* feat: remove Web3Container react container from CreateWithdrawalDialog

* feat: remove Web3Container react container around TransactionsHandler

* feat: remove unnecessary async from PendingWithdrawalsTable

* feat: remove comments from WithdrawalFeedback

* fixed z-index issue

* cypress

Co-authored-by: Bartłomiej Głownia <bglownia@gmail.com>
This commit is contained in:
Art 2022-12-21 10:29:32 +01:00 committed by GitHub
parent bdff40b4bc
commit 87e1f9998e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 3348 additions and 244 deletions

View File

@ -107,10 +107,7 @@ describe('withdraw actions', { tags: '@regression' }, () => {
cy.getByTestId('DELAY_TIME_value').should('have.text', 'None');
cy.get(amountField).clear().type('10');
cy.getByTestId(submitWithdrawBtn).click();
cy.getByTestId('dialog-title').should(
'have.text',
'Awaiting network confirmation'
);
cy.getByTestId('toast').should('contain.text', 'Awaiting confirmation');
});
it.skip('creates a withdrawal on submit'); // Needs capsule

View File

@ -0,0 +1 @@
export { VegaTransaction } from './vega-transaction';

View File

@ -0,0 +1,57 @@
import { WithdrawalFeedback } from '@vegaprotocol/withdraws';
import { OrderFeedback } from '@vegaprotocol/orders';
import {
VegaDialog,
VegaTxStatus,
isWithdrawTransaction,
isOrderCancellationTransaction,
isOrderSubmissionTransaction,
isOrderAmendmentTransaction,
} from '@vegaprotocol/wallet';
import type { VegaStoredTxState } from '@vegaprotocol/wallet';
import { useEthWithdrawApprovalsStore } from '@vegaprotocol/web3';
export const VegaTransaction = ({
transaction,
}: {
transaction: VegaStoredTxState;
}) => {
const createEthWithdrawalApproval = useEthWithdrawApprovalsStore(
(state) => state.create
);
if (isWithdrawTransaction(transaction.body)) {
if (
transaction.status === VegaTxStatus.Complete &&
transaction.withdrawal
) {
return (
<WithdrawalFeedback
transaction={transaction}
withdrawal={transaction.withdrawal}
availableTimestamp={null}
submitWithdraw={() => {
if (!transaction?.withdrawal) {
return;
}
createEthWithdrawalApproval(
transaction.withdrawal,
transaction.withdrawalApproval
);
}}
/>
);
}
} else if (
(isOrderCancellationTransaction(transaction.body) ||
isOrderSubmissionTransaction(transaction.body) ||
isOrderAmendmentTransaction(transaction.body)) &&
transaction.status === VegaTxStatus.Complete &&
transaction.order
) {
return (
<OrderFeedback transaction={transaction} order={transaction.order} />
);
}
return <VegaDialog transaction={transaction} />;
};

View File

@ -5,7 +5,15 @@ import { t } from '@vegaprotocol/react-helpers';
import {
useEagerConnect as useVegaEagerConnect,
VegaWalletProvider,
useVegaTransactionManager,
useVegaTransactionUpdater,
} from '@vegaprotocol/wallet';
import {
useEagerConnect as useEthereumEagerConnect,
useEthTransactionManager,
useEthTransactionUpdater,
useEthWithdrawApprovalsManager,
} from '@vegaprotocol/web3';
import {
EnvironmentProvider,
envTriggerMapping,
@ -18,9 +26,9 @@ import { usePageTitleStore } from '../stores';
import { Footer } from '../components/footer';
import { useEffect, useMemo, useState } from 'react';
import DialogsContainer from './dialogs-container';
import ToastsManager from './toasts-manager';
import { HashRouter, useLocation } from 'react-router-dom';
import { Connectors } from '../lib/vega-connectors';
import { useEagerConnect as useEthereumEagerConnect } from '@vegaprotocol/web3';
const DEFAULT_TITLE = t('Welcome to Vega trading!');
@ -45,6 +53,15 @@ const Title = () => {
);
};
const TransactionsHandler = () => {
useVegaTransactionManager();
useVegaTransactionUpdater();
useEthTransactionManager();
useEthTransactionUpdater();
useEthWithdrawApprovalsManager();
return null;
};
function AppBody({ Component }: AppProps) {
const location = useLocation();
const { VEGA_ENV } = useEnvironment();
@ -68,6 +85,8 @@ function AppBody({ Component }: AppProps) {
</main>
<Footer />
<DialogsContainer />
<ToastsManager />
<TransactionsHandler />
<MaybeConnectEagerly />
</div>
</Web3Provider>

View File

@ -4,13 +4,14 @@ import {
} from '@vegaprotocol/assets';
import { VegaConnectDialog } from '@vegaprotocol/wallet';
import { Connectors } from '../lib/vega-connectors';
import { WithdrawalDialog } from '@vegaprotocol/withdraws';
import { CreateWithdrawalDialog } from '@vegaprotocol/withdraws';
import { DepositDialog } from '@vegaprotocol/deposits';
import { Web3ConnectUncontrolledDialog } from '@vegaprotocol/web3';
import { WelcomeDialog } from '../components/welcome-dialog';
const DialogsContainer = () => {
const { isOpen, id, trigger, setOpen } = useAssetDetailsDialogStore();
return (
<>
<VegaConnectDialog connectors={Connectors} />
@ -23,7 +24,7 @@ const DialogsContainer = () => {
<WelcomeDialog />
<DepositDialog />
<Web3ConnectUncontrolledDialog />
<WithdrawalDialog />
<CreateWithdrawalDialog />
</>
);
};

View File

@ -0,0 +1,479 @@
import {
Button,
ExternalLink,
Intent,
ProgressBar,
ToastsContainer,
} from '@vegaprotocol/ui-toolkit';
import { useCallback, useEffect, useMemo } from 'react';
import {
useEthTransactionStore,
useEthWithdrawApprovalsStore,
TransactionContent,
EthTxStatus,
isEthereumError,
ApprovalStatus,
} from '@vegaprotocol/web3';
import {
isWithdrawTransaction,
useVegaTransactionStore,
VegaTxStatus,
} from '@vegaprotocol/wallet';
import { VegaTransaction } from '../components/vega-transaction';
import { VerificationStatus } from '@vegaprotocol/withdraws';
import compact from 'lodash/compact';
import sortBy from 'lodash/sortBy';
import type {
EthStoredTxState,
EthWithdrawalApprovalState,
} from '@vegaprotocol/web3';
import type { Toast } from '@vegaprotocol/ui-toolkit';
import type {
VegaStoredTxState,
WithdrawSubmissionBody,
WithdrawalBusEventFieldsFragment,
} from '@vegaprotocol/wallet';
import type { Asset } from '@vegaprotocol/assets';
import { useAssetsDataProvider } from '@vegaprotocol/assets';
import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers';
import {
DApp,
ETHERSCAN_TX,
EXPLORER_TX,
useEtherscanLink,
useLinks,
} from '@vegaprotocol/environment';
import { prepend0x } from '@vegaprotocol/smart-contracts';
const intentMap = {
Default: Intent.Primary,
Requested: Intent.Warning,
Pending: Intent.Warning,
Error: Intent.Danger,
Complete: Intent.Success,
Confirmed: Intent.Success,
Idle: Intent.None,
Delayed: Intent.Warning,
Ready: Intent.Success,
};
const TransactionDetails = ({
label,
amount,
asset,
}: {
label: string;
amount: string;
asset: Pick<Asset, 'symbol' | 'decimals'>;
}) => {
const num = formatNumber(toBigNum(amount, asset.decimals), asset.decimals);
return (
<div className="mt-[5px]">
<span className="font-mono text-xs p-1 bg-gray-100 rounded">
{label} {num} {asset.symbol}
</span>
</div>
);
};
const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
const { data } = useAssetsDataProvider();
if (!data) return null;
const VEGA_WITHDRAW = isWithdrawTransaction(tx.body);
if (VEGA_WITHDRAW) {
const transactionDetails = tx.body as WithdrawSubmissionBody;
const asset = data?.find(
(a) => a.id === transactionDetails.withdrawSubmission.asset
);
if (asset) {
return (
<TransactionDetails
label={t('Withdraw')}
amount={transactionDetails.withdrawSubmission.amount}
asset={asset}
/>
);
}
}
return null;
};
const EthTransactionDetails = ({ tx }: { tx: EthStoredTxState }) => {
const { data } = useAssetsDataProvider();
if (!data) return null;
const ETH_WITHDRAW =
tx.methodName === 'withdraw_asset' && tx.args.length > 2 && tx.asset;
if (ETH_WITHDRAW) {
const asset = data.find((a) => a.id === tx.asset);
if (asset) {
return (
<>
<TransactionDetails
label={t('Withdraw')}
amount={tx.args[1]}
asset={asset}
/>
{tx.requiresConfirmation && (
<div className="mt-[10px]">
<span className="font-mono text-xs">
{t('Awaiting confirmations')}{' '}
{`(${tx.confirmations}/${tx.requiredConfirmations})`}
</span>
<ProgressBar
value={(tx.confirmations / tx.requiredConfirmations) * 100}
/>
</div>
)}
</>
);
}
}
return null;
};
export const ToastsManager = () => {
const vegaTransactions = useVegaTransactionStore((state) =>
state.transactions.filter((transaction) => transaction?.dialogOpen)
);
const dismissVegaTransaction = useVegaTransactionStore(
(state) => state.dismiss
);
const ethTransactions = useEthTransactionStore((state) =>
state.transactions.filter((transaction) => transaction?.dialogOpen)
);
const dismissEthTransaction = useEthTransactionStore(
(state) => state.dismiss
);
const { withdrawApprovals, createEthWithdrawalApproval } =
useEthWithdrawApprovalsStore((state) => ({
withdrawApprovals: state.transactions.filter(
(transaction) => transaction?.dialogOpen
),
createEthWithdrawalApproval: state.create,
}));
const dismissWithdrawApproval = useEthWithdrawApprovalsStore(
(state) => state.dismiss
);
const explorerLink = useLinks(DApp.Explorer);
const etherscanLink = useEtherscanLink();
const fromVegaTransaction = useCallback(
(tx: VegaStoredTxState): Toast => {
let toast: Partial<Toast> = {};
const defaultValues = {
id: `vega-${tx.id}`,
intent: intentMap[tx.status],
render: () => {
return <VegaTransaction transaction={tx} />;
},
onClose: () => dismissVegaTransaction(tx.id),
};
if (tx.status === VegaTxStatus.Requested) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Action required')}</h3>
<p>
{t(
'Please go to your Vega wallet application and approve or reject the transaction.'
)}
</p>
<VegaTransactionDetails tx={tx} />
</div>
);
},
};
}
if (tx.status === VegaTxStatus.Pending) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3>
<p>{t('Please wait for your transaction to be confirmed')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(
EXPLORER_TX.replace(':hash', prepend0x(tx.txHash))
)}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
</div>
);
},
loader: true,
};
}
if (tx.status === VegaTxStatus.Complete) {
toast = {
render: () => {
if (isWithdrawTransaction(tx.body)) {
const completeWithdrawalButton = tx.withdrawal && (
<div className="mt-[10px]">
<Button
size="xs"
onClick={() => {
createEthWithdrawalApproval(
tx.withdrawal as WithdrawalBusEventFieldsFragment,
tx.withdrawalApproval
);
}}
>
{t('Complete withdrawal')}
</Button>
</div>
);
return (
<div>
<h3 className="font-bold">{t('Funds unlocked')}</h3>
<p>{t('Your funds have been unlocked for withdrawal')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(
EXPLORER_TX.replace(':hash', prepend0x(tx.txHash))
)}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
{completeWithdrawalButton}
</div>
);
}
return (
<div>
<h3 className="font-bold">{t('Confirmed')}</h3>
<p>{t('Your transaction has been confirmed ')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(
EXPLORER_TX.replace(':hash', prepend0x(tx.txHash))
)}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
</div>
);
},
};
}
if (tx.status === VegaTxStatus.Error) {
toast = {
render: () => {
const errorMessage = `${tx.error?.message} ${
tx.error?.data ? `: ${tx.error?.data}` : ''
}`;
return (
<div>
<h3 className="font-bold">{t('Error occurred')}</h3>
<p>{errorMessage}</p>
<VegaTransactionDetails tx={tx} />
</div>
);
},
};
}
return {
...defaultValues,
...toast,
};
},
[createEthWithdrawalApproval, dismissVegaTransaction, explorerLink]
);
const fromEthTransaction = useCallback(
(tx: EthStoredTxState): Toast => {
let toast: Partial<Toast> = {};
const defaultValues = {
id: `eth-${tx.id}`,
intent: intentMap[tx.status],
render: () => {
return <TransactionContent {...tx} />;
},
onClose: () => dismissEthTransaction(tx.id),
};
if (tx.status === EthTxStatus.Requested) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Action required')}</h3>
<p>
{t(
'Please go to your wallet application and approve or reject the transaction.'
)}
</p>
<EthTransactionDetails tx={tx} />
</div>
);
},
};
}
if (tx.status === EthTxStatus.Pending) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3>
<p>{t('Please wait for your transaction to be confirmed')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={etherscanLink(
ETHERSCAN_TX.replace(':hash', tx.txHash)
)}
rel="noreferrer"
>
{t('View on Etherscan')}
</ExternalLink>
</p>
)}
<EthTransactionDetails tx={tx} />
</div>
);
},
loader: true,
};
}
if (tx.status === EthTxStatus.Confirmed) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Transaction completed')}</h3>
<p>{t('Your transaction has been completed')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={etherscanLink(
ETHERSCAN_TX.replace(':hash', tx.txHash)
)}
rel="noreferrer"
>
{t('View on Etherscan')}
</ExternalLink>
</p>
)}
<EthTransactionDetails tx={tx} />
</div>
);
},
};
}
if (tx.status === EthTxStatus.Error) {
toast = {
render: () => {
let errorMessage = '';
if (isEthereumError(tx.error)) {
errorMessage = tx.error.reason;
} else if (tx.error instanceof Error) {
errorMessage = tx.error.message;
}
return (
<div>
<h3 className="font-bold">{t('Error occurred')}</h3>
<p>{errorMessage}</p>
<EthTransactionDetails tx={tx} />
</div>
);
},
};
}
return {
...defaultValues,
...toast,
};
},
[dismissEthTransaction, etherscanLink]
);
const fromWithdrawalApproval = useCallback(
(tx: EthWithdrawalApprovalState): Toast => ({
id: `withdrawal-${tx.id}`,
intent: intentMap[tx.status],
render: () => {
let title = '';
if (tx.status === ApprovalStatus.Error) {
title = t('Error occurred');
}
if (tx.status === ApprovalStatus.Pending) {
title = t('Pending approval');
}
if (tx.status === ApprovalStatus.Delayed) {
title = t('Delayed');
}
return (
<div>
{title.length > 0 && <h3 className="font-bold">{title}</h3>}
<VerificationStatus state={tx} />
<TransactionDetails
label={t('Withdraw')}
amount={tx.withdrawal.amount}
asset={tx.withdrawal.asset}
/>
</div>
);
},
onClose: () => dismissWithdrawApproval(tx.id),
loader: tx.status === ApprovalStatus.Pending,
}),
[dismissWithdrawApproval]
);
const toasts = useMemo(() => {
return sortBy(
[
...compact(vegaTransactions).map(fromVegaTransaction),
...compact(ethTransactions).map(fromEthTransaction),
...compact(withdrawApprovals).map(fromWithdrawalApproval),
],
['createdBy']
);
}, [
fromEthTransaction,
fromVegaTransaction,
fromWithdrawalApproval,
ethTransactions,
vegaTransactions,
withdrawApprovals,
]);
useEffect(
() =>
console.log([
...vegaTransactions,
...ethTransactions,
...withdrawApprovals,
]),
[ethTransactions, vegaTransactions, withdrawApprovals]
);
useEffect(() => console.log(toasts), [toasts]);
return <ToastsContainer order="desc" toasts={toasts} />;
};
export default ToastsManager;

View File

@ -74,6 +74,16 @@ export const useLinks = (dapp: DApp, network?: Net) => {
return link;
};
export const useEtherscanLink = () => {
const { ETHERSCAN_URL } = useEnvironment();
const baseUrl = trim(ETHERSCAN_URL, '/');
const link = useCallback(
(url?: string) => `${baseUrl}/${trim(url, '/') || ''}`,
[baseUrl]
);
return link;
};
// Vega blog
export const BLOG = 'https://blog.vega.xyz/';
@ -83,3 +93,9 @@ export const TOKEN_NEW_NETWORK_PARAM_PROPOSAL =
'/governance/propose/network-parameter';
export const TOKEN_PROPOSALS = '/governance';
export const TOKEN_PROPOSAL = '/governance/:id';
// Explorer pages
export const EXPLORER_TX = '/txs/:hash';
// Etherscan pages
export const ETHERSCAN_TX = '/tx/:hash';

View File

@ -7,6 +7,7 @@
}
.showing {
right: 0;
opacity: 1;
transition: all 0.3s;
max-height: 100vw;

View File

@ -9,6 +9,7 @@ import { useLayoutEffect } from 'react';
import { useRef } from 'react';
import { Intent } from '../../utils/intent';
import { Icon } from '../icon';
import { Loader } from '../loader';
type ToastContentProps = { id: string };
type ToastContent = (props: ToastContentProps) => JSX.Element;
@ -20,12 +21,13 @@ export type Toast = {
intent: Intent;
render: ToastContent;
closeAfter?: number;
onClose?: () => void;
signal?: 'close';
loader?: boolean;
};
type ToastProps = Toast & {
state?: ToastState;
onClose?: (id: string) => void;
};
const toastIconMapping: { [i in Intent]: IconName } = {
@ -55,6 +57,7 @@ export const Toast = ({
signal,
state = 'initial',
onClose,
loader = false,
}: ToastProps) => {
const toastRef = useRef<HTMLDivElement>(null);
@ -66,9 +69,9 @@ export const Toast = ({
}
});
setTimeout(() => {
onClose?.(id);
onClose?.();
}, CLOSE_DELAY);
}, [id, onClose]);
}, [onClose]);
useLayoutEffect(() => {
const req = requestAnimationFrame(() => {
@ -98,10 +101,11 @@ export const Toast = ({
return (
<div
data-testid="toast"
data-toast-id={id}
ref={toastRef}
className={classNames(
'relative w-[300px] top-0 right-0 rounded-md border overflow-hidden mb-2',
'relative w-[300px] top-0 rounded-md border overflow-hidden mb-2',
'text-black bg-white dark:border-zinc-700',
{
[styles['initial']]: state === 'initial',
@ -121,9 +125,18 @@ export const Toast = ({
<div
className={classNames(getToastAccent(intent), 'p-2 pt-3 text-center')}
>
<Icon name={toastIconMapping[intent]} size={4} className="!block" />
{loader ? (
<div className="w-4 h-4">
<Loader size="small" forceTheme="dark" />
</div>
) : (
<Icon name={toastIconMapping[intent]} size={4} className="!block" />
)}
</div>
<div className="flex-1 p-2 pr-6 text-sm" data-testid="toast-content">
<div
className="flex-1 p-2 pr-6 text-sm overflow-auto"
data-testid="toast-content"
>
{render({ id })}
</div>
</div>

View File

@ -1,5 +1,5 @@
import { act, render, renderHook, screen } from '@testing-library/react';
import { CLOSE_DELAY, ToastsContainer, useToasts } from '..';
import { ToastsContainer, useToasts } from '..';
import { Intent } from '../../utils/intent';
describe('ToastsContainer', () => {
@ -15,9 +15,10 @@ describe('ToastsContainer', () => {
jest.clearAllTimers();
});
it('displays a list of toasts in ascending order', () => {
const { baseElement } = render(<ToastsContainer order="asc" />);
const { result } = renderHook(() => useToasts((state) => state.add));
const add = result.current;
const { result } = renderHook(() =>
useToasts((state) => ({ add: state.add, toasts: state.toasts }))
);
const add = result.current.add;
act(() => {
add({
id: 'toast-a',
@ -35,6 +36,9 @@ describe('ToastsContainer', () => {
render: () => <p>C</p>,
});
});
const { baseElement } = render(
<ToastsContainer order="asc" toasts={result.current.toasts} />
);
const toasts = [...screen.queryAllByTestId('toast-content')].map((t) =>
t.textContent?.trim()
);
@ -42,9 +46,10 @@ describe('ToastsContainer', () => {
expect(baseElement.classList).not.toContain('flex-col-reverse');
});
it('displays a list of toasts in descending order', () => {
const { baseElement } = render(<ToastsContainer order="desc" />);
const { result } = renderHook(() => useToasts((state) => state.add));
const add = result.current;
const { result } = renderHook(() =>
useToasts((state) => ({ add: state.add, toasts: state.toasts }))
);
const add = result.current.add;
act(() => {
add({
id: 'toast-a',
@ -62,6 +67,9 @@ describe('ToastsContainer', () => {
render: () => <p>C</p>,
});
});
const { baseElement } = render(
<ToastsContainer order="desc" toasts={result.current.toasts} />
);
const toasts = [...screen.queryAllByTestId('toast-content')].map((t) =>
t.textContent?.trim()
);
@ -69,21 +77,32 @@ describe('ToastsContainer', () => {
expect(baseElement.classList).not.toContain('flex-col-reverse');
});
it('closes a toast after clicking on "Close" button', () => {
const { baseElement } = render(<ToastsContainer order="asc" />);
const { result } = renderHook(() => useToasts((state) => state.add));
const add = result.current;
const { result } = renderHook(() =>
useToasts((state) => ({
add: state.add,
remove: state.remove,
toasts: state.toasts,
}))
);
const add = result.current.add;
const remove = result.current.remove;
act(() => {
add({
id: 'toast-a',
intent: Intent.None,
render: () => <p>A</p>,
onClose: () => remove('toast-a'),
});
add({
id: 'toast-b',
intent: Intent.None,
render: () => <p>B</p>,
onClose: () => remove('toast-b'),
});
});
const { baseElement, rerender } = render(
<ToastsContainer order="asc" toasts={result.current.toasts} />
);
const closeBtn = baseElement.querySelector(
'[data-testid="toast-close"]'
) as HTMLButtonElement;
@ -91,46 +110,10 @@ describe('ToastsContainer', () => {
closeBtn.click();
jest.runAllTimers();
});
rerender(<ToastsContainer order="asc" toasts={result.current.toasts} />);
const toasts = [...screen.queryAllByTestId('toast-content')].map((t) =>
t.textContent?.trim()
);
expect(toasts).toEqual(['B']);
});
it('auto-closes a toast after given time', () => {
render(<ToastsContainer order="asc" />);
const { result } = renderHook(() => useToasts((state) => state.add));
const add = result.current;
act(() => {
add({
id: 'toast-a',
intent: Intent.None,
render: () => <p>A</p>,
closeAfter: 1000,
});
add({
id: 'toast-b',
intent: Intent.None,
render: () => <p>B</p>,
closeAfter: 2000,
});
});
act(() => {
jest.advanceTimersByTime(1000 + CLOSE_DELAY);
});
expect(
[...screen.queryAllByTestId('toast-content')].map((t) =>
t.textContent?.trim()
)
).toEqual(['B']);
act(() => {
jest.advanceTimersByTime(1000 + CLOSE_DELAY);
});
expect(
[...screen.queryAllByTestId('toast-content')].map((t) =>
t.textContent?.trim()
)
).toEqual([]);
});
});

View File

@ -83,12 +83,16 @@ const usePrice = create<PriceStore>((set) => ({
const Template: ComponentStory<typeof ToastsContainer> = (args) => {
const setPrice = usePrice((state) => state.setPrice);
const { add, close, closeAll, update } = useToasts((state) => ({
add: state.add,
close: state.close,
closeAll: state.closeAll,
update: state.update,
}));
const { add, close, closeAll, update, remove, toasts } = useToasts(
(state) => ({
add: state.add,
close: state.close,
closeAll: state.closeAll,
update: state.update,
remove: state.remove,
toasts: state.toasts,
})
);
useEffect(() => {
const i = setInterval(() => {
@ -97,7 +101,10 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
return () => clearInterval(i);
}, [setPrice]);
const addRandomToast = () => add(randomToast());
const addRandomToast = () => {
const t = randomToast();
add({ ...t, onClose: () => remove(t.id) });
};
const addRandomToastWithAction = () => {
const t = randomToast();
const words = [
@ -134,6 +141,7 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
</div>
</>
),
onClose: () => remove(t.id),
});
};
@ -157,6 +165,7 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
add({
...t,
render: () => <ToastContent />,
onClose: () => remove(t.id),
});
};
@ -186,7 +195,7 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
>
🧽
</button>
<ToastsContainer {...args} />
<ToastsContainer {...args} toasts={toasts} />
</div>
);
};

View File

@ -1,37 +1,32 @@
import classNames from 'classnames';
import { useCallback } from 'react';
import { Toast } from './toast';
import { useToasts } from './use-toasts';
type ToastsContainerProps = {
toasts: Toast[];
order: 'asc' | 'desc';
};
export const ToastsContainer = ({ order = 'asc' }: ToastsContainerProps) => {
const { toasts, remove } = useToasts();
const onClose = useCallback(
(id: string) => {
remove(id);
},
[remove]
);
export const ToastsContainer = ({
toasts,
order = 'asc',
}: ToastsContainerProps) => {
return (
<ul
className={classNames(
'absolute top-2 right-2 overflow-hidden max-w-full',
'absolute top-0 right-0 pt-2 pr-2 max-w-full z-20 max-h-full overflow-auto',
{
'flex flex-col-reverse': order === 'desc',
}
)}
>
{toasts.map((toast) => {
return (
<li key={toast.id}>
<Toast onClose={onClose} {...toast} />
</li>
);
})}
{toasts &&
toasts.map((toast) => {
return (
<li key={toast.id}>
<Toast {...toast} />
</li>
);
})}
</ul>
);
};

View File

@ -14,6 +14,10 @@ type ToastsStore = {
* Updates a toast
*/
update: (id: string, toastData: Partial<Toast>) => void;
/**
* Adds a new toast or updates if id already exists.
*/
addOrUpdate: (toast: Toast) => void;
/**
* Closes a toast
*/
@ -32,6 +36,12 @@ type ToastsStore = {
removeAll: () => void;
};
const add =
(toast: Toast) =>
(store: ToastsStore): Partial<ToastsStore> => ({
toasts: [...store.toasts, toast],
});
const update =
(id: string, toastData: Partial<Toast>) =>
(store: ToastsStore): Partial<ToastsStore> => {
@ -43,19 +53,24 @@ const update =
export const useToasts = create<ToastsStore>((set) => ({
toasts: [],
add: (toast) =>
set((state) => ({
toasts: [...state.toasts, toast],
})),
add: (toast) => set(add(toast)),
update: (id, toastData) => set(update(id, toastData)),
addOrUpdate: (toast: Toast) =>
set((store) => {
if (store.toasts.find((t) => t.id === toast.id)) {
return update(toast.id, toast)(store);
} else {
return add(toast)(store);
}
}),
close: (id) => set(update(id, { signal: 'close' })),
closeAll: () =>
set((state) => ({
toasts: [...state.toasts].map((t) => ({ ...t, signal: 'close' })),
set((store) => ({
toasts: [...store.toasts].map((t) => ({ ...t, signal: 'close' })),
})),
remove: (id) =>
set((state) => ({
toasts: [...state.toasts].filter((t) => t.id !== id),
set((store) => ({
toasts: [...store.toasts].filter((t) => t.id !== id),
})),
removeAll: () =>
set(() => ({

View File

@ -0,0 +1,20 @@
import { act } from 'react-dom/test-utils';
const actualCreate = jest.requireActual('zustand').default; // if using jest
// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set();
// when creating a store, we get its initial state, create a reset function and add it in the set
const create = (createState) => {
const store = actualCreate(createState);
const initialState = store.getState();
storeResetFns.add(() => store.setState(initialState, true));
return store;
};
// Reset all stores after each test run
beforeEach(() => {
act(() => storeResetFns.forEach((resetFn) => resetFn()));
});
export default create;

View File

@ -1,12 +1,111 @@
fragment TransactionEventFields on TransactionResult {
partyId
hash
status
error
}
subscription TransactionEvent($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [TransactionResult]) {
type
event {
... on TransactionResult {
partyId
hash
status
error
...TransactionEventFields
}
}
}
}
fragment WithdrawalBusEventFields on Withdrawal {
id
status
amount
asset {
id
name
symbol
decimals
status
source {
... on ERC20 {
contractAddress
}
}
}
createdTimestamp
withdrawnTimestamp
txHash
details {
... on Erc20WithdrawalDetails {
receiverAddress
}
}
pendingOnForeignChain @client
}
subscription WithdrawalBusEvent($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [Withdrawal]) {
event {
... on Withdrawal {
...WithdrawalBusEventFields
}
}
}
}
fragment OrderBusEventFields on Order {
type
id
status
rejectionReason
createdAt
size
price
timeInForce
expiresAt
side
market {
id
decimalPlaces
positionDecimalPlaces
tradableInstrument {
instrument {
name
}
}
}
}
subscription OrderBusEvents($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [Order]) {
type
event {
... on Order {
...OrderBusEventFields
}
}
}
}
fragment DepositBusEventFields on Deposit {
id
status
amount
asset {
id
symbol
decimals
}
createdTimestamp
creditedTimestamp
txHash
}
subscription DepositBusEvent($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [Deposit]) {
event {
... on Deposit {
...DepositBusEventFields
}
}
}

View File

@ -0,0 +1,11 @@
query WithdrawalApproval($withdrawalId: ID!) {
erc20WithdrawalApproval(withdrawalId: $withdrawalId) {
assetSource
amount
nonce
signatures
targetAddress
expiry
creation
}
}

View File

@ -3,6 +3,8 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type TransactionEventFieldsFragment = { __typename?: 'TransactionResult', partyId: string, hash: string, status: boolean, error?: string | null };
export type TransactionEventSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
@ -10,22 +12,120 @@ export type TransactionEventSubscriptionVariables = Types.Exact<{
export type TransactionEventSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', type: Types.BusEventType, event: { __typename?: 'AccountEvent' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit' } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order' } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal' } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult', partyId: string, hash: string, status: boolean, error?: string | null } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal' } }> | null };
export type WithdrawalBusEventFieldsFragment = { __typename?: 'Withdrawal', id: string, status: Types.WithdrawalStatus, amount: string, createdTimestamp: any, withdrawnTimestamp?: any | null, txHash?: string | null, pendingOnForeignChain: boolean, asset: { __typename?: 'Asset', id: string, name: string, symbol: string, decimals: number, status: Types.AssetStatus, source: { __typename?: 'BuiltinAsset' } | { __typename?: 'ERC20', contractAddress: string } }, details?: { __typename?: 'Erc20WithdrawalDetails', receiverAddress: string } | null };
export type WithdrawalBusEventSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type WithdrawalBusEventSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', event: { __typename?: 'AccountEvent' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit' } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order' } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal' } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult' } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal', id: string, status: Types.WithdrawalStatus, amount: string, createdTimestamp: any, withdrawnTimestamp?: any | null, txHash?: string | null, pendingOnForeignChain: boolean, asset: { __typename?: 'Asset', id: string, name: string, symbol: string, decimals: number, status: Types.AssetStatus, source: { __typename?: 'BuiltinAsset' } | { __typename?: 'ERC20', contractAddress: string } }, details?: { __typename?: 'Erc20WithdrawalDetails', receiverAddress: string } | null } }> | null };
export type OrderBusEventFieldsFragment = { __typename?: 'Order', type?: Types.OrderType | null, id: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, createdAt: any, size: string, price: string, timeInForce: Types.OrderTimeInForce, expiresAt?: any | null, side: Types.Side, market: { __typename?: 'Market', id: string, decimalPlaces: number, positionDecimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string } } } };
export type OrderBusEventsSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type OrderBusEventsSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', type: Types.BusEventType, event: { __typename?: 'AccountEvent' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit' } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order', type?: Types.OrderType | null, id: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, createdAt: any, size: string, price: string, timeInForce: Types.OrderTimeInForce, expiresAt?: any | null, side: Types.Side, market: { __typename?: 'Market', id: string, decimalPlaces: number, positionDecimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string } } } } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal' } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult' } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal' } }> | null };
export type DepositBusEventFieldsFragment = { __typename?: 'Deposit', id: string, status: Types.DepositStatus, amount: string, createdTimestamp: any, creditedTimestamp?: any | null, txHash?: string | null, asset: { __typename?: 'Asset', id: string, symbol: string, decimals: number } };
export type DepositBusEventSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type DepositBusEventSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', event: { __typename?: 'AccountEvent' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit', id: string, status: Types.DepositStatus, amount: string, createdTimestamp: any, creditedTimestamp?: any | null, txHash?: string | null, asset: { __typename?: 'Asset', id: string, symbol: string, decimals: number } } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order' } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal' } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult' } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal' } }> | null };
export const TransactionEventFieldsFragmentDoc = gql`
fragment TransactionEventFields on TransactionResult {
partyId
hash
status
error
}
`;
export const WithdrawalBusEventFieldsFragmentDoc = gql`
fragment WithdrawalBusEventFields on Withdrawal {
id
status
amount
asset {
id
name
symbol
decimals
status
source {
... on ERC20 {
contractAddress
}
}
}
createdTimestamp
withdrawnTimestamp
txHash
details {
... on Erc20WithdrawalDetails {
receiverAddress
}
}
pendingOnForeignChain @client
}
`;
export const OrderBusEventFieldsFragmentDoc = gql`
fragment OrderBusEventFields on Order {
type
id
status
rejectionReason
createdAt
size
price
timeInForce
expiresAt
side
market {
id
decimalPlaces
positionDecimalPlaces
tradableInstrument {
instrument {
name
}
}
}
}
`;
export const DepositBusEventFieldsFragmentDoc = gql`
fragment DepositBusEventFields on Deposit {
id
status
amount
asset {
id
symbol
decimals
}
createdTimestamp
creditedTimestamp
txHash
}
`;
export const TransactionEventDocument = gql`
subscription TransactionEvent($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [TransactionResult]) {
type
event {
... on TransactionResult {
partyId
hash
status
error
...TransactionEventFields
}
}
}
}
`;
${TransactionEventFieldsFragmentDoc}`;
/**
* __useTransactionEventSubscription__
@ -48,4 +148,107 @@ export function useTransactionEventSubscription(baseOptions: Apollo.Subscription
return Apollo.useSubscription<TransactionEventSubscription, TransactionEventSubscriptionVariables>(TransactionEventDocument, options);
}
export type TransactionEventSubscriptionHookResult = ReturnType<typeof useTransactionEventSubscription>;
export type TransactionEventSubscriptionResult = Apollo.SubscriptionResult<TransactionEventSubscription>;
export type TransactionEventSubscriptionResult = Apollo.SubscriptionResult<TransactionEventSubscription>;
export const WithdrawalBusEventDocument = gql`
subscription WithdrawalBusEvent($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [Withdrawal]) {
event {
... on Withdrawal {
...WithdrawalBusEventFields
}
}
}
}
${WithdrawalBusEventFieldsFragmentDoc}`;
/**
* __useWithdrawalBusEventSubscription__
*
* To run a query within a React component, call `useWithdrawalBusEventSubscription` and pass it any options that fit your needs.
* When your component renders, `useWithdrawalBusEventSubscription` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useWithdrawalBusEventSubscription({
* variables: {
* partyId: // value for 'partyId'
* },
* });
*/
export function useWithdrawalBusEventSubscription(baseOptions: Apollo.SubscriptionHookOptions<WithdrawalBusEventSubscription, WithdrawalBusEventSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<WithdrawalBusEventSubscription, WithdrawalBusEventSubscriptionVariables>(WithdrawalBusEventDocument, options);
}
export type WithdrawalBusEventSubscriptionHookResult = ReturnType<typeof useWithdrawalBusEventSubscription>;
export type WithdrawalBusEventSubscriptionResult = Apollo.SubscriptionResult<WithdrawalBusEventSubscription>;
export const OrderBusEventsDocument = gql`
subscription OrderBusEvents($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [Order]) {
type
event {
... on Order {
...OrderBusEventFields
}
}
}
}
${OrderBusEventFieldsFragmentDoc}`;
/**
* __useOrderBusEventsSubscription__
*
* To run a query within a React component, call `useOrderBusEventsSubscription` and pass it any options that fit your needs.
* When your component renders, `useOrderBusEventsSubscription` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useOrderBusEventsSubscription({
* variables: {
* partyId: // value for 'partyId'
* },
* });
*/
export function useOrderBusEventsSubscription(baseOptions: Apollo.SubscriptionHookOptions<OrderBusEventsSubscription, OrderBusEventsSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<OrderBusEventsSubscription, OrderBusEventsSubscriptionVariables>(OrderBusEventsDocument, options);
}
export type OrderBusEventsSubscriptionHookResult = ReturnType<typeof useOrderBusEventsSubscription>;
export type OrderBusEventsSubscriptionResult = Apollo.SubscriptionResult<OrderBusEventsSubscription>;
export const DepositBusEventDocument = gql`
subscription DepositBusEvent($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [Deposit]) {
event {
... on Deposit {
...DepositBusEventFields
}
}
}
}
${DepositBusEventFieldsFragmentDoc}`;
/**
* __useDepositBusEventSubscription__
*
* To run a query within a React component, call `useDepositBusEventSubscription` and pass it any options that fit your needs.
* When your component renders, `useDepositBusEventSubscription` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useDepositBusEventSubscription({
* variables: {
* partyId: // value for 'partyId'
* },
* });
*/
export function useDepositBusEventSubscription(baseOptions: Apollo.SubscriptionHookOptions<DepositBusEventSubscription, DepositBusEventSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<DepositBusEventSubscription, DepositBusEventSubscriptionVariables>(DepositBusEventDocument, options);
}
export type DepositBusEventSubscriptionHookResult = ReturnType<typeof useDepositBusEventSubscription>;
export type DepositBusEventSubscriptionResult = Apollo.SubscriptionResult<DepositBusEventSubscription>;

View File

@ -0,0 +1,54 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type WithdrawalApprovalQueryVariables = Types.Exact<{
withdrawalId: Types.Scalars['ID'];
}>;
export type WithdrawalApprovalQuery = { __typename?: 'Query', erc20WithdrawalApproval?: { __typename?: 'Erc20WithdrawalApproval', assetSource: string, amount: string, nonce: string, signatures: string, targetAddress: string, expiry: any, creation: string } | null };
export const WithdrawalApprovalDocument = gql`
query WithdrawalApproval($withdrawalId: ID!) {
erc20WithdrawalApproval(withdrawalId: $withdrawalId) {
assetSource
amount
nonce
signatures
targetAddress
expiry
creation
}
}
`;
/**
* __useWithdrawalApprovalQuery__
*
* To run a query within a React component, call `useWithdrawalApprovalQuery` and pass it any options that fit your needs.
* When your component renders, `useWithdrawalApprovalQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useWithdrawalApprovalQuery({
* variables: {
* withdrawalId: // value for 'withdrawalId'
* },
* });
*/
export function useWithdrawalApprovalQuery(baseOptions: Apollo.QueryHookOptions<WithdrawalApprovalQuery, WithdrawalApprovalQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<WithdrawalApprovalQuery, WithdrawalApprovalQueryVariables>(WithdrawalApprovalDocument, options);
}
export function useWithdrawalApprovalLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<WithdrawalApprovalQuery, WithdrawalApprovalQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<WithdrawalApprovalQuery, WithdrawalApprovalQueryVariables>(WithdrawalApprovalDocument, options);
}
export type WithdrawalApprovalQueryHookResult = ReturnType<typeof useWithdrawalApprovalQuery>;
export type WithdrawalApprovalLazyQueryHookResult = ReturnType<typeof useWithdrawalApprovalLazyQuery>;
export type WithdrawalApprovalQueryResult = Apollo.QueryResult<WithdrawalApprovalQuery, WithdrawalApprovalQueryVariables>;

View File

@ -283,6 +283,22 @@ export type Transaction =
| ProposalSubmissionBody
| BatchMarketInstructionSubmissionBody;
export const isWithdrawTransaction = (
transaction: Transaction
): transaction is WithdrawSubmissionBody => 'withdrawSubmission' in transaction;
export const isOrderSubmissionTransaction = (
transaction: Transaction
): transaction is OrderSubmissionBody => 'orderSubmission' in transaction;
export const isOrderCancellationTransaction = (
transaction: Transaction
): transaction is OrderCancellationBody => 'orderCancellation' in transaction;
export const isOrderAmendmentTransaction = (
transaction: Transaction
): transaction is OrderAmendmentBody => 'orderAmendment' in transaction;
export interface TransactionResponse {
transactionHash: string;
signature: string; // still to be added by core

View File

@ -2,6 +2,9 @@ export * from './context';
export * from './use-vega-wallet';
export * from './connectors';
export * from './use-vega-transaction';
export * from './use-vega-transaction-manager';
export * from './use-vega-transaction-store';
export * from './use-vega-transaction-updater';
export * from './use-transaction-result';
export * from './use-eager-connect';
export * from './manage-dialog';
@ -10,3 +13,4 @@ export * from './provider';
export * from './connect-dialog';
export * from './utils';
export * from './__generated__/TransactionResult';
export * from './__generated__/WithdrawalApproval';

View File

@ -0,0 +1,110 @@
import { useVegaTransactionManager } from './use-vega-transaction-manager';
import { renderHook } from '@testing-library/react';
import waitForNextTick from 'flush-promises';
import type { TransactionResponse } from './connectors/vega-connector';
import type {
VegaTransactionStore,
VegaStoredTxState,
} from './use-vega-transaction-store';
import { VegaTxStatus } from './use-vega-transaction';
const mockSendTx = jest.fn<Promise<Partial<TransactionResponse> | null>, []>();
const pubKey = 'pubKey';
jest.mock('./use-vega-wallet', () => ({
useVegaWallet: () => ({
sendTx: mockSendTx,
pubKey,
}),
}));
const transactionHash = 'txHash';
const signature = 'signature';
const receivedAt = 'receivedAt';
const sentAt = 'sentAt';
const transactionResponse: TransactionResponse = {
transactionHash,
signature,
receivedAt,
sentAt,
};
const pendingTransactionUpdate = {
status: VegaTxStatus.Pending,
txHash: transactionHash,
signature,
};
const update = jest.fn();
const del = jest.fn();
const defaultState: Partial<VegaTransactionStore> = {
transactions: [
{
id: 0,
status: VegaTxStatus.Requested,
} as VegaStoredTxState,
{
id: 1,
status: VegaTxStatus.Requested,
} as VegaStoredTxState,
],
update,
delete: del,
};
const mockTransactionStoreState = jest.fn<Partial<VegaTransactionStore>, []>();
jest.mock('./use-vega-transaction-store', () => ({
useVegaTransactionStore: (
selector: (state: Partial<VegaTransactionStore>) => void
) => selector(mockTransactionStoreState()),
}));
describe('useVegaTransactionManager', () => {
beforeEach(() => {
update.mockReset();
del.mockReset();
mockSendTx.mockReset();
mockTransactionStoreState.mockReset();
});
it('sendTx of first pending transaction', async () => {
mockTransactionStoreState.mockReturnValue(defaultState);
mockSendTx.mockResolvedValue(transactionResponse);
let result = renderHook(useVegaTransactionManager);
result.rerender();
expect(update).not.toBeCalled();
await waitForNextTick();
expect(update.mock.calls[0]).toEqual([0, pendingTransactionUpdate]);
expect(update.mock.calls[1]).toEqual([1, pendingTransactionUpdate]);
update.mockReset();
result = renderHook(useVegaTransactionManager);
await waitForNextTick();
expect(update).toBeCalled();
expect(update.mock.calls[0]).toEqual([0, pendingTransactionUpdate]);
result.rerender();
await waitForNextTick();
expect(update.mock.calls[1]).toEqual([1, pendingTransactionUpdate]);
});
it('del transaction on null response', async () => {
mockTransactionStoreState.mockReturnValue(defaultState);
mockSendTx.mockResolvedValue(null);
renderHook(useVegaTransactionManager);
await waitForNextTick();
expect(update).not.toBeCalled();
expect(del).toBeCalled();
});
it('sets error on reject', async () => {
mockTransactionStoreState.mockReturnValue(defaultState);
mockSendTx.mockRejectedValue(null);
renderHook(useVegaTransactionManager);
await waitForNextTick();
expect(update).toBeCalled();
expect(update.mock.calls[0][1]?.status).toEqual(VegaTxStatus.Error);
});
});

View File

@ -0,0 +1,47 @@
import { useVegaWallet } from './use-vega-wallet';
import { useEffect, useRef } from 'react';
import { ClientErrors } from './connectors';
import { WalletError } from './connectors';
import { VegaTxStatus } from './use-vega-transaction';
import { useVegaTransactionStore } from './use-vega-transaction-store';
export const useVegaTransactionManager = () => {
const { sendTx, pubKey } = useVegaWallet();
const processed = useRef<Set<number>>(new Set());
const transaction = useVegaTransactionStore((state) =>
state.transactions.find(
(transaction) =>
transaction?.status === VegaTxStatus.Requested &&
!processed.current.has(transaction.id)
)
);
const update = useVegaTransactionStore((state) => state.update);
const del = useVegaTransactionStore((state) => state.delete);
useEffect(() => {
if (!(transaction && pubKey)) {
return;
}
processed.current.add(transaction.id);
sendTx(pubKey, transaction.body)
.then((res) => {
if (res === null) {
// User rejected
del(transaction.id);
return;
}
if (res.signature && res.transactionHash) {
update(transaction.id, {
status: VegaTxStatus.Pending,
txHash: res.transactionHash,
signature: res.signature,
});
}
})
.catch((err) => {
update(transaction.id, {
error: err instanceof WalletError ? err : ClientErrors.UNKNOWN,
status: VegaTxStatus.Error,
});
});
}, [transaction, pubKey, del, sendTx, update]);
};

View File

@ -0,0 +1,96 @@
import { useVegaTransactionStore } from './use-vega-transaction-store';
import { VegaTxStatus } from './use-vega-transaction';
import type { VegaStoredTxState } from './use-vega-transaction-store';
import type {
OrderCancellationBody,
WithdrawSubmissionBody,
} from './connectors/vega-connector';
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
determineId: jest.fn((v) => v),
}));
describe('useVegaTransactionStore', () => {
const orderCancellation: OrderCancellationBody = { orderCancellation: {} };
const withdrawSubmission: WithdrawSubmissionBody = {
withdrawSubmission: {
amount: 'amount',
asset: 'asset',
ext: {
erc20: {
receiverAddress: 'receiverAddress',
},
},
},
};
const processedTransactionUpdate = {
status: VegaTxStatus.Pending,
txHash: 'txHash',
signature: 'signature',
};
const transactionResult = {
hash: processedTransactionUpdate.txHash,
} as unknown as NonNullable<VegaStoredTxState['transactionResult']>;
const withdrawal = {
id: 'signature',
} as unknown as NonNullable<VegaStoredTxState['withdrawal']>;
const withdrawalApproval = {} as unknown as NonNullable<
VegaStoredTxState['withdrawalApproval']
>;
it('creates transaction with default values', () => {
useVegaTransactionStore.getState().create(orderCancellation);
const transaction = useVegaTransactionStore.getState().transactions[0];
expect(transaction?.createdAt).toBeTruthy();
expect(transaction?.status).toEqual(VegaTxStatus.Requested);
expect(transaction?.body).toEqual(orderCancellation);
expect(transaction?.dialogOpen).toEqual(true);
});
it('updates transaction by index/id', () => {
useVegaTransactionStore.getState().create(orderCancellation);
useVegaTransactionStore.getState().create(orderCancellation);
useVegaTransactionStore.getState().create(orderCancellation);
const transaction = useVegaTransactionStore.getState().transactions[1];
useVegaTransactionStore
.getState()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.update(transaction!.id, { status: VegaTxStatus.Pending });
expect(
useVegaTransactionStore.getState().transactions.map((t) => t?.status)
).toEqual([
VegaTxStatus.Requested,
VegaTxStatus.Pending,
VegaTxStatus.Requested,
]);
});
it('sets dialogOpen to false on dismiss', () => {
useVegaTransactionStore.getState().create(orderCancellation);
useVegaTransactionStore.getState().dismiss(0);
expect(
useVegaTransactionStore.getState().transactions[0]?.dialogOpen
).toEqual(false);
});
it('updates transaction result', () => {
useVegaTransactionStore.getState().create(withdrawSubmission);
useVegaTransactionStore.getState().update(0, processedTransactionUpdate);
useVegaTransactionStore
.getState()
.updateTransactionResult(transactionResult);
expect(
useVegaTransactionStore.getState().transactions[0]?.transactionResult
).toEqual(transactionResult);
});
it('updates withdrawal', () => {
useVegaTransactionStore.getState().create(withdrawSubmission);
useVegaTransactionStore.getState().update(0, processedTransactionUpdate);
useVegaTransactionStore
.getState()
.updateTransactionResult(transactionResult);
useVegaTransactionStore
.getState()
.updateWithdrawal(withdrawal, withdrawalApproval);
const transaction = useVegaTransactionStore.getState().transactions[0];
expect(transaction?.withdrawalApproval).toEqual(withdrawalApproval);
expect(transaction?.withdrawal).toEqual(withdrawal);
});
});

View File

@ -0,0 +1,168 @@
import produce from 'immer';
import type { Transaction } from './connectors';
import {
isWithdrawTransaction,
isOrderSubmissionTransaction,
isOrderCancellationTransaction,
isOrderAmendmentTransaction,
} from './connectors';
import { determineId } from './utils';
import create from 'zustand';
import type { VegaTxState } from './use-vega-transaction';
import { VegaTxStatus } from './use-vega-transaction';
import type {
TransactionEventFieldsFragment,
WithdrawalBusEventFieldsFragment,
OrderBusEventFieldsFragment,
} from './__generated__/TransactionResult';
import type { WithdrawalApprovalQuery } from './__generated__/WithdrawalApproval';
export interface VegaStoredTxState extends VegaTxState {
id: number;
createdAt: Date;
updatedAt: Date;
body: Transaction;
transactionResult?: TransactionEventFieldsFragment;
withdrawal?: WithdrawalBusEventFieldsFragment;
withdrawalApproval?: WithdrawalApprovalQuery['erc20WithdrawalApproval'];
order?: OrderBusEventFieldsFragment;
}
export interface VegaTransactionStore {
transactions: (VegaStoredTxState | undefined)[];
create: (tx: Transaction) => number;
update: (
index: number,
update: Partial<
Pick<VegaStoredTxState, 'status' | 'txHash' | 'signature' | 'error'>
>
) => void;
dismiss: (index: number) => void;
delete: (index: number) => void;
updateWithdrawal: (
withdrawal: NonNullable<VegaStoredTxState['withdrawal']>,
withdrawalApproval: NonNullable<VegaStoredTxState['withdrawalApproval']>
) => void;
updateOrder: (order: OrderBusEventFieldsFragment) => void;
updateTransactionResult: (
transactionResult: TransactionEventFieldsFragment
) => void;
}
export const useVegaTransactionStore = create<VegaTransactionStore>(
(set, get) => ({
transactions: [] as VegaStoredTxState[],
create: (body: Transaction) => {
const transactions = get().transactions;
const now = new Date();
const transaction: VegaStoredTxState = {
id: transactions.length,
createdAt: now,
updatedAt: now,
body,
error: null,
txHash: null,
signature: null,
status: VegaTxStatus.Requested,
dialogOpen: true,
};
set({ transactions: transactions.concat(transaction) });
return transaction.id;
},
update: (index: number, update: Partial<VegaStoredTxState>) => {
set(
produce((state: VegaTransactionStore) => {
const transaction = state.transactions[index];
if (transaction) {
Object.assign(transaction, update);
transaction.dialogOpen = true;
transaction.updatedAt = new Date();
}
})
);
},
dismiss: (index: number) => {
set(
produce((state: VegaTransactionStore) => {
const transaction = state.transactions[index];
if (transaction) {
transaction.dialogOpen = false;
transaction.updatedAt = new Date();
}
})
);
},
delete: (index: number) => {
set(
produce((state: VegaTransactionStore) => {
delete state.transactions[index];
})
);
},
updateWithdrawal: (
withdrawal: NonNullable<VegaStoredTxState['withdrawal']>,
withdrawalApproval: NonNullable<VegaStoredTxState['withdrawalApproval']>
) => {
set(
produce((state: VegaTransactionStore) => {
const transaction = state.transactions.find(
(transaction) =>
transaction &&
transaction.status === VegaTxStatus.Pending &&
transaction.signature &&
isWithdrawTransaction(transaction?.body) &&
withdrawal.id === determineId(transaction.signature)
);
if (transaction) {
transaction.withdrawal = withdrawal;
transaction.withdrawalApproval = withdrawalApproval;
transaction.status = VegaTxStatus.Complete;
transaction.dialogOpen = true;
transaction.updatedAt = new Date();
}
})
);
},
updateOrder: (order: OrderBusEventFieldsFragment) => {
set(
produce((state: VegaTransactionStore) => {
const transaction = state.transactions.find(
(transaction) =>
transaction &&
transaction.status === VegaTxStatus.Pending &&
transaction.signature &&
(isOrderSubmissionTransaction(transaction?.body) ||
isOrderCancellationTransaction(transaction?.body) ||
isOrderAmendmentTransaction(transaction?.body)) &&
order.id === determineId(transaction.signature)
);
if (transaction) {
transaction.order = order;
transaction.status = VegaTxStatus.Complete;
transaction.dialogOpen = true;
transaction.updatedAt = new Date();
}
})
);
},
updateTransactionResult: (
transactionResult: TransactionEventFieldsFragment
) => {
set(
produce((state: VegaTransactionStore) => {
const transaction = state.transactions.find(
(transaction) =>
transaction?.txHash &&
transaction.txHash.toLowerCase() ===
transactionResult.hash.toLowerCase()
);
if (transaction) {
transaction.transactionResult = transactionResult;
transaction.dialogOpen = true;
transaction.updatedAt = new Date();
}
})
);
},
})
);

View File

@ -0,0 +1,216 @@
import { renderHook } from '@testing-library/react-hooks';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { ReactNode } from 'react';
import { useVegaTransactionUpdater } from './use-vega-transaction-updater';
import waitForNextTick from 'flush-promises';
import {
OrderBusEventsDocument,
TransactionEventDocument,
WithdrawalBusEventDocument,
} from './__generated__/TransactionResult';
import type {
OrderBusEventsSubscription,
OrderBusEventFieldsFragment,
WithdrawalBusEventSubscription,
WithdrawalBusEventFieldsFragment,
TransactionEventSubscription,
TransactionEventFieldsFragment,
} from './__generated__/TransactionResult';
import type { VegaTransactionStore } from './use-vega-transaction-store';
import {
AssetStatus,
BusEventType,
OrderStatus,
OrderTimeInForce,
OrderType,
Side,
WithdrawalStatus,
} from '@vegaprotocol/types';
const render = (mocks?: MockedResponse[]) => {
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks}>{children}</MockedProvider>
);
return renderHook(() => useVegaTransactionUpdater(), { wrapper });
};
const pubKey = 'pubKey';
jest.mock('./use-vega-wallet', () => ({
useVegaWallet: () => ({
pubKey,
}),
}));
const mockWaitForWithdrawalApproval = jest.fn();
jest.mock('./wait-for-withdrawal-approval', () => ({
waitForWithdrawalApproval: () => mockWaitForWithdrawalApproval(),
}));
const updateWithdrawal = jest.fn();
const updateOrder = jest.fn();
const updateTransactionResult = jest.fn();
const defaultState: Partial<VegaTransactionStore> = {
updateWithdrawal,
updateOrder,
updateTransactionResult,
};
const mockTransactionStoreState = jest.fn<Partial<VegaTransactionStore>, []>();
jest.mock('./use-vega-transaction-store', () => ({
useVegaTransactionStore: (
selector: (state: Partial<VegaTransactionStore>) => void
) => selector(mockTransactionStoreState()),
}));
const orderBusEvent: OrderBusEventFieldsFragment = {
type: OrderType.TYPE_LIMIT,
id: '9c70716f6c3698ac7bbcddc97176025b985a6bb9a0c4507ec09c9960b3216b62',
status: OrderStatus.STATUS_ACTIVE,
rejectionReason: null,
createdAt: '2022-07-05T14:25:47.815283706Z',
expiresAt: '2022-07-05T14:25:47.815283706Z',
size: '10',
price: '300000',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
side: Side.SIDE_BUY,
market: {
id: 'market-id',
decimalPlaces: 5,
positionDecimalPlaces: 0,
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
name: 'UNIDAI Monthly (30 Jun 2022)',
__typename: 'Instrument',
},
},
__typename: 'Market',
},
__typename: 'Order',
};
const mockedOrderBusEvent: MockedResponse<OrderBusEventsSubscription> = {
request: {
query: OrderBusEventsDocument,
variables: { partyId: pubKey },
},
result: {
data: {
busEvents: [
{
type: BusEventType.Order,
event: orderBusEvent,
},
],
},
},
};
const transactionResultBusEvent: TransactionEventFieldsFragment = {
__typename: 'TransactionResult',
partyId: pubKey,
hash: 'hash',
status: true,
error: null,
};
const mockedTransactionResultBusEvent: MockedResponse<TransactionEventSubscription> =
{
request: {
query: TransactionEventDocument,
variables: { partyId: pubKey },
},
result: {
data: {
busEvents: [
{
type: BusEventType.Order,
event: transactionResultBusEvent,
},
],
},
},
};
const withdrawalBusEvent: WithdrawalBusEventFieldsFragment = {
id: '2fca514cebf9f465ae31ecb4c5721e3a6f5f260425ded887ca50ba15b81a5d50',
status: WithdrawalStatus.STATUS_OPEN,
amount: '100',
asset: {
__typename: 'Asset',
id: 'asset-id',
name: 'asset-name',
symbol: 'asset-symbol',
decimals: 2,
status: AssetStatus.STATUS_ENABLED,
source: {
__typename: 'ERC20',
contractAddress: '0x123',
},
},
createdTimestamp: '2022-07-05T14:25:47.815283706Z',
withdrawnTimestamp: '2022-07-05T14:25:47.815283706Z',
txHash: '0x123',
details: {
__typename: 'Erc20WithdrawalDetails',
receiverAddress: '0x123',
},
pendingOnForeignChain: false,
__typename: 'Withdrawal',
};
const mockedWithdrawalBusEvent: MockedResponse<WithdrawalBusEventSubscription> =
{
request: {
query: WithdrawalBusEventDocument,
variables: { partyId: pubKey },
},
result: {
data: {
busEvents: [
{
event: withdrawalBusEvent,
},
],
},
},
};
describe('useVegaTransactionManager', () => {
it('updates order on OrderBusEvents', async () => {
mockTransactionStoreState.mockReturnValue(defaultState);
const { waitForNextUpdate } = render([mockedOrderBusEvent]);
waitForNextUpdate();
await waitForNextTick();
expect(updateOrder).toHaveBeenCalledWith(orderBusEvent);
});
it('updates transaction on TransactionResultBusEvents', async () => {
mockTransactionStoreState.mockReturnValue(defaultState);
const { waitForNextUpdate } = render([mockedTransactionResultBusEvent]);
waitForNextUpdate();
await waitForNextTick();
expect(updateTransactionResult).toHaveBeenCalledWith(
transactionResultBusEvent
);
});
it('updates withdrawal on WithdrawalBusEvents', async () => {
mockTransactionStoreState.mockReturnValue(defaultState);
const erc20WithdrawalApproval = {};
mockWaitForWithdrawalApproval.mockResolvedValueOnce(
erc20WithdrawalApproval
);
const { waitForNextUpdate } = render([mockedWithdrawalBusEvent]);
waitForNextUpdate();
await waitForNextTick();
expect(updateWithdrawal).toHaveBeenCalledWith(
withdrawalBusEvent,
erc20WithdrawalApproval
);
});
});

View File

@ -0,0 +1,59 @@
import { useApolloClient } from '@apollo/client';
import { useVegaWallet } from './use-vega-wallet';
import {
useOrderBusEventsSubscription,
useWithdrawalBusEventSubscription,
useTransactionEventSubscription,
} from './__generated__/TransactionResult';
import { useVegaTransactionStore } from './use-vega-transaction-store';
import { waitForWithdrawalApproval } from './wait-for-withdrawal-approval';
export const useVegaTransactionUpdater = () => {
const client = useApolloClient();
const { updateWithdrawal, updateOrder, updateTransaction } =
useVegaTransactionStore((state) => ({
updateWithdrawal: state.updateWithdrawal,
updateOrder: state.updateOrder,
updateTransaction: state.updateTransactionResult,
}));
const { pubKey } = useVegaWallet();
const variables = { partyId: pubKey || '' };
const skip = !pubKey;
useOrderBusEventsSubscription({
variables,
skip,
onData: ({ data: result }) =>
result.data?.busEvents?.forEach((event) => {
if (event.event.__typename === 'Order') {
updateOrder(event.event);
}
}),
});
useWithdrawalBusEventSubscription({
variables,
skip,
onData: ({ data: result }) =>
result.data?.busEvents?.forEach((event) => {
if (event.event.__typename === 'Withdrawal') {
const withdrawal = event.event;
waitForWithdrawalApproval(withdrawal.id, client).then((approval) =>
updateWithdrawal(withdrawal, approval)
);
}
}),
});
useTransactionEventSubscription({
variables,
skip,
onData: ({ data: result }) =>
result.data?.busEvents?.forEach((event) => {
if (event.event.__typename === 'TransactionResult') {
updateTransaction(event.event);
}
}),
});
};

View File

@ -10,8 +10,8 @@ export type VegaTransactionContentMap = {
};
export interface VegaTransactionDialogProps {
isOpen: boolean;
onChange: (isOpen: boolean) => void;
transaction: VegaTxState;
onChange?: (isOpen: boolean) => void;
intent?: Intent;
title?: string;
icon?: ReactNode;
@ -20,8 +20,8 @@ export interface VegaTransactionDialogProps {
export const VegaTransactionDialog = ({
isOpen,
onChange,
transaction,
onChange,
intent,
title,
icon,

View File

@ -0,0 +1,44 @@
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { MockLink } from '@apollo/client/testing';
import type { WithdrawalApprovalQuery } from './__generated__/WithdrawalApproval';
import { WithdrawalApprovalDocument } from './__generated__/WithdrawalApproval';
import type { MockedResponse } from '@apollo/client/testing';
import { waitForWithdrawalApproval } from './wait-for-withdrawal-approval';
const erc20WithdrawalApproval: WithdrawalApprovalQuery['erc20WithdrawalApproval'] =
{
__typename: 'Erc20WithdrawalApproval',
assetSource: 'asset-source',
amount: '100',
nonce: '1',
signatures: 'signatures',
targetAddress: 'targetAddress',
expiry: 'expiry',
creation: '1',
};
const withdrawalId =
'2fca514cebf9f465ae31ecb4c5721e3a6f5f260425ded887ca50ba15b81a5d50';
const mockedWithdrawalApproval: MockedResponse<WithdrawalApprovalQuery> = {
request: {
query: WithdrawalApprovalDocument,
variables: { withdrawalId },
},
result: {
data: {
erc20WithdrawalApproval,
},
},
};
describe('waitForWithdrawalApproval', () => {
it('resolves with matching erc20WithdrawalApproval', async () => {
const client = new ApolloClient({
cache: new InMemoryCache(),
link: new MockLink([mockedWithdrawalApproval]),
});
const approval = await waitForWithdrawalApproval(withdrawalId, client);
expect(await approval).toEqual(erc20WithdrawalApproval);
});
});

View File

@ -0,0 +1,38 @@
import type { ApolloClient } from '@apollo/client';
import type { VegaStoredTxState } from './use-vega-transaction-store';
import type {
WithdrawalApprovalQuery,
WithdrawalApprovalQueryVariables,
} from './__generated__/WithdrawalApproval';
import { WithdrawalApprovalDocument } from './__generated__/WithdrawalApproval';
export const waitForWithdrawalApproval = (
withdrawalId: string,
client: ApolloClient<object>
) =>
new Promise<NonNullable<VegaStoredTxState['withdrawalApproval']>>(
(resolve) => {
const interval = setInterval(async () => {
try {
const res = await client.query<
WithdrawalApprovalQuery,
WithdrawalApprovalQueryVariables
>({
query: WithdrawalApprovalDocument,
variables: { withdrawalId },
fetchPolicy: 'network-only',
});
if (
res.data.erc20WithdrawalApproval &&
res.data.erc20WithdrawalApproval.signatures.length > 2
) {
clearInterval(interval);
resolve(res.data.erc20WithdrawalApproval);
}
} catch (err) {
// no op as the query will error until the approval is created
}
}, 1000);
}
);

View File

@ -0,0 +1,20 @@
import { act } from 'react-dom/test-utils';
const actualCreate = jest.requireActual('zustand').default; // if using jest
// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set();
// when creating a store, we get its initial state, create a reset function and add it in the set
const create = (createState) => {
const store = actualCreate(createState);
const initialState = store.getState();
storeResetFns.add(() => store.setState(initialState, true));
return store;
};
// Reset all stores after each test run
beforeEach(() => {
act(() => storeResetFns.forEach((resetFn) => resetFn()));
});
export default create;

View File

@ -7,6 +7,13 @@ export * from './lib/use-token-decimals';
export * from './lib/use-ethereum-config';
export * from './lib/use-ethereum-read-contract';
export * from './lib/use-ethereum-transaction';
export * from './lib/use-ethereum-transaction-updater';
export * from './lib/use-ethereum-transaction-store';
export * from './lib/use-ethereum-transaction-manager';
export * from './lib/use-ethereum-withdraw-approvals-manager';
export * from './lib/use-ethereum-withdraw-approvals-store';
export * from './lib/use-get-withdraw-delay';
export * from './lib/use-get-withdraw-threshold';
export * from './lib/ethereum-transaction-dialog';
export * from './lib/web3-provider';
export * from './lib/web3-connectors';

View File

@ -20,7 +20,7 @@ export const isEthereumError = (err: unknown): err is EthereumError => {
export const isExpectedEthereumError = (error: unknown) => {
const EXPECTED_ERRORS = [4001];
if (isEthereumError(error) && EXPECTED_ERRORS.indexOf(error.code) >= 0) {
if (isEthereumError(error) && EXPECTED_ERRORS.includes(error.code)) {
return true;
}

View File

@ -7,17 +7,17 @@ import { ConfirmRow, TxRow, ConfirmationEventRow } from './dialog-rows';
export interface EthereumTransactionDialogProps {
title: string;
onChange: (isOpen: boolean) => void;
transaction: EthTxState;
onChange?: (isOpen: boolean) => void;
// Undefined means this dialog isn't expecting an additional event for a complete state, a boolean
// value means it is but hasn't been received yet
requiredConfirmations?: number;
}
export const EthereumTransactionDialog = ({
onChange,
title,
transaction,
onChange,
requiredConfirmations = 1,
}: EthereumTransactionDialogProps) => {
const { status, error, confirmations, txHash } = transaction;
@ -76,7 +76,7 @@ export const getTransactionContent = ({
};
};
const TransactionContent = ({
export const TransactionContent = ({
status,
error,
txHash,

View File

@ -0,0 +1,215 @@
import { useEthTransactionManager } from './use-ethereum-transaction-manager';
import { renderHook } from '@testing-library/react';
import waitForNextTick from 'flush-promises';
import type { CollateralBridge } from '@vegaprotocol/smart-contracts';
import type {
EthTransactionStore,
EthStoredTxState,
} from './use-ethereum-transaction-store';
import { EthTxStatus } from './use-ethereum-transaction';
const txHash = 'txHash';
const requestedTransactionUpdate = {
status: EthTxStatus.Requested,
error: null,
confirmations: 0,
};
const mockDepositAsset = jest.fn();
const mockDepositAssetStatic = jest.fn();
const receipt = { confirmations: 5 };
const mockTxWait = jest.fn<{ confirmations: number } | undefined, never>(
() => receipt
);
mockDepositAsset.mockResolvedValue({
hash: txHash,
wait: mockTxWait,
});
const contract = {
contract: {
callStatic: {
deposit_asset: mockDepositAssetStatic,
},
},
deposit_asset: mockDepositAsset,
} as unknown as CollateralBridge;
const methodName = 'deposit_asset';
const args: string[] = [];
const update = jest.fn();
const createTransaction = (
transaction?: Partial<EthStoredTxState>
): EthStoredTxState => ({
id: 0,
status: EthTxStatus.Default,
createdAt: new Date('2022-12-12T11:24:40.301Z'),
updatedAt: new Date('2022-12-12T11:24:40.301Z'),
contract,
methodName,
args,
requiredConfirmations: 1,
requiresConfirmation: false,
error: null,
confirmations: 0,
dialogOpen: false,
txHash: null,
receipt: null,
...transaction,
});
const mockTransactionStoreState = jest.fn<Partial<EthTransactionStore>, []>();
jest.mock('./use-ethereum-transaction-store', () => ({
useEthTransactionStore: (
selector: (state: Partial<EthTransactionStore>) => void
) => selector(mockTransactionStoreState()),
}));
describe('useVegaTransactionManager', () => {
beforeEach(() => {
mockTransactionStoreState.mockReset();
update.mockClear();
mockTxWait.mockClear();
});
it('sendTx of first pending transaction', async () => {
mockTransactionStoreState.mockReturnValue({
transactions: [createTransaction(), createTransaction({ id: 1 })],
update,
});
const { rerender } = renderHook(useEthTransactionManager);
await waitForNextTick();
rerender();
await waitForNextTick();
expect(update.mock.calls[0]).toEqual([0, requestedTransactionUpdate]);
expect(update.mock.calls[4]).toEqual([1, requestedTransactionUpdate]);
});
it('sets error if contract is undefined', async () => {
mockTransactionStoreState.mockReturnValue({
update,
transactions: [createTransaction({ contract: undefined })],
});
renderHook(useEthTransactionManager);
await waitForNextTick();
expect(
update.mock.calls[update.mock.calls.length - 1][1].error
).toBeTruthy();
expect(update.mock.calls[update.mock.calls.length - 1][1].status).toBe(
EthTxStatus.Error
);
});
it('sets error if contract static method do not exists', async () => {
mockTransactionStoreState.mockReturnValue({
update,
transactions: [
createTransaction({
contract: {
...contract,
contract: {
callStatic: {},
},
} as unknown as CollateralBridge,
}),
],
});
renderHook(useEthTransactionManager);
await waitForNextTick();
expect(
update.mock.calls[update.mock.calls.length - 1][1].error
).toBeTruthy();
expect(update.mock.calls[update.mock.calls.length - 1][1].status).toBe(
EthTxStatus.Error
);
});
it('sets error if contract method do not exists', async () => {
mockTransactionStoreState.mockReturnValue({
update,
transactions: [
createTransaction({
contract: {
...contract,
[methodName]: undefined,
} as unknown as CollateralBridge,
}),
],
});
renderHook(useEthTransactionManager);
await waitForNextTick();
expect(
update.mock.calls[update.mock.calls.length - 1][1].error
).toBeTruthy();
expect(update.mock.calls[update.mock.calls.length - 1][1].status).toBe(
EthTxStatus.Error
);
});
it('sets status to pending and updates tx hash', async () => {
mockTransactionStoreState.mockReturnValue({
update,
transactions: [createTransaction()],
});
renderHook(useEthTransactionManager);
await waitForNextTick();
expect(update.mock.calls[1][1]).toEqual({
status: EthTxStatus.Pending,
txHash,
});
});
it('sets status to error if no receipt', async () => {
mockTxWait.mockReturnValueOnce(undefined);
mockTransactionStoreState.mockReturnValue({
update,
transactions: [createTransaction()],
});
renderHook(useEthTransactionManager);
await waitForNextTick();
expect(
update.mock.calls[update.mock.calls.length - 1][1].error
).toBeTruthy();
expect(update.mock.calls[update.mock.calls.length - 1][1].status).toBe(
EthTxStatus.Error
);
});
it('calls wait as many times as required confirmations', async () => {
const requiredConfirmations = 3;
mockTransactionStoreState.mockReturnValue({
update,
transactions: [createTransaction({ requiredConfirmations })],
});
renderHook(useEthTransactionManager);
await waitForNextTick();
expect(mockTxWait).toBeCalledTimes(requiredConfirmations);
});
it('sets status to confirmed and updates receipt', async () => {
mockTransactionStoreState.mockReturnValue({
update,
transactions: [createTransaction()],
});
renderHook(useEthTransactionManager);
await waitForNextTick();
expect(update.mock.calls[3][1]).toEqual({
status: EthTxStatus.Confirmed,
receipt,
});
});
it('sets status to complete if requires confirmation', async () => {
mockTransactionStoreState.mockReturnValue({
update,
transactions: [createTransaction({ requiresConfirmation: true })],
});
renderHook(useEthTransactionManager);
await waitForNextTick();
expect(update.mock.calls[3][1]).toEqual({
status: EthTxStatus.Complete,
receipt,
});
});
});

View File

@ -0,0 +1,99 @@
import type { ethers } from 'ethers';
import { useRef, useEffect } from 'react';
import type { EthereumError } from './ethereum-error';
import { isExpectedEthereumError } from './ethereum-error';
import { isEthereumError } from './ethereum-error';
import { EthTxStatus } from './use-ethereum-transaction';
import { useEthTransactionStore } from './use-ethereum-transaction-store';
export const useEthTransactionManager = () => {
const update = useEthTransactionStore((state) => state.update);
const processed = useRef<Set<number>>(new Set());
const transaction = useEthTransactionStore((state) =>
state.transactions.find(
(transaction) =>
transaction?.status === EthTxStatus.Default &&
!processed.current.has(transaction.id)
)
);
useEffect(() => {
if (!transaction) {
return;
}
processed.current.add(transaction.id);
update(transaction.id, {
status: EthTxStatus.Requested,
error: null,
confirmations: 0,
});
const {
contract,
methodName,
args,
requiredConfirmations,
requiresConfirmation,
} = transaction;
(async () => {
try {
if (
!contract ||
// @ts-ignore method vary depends on contract
typeof contract[methodName] !== 'function' ||
typeof contract.contract.callStatic[methodName] !== 'function'
) {
throw new Error('method not found on contract');
}
await contract.contract.callStatic[methodName](...args);
} catch (err) {
update(transaction.id, {
status: EthTxStatus.Error,
error: err as EthereumError,
});
return;
}
try {
// @ts-ignore args will vary depends on contract and method
const tx = await contract[methodName].call(contract, ...args);
let receipt: ethers.ContractReceipt | null = null;
update(transaction.id, {
status: EthTxStatus.Pending,
txHash: tx.hash,
});
for (let i = 1; i <= requiredConfirmations; i++) {
receipt = await tx.wait(i);
update(transaction.id, {
confirmations: receipt
? receipt.confirmations
: requiredConfirmations,
});
}
if (!receipt) {
throw new Error('no receipt after confirmations are met');
}
if (requiresConfirmation) {
update(transaction.id, { status: EthTxStatus.Complete, receipt });
} else {
update(transaction.id, { status: EthTxStatus.Confirmed, receipt });
}
} catch (err) {
if (err instanceof Error || isEthereumError(err)) {
if (!isExpectedEthereumError(err)) {
update(transaction.id, { status: EthTxStatus.Error, error: err });
}
} else {
update(transaction.id, {
status: EthTxStatus.Error,
error: new Error('Something went wrong'),
});
}
return;
}
})();
}, [transaction, update]);
};

View File

@ -0,0 +1,85 @@
import { useEthTransactionStore } from './use-ethereum-transaction-store';
import { EthTxStatus } from './use-ethereum-transaction';
import type { CollateralBridge } from '@vegaprotocol/smart-contracts';
import type { EthStoredTxState } from './use-ethereum-transaction-store';
describe('useEthTransactionStore', () => {
const txHash = 'txHash';
const deposit = { txHash } as unknown as NonNullable<
EthStoredTxState['deposit']
>;
const processedTransactionUpdate = {
status: EthTxStatus.Pending,
txHash,
};
const contract = {} as unknown as CollateralBridge;
const methodName = 'withdraw_asset';
const args = ['arg1'];
const requiredConfirmations = 3;
const requiresConfirmation = true;
const asset = undefined;
it('creates transaction with default values', () => {
useEthTransactionStore
.getState()
.create(
contract,
methodName,
args,
asset,
requiredConfirmations,
requiresConfirmation
);
const transaction = useEthTransactionStore.getState().transactions[0];
expect(transaction?.createdAt).toBeTruthy();
expect(transaction?.contract).toBe(contract);
expect(transaction?.methodName).toBe(methodName);
expect(transaction?.args).toBe(args);
expect(transaction?.requiredConfirmations).toBe(requiredConfirmations);
expect(transaction?.requiresConfirmation).toBe(requiresConfirmation);
expect(transaction?.status).toEqual(EthTxStatus.Default);
expect(transaction?.confirmations).toEqual(0);
expect(transaction?.dialogOpen).toEqual(true);
});
it('updates transaction by index/id', () => {
useEthTransactionStore.getState().create(contract, methodName, args);
useEthTransactionStore.getState().create(contract, methodName, args);
useEthTransactionStore.getState().create(contract, methodName, args);
const transaction = useEthTransactionStore.getState().transactions[1];
useEthTransactionStore
.getState()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.update(transaction!.id, { status: EthTxStatus.Pending });
expect(
useEthTransactionStore.getState().transactions.map((t) => t?.status)
).toEqual([EthTxStatus.Default, EthTxStatus.Pending, EthTxStatus.Default]);
});
it('sets dialogOpen to false on dismiss', () => {
useEthTransactionStore.getState().create(contract, methodName, args);
useEthTransactionStore.getState().dismiss(0);
expect(
useEthTransactionStore.getState().transactions[0]?.dialogOpen
).toEqual(false);
});
it('updates deposit', () => {
useEthTransactionStore.getState().create(contract, methodName, args);
useEthTransactionStore.getState().update(0, processedTransactionUpdate);
useEthTransactionStore.getState().updateDeposit(deposit);
const transaction = useEthTransactionStore.getState().transactions[0];
expect(transaction?.deposit).toEqual(deposit);
expect(transaction?.status).toEqual(EthTxStatus.Confirmed);
});
it('deletes transaction', () => {
useEthTransactionStore.getState().create(contract, methodName, args);
useEthTransactionStore.getState().create(contract, methodName, args);
useEthTransactionStore.getState().delete(0);
expect(useEthTransactionStore.getState().transactions[0]).toBeUndefined();
expect(
useEthTransactionStore.getState().transactions[1]
).not.toBeUndefined();
});
});

View File

@ -0,0 +1,145 @@
import create from 'zustand';
import produce from 'immer';
import type { MultisigControl } from '@vegaprotocol/smart-contracts';
import type { CollateralBridge } from '@vegaprotocol/smart-contracts';
import type { Token } from '@vegaprotocol/smart-contracts';
import type { TokenFaucetable } from '@vegaprotocol/smart-contracts';
import type { DepositBusEventFieldsFragment } from '@vegaprotocol/wallet';
import type { EthTxState } from './use-ethereum-transaction';
import { EthTxStatus } from './use-ethereum-transaction';
type Contract = MultisigControl | CollateralBridge | Token | TokenFaucetable;
type ContractMethod =
| keyof MultisigControl
| keyof CollateralBridge
| keyof Token
| keyof TokenFaucetable;
export interface EthStoredTxState extends EthTxState {
id: number;
createdAt: Date;
updatedAt: Date;
contract: Contract;
methodName: ContractMethod;
args: string[];
requiredConfirmations: number;
requiresConfirmation: boolean;
asset?: string;
deposit?: DepositBusEventFieldsFragment;
}
export interface EthTransactionStore {
transactions: (EthStoredTxState | undefined)[];
create: (
contract: Contract,
methodName: ContractMethod,
args: string[],
assetId?: string,
requiredConfirmations?: number,
requiresConfirmation?: boolean
) => number;
update: (
id: EthStoredTxState['id'],
update?: Partial<
Pick<
EthStoredTxState,
'status' | 'error' | 'receipt' | 'confirmations' | 'txHash'
>
>
) => void;
dismiss: (index: number) => void;
updateDeposit: (deposit: DepositBusEventFieldsFragment) => void;
delete: (index: number) => void;
}
export const useEthTransactionStore = create<EthTransactionStore>(
(set, get) => ({
transactions: [] as EthStoredTxState[],
create: (
contract: Contract,
methodName: ContractMethod,
args: string[] = [],
asset,
requiredConfirmations = 1,
requiresConfirmation = false
) => {
const transactions = get().transactions;
const now = new Date();
const transaction: EthStoredTxState = {
id: transactions.length,
createdAt: now,
updatedAt: now,
contract,
methodName,
args,
status: EthTxStatus.Default,
error: null,
txHash: null,
receipt: null,
confirmations: 0,
dialogOpen: true,
requiredConfirmations,
requiresConfirmation,
asset: asset,
};
set({ transactions: transactions.concat(transaction) });
return transaction.id;
},
update: (
id: EthStoredTxState['id'],
update?: Partial<EthStoredTxState>
) => {
set({
transactions: produce(get().transactions, (draft) => {
const transaction = draft.find(
(transaction) => transaction?.id === id
);
if (transaction) {
Object.assign(transaction, update);
transaction.dialogOpen = true;
transaction.updatedAt = new Date();
}
}),
});
},
dismiss: (index: number) => {
set(
produce((state: EthTransactionStore) => {
const transaction = state.transactions[index];
if (transaction) {
transaction.dialogOpen = false;
transaction.updatedAt = new Date();
}
})
);
},
updateDeposit: (deposit: DepositBusEventFieldsFragment) => {
set(
produce((state: EthTransactionStore) => {
const transaction = state.transactions.find(
(transaction) =>
transaction &&
transaction.status === EthTxStatus.Pending &&
deposit.txHash === transaction.txHash
);
if (!transaction) {
return;
}
transaction.status = EthTxStatus.Confirmed;
transaction.deposit = deposit;
transaction.dialogOpen = true;
transaction.updatedAt = new Date();
})
);
},
delete: (index: number) => {
set(
produce((state: EthTransactionStore) => {
delete state.transactions[index];
})
);
},
})
);

View File

@ -0,0 +1,85 @@
import { renderHook } from '@testing-library/react-hooks';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { ReactNode } from 'react';
import { useEthTransactionUpdater } from './use-ethereum-transaction-updater';
import waitForNextTick from 'flush-promises';
import {
DepositBusEventDocument,
VegaWalletContext,
} from '@vegaprotocol/wallet';
import type {
DepositBusEventSubscription,
DepositBusEventFieldsFragment,
VegaWalletContextShape,
} from '@vegaprotocol/wallet';
import type { EthTransactionStore } from './use-ethereum-transaction-store';
import { DepositStatus } from '@vegaprotocol/types';
const pubKey = 'pubKey';
const render = (mocks?: MockedResponse[]) => {
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks}>
<VegaWalletContext.Provider value={{ pubKey } as VegaWalletContextShape}>
{children}
</VegaWalletContext.Provider>
</MockedProvider>
);
return renderHook(() => useEthTransactionUpdater(), { wrapper });
};
const updateDeposit = jest.fn();
const defaultState: Partial<EthTransactionStore> = {
updateDeposit,
};
const mockTransactionStoreState = jest.fn<Partial<EthTransactionStore>, []>();
jest.mock('./use-ethereum-transaction-store', () => ({
useEthTransactionStore: (
selector: (state: Partial<EthTransactionStore>) => void
) => selector(mockTransactionStoreState()),
}));
const depositBusEvent: DepositBusEventFieldsFragment = {
id: '2fca514cebf9f465ae31ecb4c5721e3a6f5f260425ded887ca50ba15b81a5d50',
status: DepositStatus.STATUS_FINALIZED,
amount: '100',
asset: {
__typename: 'Asset',
id: 'asset-id',
symbol: 'asset-symbol',
decimals: 2,
},
createdTimestamp: '2022-07-05T14:25:47.815283706Z',
creditedTimestamp: '2022-07-05T14:25:47.815283706Z',
txHash: '0x123',
__typename: 'Deposit',
};
const mockedDepositBusEvent: MockedResponse<DepositBusEventSubscription> = {
request: {
query: DepositBusEventDocument,
variables: { partyId: pubKey },
},
result: {
data: {
busEvents: [
{
event: depositBusEvent,
},
],
},
},
};
describe('useEthTransactionUpdater', () => {
it('updates deposit on DepositBusEvents', async () => {
mockTransactionStoreState.mockReturnValue(defaultState);
const { waitForNextUpdate } = render([mockedDepositBusEvent]);
waitForNextUpdate();
await waitForNextTick();
expect(updateDeposit).toHaveBeenCalledWith(depositBusEvent);
});
});

View File

@ -0,0 +1,29 @@
import { DepositStatus } from '@vegaprotocol/types';
import {
useDepositBusEventSubscription,
useVegaWallet,
} from '@vegaprotocol/wallet';
import { useEthTransactionStore } from './use-ethereum-transaction-store';
export const useEthTransactionUpdater = () => {
const { pubKey } = useVegaWallet();
const updateDeposit = useEthTransactionStore((state) => state.updateDeposit);
const variables = { partyId: pubKey || '' };
const skip = !pubKey;
useDepositBusEventSubscription({
variables,
skip,
onData: ({ data: result }) =>
result.data?.busEvents?.forEach((event) => {
if (
event.event.__typename === 'Deposit' &&
// Note there is a bug in data node where the subscription is not emitted when the status
// changes from 'Open' to 'Finalized' as a result the deposit UI will hang in a pending state right now
// https://github.com/vegaprotocol/data-node/issues/460
event.event.status === DepositStatus.STATUS_FINALIZED
) {
updateDeposit(event.event);
}
}),
});
};

View File

@ -0,0 +1,304 @@
import { useEthWithdrawApprovalsManager } from './use-ethereum-withdraw-approvals-manager';
import { renderHook } from '@testing-library/react';
import type { MockedResponse } from '@apollo/client/testing';
import type { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import waitForNextTick from 'flush-promises';
import * as Schema from '@vegaprotocol/types';
import { ApprovalStatus } from './use-ethereum-withdraw-approvals-store';
import BigNumber from 'bignumber.js';
import type {
EthWithdrawApprovalStore,
EthWithdrawalApprovalState,
} from './use-ethereum-withdraw-approvals-store';
import type { EthTransactionStore } from './use-ethereum-transaction-store';
import { WithdrawalApprovalDocument } from '@vegaprotocol/wallet';
import type { WithdrawalApprovalQuery } from '@vegaprotocol/wallet';
import { NetworkParamsDocument } from '@vegaprotocol/react-helpers';
import type { NetworkParamsQuery } from '@vegaprotocol/react-helpers';
const mockWeb3Provider = jest.fn();
jest.mock('@web3-react/core', () => ({
useWeb3React: () => ({
provider: mockWeb3Provider(),
}),
}));
const mockEthTransactionStoreState = jest.fn<
Partial<EthTransactionStore>,
[]
>();
jest.mock('./use-ethereum-transaction-store', () => ({
...jest.requireActual('./use-ethereum-transaction-store'),
useEthTransactionStore: (
selector: (state: Partial<EthTransactionStore>) => void
) => selector(mockEthTransactionStoreState()),
}));
const mockEthWithdrawApprovalsStoreState = jest.fn<
Partial<EthWithdrawApprovalStore>,
[]
>();
jest.mock('./use-ethereum-withdraw-approvals-store', () => ({
...jest.requireActual('./use-ethereum-withdraw-approvals-store'),
useEthWithdrawApprovalsStore: (
selector: (state: Partial<EthWithdrawApprovalStore>) => void
) => selector(mockEthWithdrawApprovalsStoreState()),
}));
const mockUseGetWithdrawThreshold = jest.fn();
jest.mock('./use-get-withdraw-threshold', () => ({
useGetWithdrawThreshold: () => mockUseGetWithdrawThreshold(),
}));
const mockUseGetWithdrawDelay = jest.fn();
jest.mock('./use-get-withdraw-delay', () => ({
useGetWithdrawDelay: () => mockUseGetWithdrawDelay(),
}));
const mockUseEthereumConfig = jest.fn(() => ({
collateral_bridge_contract: {
address: 'address',
},
}));
jest.mock('./use-ethereum-config', () => ({
useEthereumConfig: () => ({
config: mockUseEthereumConfig(),
}),
}));
jest.mock('@vegaprotocol/smart-contracts', () => ({
CollateralBridge: jest.fn().mockImplementation(),
}));
const update = jest.fn();
const withdrawalId = 'withdrawalId';
const createWithdrawTransaction = (
transaction?: Partial<EthWithdrawalApprovalState>
): EthWithdrawalApprovalState => ({
id: 0,
status: ApprovalStatus.Idle,
createdAt: new Date('2022-12-12T11:24:40.301Z'),
dialogOpen: true,
withdrawal: {
id: withdrawalId,
status: Schema.WithdrawalStatus.STATUS_OPEN,
createdTimestamp: '2022-12-12T11:24:40.301Z',
pendingOnForeignChain: false,
amount: '50',
asset: {
__typename: 'Asset',
id: 'fdf0ec118d98393a7702cf72e46fc87ad680b152f64b2aac59e093ac2d688fbb',
name: 'USDT-T',
symbol: 'USDT-T',
decimals: 18,
status: Schema.AssetStatus.STATUS_ENABLED,
source: {
__typename: 'ERC20',
contractAddress: 'contractAddress',
},
},
},
...transaction,
});
const create = jest.fn();
const getSigner = jest.fn();
mockWeb3Provider.mockReturnValue({
getSigner,
});
mockUseGetWithdrawDelay.mockReturnValue(() => Promise.resolve(60));
mockUseGetWithdrawThreshold.mockReturnValue(() =>
Promise.resolve(new BigNumber(100))
);
let dateNowSpy: jest.SpyInstance<number, []>;
const erc20WithdrawalApproval = {
assetSource: 'asset-source',
amount: '100',
nonce: '1',
creation: '1',
signatures: 'signatures',
targetAddress: 'target-address',
expiry: 'expiry',
};
const mockedNetworkParams: MockedResponse<NetworkParamsQuery> = {
request: {
query: NetworkParamsDocument,
variables: {},
},
result: {
data: {
networkParametersConnection: {
edges: [
{
node: {
key: 'blockchains.ethereumConfig',
value: JSON.stringify({
collateral_bridge_contract: { address: '' },
}),
},
},
],
},
},
},
};
const mockedWithdrawalApproval: MockedResponse<WithdrawalApprovalQuery> = {
request: {
query: WithdrawalApprovalDocument,
variables: { withdrawalId },
},
result: {
data: { erc20WithdrawalApproval },
},
};
const render = (
mocks: MockedResponse[] = [mockedWithdrawalApproval, mockedNetworkParams]
) => {
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks}>{children}</MockedProvider>
);
return renderHook(() => useEthWithdrawApprovalsManager(), { wrapper });
};
describe('useEthWithdrawApprovalsManager', () => {
beforeEach(() => {
update.mockReset();
create.mockReset();
mockEthTransactionStoreState.mockReset();
mockEthWithdrawApprovalsStoreState.mockReset();
});
afterEach(() => {
if (dateNowSpy) {
dateNowSpy.mockRestore();
}
});
it('sendTx of first pending transaction', async () => {
mockEthTransactionStoreState.mockReturnValue({ create });
mockEthWithdrawApprovalsStoreState.mockReturnValue({
transactions: [
createWithdrawTransaction(),
createWithdrawTransaction({ id: 1 }),
],
update,
});
const { rerender } = render();
expect(update.mock.calls[0][0]).toEqual(0);
expect(update.mock.calls[0][1].status).toEqual(ApprovalStatus.Pending);
rerender();
expect(update.mock.calls[1][0]).toEqual(1);
expect(update.mock.calls[1][1].status).toEqual(ApprovalStatus.Pending);
});
it('sets status to error if wrong asset type', async () => {
const transaction = createWithdrawTransaction();
transaction.withdrawal.asset.source.__typename = 'BuiltinAsset';
mockEthTransactionStoreState.mockReturnValue({ create });
mockEthWithdrawApprovalsStoreState.mockReturnValue({
transactions: [transaction],
update,
});
render();
expect(update.mock.calls[0][1].status).toEqual(ApprovalStatus.Error);
});
it('sets status to pending', async () => {
mockEthWithdrawApprovalsStoreState.mockReturnValue({
transactions: [createWithdrawTransaction()],
update,
});
mockEthTransactionStoreState.mockReturnValue({ create });
render();
expect(update.mock.calls[0][1].status).toEqual(ApprovalStatus.Pending);
});
it('sets status to delayed if amount is greater than threshold', async () => {
const transaction = createWithdrawTransaction();
mockUseGetWithdrawThreshold.mockReturnValueOnce(() =>
Promise.resolve(
new BigNumber(transaction.withdrawal.amount)
.dividedBy(Math.pow(10, transaction.withdrawal.asset.decimals))
.dividedBy(2)
)
);
mockEthWithdrawApprovalsStoreState.mockReturnValue({
transactions: [transaction],
update,
});
mockEthTransactionStoreState.mockReturnValue({ create });
dateNowSpy = jest
.spyOn(Date, 'now')
.mockImplementation(() =>
new Date(transaction.withdrawal.createdTimestamp).valueOf()
);
render();
await waitForNextTick();
expect(update.mock.calls[1][1].status).toEqual(ApprovalStatus.Delayed);
});
it('fetch approval if not provided', async () => {
const transaction = createWithdrawTransaction();
mockEthWithdrawApprovalsStoreState.mockReturnValue({
transactions: [transaction],
update,
});
mockEthTransactionStoreState.mockReturnValue({ create });
render();
await waitForNextTick();
await waitForNextTick();
expect(update.mock.calls[1][1].approval).toEqual(erc20WithdrawalApproval);
});
it('sets status to error if withdraw dependencies not met', async () => {
const transaction = createWithdrawTransaction();
transaction.approval = {
...erc20WithdrawalApproval,
signatures: '',
};
mockEthWithdrawApprovalsStoreState.mockReturnValue({
transactions: [transaction],
update,
});
mockEthTransactionStoreState.mockReturnValue({ create });
render();
await waitForNextTick();
expect(update.mock.calls[1][1].status).toEqual(ApprovalStatus.Error);
});
it('sets status to ready and creates eth transaction', async () => {
const transaction = createWithdrawTransaction();
transaction.approval = erc20WithdrawalApproval;
mockEthWithdrawApprovalsStoreState.mockReturnValue({
transactions: [transaction],
update,
});
mockEthTransactionStoreState.mockReturnValue({ create });
render();
await waitForNextTick();
expect(create).toBeCalledWith({}, 'withdraw_asset', [
erc20WithdrawalApproval.assetSource,
erc20WithdrawalApproval.amount,
erc20WithdrawalApproval.targetAddress,
erc20WithdrawalApproval.creation,
erc20WithdrawalApproval.nonce,
erc20WithdrawalApproval.signatures,
]);
});
});

View File

@ -0,0 +1,135 @@
import { useApolloClient } from '@apollo/client';
import BigNumber from 'bignumber.js';
import { useRef, useEffect } from 'react';
import { addDecimal } from '@vegaprotocol/react-helpers';
import { useGetWithdrawThreshold } from './use-get-withdraw-threshold';
import { useGetWithdrawDelay } from './use-get-withdraw-delay';
import { t } from '@vegaprotocol/react-helpers';
import { CollateralBridge } from '@vegaprotocol/smart-contracts';
import { useEthereumConfig } from './use-ethereum-config';
import { useWeb3React } from '@web3-react/core';
import type {
WithdrawalApprovalQuery,
WithdrawalApprovalQueryVariables,
} from '@vegaprotocol/wallet';
import { WithdrawalApprovalDocument } from '@vegaprotocol/wallet';
import { useEthTransactionStore } from './use-ethereum-transaction-store';
import {
useEthWithdrawApprovalsStore,
ApprovalStatus,
} from './use-ethereum-withdraw-approvals-store';
export const useEthWithdrawApprovalsManager = () => {
const getThreshold = useGetWithdrawThreshold();
const getDelay = useGetWithdrawDelay();
const { query } = useApolloClient();
const { provider } = useWeb3React();
const { config } = useEthereumConfig();
const createEthTransaction = useEthTransactionStore((state) => state.create);
const update = useEthWithdrawApprovalsStore((state) => state.update);
const processed = useRef<Set<number>>(new Set());
const transaction = useEthWithdrawApprovalsStore((state) =>
state.transactions.find(
(transaction) =>
transaction?.status === ApprovalStatus.Idle &&
!processed.current.has(transaction.id)
)
);
useEffect(() => {
if (!transaction) {
return;
}
processed.current.add(transaction.id);
const { withdrawal } = transaction;
let { approval } = transaction;
if (withdrawal.asset.source.__typename !== 'ERC20') {
update(transaction.id, {
status: ApprovalStatus.Error,
message: t(
`Invalid asset source: ${withdrawal.asset.source.__typename}`
),
});
return;
}
update(transaction.id, {
status: ApprovalStatus.Pending,
message: t('Verifying withdrawal approval'),
});
const amount = new BigNumber(
addDecimal(withdrawal.amount, withdrawal.asset.decimals)
);
(async () => {
const threshold = await getThreshold(withdrawal.asset);
if (threshold && amount.isGreaterThan(threshold)) {
const delaySecs = await getDelay();
const completeTimestamp =
new Date(withdrawal.createdTimestamp).getTime() + delaySecs * 1000;
const now = Date.now();
if (now < completeTimestamp) {
update(transaction.id, {
status: ApprovalStatus.Delayed,
threshold,
completeTimestamp,
});
return;
}
}
if (!approval) {
const res = await query<
WithdrawalApprovalQuery,
WithdrawalApprovalQueryVariables
>({
query: WithdrawalApprovalDocument,
variables: { withdrawalId: withdrawal.id },
});
approval = res.data.erc20WithdrawalApproval;
}
if (!(provider && config && approval) || approval.signatures.length < 3) {
update(transaction.id, {
status: ApprovalStatus.Error,
approval,
message: t(`Withdraw dependencies not met.`),
});
return;
}
update(transaction.id, {
status: ApprovalStatus.Ready,
approval,
dialogOpen: false,
});
const signer = provider.getSigner();
createEthTransaction(
new CollateralBridge(
config.collateral_bridge_contract.address,
signer || provider
),
'withdraw_asset',
[
approval.assetSource,
approval.amount,
approval.targetAddress,
approval.creation,
approval.nonce,
approval.signatures,
]
);
})();
}, [
getThreshold,
getDelay,
config,
createEthTransaction,
provider,
query,
transaction,
update,
]);
};

View File

@ -0,0 +1,60 @@
import { useEthWithdrawApprovalsStore } from './use-ethereum-withdraw-approvals-store';
import type { VegaStoredTxState } from '@vegaprotocol/wallet';
import { ApprovalStatus } from './use-ethereum-withdraw-approvals-store';
import type { EthWithdrawalApprovalState } from './use-ethereum-withdraw-approvals-store';
const mockFindVegaTransaction = jest.fn<VegaStoredTxState, []>();
const mockDismissVegaTransaction = jest.fn();
jest.mock('@vegaprotocol/wallet', () => ({
useVegaTransactionStore: {
getState: () => ({
transactions: {
find: mockFindVegaTransaction,
},
dismiss: mockDismissVegaTransaction,
}),
},
}));
describe('useEthWithdrawApprovalsStore', () => {
const withdrawal = {} as unknown as EthWithdrawalApprovalState['withdrawal'];
const approval = {} as unknown as NonNullable<
EthWithdrawalApprovalState['approval']
>;
it('creates approval with default values, dismiss possible vega transaction', () => {
const vegaTransaction = { id: 0 } as unknown as VegaStoredTxState;
mockFindVegaTransaction.mockReturnValueOnce(vegaTransaction);
useEthWithdrawApprovalsStore.getState().create(withdrawal, approval);
const transaction = useEthWithdrawApprovalsStore.getState().transactions[0];
expect(transaction?.createdAt).toBeTruthy();
expect(transaction?.withdrawal).toBe(withdrawal);
expect(transaction?.approval).toBe(approval);
expect(transaction?.status).toEqual(ApprovalStatus.Idle);
expect(transaction?.dialogOpen).toEqual(true);
expect(mockDismissVegaTransaction).toBeCalledWith(vegaTransaction.id);
});
it('updates approval by index/id', () => {
useEthWithdrawApprovalsStore.getState().create(withdrawal);
useEthWithdrawApprovalsStore.getState().create(withdrawal);
useEthWithdrawApprovalsStore.getState().create(withdrawal);
useEthWithdrawApprovalsStore
.getState()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.update(1, { status: ApprovalStatus.Pending });
expect(
useEthWithdrawApprovalsStore.getState().transactions.map((t) => t?.status)
).toEqual([
ApprovalStatus.Idle,
ApprovalStatus.Pending,
ApprovalStatus.Idle,
]);
});
it('sets dialogOpen to false on dismiss', () => {
const id = useEthWithdrawApprovalsStore.getState().create(withdrawal);
useEthWithdrawApprovalsStore.getState().dismiss(id);
expect(
useEthWithdrawApprovalsStore.getState().transactions[id]?.dialogOpen
).toEqual(false);
});
});

View File

@ -0,0 +1,111 @@
import create from 'zustand';
import produce from 'immer';
import type BigNumber from 'bignumber.js';
import type { WithdrawalBusEventFieldsFragment } from '@vegaprotocol/wallet';
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import type { WithdrawalApprovalQuery } from '@vegaprotocol/wallet';
export enum ApprovalStatus {
Idle = 'Idle',
Pending = 'Pending',
Delayed = 'Delayed',
Error = 'Error',
Ready = 'Ready',
}
export interface EthWithdrawalApprovalState {
id: number;
createdAt: Date;
status: ApprovalStatus;
message?: string; //#TODO message is not use anywhere
threshold?: BigNumber;
completeTimestamp?: number | null;
dialogOpen?: boolean;
withdrawal: WithdrawalBusEventFieldsFragment;
approval?: WithdrawalApprovalQuery['erc20WithdrawalApproval'];
}
export interface EthWithdrawApprovalStore {
transactions: (EthWithdrawalApprovalState | undefined)[];
create: (
withdrawal: EthWithdrawalApprovalState['withdrawal'],
approval?: EthWithdrawalApprovalState['approval']
) => number;
update: (
id: EthWithdrawalApprovalState['id'],
update?: Partial<
Pick<
EthWithdrawalApprovalState,
| 'approval'
| 'status'
| 'message'
| 'threshold'
| 'completeTimestamp'
| 'dialogOpen'
>
>
) => void;
dismiss: (index: number) => void;
}
export const useEthWithdrawApprovalsStore = create<EthWithdrawApprovalStore>(
(set, get) => ({
transactions: [] as EthWithdrawalApprovalState[],
create: (
withdrawal: EthWithdrawalApprovalState['withdrawal'],
approval?: EthWithdrawalApprovalState['approval']
) => {
const transactions = get().transactions;
const transaction: EthWithdrawalApprovalState = {
id: transactions.length,
createdAt: new Date(),
status: ApprovalStatus.Idle,
withdrawal,
approval,
dialogOpen: true,
};
// dismiss possible vega transaction dialog/toast
const vegaTransaction = useVegaTransactionStore
.getState()
.transactions.find((t) => t?.withdrawal?.id === withdrawal.id);
if (vegaTransaction) {
useVegaTransactionStore.getState().dismiss(vegaTransaction.id);
}
set({ transactions: transactions.concat(transaction) });
return transaction.id;
},
update: (
id: EthWithdrawalApprovalState['id'],
update?: Partial<
Pick<
EthWithdrawalApprovalState,
| 'approval'
| 'status'
| 'message'
| 'threshold'
| 'completeTimestamp'
| 'dialogOpen'
>
>
) =>
set({
transactions: produce(get().transactions, (draft) => {
const transaction = draft.find(
(transaction) => transaction?.id === id
);
if (transaction) {
Object.assign(transaction, update);
}
}),
}),
dismiss: (index: number) => {
set(
produce((state: EthWithdrawApprovalStore) => {
const transaction = state.transactions[index];
if (transaction) {
transaction.dialogOpen = false;
}
})
);
},
})
);

View File

@ -1,5 +1,5 @@
import * as Sentry from '@sentry/react';
import { useBridgeContract } from '@vegaprotocol/web3';
import { useBridgeContract } from './use-bridge-contract';
import { useCallback } from 'react';
/**

View File

@ -1,8 +1,8 @@
import { useCallback } from 'react';
import { useBridgeContract } from '@vegaprotocol/web3';
import { useBridgeContract } from './use-bridge-contract';
import BigNumber from 'bignumber.js';
import { addDecimal } from '@vegaprotocol/react-helpers';
import type { WithdrawalFieldsFragment } from './__generated__/Withdrawal';
import type { WithdrawalBusEventFieldsFragment } from '@vegaprotocol/wallet';
/**
* Returns a function to get the threshold amount for a withdrawal. If a withdrawal amount
@ -14,7 +14,7 @@ export const useGetWithdrawThreshold = () => {
const getThreshold = useCallback(
async (
asset:
| Pick<WithdrawalFieldsFragment['asset'], 'source' | 'decimals'>
| Pick<WithdrawalBusEventFieldsFragment['asset'], 'source' | 'decimals'>
| undefined
) => {
if (!contract || asset?.source.__typename !== 'ERC20') {

View File

@ -1,3 +1,4 @@
export * from './lib/create-withdrawal-dialog';
export * from './lib/withdrawal-dialog';
export * from './lib/withdraw-form';
export * from './lib/withdraw-form-container';

View File

@ -0,0 +1,43 @@
import { t } from '@vegaprotocol/react-helpers';
import { Dialog } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { WithdrawFormContainer } from './withdraw-form-container';
import { useWeb3ConnectStore } from '@vegaprotocol/web3';
import { useWithdrawalDialog } from './withdrawal-dialog';
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
export const CreateWithdrawalDialog = () => {
const { assetId, isOpen, open, close } = useWithdrawalDialog();
const { pubKey } = useVegaWallet();
const createTransaction = useVegaTransactionStore((state) => state.create);
const connectWalletDialogIsOpen = useWeb3ConnectStore(
(state) => state.isOpen
);
return (
<Dialog
title={t('Withdraw')}
open={isOpen && !connectWalletDialogIsOpen}
onChange={(isOpen) => (isOpen ? open() : close())}
size="small"
>
<WithdrawFormContainer
assetId={assetId}
partyId={pubKey ? pubKey : undefined}
submit={({ amount, asset, receiverAddress }) => {
createTransaction({
withdrawSubmission: {
amount,
asset,
ext: {
erc20: {
receiverAddress,
},
},
},
});
close();
}}
/>
</Dialog>
);
};

View File

@ -13,7 +13,6 @@ import type {
} from '@vegaprotocol/ui-toolkit';
import { Button } from '@vegaprotocol/ui-toolkit';
import {
Dialog,
Link,
AgGridDynamic as AgGrid,
Intent,
@ -21,130 +20,100 @@ import {
Icon,
} from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment';
import { useCompleteWithdraw } from './use-complete-withdraw';
import { useEthWithdrawApprovalsStore } from '@vegaprotocol/web3';
import type { WithdrawalFieldsFragment } from './__generated__/Withdrawal';
import type { VerifyState } from './use-verify-withdrawal';
import { ApprovalStatus, useVerifyWithdrawal } from './use-verify-withdrawal';
import { ApprovalStatus } from './use-verify-withdrawal';
export const PendingWithdrawalsTable = (
props: TypedDataAgGrid<WithdrawalFieldsFragment>
) => {
const { ETHERSCAN_URL } = useEnvironment();
const {
submit,
reset: resetTx,
Dialog: EthereumTransactionDialog,
} = useCompleteWithdraw();
const {
verify,
state: verifyState,
reset: resetVerification,
} = useVerifyWithdrawal();
const createWithdrawApproval = useEthWithdrawApprovalsStore(
(store) => store.create
);
return (
<>
<AgGrid
overlayNoRowsTemplate={t('No withdrawals')}
defaultColDef={{ flex: 1, resizable: true }}
style={{ width: '100%' }}
components={{ CompleteCell }}
suppressCellFocus={true}
domLayout="autoHeight"
rowHeight={30}
{...props}
>
<AgGridColumn headerName="Asset" field="asset.symbol" />
<AgGridColumn
headerName={t('Amount')}
field="amount"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<WithdrawalFieldsFragment, 'amount'>) => {
return isNumeric(value) && data?.asset
? addDecimalsFormatNumber(value, data.asset.decimals)
: null;
}}
/>
<AgGridColumn
headerName={t('Recipient')}
field="details.receiverAddress"
cellRenderer={({
ethUrl,
value,
valueFormatted,
}: VegaICellRendererParams<
WithdrawalFieldsFragment,
'details.receiverAddress'
> & {
ethUrl: string;
}) => (
<Link
title={t('View on Etherscan (opens in a new tab)')}
href={`${ethUrl}/address/${value}`}
data-testid="etherscan-link"
target="_blank"
>
{valueFormatted}
</Link>
)}
cellRendererParams={{ ethUrl: ETHERSCAN_URL }}
valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFieldsFragment,
'details.receiverAddress'
>) => {
if (!value) return '-';
return truncateByChars(value);
}}
/>
<AgGridColumn
headerName={t('Created')}
field="createdTimestamp"
valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFieldsFragment,
'createdTimestamp'
>) => {
return value ? getDateTimeFormat().format(new Date(value)) : '';
}}
/>
<AgGridColumn
headerName=""
field="status"
flex={2}
cellRendererParams={{
complete: async (withdrawal: WithdrawalFieldsFragment) => {
const verified = await verify(withdrawal);
if (!verified) {
return;
}
submit(withdrawal.id);
},
}}
cellRenderer="CompleteCell"
/>
</AgGrid>
<Dialog
title={t('Withdrawal verification')}
onChange={(isOpen) => {
if (!isOpen) {
resetTx();
resetVerification();
}
<AgGrid
overlayNoRowsTemplate={t('No withdrawals')}
defaultColDef={{ flex: 1, resizable: true }}
style={{ width: '100%' }}
components={{ CompleteCell }}
suppressCellFocus={true}
domLayout="autoHeight"
rowHeight={30}
{...props}
>
<AgGridColumn headerName="Asset" field="asset.symbol" />
<AgGridColumn
headerName={t('Amount')}
field="amount"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<WithdrawalFieldsFragment, 'amount'>) => {
return isNumeric(value) && data?.asset
? addDecimalsFormatNumber(value, data.asset.decimals)
: null;
}}
open={verifyState.dialogOpen}
size="small"
{...getVerifyDialogProps(verifyState.status)}
>
<VerificationStatus state={verifyState} />
</Dialog>
<EthereumTransactionDialog />
</>
/>
<AgGridColumn
headerName={t('Recipient')}
field="details.receiverAddress"
cellRenderer={({
ethUrl,
value,
valueFormatted,
}: VegaICellRendererParams<
WithdrawalFieldsFragment,
'details.receiverAddress'
> & {
ethUrl: string;
}) => (
<Link
title={t('View on Etherscan (opens in a new tab)')}
href={`${ethUrl}/address/${value}`}
data-testid="etherscan-link"
target="_blank"
>
{valueFormatted}
</Link>
)}
cellRendererParams={{ ethUrl: ETHERSCAN_URL }}
valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFieldsFragment,
'details.receiverAddress'
>) => {
if (!value) return '-';
return truncateByChars(value);
}}
/>
<AgGridColumn
headerName={t('Created')}
field="createdTimestamp"
valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFieldsFragment,
'createdTimestamp'
>) => {
return value ? getDateTimeFormat().format(new Date(value)) : '';
}}
/>
<AgGridColumn
headerName=""
field="status"
flex={2}
cellRendererParams={{
complete: (withdrawal: WithdrawalFieldsFragment) => {
createWithdrawApproval(withdrawal);
},
}}
cellRenderer="CompleteCell"
/>
</AgGrid>
);
};
@ -162,7 +131,7 @@ export const CompleteCell = ({ data, complete }: CompleteCellProps) => (
</Button>
);
const getVerifyDialogProps = (status: ApprovalStatus) => {
export const getVerifyDialogProps = (status: ApprovalStatus) => {
if (status === ApprovalStatus.Error) {
return {
intent: Intent.Danger,
@ -181,7 +150,7 @@ const getVerifyDialogProps = (status: ApprovalStatus) => {
return { intent: Intent.None };
};
const VerificationStatus = ({ state }: { state: VerifyState }) => {
export const VerificationStatus = ({ state }: { state: VerifyState }) => {
if (state.status === ApprovalStatus.Error) {
return <p>{t('Something went wrong')}</p>;
}

View File

@ -2,8 +2,10 @@ import { useCallback, useState } from 'react';
import { captureException } from '@sentry/react';
import BigNumber from 'bignumber.js';
import { addDecimal, t } from '@vegaprotocol/react-helpers';
import { useGetWithdrawThreshold } from './use-get-withdraw-threshold';
import { useGetWithdrawDelay } from './use-get-withdraw-delay';
import {
useGetWithdrawThreshold,
useGetWithdrawDelay,
} from '@vegaprotocol/web3';
import type { WithdrawalFieldsFragment } from './__generated__/Withdrawal';
import { Erc20ApprovalDocument } from './__generated__/Erc20Approval';
import type {
@ -22,10 +24,10 @@ export enum ApprovalStatus {
export interface VerifyState {
status: ApprovalStatus;
message: string;
threshold: BigNumber;
completeTimestamp: number | null;
dialogOpen: boolean;
message?: string;
threshold?: BigNumber;
completeTimestamp?: number | null;
dialogOpen?: boolean;
}
const initialState = {

View File

@ -5,8 +5,10 @@ import * as Schema from '@vegaprotocol/types';
import BigNumber from 'bignumber.js';
import { useCallback, useEffect } from 'react';
import type { AccountFieldsFragment } from '@vegaprotocol/accounts';
import { useGetWithdrawDelay } from './use-get-withdraw-delay';
import { useGetWithdrawThreshold } from './use-get-withdraw-threshold';
import {
useGetWithdrawDelay,
useGetWithdrawThreshold,
} from '@vegaprotocol/web3';
import { useWithdrawStore } from './withdraw-store';
export const useWithdrawAsset = (

View File

@ -23,13 +23,11 @@ jest.mock('./use-withdraw-asset', () => ({
}),
}));
jest.mock('./use-get-withdraw-threshold', () => ({
jest.mock('@vegaprotocol/web3', () => ({
...jest.requireActual('@vegaprotocol/web3'),
useGetWithdrawThreshold: () => {
return () => Promise.resolve(new BigNumber(100));
},
}));
jest.mock('./use-get-withdraw-delay', () => ({
useGetWithdrawDelay: () => {
return () => Promise.resolve(10000);
},

View File

@ -29,6 +29,7 @@ export const WithdrawalFeedback = ({
const { VEGA_EXPLORER_URL } = useEnvironment();
const isAvailable =
availableTimestamp === null || Date.now() > availableTimestamp;
return (
<div>
<p className="mb-2">

View File

@ -124,6 +124,7 @@
"@svgr/webpack": "^5.4.0",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "13.3.0",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.4.1",
"@types/classnames": "^2.3.1",
"@types/faker": "^5.5.8",
@ -159,6 +160,7 @@
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-unicorn": "^41.0.0",
"faker": "^5.5.3",
"flush-promises": "^1.0.2",
"glob": "^8.0.3",
"husky": "^7.0.4",
"jest": "27.5.1",

View File

@ -6597,6 +6597,14 @@
lodash "^4.17.15"
redent "^3.0.0"
"@testing-library/react-hooks@^8.0.1":
version "8.0.1"
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12"
integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==
dependencies:
"@babel/runtime" "^7.12.5"
react-error-boundary "^3.1.0"
"@testing-library/react@13.3.0":
version "13.3.0"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-13.3.0.tgz#bf298bfbc5589326bbcc8052b211f3bb097a97c5"
@ -13220,6 +13228,11 @@ flatted@^3.1.0:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787"
integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
flush-promises@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flush-promises/-/flush-promises-1.0.2.tgz#4948fd58f15281fed79cbafc86293d5bb09b2ced"
integrity sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==
flush-write-stream@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"
@ -19327,6 +19340,13 @@ react-element-to-jsx-string@^14.3.4:
is-plain-object "5.0.0"
react-is "17.0.2"
react-error-boundary@^3.1.0:
version "3.1.4"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
dependencies:
"@babel/runtime" "^7.12.5"
react-hook-form@^7.27.0:
version "7.37.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.37.0.tgz#4d1738f092d3d8a3ade34ee892d97350b1032b19"