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:
parent
bdff40b4bc
commit
87e1f9998e
@ -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
|
||||
|
1
apps/trading/components/vega-transaction/index.tsx
Normal file
1
apps/trading/components/vega-transaction/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { VegaTransaction } from './vega-transaction';
|
@ -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} />;
|
||||
};
|
@ -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>
|
||||
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
479
apps/trading/pages/toasts-manager.tsx
Normal file
479
apps/trading/pages/toasts-manager.tsx
Normal 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;
|
@ -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';
|
||||
|
@ -7,6 +7,7 @@
|
||||
}
|
||||
|
||||
.showing {
|
||||
right: 0;
|
||||
opacity: 1;
|
||||
transition: all 0.3s;
|
||||
max-height: 100vw;
|
||||
|
@ -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>
|
||||
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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(() => ({
|
||||
|
20
libs/wallet/__mocks__/zustand.js
Normal file
20
libs/wallet/__mocks__/zustand.js
Normal 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;
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
11
libs/wallet/src/WithdrawalApproval.graphql
Normal file
11
libs/wallet/src/WithdrawalApproval.graphql
Normal file
@ -0,0 +1,11 @@
|
||||
query WithdrawalApproval($withdrawalId: ID!) {
|
||||
erc20WithdrawalApproval(withdrawalId: $withdrawalId) {
|
||||
assetSource
|
||||
amount
|
||||
nonce
|
||||
signatures
|
||||
targetAddress
|
||||
expiry
|
||||
creation
|
||||
}
|
||||
}
|
215
libs/wallet/src/__generated__/TransactionResult.ts
generated
215
libs/wallet/src/__generated__/TransactionResult.ts
generated
@ -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>;
|
54
libs/wallet/src/__generated__/WithdrawalApproval.ts
generated
Normal file
54
libs/wallet/src/__generated__/WithdrawalApproval.ts
generated
Normal 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>;
|
@ -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
|
||||
|
@ -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';
|
||||
|
110
libs/wallet/src/use-vega-transaction-manager.spec.tsx
Normal file
110
libs/wallet/src/use-vega-transaction-manager.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
47
libs/wallet/src/use-vega-transaction-manager.tsx
Normal file
47
libs/wallet/src/use-vega-transaction-manager.tsx
Normal 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]);
|
||||
};
|
96
libs/wallet/src/use-vega-transaction-store.spec.tsx
Normal file
96
libs/wallet/src/use-vega-transaction-store.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
168
libs/wallet/src/use-vega-transaction-store.tsx
Normal file
168
libs/wallet/src/use-vega-transaction-store.tsx
Normal 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();
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
216
libs/wallet/src/use-vega-transaction-updater.spec.tsx
Normal file
216
libs/wallet/src/use-vega-transaction-updater.spec.tsx
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
59
libs/wallet/src/use-vega-transaction-updater.tsx
Normal file
59
libs/wallet/src/use-vega-transaction-updater.tsx
Normal 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);
|
||||
}
|
||||
}),
|
||||
});
|
||||
};
|
@ -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,
|
||||
|
44
libs/wallet/src/wait-for-withdrawal-approval.spec.ts
Normal file
44
libs/wallet/src/wait-for-withdrawal-approval.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
38
libs/wallet/src/wait-for-withdrawal-approval.ts
Normal file
38
libs/wallet/src/wait-for-withdrawal-approval.ts
Normal 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);
|
||||
}
|
||||
);
|
20
libs/web3/__mocks__/zustand.js
Normal file
20
libs/web3/__mocks__/zustand.js
Normal 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;
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
215
libs/web3/src/lib/use-ethereum-transaction-manager.spec.tsx
Normal file
215
libs/web3/src/lib/use-ethereum-transaction-manager.spec.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
99
libs/web3/src/lib/use-ethereum-transaction-manager.tsx
Normal file
99
libs/web3/src/lib/use-ethereum-transaction-manager.tsx
Normal 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]);
|
||||
};
|
85
libs/web3/src/lib/use-ethereum-transaction-store.spec.tsx
Normal file
85
libs/web3/src/lib/use-ethereum-transaction-store.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
145
libs/web3/src/lib/use-ethereum-transaction-store.tsx
Normal file
145
libs/web3/src/lib/use-ethereum-transaction-store.tsx
Normal 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];
|
||||
})
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
85
libs/web3/src/lib/use-ethereum-transaction-updater.spec.tsx
Normal file
85
libs/web3/src/lib/use-ethereum-transaction-updater.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
29
libs/web3/src/lib/use-ethereum-transaction-updater.tsx
Normal file
29
libs/web3/src/lib/use-ethereum-transaction-updater.tsx
Normal 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);
|
||||
}
|
||||
}),
|
||||
});
|
||||
};
|
@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
135
libs/web3/src/lib/use-ethereum-withdraw-approvals-manager.tsx
Normal file
135
libs/web3/src/lib/use-ethereum-withdraw-approvals-manager.tsx
Normal 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,
|
||||
]);
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
111
libs/web3/src/lib/use-ethereum-withdraw-approvals-store.tsx
Normal file
111
libs/web3/src/lib/use-ethereum-withdraw-approvals-store.tsx
Normal 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;
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
@ -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';
|
||||
|
||||
/**
|
@ -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') {
|
@ -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';
|
||||
|
43
libs/withdraws/src/lib/create-withdrawal-dialog.tsx
Normal file
43
libs/withdraws/src/lib/create-withdrawal-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>;
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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 = (
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -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">
|
||||
|
@ -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",
|
||||
|
20
yarn.lock
20
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user