refactor: handle Ethereum dialog state from hook (#851)

* refactor: pass dialog down from hook

* feat: convert dialog to be returned as component

* chore: fix linting
This commit is contained in:
Matthew Russell 2022-07-25 14:23:20 +01:00 committed by GitHub
parent 62b6cd7580
commit b7f08def47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 183 additions and 176 deletions

View File

@ -16,7 +16,6 @@ import { addDecimal } from '../../lib/decimals';
import { truncateMiddle } from '../../lib/truncate-middle';
import type { Withdrawals_party_withdrawals } from '@vegaprotocol/withdraws';
import { useCompleteWithdraw, useWithdrawals } from '@vegaprotocol/withdraws';
import { TransactionDialog } from '@vegaprotocol/web3';
import { WithdrawalStatus } from '../../__generated__/globalTypes';
import { Flags } from '../../config';
@ -35,9 +34,7 @@ const Withdrawals = () => {
const WithdrawPendingContainer = () => {
const { t } = useTranslation();
const { transaction, submit } = useCompleteWithdraw(
Flags.USE_NEW_BRIDGE_CONTRACT
);
const { submit, Dialog } = useCompleteWithdraw(Flags.USE_NEW_BRIDGE_CONTRACT);
const { data, loading, error } = useWithdrawals();
const withdrawals = React.useMemo(() => {
@ -83,7 +80,7 @@ const WithdrawPendingContainer = () => {
</li>
))}
</ul>
<TransactionDialog name="withdraw" {...transaction} />
<Dialog />
</>
);
};

View File

@ -7,11 +7,7 @@ import { useSubmitApproval } from './use-submit-approval';
import { useGetDepositLimits } from './use-get-deposit-limits';
import { useGetAllowance } from './use-get-allowance';
import { useSubmitFaucet } from './use-submit-faucet';
import {
EthTxStatus,
TransactionDialog,
useEthereumConfig,
} from '@vegaprotocol/web3';
import { EthTxStatus, useEthereumConfig } from '@vegaprotocol/web3';
import { useTokenContract } from '@vegaprotocol/web3';
import { removeDecimal } from '@vegaprotocol/react-helpers';
@ -81,7 +77,7 @@ export const DepositManager = ({
const approve = useSubmitApproval(tokenContract);
// Set up deposit transaction
const { confirmationEvent, ...deposit } = useSubmitDeposit();
const deposit = useSubmitDeposit();
// Set up faucet transaction
const faucet = useSubmitFaucet(tokenContract);
@ -89,16 +85,16 @@ export const DepositManager = ({
// Update balance after confirmation event has been received
useEffect(() => {
if (
faucet.transaction.status === EthTxStatus.Complete ||
confirmationEvent !== null
faucet.transaction.status === EthTxStatus.Confirmed ||
deposit.transaction.status === EthTxStatus.Confirmed
) {
refetchBalance();
}
}, [confirmationEvent, refetchBalance, faucet.transaction.status]);
}, [deposit.transaction.status, faucet.transaction.status, refetchBalance]);
// After an approval transaction refetch allowance
useEffect(() => {
if (approve.transaction.status === EthTxStatus.Complete) {
if (approve.transaction.status === EthTxStatus.Confirmed) {
refetchAllowance();
}
}, [approve.transaction.status, refetchAllowance]);
@ -123,15 +119,9 @@ export const DepositManager = ({
allowance={allowance}
isFaucetable={isFaucetable}
/>
<TransactionDialog {...approve.transaction} name="approve" />
<TransactionDialog {...faucet.transaction} name="faucet" />
<TransactionDialog
{...deposit}
name="deposit"
confirmed={Boolean(confirmationEvent)}
// Must wait for additional confirmations for Vega to pick up the Ethereum transaction
requiredConfirmations={config?.confirmations}
/>
<approve.Dialog />
<faucet.Dialog />
<deposit.Dialog />
</>
);
};

View File

@ -2,7 +2,6 @@ import { gql, useSubscription } from '@apollo/client';
import type {
DepositEvent,
DepositEventVariables,
DepositEvent_busEvents_event_Deposit,
} from './__generated__/DepositEvent';
import { DepositStatus } from '@vegaprotocol/types';
import { useState } from 'react';
@ -35,15 +34,15 @@ const DEPOSIT_EVENT_SUB = gql`
export const useSubmitDeposit = () => {
const { config } = useEthereumConfig();
const contract = useBridgeContract(true);
const [confirmationEvent, setConfirmationEvent] =
useState<DepositEvent_busEvents_event_Deposit | null>(null);
// Store public key from contract arguments for use in the subscription,
// NOTE: it may be different from the users connected key
const [partyId, setPartyId] = useState<string | null>(null);
const { transaction, perform } = useEthereumTransaction<
const transaction = useEthereumTransaction<
CollateralBridgeNew | CollateralBridge,
'deposit_asset'
>(contract, 'deposit_asset', config?.confirmations);
>(contract, 'deposit_asset', config?.confirmations, true);
useSubscription<DepositEvent, DepositEventVariables>(DEPOSIT_EVENT_SUB, {
variables: { partyId: partyId ? remove0x(partyId) : '' },
@ -59,7 +58,7 @@ export const useSubmitDeposit = () => {
}
if (
e.event.txHash === transaction.txHash &&
e.event.txHash === transaction.transaction.txHash &&
// 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
@ -72,19 +71,17 @@ export const useSubmitDeposit = () => {
});
if (matchingDeposit && matchingDeposit.event.__typename === 'Deposit') {
setConfirmationEvent(matchingDeposit.event);
transaction.setConfirmed();
}
},
});
return {
...transaction,
perform: (...args: Parameters<typeof perform>) => {
setConfirmationEvent(null);
perform: (...args: Parameters<typeof transaction.perform>) => {
setPartyId(args[2]);
const publicKey = prepend0x(args[2]);
perform(args[0], args[1], publicKey);
transaction.perform(args[0], args[1], publicKey);
},
confirmationEvent,
};
};

View File

@ -79,18 +79,14 @@ export const TxRow = ({
interface ConfirmationEventRowProps {
status: EthTxStatus;
confirmed: boolean;
}
export const ConfirmationEventRow = ({
status,
confirmed,
}: ConfirmationEventRowProps) => {
if (status !== EthTxStatus.Complete) {
export const ConfirmationEventRow = ({ status }: ConfirmationEventRowProps) => {
if (status !== EthTxStatus.Complete && status !== EthTxStatus.Confirmed) {
return <p>{t('Vega confirmation')}</p>;
}
if (!confirmed) {
if (status === EthTxStatus.Complete) {
return (
<p className="text-black dark:text-white">
{t('Vega is confirming your transaction...')}

View File

@ -15,9 +15,7 @@ export const DialogWrapper = ({
<div className="flex gap-12 max-w-full text-ui">
<div className="pt-8 fill-current">{icon}</div>
<div className="flex-1">
<h1 className="text-h4 text-black dark:text-white capitalize mb-12">
{title}
</h1>
<h1 className="text-h4 text-black dark:text-white mb-12">{title}</h1>
<div className="text-black-40 dark:text-white-40">{children}</div>
</div>
</div>

View File

@ -1,4 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest';
import { EthereumError } from '../ethereum-error';
import { EthTxStatus } from '../use-ethereum-transaction';
import type { TransactionDialogProps } from './transaction-dialog';
@ -15,15 +17,21 @@ let props: TransactionDialogProps;
beforeEach(() => {
props = {
name: 'test',
onChange: jest.fn(),
transaction: {
status: EthTxStatus.Default,
txHash: null,
error: null,
confirmations: 1,
receipt: null,
dialogOpen: true,
},
};
});
const generateJsx = (moreProps?: Partial<TransactionDialogProps>) => {
return <TransactionDialog {...props} {...moreProps} />;
const generateJsx = (moreProps?: PartialDeep<TransactionDialogProps>) => {
const mergedProps = merge(props, moreProps);
return <TransactionDialog {...mergedProps} />;
};
it('Opens when tx starts and closes if the user rejects the tx', () => {
@ -32,15 +40,17 @@ it('Opens when tx starts and closes if the user rejects the tx', () => {
// Dialog closed by default
expect(container).toBeEmptyDOMElement();
rerender(generateJsx({ status: EthTxStatus.Pending }));
rerender(generateJsx({ transaction: { status: EthTxStatus.Pending } }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// User rejecting the tx closes the dialog
rerender(
generateJsx({
transaction: {
status: EthTxStatus.Error,
error: new EthereumError('User rejected', 4001, 'reason'),
},
})
);
@ -49,26 +59,34 @@ it('Opens when tx starts and closes if the user rejects the tx', () => {
it('Doesn\t repoen if user dismissed the dialog', () => {
const { container, rerender } = render(
generateJsx({ status: EthTxStatus.Pending })
generateJsx({ transaction: { status: EthTxStatus.Pending } })
);
fireEvent.click(screen.getByTestId('dialog-close'));
expect(container).toBeEmptyDOMElement();
rerender(generateJsx({ status: EthTxStatus.Complete }));
rerender(generateJsx({ transaction: { status: EthTxStatus.Complete } }));
// Should still be closed even though tx updated
expect(container).toBeEmptyDOMElement();
});
it('Dialog states', () => {
const { rerender } = render(generateJsx({ status: EthTxStatus.Requested }));
// Requested
const { rerender } = render(
generateJsx({ transaction: { status: EthTxStatus.Requested } })
);
expect(screen.getByText('Confirm transaction')).toBeInTheDocument();
expect(screen.getByText('Confirm transaction in wallet')).toBeInTheDocument();
expect(screen.getByText('Await Ethereum transaction')).toBeInTheDocument();
rerender(generateJsx({ status: EthTxStatus.Pending, confirmations: 0 }));
// Pending
rerender(
generateJsx({
transaction: { status: EthTxStatus.Pending, confirmations: 0 },
})
);
expect(screen.getByText(`${props.name} pending`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
expect(
@ -76,38 +94,40 @@ it('Dialog states', () => {
).toBeInTheDocument();
expect(screen.getByTestId('link')).toBeInTheDocument();
rerender(generateJsx({ status: EthTxStatus.Complete, confirmations: 1 }));
expect(screen.getByText(`${props.name} complete`)).toBeInTheDocument();
// Ethereum complete
rerender(
generateJsx({
transaction: { status: EthTxStatus.Complete, confirmations: 1 },
})
);
expect(screen.getByText(`${props.name} pending`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
expect(screen.getByText('Ethereum transaction complete')).toBeInTheDocument();
// Ethereum confirmed (via api)
rerender(
generateJsx({
transaction: {
status: EthTxStatus.Confirmed,
error: null,
},
})
);
expect(screen.getByText(`${props.name} complete`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
expect(screen.getByText('Transaction confirmed')).toBeInTheDocument();
// Error
const errorMsg = 'Something went wrong';
const reason = 'Transaction failed';
rerender(
generateJsx({
transaction: {
status: EthTxStatus.Error,
error: new EthereumError(errorMsg, 1, reason),
},
})
);
expect(screen.getByText(`${props.name} failed`)).toBeInTheDocument();
expect(screen.getByText(`Error: ${reason}`)).toBeInTheDocument();
});
it('Success state waits for confirmation event if provided', () => {
const { rerender } = render(
generateJsx({ status: EthTxStatus.Complete, confirmed: false })
);
expect(screen.getByText(`${props.name} pending`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
expect(screen.getByText('Ethereum transaction complete')).toBeInTheDocument();
expect(
screen.getByText('Vega is confirming your transaction...')
).toBeInTheDocument();
// @ts-ignore enforce truthy on confirmation event
rerender(generateJsx({ confirmed: true, status: EthTxStatus.Complete }));
expect(
screen.queryByText('Vega is confirming your transaction...')
).not.toBeInTheDocument();
expect(screen.getByText('Transaction confirmed')).toBeInTheDocument();
});

View File

@ -1,35 +1,27 @@
import { useEffect, useRef, useState } from 'react';
import { t } from '@vegaprotocol/react-helpers';
import { Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit';
import { isEthereumError, isExpectedEthereumError } from '../ethereum-error';
import type { TxError } from '../use-ethereum-transaction';
import { isEthereumError } from '../ethereum-error';
import type { EthTxState } from '../use-ethereum-transaction';
import { EthTxStatus } from '../use-ethereum-transaction';
import { ConfirmRow, TxRow, ConfirmationEventRow } from './dialog-rows';
import { DialogWrapper } from './dialog-wrapper';
export interface TransactionDialogProps {
name: string;
status: EthTxStatus;
error: TxError | null;
confirmations: number;
txHash: string | null;
requiredConfirmations?: number;
onChange: (isOpen: boolean) => void;
transaction: EthTxState;
// 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
confirmed?: boolean;
requiredConfirmations?: number;
}
export const TransactionDialog = ({
onChange,
name,
status,
error,
confirmations,
txHash,
transaction,
requiredConfirmations = 1,
confirmed,
}: TransactionDialogProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const dialogDismissed = useRef(false);
const { status, error, confirmations, txHash } = transaction;
const renderContent = () => {
if (status === EthTxStatus.Error) {
@ -67,15 +59,18 @@ export const TransactionDialog = ({
requiredConfirmations={requiredConfirmations}
highlightComplete={false}
/>
{confirmed !== undefined && (
<ConfirmationEventRow status={status} confirmed={confirmed} />
)}
<ConfirmationEventRow status={status} />
</>
);
};
const getWrapperProps = () => {
const propsMap = {
[EthTxStatus.Default]: {
title: '',
icon: null,
intent: undefined,
},
[EthTxStatus.Error]: {
title: t(`${name} failed`),
icon: <Icon name="warning-sign" size={20} />,
@ -92,56 +87,24 @@ export const TransactionDialog = ({
intent: Intent.None,
},
[EthTxStatus.Complete]: {
title: t(`${name} pending`),
icon: <Loader size="small" />,
intent: Intent.None,
},
[EthTxStatus.Confirmed]: {
title: t(`${name} complete`),
icon: <Icon name="tick" />,
intent: Intent.Success,
},
};
// Dialog not showing
if (status === EthTxStatus.Default) {
return { intent: undefined, title: '', icon: null };
}
// Confirmation event bool is required so
if (confirmed !== undefined) {
// Vega has confirmed Tx
if (confirmed === true) {
return propsMap[EthTxStatus.Complete];
}
// Tx is complete but still awaiting for Vega to confirm
else if (status === EthTxStatus.Complete) {
return propsMap[EthTxStatus.Pending];
}
}
return propsMap[status];
};
useEffect(() => {
// Close dialog if error is due to user rejecting the tx
if (status === EthTxStatus.Error && isExpectedEthereumError(error)) {
setDialogOpen(false);
return;
}
if (status !== EthTxStatus.Default && !dialogDismissed.current) {
setDialogOpen(true);
return;
}
}, [status, error]);
const { intent, ...wrapperProps } = getWrapperProps();
return (
<Dialog
open={dialogOpen}
onChange={(isOpen) => {
setDialogOpen(isOpen);
dialogDismissed.current = true;
}}
intent={intent}
>
<Dialog open={transaction.dialogOpen} onChange={onChange} intent={intent}>
<DialogWrapper {...wrapperProps}>{renderContent()}</DialogWrapper>
</Dialog>
);

View File

@ -67,14 +67,19 @@ it('Ethereum transaction flow', async () => {
error: null,
confirmations: 0,
receipt: null,
dialogOpen: false,
},
Dialog: expect.any(Function),
setConfirmed: expect.any(Function),
perform: expect.any(Function),
reset: expect.any(Function),
});
act(() => {
result.current.perform('asset-source', '100', 'vega-key');
});
expect(result.current.transaction.status).toEqual(EthTxStatus.Default); // still default as we await result of static call
expect(result.current.transaction.status).toEqual(EthTxStatus.Requested); // still default as we await result of static call
expect(result.current.transaction.confirmations).toBe(0);
await act(async () => {
@ -105,7 +110,7 @@ it('Ethereum transaction flow', async () => {
expect(result.current.transaction.confirmations).toBe(3);
// Now complete as required confirmations have been surpassed
expect(result.current.transaction.status).toEqual(EthTxStatus.Complete);
expect(result.current.transaction.status).toEqual(EthTxStatus.Confirmed);
expect(result.current.transaction.receipt).toEqual({
from: 'foo',
confirmations: result.current.transaction.confirmations,

View File

@ -1,13 +1,17 @@
import { formatLabel } from '@vegaprotocol/react-helpers';
import type { ethers } from 'ethers';
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { EthereumError } from './ethereum-error';
import { isExpectedEthereumError } from './ethereum-error';
import { isEthereumError } from './ethereum-error';
import { TransactionDialog } from './transaction-dialog';
export enum EthTxStatus {
Default = 'Default',
Requested = 'Requested',
Pending = 'Pending',
Complete = 'Complete',
Confirmed = 'Confirmed',
Error = 'Error',
}
@ -19,6 +23,7 @@ export interface EthTxState {
txHash: string | null;
receipt: ethers.ContractReceipt | null;
confirmations: number;
dialogOpen: boolean;
}
export const initialState = {
@ -27,6 +32,7 @@ export const initialState = {
txHash: null,
receipt: null,
confirmations: 0,
dialogOpen: false,
};
type DefaultContract = {
@ -39,7 +45,8 @@ export const useEthereumTransaction = <
>(
contract: TContract | null,
methodName: keyof TContract,
requiredConfirmations = 1
requiredConfirmations = 1,
requiresConfirmation = false
) => {
const [transaction, _setTransaction] = useState<EthTxState>(initialState);
@ -54,6 +61,13 @@ export const useEthereumTransaction = <
// @ts-ignore TS errors here as TMethod doesn't satisfy the constraints on TContract
// its a tricky one to fix but does enforce the correct types when calling perform
async (...args: Parameters<TContract[TMethod]>) => {
setTransaction({
status: EthTxStatus.Requested,
error: null,
confirmations: 0,
dialogOpen: true,
});
try {
if (
!contract ||
@ -72,12 +86,6 @@ export const useEthereumTransaction = <
return;
}
setTransaction({
status: EthTxStatus.Requested,
error: null,
confirmations: 0,
});
try {
const method = contract[methodName];
@ -104,10 +112,18 @@ export const useEthereumTransaction = <
throw new Error('no receipt after confirmations are met');
}
if (requiresConfirmation) {
setTransaction({ status: EthTxStatus.Complete, receipt });
} else {
setTransaction({ status: EthTxStatus.Confirmed, receipt });
}
} catch (err) {
if (err instanceof Error || isEthereumError(err)) {
if (isExpectedEthereumError(err)) {
setTransaction({ dialogOpen: false });
} else {
setTransaction({ status: EthTxStatus.Error, error: err });
}
} else {
setTransaction({
status: EthTxStatus.Error,
@ -116,12 +132,35 @@ export const useEthereumTransaction = <
}
}
},
[contract, methodName, requiredConfirmations, setTransaction]
[
contract,
methodName,
requiredConfirmations,
requiresConfirmation,
setTransaction,
]
);
const reset = useCallback(() => {
setTransaction(initialState);
}, [setTransaction]);
return { perform, transaction, reset };
const setConfirmed = useCallback(() => {
setTransaction({ status: EthTxStatus.Confirmed });
}, [setTransaction]);
const Dialog = useMemo(() => {
return () => (
<TransactionDialog
name={formatLabel(methodName as string)}
onChange={() => {
reset();
}}
transaction={transaction}
requiredConfirmations={requiredConfirmations}
/>
);
}, [methodName, transaction, requiredConfirmations, reset]);
return { perform, transaction, reset, setConfirmed, Dialog };
};

View File

@ -25,7 +25,7 @@ export const useCompleteWithdraw = (isNewContract: boolean) => {
const { query, cache } = useApolloClient();
const contract = useBridgeContract(isNewContract);
const [id, setId] = useState('');
const { transaction, perform } = useEthereumTransaction<
const { transaction, perform, Dialog } = useEthereumTransaction<
CollateralBridgeNew | CollateralBridge,
'withdraw_asset'
>(contract, 'withdraw_asset');
@ -91,5 +91,5 @@ export const useCompleteWithdraw = (isNewContract: boolean) => {
}
}, [cache, transaction.txHash, id]);
return { transaction, submit, withdrawalId: id };
return { transaction, Dialog, submit, withdrawalId: id };
};

View File

@ -120,6 +120,25 @@ const getProps = (
},
};
const completeProps = {
title: t('Withdrawal complete'),
icon: <Icon name="tick" />,
intent: Intent.Success,
children: (
<Step>
<span>{t('Ethereum transaction complete')}</span>
<Link
href={`${ethUrl}/tx/${ethTx.txHash}`}
title={t('View transaction on Etherscan')}
className="text-vega-pink dark:text-vega-yellow"
target="_blank"
>
{t('View on Etherscan')}
</Link>
</Step>
),
};
const ethTxPropsMap: Record<EthTxStatus, DialogProps> = {
[EthTxStatus.Default]: {
title: '',
@ -169,24 +188,8 @@ const getProps = (
</Step>
),
},
[EthTxStatus.Complete]: {
title: t('Withdrawal complete'),
icon: <Icon name="tick" />,
intent: Intent.Success,
children: (
<Step>
<span>{t('Ethereum transaction complete')}</span>
<Link
href={`${ethUrl}/tx/${ethTx.txHash}`}
title={t('View transaction on Etherscan')}
className="text-vega-pink dark:text-vega-yellow"
target="_blank"
>
{t('View on Etherscan')}
</Link>
</Step>
),
},
[EthTxStatus.Complete]: completeProps,
[EthTxStatus.Confirmed]: completeProps,
};
return approval ? ethTxPropsMap[ethTx.status] : vegaTxPropsMap[vegaTx.status];

View File

@ -12,7 +12,6 @@ import {
import { WithdrawalStatus } from '@vegaprotocol/types';
import { Link, AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment';
import { TransactionDialog } from '@vegaprotocol/web3';
import { useCompleteWithdraw } from './use-complete-withdraw';
import type { Withdrawals_party_withdrawals } from './__generated__/Withdrawals';
@ -22,7 +21,7 @@ export interface WithdrawalsTableProps {
export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => {
const { ETHERSCAN_URL } = useEnvironment();
const { transaction, submit } = useCompleteWithdraw(true);
const { submit, Dialog } = useCompleteWithdraw(true);
return (
<>
@ -65,7 +64,7 @@ export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => {
cellRendererParams={{ complete: submit, ethUrl: ETHERSCAN_URL }}
/>
</AgGrid>
<TransactionDialog name="withdraw" {...transaction} />
<Dialog />
</>
);
};