feat (#896): large withdraws (#1180)

* feat: add deposits table to deposits tab on portfolio page

* feat: refactor use-withdraw hook to not invoke eth tx

* feat: rename hook for clarity

* feat: fix withdrawal creation test

* feat: update withdraw-manager and withrawals-table tests

* chore: fix lint

* feat: remove web3 input to avoid double dialog

* chore: use renderHook from testing-library/react

* chore: update to use non deprecated fields

* chore: remove usage of all bridge contract

* feat: correctly merge cache update in withdrawals table

* feat: changes to support token app withdrawals

* chore: add height to ag grid table wrapping element

* feat: add txhash col to withdraw table

* feat: provide better ui if withdrawal is not ready to be completed

* feat: use separate dialogs for txs

* feat: allow user to immediately complete withdrawal if delay not triggered

* feat: add withdraw store to tidy up state management

* chore: fix tests

* chore: convert callback to promises, fix tests, delete withdraw page

* chore: fix lint errors

* fix: withdrawals link in nav

* feat: style changes after design update

* fix: proposal form test

* chore: tidy error ui logic

* feat: review comments

* chore: lint

* feat: add better typing for tables

* chore: put withdrawals tab at the end

* chore: update i18n

* fix: dialog in positions manager due to rename

* chore: increase spacing in withdrawal form

* chore: update tests

* chore: lint

* chore: use new assetsConnection and update cy test

* fix: incorrect shape of withdrawal generate function

* feat: delete withdrawals page now that its shown on the portfolio page

* chore: update tests to check for withdrawals page

* chore: fix tests again

* fix: page title test
This commit is contained in:
Matthew Russell 2022-09-05 18:30:13 -07:00 committed by GitHub
parent 578d6ecf6f
commit 5eb06254de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
91 changed files with 2713 additions and 2182 deletions

View File

@ -110,8 +110,7 @@ export const DealTicketSteps = ({
fieldErrors: errors, fieldErrors: errors,
}); });
const { submit, transaction, finalizedOrder, TransactionDialog } = const { submit, transaction, finalizedOrder, Dialog } = useOrderSubmit();
useOrderSubmit();
const onSizeChange = (value: number[]) => { const onSizeChange = (value: number[]) => {
const newVal = new BigNumber(value[0]) const newVal = new BigNumber(value[0])
@ -249,13 +248,13 @@ export const DealTicketSteps = ({
notionalSize={notionalSize || emptyString} notionalSize={notionalSize || emptyString}
fees={fees || emptyString} fees={fees || emptyString}
/> />
<TransactionDialog <Dialog
title={getOrderDialogTitle(finalizedOrder?.status)} title={getOrderDialogTitle(finalizedOrder?.status)}
intent={getOrderDialogIntent(finalizedOrder?.status)} intent={getOrderDialogIntent(finalizedOrder?.status)}
icon={getOrderDialogIcon(finalizedOrder?.status)} icon={getOrderDialogIcon(finalizedOrder?.status)}
> >
<OrderFeedback transaction={transaction} order={finalizedOrder} /> <OrderFeedback transaction={transaction} order={finalizedOrder} />
</TransactionDialog> </Dialog>
</div> </div>
), ),
disabled: true, disabled: true,

View File

@ -3,7 +3,7 @@ const navHome = '[href="/"]';
const navVesting = '[href="/vesting"]'; const navVesting = '[href="/vesting"]';
const navStaking = '[href="/staking"]'; const navStaking = '[href="/staking"]';
const navRewards = '[href="/rewards"]'; const navRewards = '[href="/rewards"]';
const navWithdraw = '[href="/withdraw"]'; const navWithdraw = '[href="/withdrawals"]';
const navGovernance = '[href="/governance"]'; const navGovernance = '[href="/governance"]';
const tokenDetailsTable = '.token-details'; const tokenDetailsTable = '.token-details';

View File

@ -1,18 +1,17 @@
const connectToVegaBtn = '[data-testid="connect-to-vega-wallet-btn"]'; const connectToVegaBtn = '[data-testid="connect-to-vega-wallet-btn"]';
const warning = '[data-testid="callout"]';
context('Withdraw Page - verify elements on page', function () { context('Withdraw Page - verify elements on page', function () {
before('navigate to withdraw page', function () { before('navigate to withdrawals page', function () {
cy.visit('/').navigate_to('withdraw'); cy.visit('/').navigate_to('withdrawals');
}); });
describe('with wallets disconnected', function () { describe('with wallets disconnected', function () {
it('should have withdraw tab highlighted', function () { it('should have withdraw tab highlighted', function () {
cy.verify_tab_highlighted('withdraw'); cy.verify_tab_highlighted('withdrawals');
}); });
it('should have WITHDRAW header visible', function () { it('should have WITHDRAW header visible', function () {
cy.verify_page_header('Withdraw'); cy.verify_page_header('Withdrawals');
}); });
it('should have connect Vega wallet button', function () { it('should have connect Vega wallet button', function () {
@ -20,9 +19,5 @@ context('Withdraw Page - verify elements on page', function () {
.should('be.visible') .should('be.visible')
.and('have.text', 'Connect Vega wallet'); .and('have.text', 'Connect Vega wallet');
}); });
it('should have withdraw information box', function () {
cy.get(warning).should('be.visible');
});
}); });
}); });

View File

@ -12,7 +12,7 @@ const navigation = {
vesting: '[href="/vesting"]', vesting: '[href="/vesting"]',
staking: '[href="/staking"]', staking: '[href="/staking"]',
rewards: '[href="/rewards"]', rewards: '[href="/rewards"]',
withdraw: '[href="/withdraw"]', withdrawals: '[href="/withdrawals"]',
governance: '[href="/governance"]', governance: '[href="/governance"]',
pageSpinner: '[data-testid="splash-loader"]', pageSpinner: '[data-testid="splash-loader"]',
}; };

View File

@ -224,7 +224,7 @@ const NavLinks = ({
{ route: Routes.VESTING, text: t('Vesting') }, { route: Routes.VESTING, text: t('Vesting') },
{ route: Routes.STAKING, text: t('Staking') }, { route: Routes.STAKING, text: t('Staking') },
{ route: Routes.REWARDS, text: t('Rewards') }, { route: Routes.REWARDS, text: t('Rewards') },
{ route: Routes.WITHDRAW, text: t('Withdraw') }, { route: Routes.WITHDRAWALS, text: t('Withdraw') },
{ route: Routes.GOVERNANCE, text: t('Governance') }, { route: Routes.GOVERNANCE, text: t('Governance') },
]; ];
const navClasses = classNames('flex', { const navClasses = classNames('flex', {

View File

@ -452,7 +452,7 @@
"withdrawFormAmountLabel": "How much would you like to withdraw?", "withdrawFormAmountLabel": "How much would you like to withdraw?",
"withdrawFormSubmitButtonIdle": "Withdraw {{amount}} {{symbol}} tokens", "withdrawFormSubmitButtonIdle": "Withdraw {{amount}} {{symbol}} tokens",
"withdrawFormSubmitButtonPending": "Preparing", "withdrawFormSubmitButtonPending": "Preparing",
"withdrawalsTitle": "Incomplete withdrawals", "withdrawalsTitle": "Withdrawals",
"withdrawalsText": "These withdrawals need to be completed with an Ethereum transaction.", "withdrawalsText": "These withdrawals need to be completed with an Ethereum transaction.",
"withdrawalsNone": "You don't have any pending withdrawals.", "withdrawalsNone": "You don't have any pending withdrawals.",
"withdrawalsCompleteButton": "Finish withdrawal", "withdrawalsCompleteButton": "Finish withdrawal",

View File

@ -143,13 +143,6 @@ const LazyContracts = React.lazy(
) )
); );
const LazyWithdraw = React.lazy(
() =>
import(
/* webpackChunkName: "route-withdraw", webpackPrefetch: true */ './withdraw'
)
);
const LazyWithdrawals = React.lazy( const LazyWithdrawals = React.lazy(
() => () =>
import( import(
@ -201,11 +194,6 @@ const routerConfig = [
name: 'Rewards', name: 'Rewards',
component: LazyRewards, component: LazyRewards,
}, },
{
path: Routes.WITHDRAW,
name: 'Withdraw',
component: LazyWithdraw,
},
{ {
path: Routes.WITHDRAWALS, path: Routes.WITHDRAWALS,
name: 'Withdrawals', name: 'Withdrawals',

View File

@ -4,7 +4,6 @@ export default {
CLAIM: '/claim', CLAIM: '/claim',
STAKING: '/staking', STAKING: '/staking',
REWARDS: '/rewards', REWARDS: '/rewards',
WITHDRAW: '/withdraw',
WITHDRAWALS: '/withdrawals', WITHDRAWALS: '/withdrawals',
GOVERNANCE: '/governance', GOVERNANCE: '/governance',
VESTING: '/vesting', VESTING: '/vesting',

View File

@ -1,207 +0,0 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { AccountType, WithdrawalStatus } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: WithdrawPage
// ====================================================
export interface WithdrawPage_party_accounts_asset_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface WithdrawPage_party_accounts_asset_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
*/
contractAddress: string;
}
export type WithdrawPage_party_accounts_asset_source = WithdrawPage_party_accounts_asset_source_BuiltinAsset | WithdrawPage_party_accounts_asset_source_ERC20;
export interface WithdrawPage_party_accounts_asset {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: WithdrawPage_party_accounts_asset_source;
}
export interface WithdrawPage_party_accounts {
__typename: "Account";
/**
* Balance as string - current account balance (approx. as balances can be updated several times per second)
*/
balance: string;
balanceFormatted: string;
/**
* Account type (General, Margin, etc)
*/
type: AccountType;
/**
* Asset, the 'currency'
*/
asset: WithdrawPage_party_accounts_asset;
}
export interface WithdrawPage_party_withdrawals_asset {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
}
export interface WithdrawPage_party_withdrawals_details {
__typename: "Erc20WithdrawalDetails";
/**
* The ethereum address of the receiver of the asset funds
*/
receiverAddress: string;
}
export interface WithdrawPage_party_withdrawals {
__typename: "Withdrawal";
/**
* The Vega internal ID of the withdrawal
*/
id: string;
/**
* The amount to be withdrawn
*/
amount: string;
/**
* The asset to be withdrawn
*/
asset: WithdrawPage_party_withdrawals_asset;
/**
* The current status of the withdrawal
*/
status: WithdrawalStatus;
/**
* RFC3339Nano time at which the withdrawal was created
*/
createdTimestamp: string;
/**
* RFC3339Nano time at which the withdrawal was finalised
*/
withdrawnTimestamp: string | null;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
/**
* Foreign chain specific details about the withdrawal
*/
details: WithdrawPage_party_withdrawals_details | null;
}
export interface WithdrawPage_party {
__typename: "Party";
/**
* Party identifier
*/
id: string;
/**
* Collateral accounts relating to a party
*/
accounts: WithdrawPage_party_accounts[] | null;
/**
* The list of all withdrawals initiated by the party
*/
withdrawals: WithdrawPage_party_withdrawals[] | null;
}
export interface WithdrawPage_assetsConnection_edges_node_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface WithdrawPage_assetsConnection_edges_node_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
*/
contractAddress: string;
}
export type WithdrawPage_assetsConnection_edges_node_source = WithdrawPage_assetsConnection_edges_node_source_BuiltinAsset | WithdrawPage_assetsConnection_edges_node_source_ERC20;
export interface WithdrawPage_assetsConnection_edges_node {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: WithdrawPage_assetsConnection_edges_node_source;
}
export interface WithdrawPage_assetsConnection_edges {
__typename: "AssetEdge";
node: WithdrawPage_assetsConnection_edges_node;
}
export interface WithdrawPage_assetsConnection {
__typename: "AssetsConnection";
/**
* The assets
*/
edges: (WithdrawPage_assetsConnection_edges | null)[] | null;
}
export interface WithdrawPage {
/**
* An entity that is trading on the Vega network
*/
party: WithdrawPage_party | null;
/**
* The list of all assets in use in the Vega network or the specified asset if ID is provided
*/
assetsConnection: WithdrawPage_assetsConnection;
}
export interface WithdrawPageVariables {
partyId: string;
}

View File

@ -1,57 +0,0 @@
import {
Callout,
FormGroup,
Intent,
Input,
ButtonLink,
} from '@vegaprotocol/ui-toolkit';
import React from 'react';
import { useTranslation } from 'react-i18next';
interface EthAddressSelectorProps {
address: string;
connectedAddress: string;
onChange: (newAddress: string) => void;
isValid: boolean;
}
export const EthAddressInput = ({
connectedAddress,
address,
onChange,
isValid,
}: EthAddressSelectorProps) => {
const { t } = useTranslation();
const [useConnectedWallet, setUseConnectedWallet] =
React.useState<boolean>(true);
React.useEffect(() => {
if (useConnectedWallet) {
onChange(connectedAddress);
}
}, [connectedAddress, onChange, useConnectedWallet]);
return (
<FormGroup label={t('To')} labelFor="ethAddressInput">
<Input
data-testid="token-amount-input"
className="token-input__input"
name="ethAddressInput"
onChange={(e) => onChange(e.target.value)}
value={address}
disabled={useConnectedWallet}
// leftElement={<Ethereum />} TODO: render Ethereum icon in input when https://github.com/vegaprotocol/frontend-monorepo/issues/273
autoComplete="off"
type="text"
required={true}
/>
<div className="flex justify-center">
<ButtonLink onClick={() => setUseConnectedWallet(!useConnectedWallet)}>
{useConnectedWallet ? t('enterAddress') : t('useConnectedWallet')}
</ButtonLink>
</div>
{isValid ? null : (
<Callout intent={Intent.Warning}>{t('invalidAddress')}</Callout>
)}
</FormGroup>
);
};

View File

@ -1,166 +0,0 @@
import { gql, useQuery } from '@apollo/client';
import { Callout, Intent, Splash } from '@vegaprotocol/ui-toolkit';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Heading } from '../../components/heading';
import { SplashLoader } from '../../components/splash-loader';
import { VegaWalletContainer } from '../../components/vega-wallet-container';
import type { VegaKeyExtended } from '@vegaprotocol/wallet';
import Routes from '../routes';
import type {
WithdrawPage,
WithdrawPageVariables,
} from './__generated__/WithdrawPage';
import { WithdrawManager } from '@vegaprotocol/withdraws';
import { AccountType } from '@vegaprotocol/types';
import { assetsConnectionToAssets } from '@vegaprotocol/react-helpers';
const Withdraw = () => {
const { t } = useTranslation();
return (
<>
<Heading title={t('withdrawPageHeading')} />
<p>{t('withdrawPageText')}</p>
<div className="mb-8">
<VegaWalletContainer>
{(currVegaKey) => <WithdrawContainer currVegaKey={currVegaKey} />}
</VegaWalletContainer>
</div>
<Callout title={t('withdrawPageInfoCalloutTitle')}>
<p className="mb-0">{t('withdrawPageInfoCalloutText')}</p>
</Callout>
</>
);
};
const WITHDRAW_PAGE_QUERY = gql`
query WithdrawPage($partyId: ID!) {
party(id: $partyId) {
id
accounts {
balance
balanceFormatted @client
type
asset {
id
name
symbol
decimals
source {
__typename
... on ERC20 {
contractAddress
}
}
}
}
withdrawals {
id
amount
asset {
id
symbol
decimals
}
status
createdTimestamp
withdrawnTimestamp
txHash
details {
... on Erc20WithdrawalDetails {
receiverAddress
}
}
}
}
assetsConnection {
edges {
node {
id
symbol
name
decimals
source {
... on ERC20 {
contractAddress
}
}
}
}
}
}
`;
interface WithdrawContainerProps {
currVegaKey: VegaKeyExtended;
}
export const WithdrawContainer = ({ currVegaKey }: WithdrawContainerProps) => {
const { t } = useTranslation();
const { data, loading, error } = useQuery<
WithdrawPage,
WithdrawPageVariables
>(WITHDRAW_PAGE_QUERY, {
variables: { partyId: currVegaKey?.pub },
});
const accounts = React.useMemo(() => {
if (!data?.party?.accounts) return [];
// You can only withdraw from general accounts
return data.party.accounts.filter(
(a) => a.type === AccountType.ACCOUNT_TYPE_GENERAL
);
}, [data]);
// Note there is a small period where the withdrawal might have a tx hash but is technically
// not complete yet as the tx hash gets set before the transaction is confirmed
const hasPendingWithdrawals = React.useMemo(() => {
if (!data?.party?.withdrawals?.length) return false;
return data.party.withdrawals.some((w) => w.txHash === null);
}, [data]);
if (error) {
return (
<section>
<p>{t('Something went wrong')}</p>
{error && <pre>{error.message}</pre>}
</section>
);
}
if (loading || !data) {
return (
<Splash>
<SplashLoader />
</Splash>
);
}
const assets = assetsConnectionToAssets(data.assetsConnection);
return (
<>
{hasPendingWithdrawals && (
<div className="mb-8">
<Callout
title={t('pendingWithdrawalsCalloutTitle')}
intent={Intent.Warning}
>
<p>{t('pendingWithdrawalsCalloutText')}</p>
<p>
<Link to={Routes.WITHDRAWALS} className="underline text-white">
{t('pendingWithdrawalsCalloutButton')}
</Link>
</p>
</Callout>
</div>
)}
<WithdrawManager assets={assets || []} accounts={accounts} />
</>
);
};
export default Withdraw;

View File

@ -1,22 +1,15 @@
import { Button, Splash } from '@vegaprotocol/ui-toolkit'; import { Button, Splash } from '@vegaprotocol/ui-toolkit';
import { format } from 'date-fns';
import orderBy from 'lodash/orderBy';
import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment';
import { Heading } from '../../components/heading'; import { Heading } from '../../components/heading';
import { KeyValueTable, KeyValueTableRow } from '@vegaprotocol/ui-toolkit';
import { SplashLoader } from '../../components/splash-loader'; import { SplashLoader } from '../../components/splash-loader';
import { VegaWalletContainer } from '../../components/vega-wallet-container'; import { VegaWalletContainer } from '../../components/vega-wallet-container';
import { BigNumber } from '../../lib/bignumber'; import {
import { DATE_FORMAT_DETAILED } from '../../lib/date-formats'; useWithdrawals,
import { addDecimal } from '../../lib/decimals'; WithdrawalDialogs,
import { truncateMiddle } from '../../lib/truncate-middle'; WithdrawalsTable,
import type { Withdrawals_party_withdrawals } from '@vegaprotocol/withdraws'; } from '@vegaprotocol/withdraws';
import { useCompleteWithdraw, useWithdrawals } from '@vegaprotocol/withdraws'; import { useState } from 'react';
import { WithdrawalStatus } from '@vegaprotocol/types';
const Withdrawals = () => { const Withdrawals = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -32,19 +25,9 @@ const Withdrawals = () => {
}; };
const WithdrawPendingContainer = () => { const WithdrawPendingContainer = () => {
const [withdrawDialog, setWithdrawDialog] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const { submit, Dialog } = useCompleteWithdraw(); const { withdrawals, loading, error } = useWithdrawals();
const { data, loading, error } = useWithdrawals();
const withdrawals = React.useMemo(() => {
if (!data?.party?.withdrawals?.length) return [];
return orderBy(
data.party.withdrawals,
[(w) => new Date(w.createdTimestamp)],
['desc']
);
}, [data]);
if (error) { if (error) {
return ( return (
@ -55,7 +38,7 @@ const WithdrawPendingContainer = () => {
); );
} }
if (loading || !data) { if (loading) {
return ( return (
<Splash> <Splash>
<SplashLoader /> <SplashLoader />
@ -63,122 +46,23 @@ const WithdrawPendingContainer = () => {
); );
} }
if (!withdrawals.length) {
return <p>{t('withdrawalsNone')}</p>;
}
return ( return (
<> <>
<h2>{t('withdrawalsPreparedWarningHeading')}</h2> <header className="flex items-start justify-between">
<h2>{t('withdrawalsPreparedWarningHeading')}</h2>
<Button onClick={() => setWithdrawDialog(true)}>Withdraw</Button>
</header>
<p>{t('withdrawalsText')}</p> <p>{t('withdrawalsText')}</p>
<p className="mb-8">{t('withdrawalsPreparedWarningText')}</p> <p className="mb-8">{t('withdrawalsPreparedWarningText')}</p>
<ul role="list"> <div className="w-full h-[500px]">
{withdrawals.map((w) => ( <WithdrawalsTable withdrawals={withdrawals} />
<li key={w.id} className="mb-10 last:mb-0"> </div>
<Withdrawal withdrawal={w} complete={submit} /> <WithdrawalDialogs
</li> withdrawDialog={withdrawDialog}
))} setWithdrawDialog={setWithdrawDialog}
</ul> />
<Dialog />
</> </>
); );
}; };
interface WithdrawalProps {
withdrawal: Withdrawals_party_withdrawals;
complete: (withdrawalId: string) => void;
}
export const Withdrawal = ({ withdrawal, complete }: WithdrawalProps) => {
const { ETHERSCAN_URL } = useEnvironment();
const { t } = useTranslation();
let status;
let footer = null;
if (withdrawal.pendingOnForeignChain) {
status = t('Pending');
footer = (
<Button
fill={true}
disabled={true}
onClick={() => complete(withdrawal.id)}
>
{t('withdrawalsCompleteButton')}
</Button>
);
} else if (withdrawal.status === WithdrawalStatus.STATUS_FINALIZED) {
if (withdrawal.txHash) {
status = t('Complete');
} else {
status = t('Incomplete');
footer = (
<Button fill={true} onClick={() => complete(withdrawal.id)}>
{t('withdrawalsCompleteButton')}
</Button>
);
}
} else {
status = withdrawal.status;
}
return (
<div>
<KeyValueTable>
<KeyValueTableRow>
{t('Withdraw')}
<span>
{addDecimal(
new BigNumber(withdrawal.amount),
withdrawal.asset.decimals
)}{' '}
{withdrawal.asset.symbol}
</span>
</KeyValueTableRow>
<KeyValueTableRow>
{t('toEthereum')}
<span>
<Link
title={t('View on Etherscan (opens in a new tab)')}
href={`${ETHERSCAN_URL}/tx/${withdrawal.details?.receiverAddress}`}
target="_blank"
>
{truncateMiddle(withdrawal.details?.receiverAddress ?? '')}
</Link>
</span>
</KeyValueTableRow>
<KeyValueTableRow>
{t('created')}
<span>
{format(
new Date(withdrawal.createdTimestamp),
DATE_FORMAT_DETAILED
)}
</span>
</KeyValueTableRow>
<KeyValueTableRow>
{t('withdrawalTransaction', { foreignChain: 'Ethereum' })}
<span>
{withdrawal.txHash ? (
<Link
title={t('View transaction on Etherscan')}
href={`${ETHERSCAN_URL}/tx/${withdrawal.txHash}`}
target="_blank"
>
{truncateMiddle(withdrawal.txHash)}
</Link>
) : (
'-'
)}
</span>
</KeyValueTableRow>
<KeyValueTableRow>
{t('status')}
{status}
</KeyValueTableRow>
</KeyValueTable>
{footer}
</div>
);
};
export default Withdrawals; export default Withdrawals;

View File

@ -1,7 +1,8 @@
import { aliasQuery } from '@vegaprotocol/cypress'; import { aliasQuery } from '@vegaprotocol/cypress';
import { connectEthereumWallet } from '../support/ethereum-wallet'; import { connectEthereumWallet } from '../support/ethereum-wallet';
import { generateNetworkParameters } from '../support/mocks/generate-network-parameters'; import { generateNetworkParameters } from '../support/mocks/generate-network-parameters';
import { generateWithdrawPageQuery } from '../support/mocks/generate-withdraw-page-query'; import { generateWithdrawFormQuery } from '../support/mocks/generate-withdraw-page-query';
import { generateWithdrawals } from '../support/mocks/generate-withdrawals';
import { connectVegaWallet } from '../support/vega-wallet'; import { connectVegaWallet } from '../support/vega-wallet';
describe('withdraw', () => { describe('withdraw', () => {
@ -16,10 +17,13 @@ describe('withdraw', () => {
beforeEach(() => { beforeEach(() => {
cy.mockWeb3Provider(); cy.mockWeb3Provider();
cy.mockGQL((req) => { cy.mockGQL((req) => {
aliasQuery(req, 'WithdrawPageQuery', generateWithdrawPageQuery()); aliasQuery(req, 'Withdrawals', generateWithdrawals());
aliasQuery(req, 'NetworkParamsQuery', generateNetworkParameters()); aliasQuery(req, 'NetworkParamsQuery', generateNetworkParameters());
aliasQuery(req, 'WithdrawFormQuery', generateWithdrawFormQuery());
}); });
cy.visit('/portfolio/withdraw');
cy.visit('/portfolio');
cy.getByTestId('Withdrawals').click();
// Withdraw page requires vega wallet connection // Withdraw page requires vega wallet connection
connectVegaWallet(); connectVegaWallet();
@ -27,15 +31,14 @@ describe('withdraw', () => {
// It also requires connection Ethereum wallet // It also requires connection Ethereum wallet
connectEthereumWallet(); connectEthereumWallet();
cy.wait('@WithdrawPageQuery'); cy.mockGQL((req) => {
cy.contains('Withdraw'); aliasQuery(req, 'WithdrawFormQuery', generateWithdrawFormQuery());
});
cy.getByTestId('withdraw-dialog-button').click();
cy.wait('@WithdrawFormQuery');
}); });
it('form validation', () => { it('form validation', () => {
// Prompts that there are incomplete withdrawals
cy.contains('You have incomplete withdrawals');
cy.getByTestId('complete-withdrawals-prompt').should('exist');
cy.getByTestId(submitWithdrawBtn).click(); cy.getByTestId(submitWithdrawBtn).click();
cy.getByTestId(formFieldError).should('contain.text', 'Required'); cy.getByTestId(formFieldError).should('contain.text', 'Required');
@ -82,9 +85,8 @@ describe('withdraw', () => {
cy.getByTestId(submitWithdrawBtn).click(); cy.getByTestId(submitWithdrawBtn).click();
cy.getByTestId('dialog-title').should( cy.getByTestId('dialog-title').should(
'have.text', 'have.text',
'Withdrawal transaction pending' 'Awaiting network confirmation'
); );
cy.getByTestId('dialog-text').should('have.text', 'Awaiting transaction');
}); });
it.skip('creates a withdrawal on submit'); // Needs capsule it.skip('creates a withdrawal on submit'); // Needs capsule

View File

@ -1,77 +0,0 @@
import { aliasQuery } from '@vegaprotocol/cypress';
import { connectEthereumWallet } from '../support/ethereum-wallet';
import { generateNetworkParameters } from '../support/mocks/generate-network-parameters';
import { generateWithdrawals } from '../support/mocks/generate-withdrawals';
import { connectVegaWallet } from '../support/vega-wallet';
describe('withdrawals', () => {
beforeEach(() => {
cy.mockWeb3Provider();
cy.mockGQL((req) => {
aliasQuery(req, 'Withdrawals', generateWithdrawals());
aliasQuery(req, 'NetworkParamsQuery', generateNetworkParameters());
});
cy.visit('/portfolio/withdrawals');
// Withdraw page requires vega wallet connection
connectVegaWallet();
// It also requires connection Ethereum wallet
connectEthereumWallet();
});
it('renders history of withdrawals', () => {
const ethAddressLink = `${Cypress.env(
'ETHERSCAN_URL'
)}/address/0x72c22822A19D20DE7e426fB84aa047399Ddd8853`;
const etherScanLink = `${Cypress.env(
'ETHERSCAN_URL'
)}/tx/0x5d7b1a35ba6bd23be17bb7a159c13cdbb3121fceb94e9c6c510f5503dce48d03`;
const row = '.ag-center-cols-container[role="rowgroup"] > [role="row"]';
// First row is incomplete
cy.get(row)
.eq(0)
.find('[col-id="asset.symbol"]')
.should('contain.text', 'AST0');
cy.get(row)
.eq(0)
.find('[col-id="amount"]')
.should('contain.text', '0.00100');
cy.get(row)
.eq(0)
.find('[col-id="details.receiverAddress"]')
.should('contain.text', '0x72c2…dd8853')
.find('a')
.should('have.attr', 'href', ethAddressLink);
cy.get(row)
.eq(0)
.find('[col-id="createdTimestamp"]')
.invoke('text')
.should('not.be.empty');
cy.get(row)
.eq(0)
.find('[col-id="status"]')
.should('contain.text', 'Open')
.find('button')
.contains('Click to complete');
// Second row is complete so last cell should have a link to the tx
cy.get(row)
.eq(1)
.find('[col-id="status"]')
.should('contain.text', 'Finalized')
.find('a')
.contains('View on Etherscan')
.should('have.attr', 'href', etherScanLink);
});
it('renders a link to start a new withdrawal', () => {
cy.getByTestId('start-withdrawal').click();
cy.url().should('include', '/portfolio/withdraw');
});
it.skip('renders pending and unfinished withdrawals');
it.skip('can complete unfinished withdrawals'); // Needs capsule
});

View File

@ -2,7 +2,7 @@ import { AccountType } from '@vegaprotocol/types';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
export const generateWithdrawPageQuery = ( export const generateWithdrawFormQuery = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
override?: PartialDeep<any> override?: PartialDeep<any>
) => { ) => {

View File

@ -1,55 +1,74 @@
import { WithdrawalStatus } from '@vegaprotocol/types'; import { WithdrawalStatus } from '@vegaprotocol/types';
import type { Withdrawals } from '@vegaprotocol/withdraws';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
export const generateWithdrawals = ( export const generateWithdrawals = (override?: PartialDeep<Withdrawals>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const defaultResult: Withdrawals = {
override?: PartialDeep<any>
) => {
const defaultResult = {
party: { party: {
id: 'party-0', id: 'party-0',
withdrawals: [ withdrawalsConnection: {
{ __typename: 'WithdrawalsConnection',
id: 'withdrawal-0', edges: [
status: WithdrawalStatus.STATUS_FINALIZED, {
amount: '100', __typename: 'WithdrawalEdge',
txHash: null, node: {
createdTimestamp: new Date('2022-02-02').toISOString(), id: 'withdrawal-0',
withdrawnTimestamp: new Date('2022-02-02').toISOString(), status: WithdrawalStatus.STATUS_FINALIZED,
details: { amount: '100',
__typename: 'Erc20WithdrawalDetails', txHash: null,
receiverAddress: '0x72c22822A19D20DE7e426fB84aa047399Ddd8853', createdTimestamp: new Date('2022-02-02').toISOString(),
withdrawnTimestamp: new Date('2022-02-02').toISOString(),
pendingOnForeignChain: false,
details: {
__typename: 'Erc20WithdrawalDetails',
receiverAddress: '0x72c22822A19D20DE7e426fB84aa047399Ddd8853',
},
asset: {
__typename: 'Asset',
id: 'asset-0',
name: 'asset-0 name',
symbol: 'AST0',
decimals: 5,
source: {
__typename: 'ERC20',
contractAddress: '0x123',
},
},
__typename: 'Withdrawal',
},
}, },
asset: { {
__typename: 'Asset', __typename: 'WithdrawalEdge',
id: 'asset-0', node: {
symbol: 'AST0', id: 'withdrawal-1',
decimals: 5, status: WithdrawalStatus.STATUS_FINALIZED,
amount: '100',
txHash:
'0x5d7b1a35ba6bd23be17bb7a159c13cdbb3121fceb94e9c6c510f5503dce48d03',
createdTimestamp: new Date('2022-02-01').toISOString(),
withdrawnTimestamp: new Date('2022-02-01').toISOString(),
pendingOnForeignChain: false,
details: {
__typename: 'Erc20WithdrawalDetails',
receiverAddress: '0x72c22822A19D20DE7e426fB84aa047399Ddd8853',
},
asset: {
__typename: 'Asset',
id: 'asset-0',
name: 'asset-0 name',
symbol: 'AST0',
decimals: 5,
source: {
__typename: 'ERC20',
contractAddress: '0x123',
},
},
__typename: 'Withdrawal',
},
}, },
__typename: 'Withdrawal', ],
}, },
{
id: 'withdrawal-1',
status: WithdrawalStatus.STATUS_FINALIZED,
amount: '100',
txHash:
'0x5d7b1a35ba6bd23be17bb7a159c13cdbb3121fceb94e9c6c510f5503dce48d03',
createdTimestamp: new Date('2022-02-01').toISOString(),
withdrawnTimestamp: new Date('2022-02-01').toISOString(),
details: {
__typename: 'Erc20WithdrawalDetails',
receiverAddress: '0x72c22822A19D20DE7e426fB84aa047399Ddd8853',
},
asset: {
__typename: 'Asset',
id: 'asset-0',
symbol: 'AST0',
decimals: 5,
},
__typename: 'Withdrawal',
},
],
__typename: 'Party', __typename: 'Party',
}, },
}; };

View File

@ -1,6 +1,6 @@
# App configuration variables # App configuration variables
NX_VEGA_ENV=TESTNET NX_VEGA_ENV=TESTNET
NX_VEGA_URL=https://api.n11.testnet.vega.xyz/graphql NX_VEGA_URL=https://api.n09.testnet.vega.xyz/graphql
NX_ETHEREUM_PROVIDER_URL=https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8 NX_ETHEREUM_PROVIDER_URL=https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8
NX_ETHERSCAN_URL=https://ropsten.etherscan.io NX_ETHERSCAN_URL=https://ropsten.etherscan.io
NX_VEGA_NETWORKS={\"MAINNET\":\"https://alpha.console.vega.xyz\"} NX_VEGA_NETWORKS={\"MAINNET\":\"https://alpha.console.vega.xyz\"}

View File

@ -1,6 +1,6 @@
# App configuration variables # App configuration variables
NX_VEGA_ENV=TESTNET NX_VEGA_ENV=TESTNET
NX_VEGA_URL=https://api.n11.testnet.vega.xyz/graphql NX_VEGA_URL=https://api.n09.testnet.vega.xyz/graphql
NX_VEGA_NETWORKS='{\"MAINNET\":\"https://alpha.console.vega.xyz\"}' NX_VEGA_NETWORKS='{\"MAINNET\":\"https://alpha.console.vega.xyz\"}'
NX_ETHEREUM_PROVIDER_URL=https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8 NX_ETHEREUM_PROVIDER_URL=https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8
NX_ETHERSCAN_URL=https://ropsten.etherscan.io NX_ETHERSCAN_URL=https://ropsten.etherscan.io

View File

@ -51,6 +51,9 @@ export function createClient(base?: string) {
}, },
}, },
}, },
ERC20: {
keyFields: ['contractAddress'],
},
}, },
}); });

View File

@ -0,0 +1,46 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL fragment: AssetFields
// ====================================================
export interface AssetFields_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface AssetFields_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
*/
contractAddress: string;
}
export type AssetFields_source = AssetFields_source_BuiltinAsset | AssetFields_source_ERC20;
export interface AssetFields {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: AssetFields_source;
}

View File

@ -0,0 +1,119 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { AccountType } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: WithdrawFormQuery
// ====================================================
export interface WithdrawFormQuery_party_withdrawals {
__typename: "Withdrawal";
/**
* The Vega internal ID of the withdrawal
*/
id: string;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
}
export interface WithdrawFormQuery_party_accounts_asset {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
}
export interface WithdrawFormQuery_party_accounts {
__typename: "Account";
/**
* Account type (General, Margin, etc)
*/
type: AccountType;
/**
* Balance as string - current account balance (approx. as balances can be updated several times per second)
*/
balance: string;
/**
* Asset, the 'currency'
*/
asset: WithdrawFormQuery_party_accounts_asset;
}
export interface WithdrawFormQuery_party {
__typename: "Party";
/**
* Party identifier
*/
id: string;
/**
* The list of all withdrawals initiated by the party
*/
withdrawals: WithdrawFormQuery_party_withdrawals[] | null;
/**
* Collateral accounts relating to a party
*/
accounts: WithdrawFormQuery_party_accounts[] | null;
}
export interface WithdrawFormQuery_assets_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface WithdrawFormQuery_assets_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
*/
contractAddress: string;
}
export type WithdrawFormQuery_assets_source = WithdrawFormQuery_assets_source_BuiltinAsset | WithdrawFormQuery_assets_source_ERC20;
export interface WithdrawFormQuery_assets {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: WithdrawFormQuery_assets_source;
}
export interface WithdrawFormQuery {
/**
* An entity that is trading on the Vega network
*/
party: WithdrawFormQuery_party | null;
/**
* The list of all assets in use in the Vega network
*/
assets: WithdrawFormQuery_assets[] | null;
}
export interface WithdrawFormQueryVariables {
partyId: string;
}

View File

@ -1,17 +1,29 @@
import { AsyncRenderer, Button } from '@vegaprotocol/ui-toolkit';
import { DepositsTable } from '@vegaprotocol/deposits';
import { useDeposits } from '@vegaprotocol/deposits';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { Button } from '@vegaprotocol/ui-toolkit';
import Link from 'next/link'; import Link from 'next/link';
export const DepositsContainer = () => { export const DepositsContainer = () => {
const { deposits, loading, error } = useDeposits();
return ( return (
<div className="grid grid-cols-[1fr_min-content] gap-4 h-full"> <div className="h-full grid grid-rows-[min-content_1fr]">
<div /> <header className="flex justify-between items-center p-4">
<div className="p-4"> <h4 className="text-lg text-black dark:text-white">{t('Deposits')}</h4>
<Link href="/portfolio/deposit" passHref={true}> <Link href="/portfolio/deposit" passHref={true}>
<Button size="md" data-testid="deposit"> <Button>Deposit</Button>
{t('Deposit')}
</Button>
</Link> </Link>
</header>
<div>
<AsyncRenderer
data={deposits}
loading={loading}
error={error}
render={(data) => {
return <DepositsTable deposits={data} />;
}}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,4 +1,3 @@
import { Web3Container } from '@vegaprotocol/web3';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { PositionsContainer } from '@vegaprotocol/positions'; import { PositionsContainer } from '@vegaprotocol/positions';
import { OrderListContainer } from '@vegaprotocol/orders'; import { OrderListContainer } from '@vegaprotocol/orders';
@ -67,14 +66,12 @@ const Portfolio = () => {
</VegaWalletContainer> </VegaWalletContainer>
</Tab> </Tab>
<Tab id="deposits" name={t('Deposits')}> <Tab id="deposits" name={t('Deposits')}>
<DepositsContainer /> <VegaWalletContainer>
<DepositsContainer />
</VegaWalletContainer>
</Tab> </Tab>
<Tab id="withdrawals" name={t('Withdrawals')}> <Tab id="withdrawals" name={t('Withdrawals')}>
<Web3Container> <WithdrawalsContainer />
<VegaWalletContainer>
<WithdrawalsContainer />
</VegaWalletContainer>
</Web3Container>
</Tab> </Tab>
</Tabs> </Tabs>
</PortfolioGridChild> </PortfolioGridChild>

View File

@ -1,36 +0,0 @@
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import { WithdrawPageContainer } from './withdraw-page-container';
import { VegaWalletContainer } from '../../../components/vega-wallet-container';
import { Web3Container } from '@vegaprotocol/web3';
import { t } from '@vegaprotocol/react-helpers';
const Withdraw = () => {
const { query } = useRouter();
// AssetId can be specified in the query string to allow link to deposit a particular asset
const assetId = useMemo(() => {
if (query.assetId && Array.isArray(query.assetId)) {
return undefined;
}
if (Array.isArray(query.assetId)) {
return undefined;
}
return query.assetId;
}, [query]);
return (
<VegaWalletContainer>
<Web3Container>
<div className="max-w-[420px] p-8 mx-auto">
<h1 className="text-2xl mb-4">{t('Withdraw')}</h1>
<WithdrawPageContainer assetId={assetId} />
</div>
</Web3Container>
</VegaWalletContainer>
);
};
export default Withdraw;

View File

@ -1,101 +0,0 @@
import { gql } from '@apollo/client';
import { assetsConnectionToAssets, t } from '@vegaprotocol/react-helpers';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { WithdrawManager } from '@vegaprotocol/withdraws';
import { ASSET_FRAGMENT } from '../../../lib/query-fragments';
import Link from 'next/link';
import { PageQueryContainer } from '../../../components/page-query-container';
import type {
WithdrawPageQuery,
WithdrawPageQueryVariables,
} from './__generated__/WithdrawPageQuery';
const WITHDRAW_PAGE_QUERY = gql`
${ASSET_FRAGMENT}
query WithdrawPageQuery($partyId: ID!) {
party(id: $partyId) {
id
withdrawals {
id
txHash
}
accounts {
type
balance
asset {
id
symbol
}
}
}
assetsConnection {
edges {
node {
...AssetFields
}
}
}
}
`;
interface WithdrawPageContainerProps {
assetId?: string;
}
/**
* Fetches data required for the Deposit page
*/
export const WithdrawPageContainer = ({
assetId,
}: WithdrawPageContainerProps) => {
const { keypair } = useVegaWallet();
return (
<PageQueryContainer<WithdrawPageQuery, WithdrawPageQueryVariables>
query={WITHDRAW_PAGE_QUERY}
options={{
variables: { partyId: keypair?.pub || '' },
skip: !keypair?.pub,
}}
render={(data) => {
const assets = assetsConnectionToAssets(data.assetsConnection);
if (!assets.length) {
return (
<Splash>
<p>{t('No assets on this network')}</p>
</Splash>
);
}
const hasIncompleteWithdrawals = data.party?.withdrawals?.some(
(w) => w.txHash === null
);
return (
<>
{hasIncompleteWithdrawals ? (
<p className="mb-6">
{t('You have incomplete withdrawals.')}{' '}
<Link href="/portfolio/withdrawals" passHref={true}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
className="underline"
data-testid="complete-withdrawals-prompt"
>
{t('Click here to finish withdrawal')}
</a>
</Link>
</p>
) : null}
<WithdrawManager
assets={assets}
accounts={data.party?.accounts || []}
initialAssetId={assetId}
/>
</>
);
}}
/>
);
};

View File

@ -1,36 +1,50 @@
import orderBy from 'lodash/orderBy';
import { AsyncRenderer, Button } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer, Button } from '@vegaprotocol/ui-toolkit';
import { useWithdrawals, WithdrawalsTable } from '@vegaprotocol/withdraws'; import {
import Link from 'next/link'; useWithdrawals,
WithdrawalDialogs,
WithdrawalsTable,
} from '@vegaprotocol/withdraws';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { useState } from 'react';
import { VegaWalletContainer } from '../../components/vega-wallet-container';
import { Web3Container } from '@vegaprotocol/web3';
export const WithdrawalsContainer = () => { export const WithdrawalsContainer = () => {
const { data, loading, error } = useWithdrawals(); const { withdrawals, loading, error } = useWithdrawals();
const [withdrawDialog, setWithdrawDialog] = useState(false);
console.log('render');
return ( return (
<AsyncRenderer <Web3Container>
data={data} <VegaWalletContainer>
loading={loading} <div className="h-full grid grid-rows-[min-content_1fr]">
error={error} <header className="flex justify-between items-center p-4">
render={(data) => { <h4 className="text-lg text-black dark:text-white">
const withdrawals = orderBy( {t('Withdrawals')}
data.party?.withdrawals || [], </h4>
(w) => new Date(w.createdTimestamp).getTime(), <Button
'desc' onClick={() => setWithdrawDialog(true)}
); data-testid="withdraw-dialog-button"
return ( >
<div className="grid grid-cols-[1fr_min-content] gap-4 h-full"> {t('Withdraw')}
<WithdrawalsTable withdrawals={withdrawals} /> </Button>
<div className="p-4"> </header>
<Link href="/portfolio/withdraw" passHref={true}> <div>
<Button size="md" data-testid="start-withdrawal"> <AsyncRenderer
{t('Withdraw')} data={withdrawals}
</Button> loading={loading}
</Link> error={error}
</div> render={(data) => {
return <WithdrawalsTable withdrawals={data} />;
}}
/>
</div> </div>
); </div>
}} <WithdrawalDialogs
/> withdrawDialog={withdrawDialog}
setWithdrawDialog={setWithdrawDialog}
/>
</VegaWalletContainer>
</Web3Container>
); );
}; };

View File

@ -1,15 +0,0 @@
import { VegaWalletContainer } from '../../../components/vega-wallet-container';
import { Web3Container } from '@vegaprotocol/web3';
import { WithdrawalsContainer } from '../withdrawals-container';
const Withdrawals = () => {
return (
<VegaWalletContainer>
<Web3Container>
<WithdrawalsContainer />
</Web3Container>
</VegaWalletContainer>
);
};
export default Withdrawals;

View File

@ -16,8 +16,7 @@ export const DealTicketManager = ({
market, market,
children, children,
}: DealTicketManagerProps) => { }: DealTicketManagerProps) => {
const { submit, transaction, finalizedOrder, TransactionDialog } = const { submit, transaction, finalizedOrder, Dialog } = useOrderSubmit();
useOrderSubmit();
return ( return (
<> <>
@ -33,13 +32,13 @@ export const DealTicketManager = ({
} }
/> />
)} )}
<TransactionDialog <Dialog
title={getOrderDialogTitle(finalizedOrder?.status)} title={getOrderDialogTitle(finalizedOrder?.status)}
intent={getOrderDialogIntent(finalizedOrder?.status)} intent={getOrderDialogIntent(finalizedOrder?.status)}
icon={getOrderDialogIcon(finalizedOrder?.status)} icon={getOrderDialogIcon(finalizedOrder?.status)}
> >
<OrderFeedback transaction={transaction} order={finalizedOrder} /> <OrderFeedback transaction={transaction} order={finalizedOrder} />
</TransactionDialog> </Dialog>
</> </>
); );
}; };

View File

@ -1 +1,3 @@
export * from './lib/deposit-manager'; export * from './lib/deposit-manager';
export * from './lib/use-deposits';
export * from './lib/deposits-table';

View File

@ -0,0 +1,83 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { DepositStatus } from "@vegaprotocol/types";
// ====================================================
// GraphQL subscription operation: DepositEventSub
// ====================================================
export interface DepositEventSub_busEvents_event_TimeUpdate {
__typename: "TimeUpdate" | "MarketEvent" | "TransferResponses" | "PositionResolution" | "Order" | "Trade" | "Account" | "Party" | "MarginLevels" | "Proposal" | "Vote" | "MarketData" | "NodeSignature" | "LossSocialization" | "SettlePosition" | "Market" | "Asset" | "MarketTick" | "SettleDistressed" | "AuctionEvent" | "RiskFactor" | "Withdrawal" | "OracleSpec" | "LiquidityProvision";
}
export interface DepositEventSub_busEvents_event_Deposit_asset {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
}
export interface DepositEventSub_busEvents_event_Deposit {
__typename: "Deposit";
/**
* The Vega internal ID of the deposit
*/
id: string;
/**
* The current status of the deposit
*/
status: DepositStatus;
/**
* The amount to be withdrawn
*/
amount: string;
/**
* The asset to be withdrawn
*/
asset: DepositEventSub_busEvents_event_Deposit_asset;
/**
* RFC3339Nano time at which the deposit was created
*/
createdTimestamp: string;
/**
* RFC3339Nano time at which the deposit was finalised
*/
creditedTimestamp: string | null;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
}
export type DepositEventSub_busEvents_event = DepositEventSub_busEvents_event_TimeUpdate | DepositEventSub_busEvents_event_Deposit;
export interface DepositEventSub_busEvents {
__typename: "BusEvent";
/**
* the payload - the wrapped event
*/
event: DepositEventSub_busEvents_event;
}
export interface DepositEventSub {
/**
* Subscribe to event data from the event bus
*/
busEvents: DepositEventSub_busEvents[] | null;
}
export interface DepositEventSubVariables {
partyId: string;
}

View File

@ -0,0 +1,58 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { DepositStatus } from "@vegaprotocol/types";
// ====================================================
// GraphQL fragment: DepositFields
// ====================================================
export interface DepositFields_asset {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
}
export interface DepositFields {
__typename: "Deposit";
/**
* The Vega internal ID of the deposit
*/
id: string;
/**
* The current status of the deposit
*/
status: DepositStatus;
/**
* The amount to be withdrawn
*/
amount: string;
/**
* The asset to be withdrawn
*/
asset: DepositFields_asset;
/**
* RFC3339Nano time at which the deposit was created
*/
createdTimestamp: string;
/**
* RFC3339Nano time at which the deposit was finalised
*/
creditedTimestamp: string | null;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
}

View File

@ -0,0 +1,94 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { DepositStatus } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: Deposits
// ====================================================
export interface Deposits_party_depositsConnection_edges_node_asset {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
}
export interface Deposits_party_depositsConnection_edges_node {
__typename: "Deposit";
/**
* The Vega internal ID of the deposit
*/
id: string;
/**
* The current status of the deposit
*/
status: DepositStatus;
/**
* The amount to be withdrawn
*/
amount: string;
/**
* The asset to be withdrawn
*/
asset: Deposits_party_depositsConnection_edges_node_asset;
/**
* RFC3339Nano time at which the deposit was created
*/
createdTimestamp: string;
/**
* RFC3339Nano time at which the deposit was finalised
*/
creditedTimestamp: string | null;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
}
export interface Deposits_party_depositsConnection_edges {
__typename: "DepositEdge";
node: Deposits_party_depositsConnection_edges_node;
}
export interface Deposits_party_depositsConnection {
__typename: "DepositsConnection";
/**
* The deposits
*/
edges: (Deposits_party_depositsConnection_edges | null)[] | null;
}
export interface Deposits_party {
__typename: "Party";
/**
* Party identifier
*/
id: string;
/**
* The list of all deposits for a party by the party
*/
depositsConnection: Deposits_party_depositsConnection;
}
export interface Deposits {
/**
* An entity that is trading on the Vega network
*/
party: Deposits_party | null;
}
export interface DepositsVariables {
partyId: string;
}

View File

@ -0,0 +1,94 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { DepositStatus } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: DepositsQuery
// ====================================================
export interface DepositsQuery_party_depositsConnection_edges_node_asset {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
}
export interface DepositsQuery_party_depositsConnection_edges_node {
__typename: "Deposit";
/**
* The Vega internal ID of the deposit
*/
id: string;
/**
* The current status of the deposit
*/
status: DepositStatus;
/**
* The amount to be withdrawn
*/
amount: string;
/**
* The asset to be withdrawn
*/
asset: DepositsQuery_party_depositsConnection_edges_node_asset;
/**
* RFC3339Nano time at which the deposit was created
*/
createdTimestamp: string;
/**
* RFC3339Nano time at which the deposit was finalised
*/
creditedTimestamp: string | null;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
}
export interface DepositsQuery_party_depositsConnection_edges {
__typename: "DepositEdge";
node: DepositsQuery_party_depositsConnection_edges_node;
}
export interface DepositsQuery_party_depositsConnection {
__typename: "DepositsConnection";
/**
* The deposits
*/
edges: (DepositsQuery_party_depositsConnection_edges | null)[] | null;
}
export interface DepositsQuery_party {
__typename: "Party";
/**
* Party identifier
*/
id: string;
/**
* The list of all deposits for a party by the party
*/
depositsConnection: DepositsQuery_party_depositsConnection;
}
export interface DepositsQuery {
/**
* An entity that is trading on the Vega network
*/
party: DepositsQuery_party | null;
}
export interface DepositsQueryVariables {
partyId: string;
}

View File

@ -0,0 +1,81 @@
import { AgGridColumn } from 'ag-grid-react';
import {
t,
addDecimalsFormatNumber,
getDateTimeFormat,
truncateByChars,
} from '@vegaprotocol/react-helpers';
import type {
VegaICellRendererParams,
VegaValueFormatterParams,
} from '@vegaprotocol/ui-toolkit';
import { AgGridDynamic as AgGrid, Link } from '@vegaprotocol/ui-toolkit';
import type { DepositFields } from './__generated__/DepositFields';
import { useEnvironment } from '@vegaprotocol/environment';
import { DepositStatusMapping } from '@vegaprotocol/types';
export interface DepositsTableProps {
deposits: DepositFields[];
}
export const DepositsTable = ({ deposits }: DepositsTableProps) => {
const { ETHERSCAN_URL } = useEnvironment();
return (
<AgGrid
rowData={deposits}
overlayNoRowsTemplate={t('No deposits')}
defaultColDef={{ flex: 1, resizable: true }}
style={{ width: '100%', height: '100%' }}
suppressCellFocus={true}
>
<AgGridColumn headerName="Asset" field="asset.symbol" />
<AgGridColumn
headerName="Amount"
field="amount"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<DepositFields, 'amount'>) => {
return addDecimalsFormatNumber(value, data.asset.decimals);
}}
/>
<AgGridColumn
headerName="Created at"
field="createdTimestamp"
valueFormatter={({
value,
}: VegaValueFormatterParams<DepositFields, 'createdTimestamp'>) => {
return getDateTimeFormat().format(new Date(value));
}}
/>
<AgGridColumn
headerName="Status"
field="status"
valueFormatter={({
value,
}: VegaValueFormatterParams<DepositFields, 'status'>) => {
return DepositStatusMapping[value];
}}
/>
<AgGridColumn
headerName="Tx hash"
field="txHash"
cellRenderer={({
value,
}: VegaICellRendererParams<DepositFields, 'txHash'>) => {
if (!value) return '-';
return (
<Link
title={t('View transaction on Etherscan')}
href={`${ETHERSCAN_URL}/tx/${value}`}
data-testid="etherscan-link"
target="_blank"
>
{truncateByChars(value)}
</Link>
);
}}
/>
</AgGrid>
);
};

View File

@ -0,0 +1,153 @@
import uniqBy from 'lodash/uniqBy';
import compact from 'lodash/compact';
import orderBy from 'lodash/orderBy';
import { gql, useQuery } from '@apollo/client';
import type { UpdateQueryFn } from '@apollo/client/core/watchQueryOptions';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useEffect, useMemo } from 'react';
import type {
DepositEventSub,
DepositEventSubVariables,
DepositEventSub_busEvents_event,
DepositEventSub_busEvents_event_Deposit,
} from './__generated__/DepositEventSub';
import type { Deposits, DepositsVariables } from './__generated__/Deposits';
const DEPOSIT_FRAGMENT = gql`
fragment DepositFields on Deposit {
id
status
amount
asset {
id
symbol
decimals
}
createdTimestamp
creditedTimestamp
txHash
}
`;
const DEPOSITS_QUERY = gql`
${DEPOSIT_FRAGMENT}
query DepositsQuery($partyId: ID!) {
party(id: $partyId) {
id
depositsConnection {
edges {
node {
...DepositFields
}
}
}
}
}
`;
const DEPOSITS_BUS_EVENT_SUB = gql`
${DEPOSIT_FRAGMENT}
subscription DepositEventSub($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [Deposit]) {
event {
... on Deposit {
...DepositFields
}
}
}
}
`;
export const useDeposits = () => {
const { keypair } = useVegaWallet();
const { data, loading, error, subscribeToMore } = useQuery<
Deposits,
DepositsVariables
>(DEPOSITS_QUERY, {
variables: { partyId: keypair?.pub || '' },
skip: !keypair?.pub,
});
const deposits = useMemo(() => {
if (!data?.party?.depositsConnection.edges?.length) {
return [];
}
return orderBy(
compact(data.party?.depositsConnection.edges?.map((d) => d?.node)),
['createdTimestamp'],
['desc']
);
}, [data]);
useEffect(() => {
if (!keypair?.pub) return;
const unsub = subscribeToMore<DepositEventSub, DepositEventSubVariables>({
document: DEPOSITS_BUS_EVENT_SUB,
variables: { partyId: keypair?.pub },
updateQuery,
});
return () => {
unsub();
};
}, [keypair?.pub, subscribeToMore]);
return { data, loading, error, deposits };
};
const updateQuery: UpdateQueryFn<
Deposits,
DepositEventSubVariables,
DepositEventSub
> = (prev, { subscriptionData, variables }) => {
console.log(subscriptionData);
if (!subscriptionData.data.busEvents?.length || !variables?.partyId) {
return prev;
}
const curr =
compact(prev.party?.depositsConnection.edges?.map((e) => e?.node)) || [];
const incoming = subscriptionData.data.busEvents
.map((e) => e.event)
.filter(isDepositEvent);
const deposits = uniqBy([...incoming, ...curr], 'id');
if (!prev.party) {
return {
...prev,
party: {
__typename: 'Party',
id: variables?.partyId,
depositsConnection: {
__typename: 'DepositsConnection',
edges: deposits.map((d) => ({ __typename: 'DepositEdge', node: d })),
},
},
};
}
return {
...prev,
party: {
...prev.party,
id: variables?.partyId,
depositsConnection: {
__typename: 'DepositsConnection',
edges: deposits.map((d) => ({ __typename: 'DepositEdge', node: d })),
},
},
};
};
const isDepositEvent = (
event: DepositEventSub_busEvents_event
): event is DepositEventSub_busEvents_event_Deposit => {
if (event.__typename === 'Deposit') {
return true;
}
return false;
};

View File

@ -160,7 +160,7 @@ describe('ProposalForm', () => {
}); });
expect(screen.getByTestId('dialog-title')).toHaveTextContent( expect(screen.getByTestId('dialog-title')).toHaveTextContent(
'Proposal rejected' 'Proposal submitted'
); );
}); });

View File

@ -12,7 +12,6 @@ import {
getProposalDialogTitle, getProposalDialogTitle,
} from '../utils'; } from '../utils';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { ProposalState } from '@vegaprotocol/types';
export interface FormFields { export interface FormFields {
proposalData: string; proposalData: string;
@ -24,7 +23,7 @@ export const ProposalForm = () => {
handleSubmit, handleSubmit,
formState: { isSubmitting, errors }, formState: { isSubmitting, errors },
} = useForm<FormFields>(); } = useForm<FormFields>();
const { finalizedProposal, submit, TransactionDialog } = useProposalSubmit(); const { finalizedProposal, submit, Dialog } = useProposalSubmit();
const hasError = Boolean(errors.proposalData?.message); const hasError = Boolean(errors.proposalData?.message);
@ -63,31 +62,23 @@ export const ProposalForm = () => {
</InputError> </InputError>
)} )}
</FormGroup> </FormGroup>
<span className="my-20"> <Button
<Button variant="primary"
variant="primary" type="submit"
type="submit" data-testid="proposal-submit"
data-testid="proposal-submit" disabled={isSubmitting}
disabled={isSubmitting} >
> {isSubmitting ? t('Submitting') : t('Submit')} {t('Proposal')}
{isSubmitting ? t('Submitting') : t('Submit')} {t('Proposal')} </Button>
</Button> <Dialog
</span> title={getProposalDialogTitle(finalizedProposal?.state)}
{finalizedProposal?.rejectionReason ? ( intent={getProposalDialogIntent(finalizedProposal?.state)}
<TransactionDialog icon={getProposalDialogIcon(finalizedProposal?.state)}
title={t('Proposal rejected')} >
intent={getProposalDialogIntent(ProposalState.STATE_REJECTED)} {finalizedProposal?.rejectionReason ? (
icon={getProposalDialogIcon(ProposalState.STATE_REJECTED)}
>
<p>{finalizedProposal.rejectionReason}</p> <p>{finalizedProposal.rejectionReason}</p>
</TransactionDialog> ) : undefined}
) : ( </Dialog>
<TransactionDialog
title={getProposalDialogTitle(finalizedProposal?.state)}
intent={getProposalDialogIntent(finalizedProposal?.state)}
icon={getProposalDialogIcon(finalizedProposal?.state)}
/>
)}
</form> </form>
); );
}; };

View File

@ -10,8 +10,7 @@ export const useProposalSubmit = () => {
const { keypair } = useVegaWallet(); const { keypair } = useVegaWallet();
const waitForProposalEvent = useProposalEvent(); const waitForProposalEvent = useProposalEvent();
const { send, transaction, setComplete, TransactionDialog } = const { send, transaction, setComplete, Dialog } = useVegaTransaction();
useVegaTransaction();
const [finalizedProposal, setFinalizedProposal] = const [finalizedProposal, setFinalizedProposal] =
useState<ProposalEvent_busEvents_event_Proposal | null>(null); useState<ProposalEvent_busEvents_event_Proposal | null>(null);
@ -52,7 +51,7 @@ export const useProposalSubmit = () => {
return { return {
transaction, transaction,
finalizedProposal, finalizedProposal,
TransactionDialog, Dialog,
submit, submit,
}; };
}; };

View File

@ -62,7 +62,7 @@ export const OrderList = forwardRef<AgGridReact, OrderListProps>(
ref={ref} ref={ref}
setEditOrder={setEditOrder} setEditOrder={setEditOrder}
/> />
<orderCancel.TransactionDialog <orderCancel.Dialog
title={getCancelDialogTitle(orderCancel.cancelledOrder?.status)} title={getCancelDialogTitle(orderCancel.cancelledOrder?.status)}
intent={getCancelDialogIntent(orderCancel.cancelledOrder?.status)} intent={getCancelDialogIntent(orderCancel.cancelledOrder?.status)}
> >
@ -70,15 +70,15 @@ export const OrderList = forwardRef<AgGridReact, OrderListProps>(
transaction={orderCancel.transaction} transaction={orderCancel.transaction}
order={orderCancel.cancelledOrder} order={orderCancel.cancelledOrder}
/> />
</orderCancel.TransactionDialog> </orderCancel.Dialog>
<orderEdit.TransactionDialog <orderEdit.Dialog
title={getEditDialogTitle(orderEdit.updatedOrder?.status)} title={getEditDialogTitle(orderEdit.updatedOrder?.status)}
> >
<OrderFeedback <OrderFeedback
transaction={orderEdit.transaction} transaction={orderEdit.transaction}
order={orderEdit.updatedOrder} order={orderEdit.updatedOrder}
/> />
</orderEdit.TransactionDialog> </orderEdit.Dialog>
{editOrder && ( {editOrder && (
<OrderEditDialog <OrderEditDialog
isOpen={Boolean(editOrder)} isOpen={Boolean(editOrder)}

View File

@ -21,7 +21,7 @@ export const useOrderCancel = () => {
transaction, transaction,
reset: resetTransaction, reset: resetTransaction,
setComplete, setComplete,
TransactionDialog, Dialog,
} = useVegaTransaction(); } = useVegaTransaction();
const reset = useCallback(() => { const reset = useCallback(() => {
@ -62,7 +62,7 @@ export const useOrderCancel = () => {
return { return {
transaction, transaction,
cancelledOrder, cancelledOrder,
TransactionDialog, Dialog,
cancel, cancel,
reset, reset,
}; };

View File

@ -22,7 +22,7 @@ export const useOrderEdit = (order: OrderFields | null) => {
transaction, transaction,
reset: resetTransaction, reset: resetTransaction,
setComplete, setComplete,
TransactionDialog, Dialog,
} = useVegaTransaction(); } = useVegaTransaction();
const waitForOrderEvent = useOrderEvent(); const waitForOrderEvent = useOrderEvent();
@ -71,7 +71,7 @@ export const useOrderEdit = (order: OrderFields | null) => {
return { return {
transaction, transaction,
updatedOrder, updatedOrder,
TransactionDialog, Dialog,
edit, edit,
reset, reset,
}; };

View File

@ -102,7 +102,7 @@ export const useOrderSubmit = () => {
transaction, transaction,
reset: resetTransaction, reset: resetTransaction,
setComplete, setComplete,
TransactionDialog, Dialog,
} = useVegaTransaction(); } = useVegaTransaction();
const [finalizedOrder, setFinalizedOrder] = const [finalizedOrder, setFinalizedOrder] =
@ -158,7 +158,7 @@ export const useOrderSubmit = () => {
return { return {
transaction, transaction,
finalizedOrder, finalizedOrder,
TransactionDialog, Dialog,
submit, submit,
reset, reset,
}; };

View File

@ -16,7 +16,7 @@ const getSymbols = (positions: Position[]) =>
export const PositionsManager = ({ partyId }: PositionsManagerProps) => { export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
const variables = useMemo(() => ({ partyId }), [partyId]); const variables = useMemo(() => ({ partyId }), [partyId]);
const assetSymbols = useRef<string[] | undefined>(); const assetSymbols = useRef<string[] | undefined>();
const { submit, TransactionDialog } = useClosePosition(); const { submit, Dialog } = useClosePosition();
const onClose = useCallback( const onClose = useCallback(
(position: Position) => { (position: Position) => {
submit(position); submit(position);
@ -55,9 +55,9 @@ export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
/> />
))} ))}
</AsyncRenderer> </AsyncRenderer>
<TransactionDialog> <Dialog>
<p>Your position was not closed! This is still not implemented. </p> <p>Your position was not closed! This is still not implemented. </p>
</TransactionDialog> </Dialog>
</> </>
); );
}; };

View File

@ -15,7 +15,7 @@ export const useClosePosition = () => {
transaction, transaction,
reset: resetTransaction, reset: resetTransaction,
setComplete, setComplete,
TransactionDialog, Dialog,
} = useVegaTransaction(); } = useVegaTransaction();
const reset = useCallback(() => { const reset = useCallback(() => {
@ -57,7 +57,7 @@ export const useClosePosition = () => {
return { return {
transaction, transaction,
TransactionDialog, Dialog,
submit, submit,
reset, reset,
}; };

View File

@ -34,6 +34,9 @@ export class CollateralBridge {
get_withdraw_threshold(assetSource: string) { get_withdraw_threshold(assetSource: string) {
return this.contract.get_withdraw_threshold(assetSource); return this.contract.get_withdraw_threshold(assetSource);
} }
default_withdraw_delay() {
return this.contract.default_withdraw_delay();
}
withdraw_asset( withdraw_asset(
assetSource: string, assetSource: string,
amount: string, amount: string,

View File

@ -1,2 +1,30 @@
import type { Get } from 'type-fest';
import type {
ICellRendererParams,
ValueFormatterParams,
} from 'ag-grid-community';
export * from './ag-grid-lazy'; export * from './ag-grid-lazy';
export * from './ag-grid-dynamic'; export * from './ag-grid-dynamic';
type Field = string | readonly string[];
type RowHelper<TObj, TRow, TField extends Field> = Omit<
TObj,
'data' | 'value'
> & {
data: TRow;
value: Get<TRow, TField>;
};
export type VegaValueFormatterParams<TRow, TField extends Field> = RowHelper<
ValueFormatterParams,
TRow,
TField
>;
export type VegaICellRendererParams<TRow, TField extends Field> = RowHelper<
ICellRendererParams,
TRow,
TField
>;

View File

@ -102,7 +102,7 @@ export const useVegaTransaction = () => {
[sendTx, setTransaction, reset] [sendTx, setTransaction, reset]
); );
const TransactionDialog = useMemo(() => { const Dialog = useMemo(() => {
return (props: DialogProps) => ( return (props: DialogProps) => (
<VegaTransactionDialog <VegaTransactionDialog
{...props} {...props}
@ -122,7 +122,7 @@ export const useVegaTransaction = () => {
reset, reset,
setComplete, setComplete,
setTransaction, setTransaction,
TransactionDialog, Dialog,
}; };
}; };

View File

@ -43,6 +43,7 @@ export const VegaTransactionDialog = ({
intent={computedIntent} intent={computedIntent}
title={computedTitle} title={computedTitle}
icon={computedIcon} icon={computedIcon}
size="small"
> >
{content} {content}
</Dialog> </Dialog>
@ -59,8 +60,9 @@ interface VegaDialogProps {
export const VegaDialog = ({ transaction }: VegaDialogProps) => { export const VegaDialog = ({ transaction }: VegaDialogProps) => {
const { VEGA_EXPLORER_URL } = useEnvironment(); const { VEGA_EXPLORER_URL } = useEnvironment();
let content = null;
if (transaction.status === VegaTxStatus.Requested) { if (transaction.status === VegaTxStatus.Requested) {
return ( content = (
<p data-testid={transaction.status}> <p data-testid={transaction.status}>
{t( {t(
'Please open your wallet application and confirm or reject the transaction' 'Please open your wallet application and confirm or reject the transaction'
@ -70,7 +72,7 @@ export const VegaDialog = ({ transaction }: VegaDialogProps) => {
} }
if (transaction.status === VegaTxStatus.Error) { if (transaction.status === VegaTxStatus.Error) {
return ( content = (
<div data-testid={transaction.status}> <div data-testid={transaction.status}>
<p>{transaction.error && formatLabel(transaction.error)}</p> <p>{transaction.error && formatLabel(transaction.error)}</p>
{transaction.details && ( {transaction.details && (
@ -81,7 +83,7 @@ export const VegaDialog = ({ transaction }: VegaDialogProps) => {
} }
if (transaction.status === VegaTxStatus.Pending) { if (transaction.status === VegaTxStatus.Pending) {
return ( content = (
<div data-testid={transaction.status}> <div data-testid={transaction.status}>
<p className="break-all"> <p className="break-all">
{t('Please wait for your transaction to be confirmed')} - &nbsp; {t('Please wait for your transaction to be confirmed')} - &nbsp;
@ -102,7 +104,7 @@ export const VegaDialog = ({ transaction }: VegaDialogProps) => {
} }
if (transaction.status === VegaTxStatus.Complete) { if (transaction.status === VegaTxStatus.Complete) {
return ( content = (
<div data-testid={transaction.status}> <div data-testid={transaction.status}>
<p className="break-all"> <p className="break-all">
{t('Your transaction has been confirmed')} - &nbsp; {t('Your transaction has been confirmed')} - &nbsp;
@ -122,7 +124,7 @@ export const VegaDialog = ({ transaction }: VegaDialogProps) => {
); );
} }
return null; return <div className="text-sm">{content}</div>;
}; };
const getIntent = (transaction: VegaTxState) => { const getIntent = (transaction: VegaTxState) => {
@ -160,7 +162,11 @@ const getIcon = (transaction: VegaTxState) => {
case VegaTxStatus.Requested: case VegaTxStatus.Requested:
return <Icon name="hand-up" />; return <Icon name="hand-up" />;
case VegaTxStatus.Pending: case VegaTxStatus.Pending:
return <Loader size="small" />; return (
<span className="mt-1">
<Loader size="small" />
</span>
);
case VegaTxStatus.Error: case VegaTxStatus.Error:
return <Icon name="warning-sign" />; return <Icon name="warning-sign" />;
case VegaTxStatus.Complete: case VegaTxStatus.Complete:

View File

@ -6,7 +6,7 @@ export * from './lib/use-token-decimals';
export * from './lib/use-ethereum-config'; export * from './lib/use-ethereum-config';
export * from './lib/use-ethereum-read-contract'; export * from './lib/use-ethereum-read-contract';
export * from './lib/use-ethereum-transaction'; export * from './lib/use-ethereum-transaction';
export * from './lib/transaction-dialog'; export * from './lib/ethereum-transaction-dialog';
export * from './lib/web3-provider'; export * from './lib/web3-provider';
export * from './lib/web3-connect-dialog'; export * from './lib/web3-connect-dialog';
export * from './lib/web3-wallet-input'; export * from './lib/web3-wallet-input';

View File

@ -3,8 +3,8 @@ import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
import { EthereumError } from '../ethereum-error'; import { EthereumError } from '../ethereum-error';
import { EthTxStatus } from '../use-ethereum-transaction'; import { EthTxStatus } from '../use-ethereum-transaction';
import type { TransactionDialogProps } from './transaction-dialog'; import type { EthereumTransactionDialogProps } from './ethereum-transaction-dialog';
import { TransactionDialog } from './transaction-dialog'; import { EthereumTransactionDialog } from './ethereum-transaction-dialog';
jest.mock('@web3-react/core', () => ({ jest.mock('@web3-react/core', () => ({
useWeb3React: () => ({ useWeb3React: () => ({
@ -12,11 +12,11 @@ jest.mock('@web3-react/core', () => ({
}), }),
})); }));
let props: TransactionDialogProps; let props: EthereumTransactionDialogProps;
beforeEach(() => { beforeEach(() => {
props = { props = {
name: 'test', title: 'test',
onChange: jest.fn(), onChange: jest.fn(),
transaction: { transaction: {
status: EthTxStatus.Default, status: EthTxStatus.Default,
@ -29,9 +29,11 @@ beforeEach(() => {
}; };
}); });
const generateJsx = (moreProps?: PartialDeep<TransactionDialogProps>) => { const generateJsx = (
moreProps?: PartialDeep<EthereumTransactionDialogProps>
) => {
const mergedProps = merge(props, moreProps); const mergedProps = merge(props, moreProps);
return <TransactionDialog {...mergedProps} />; return <EthereumTransactionDialog {...mergedProps} />;
}; };
it('Opens when tx starts and closes if the user rejects the tx', () => { it('Opens when tx starts and closes if the user rejects the tx', () => {
@ -57,7 +59,7 @@ it('Opens when tx starts and closes if the user rejects the tx', () => {
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it('Doesn\t repoen if user dismissed the dialog', () => { it("Doesn't repoen if user dismissed the dialog", () => {
const { container, rerender } = render( const { container, rerender } = render(
generateJsx({ transaction: { status: EthTxStatus.Pending } }) generateJsx({ transaction: { status: EthTxStatus.Pending } })
); );
@ -87,7 +89,7 @@ it('Dialog states', () => {
transaction: { status: EthTxStatus.Pending, confirmations: 0 }, transaction: { status: EthTxStatus.Pending, confirmations: 0 },
}) })
); );
expect(screen.getByText(`${props.name} pending`)).toBeInTheDocument(); expect(screen.getByText(`${props.title} pending`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument(); expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
expect( expect(
screen.getByText('Awaiting Ethereum transaction 0/1 confirmations...') screen.getByText('Awaiting Ethereum transaction 0/1 confirmations...')
@ -100,7 +102,7 @@ it('Dialog states', () => {
transaction: { status: EthTxStatus.Complete, confirmations: 1 }, transaction: { status: EthTxStatus.Complete, confirmations: 1 },
}) })
); );
expect(screen.getByText(`${props.name} pending`)).toBeInTheDocument(); expect(screen.getByText(`${props.title} pending`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument(); expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
expect(screen.getByText('Ethereum transaction complete')).toBeInTheDocument(); expect(screen.getByText('Ethereum transaction complete')).toBeInTheDocument();
@ -113,7 +115,7 @@ it('Dialog states', () => {
}, },
}) })
); );
expect(screen.getByText(`${props.name} complete`)).toBeInTheDocument(); expect(screen.getByText(`${props.title} complete`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument(); expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
expect(screen.getByText('Transaction confirmed')).toBeInTheDocument(); expect(screen.getByText('Transaction confirmed')).toBeInTheDocument();
@ -128,6 +130,6 @@ it('Dialog states', () => {
}, },
}) })
); );
expect(screen.getByText(`${props.name} failed`)).toBeInTheDocument(); expect(screen.getByText(`${props.title} failed`)).toBeInTheDocument();
expect(screen.getByText(`Error: ${reason}`)).toBeInTheDocument(); expect(screen.getByText(`Error: ${reason}`)).toBeInTheDocument();
}); });

View File

@ -0,0 +1,125 @@
import { t } from '@vegaprotocol/react-helpers';
import { Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit';
import { isEthereumError } from '../ethereum-error';
import type { EthTxState, TxError } from '../use-ethereum-transaction';
import { EthTxStatus } from '../use-ethereum-transaction';
import { ConfirmRow, TxRow, ConfirmationEventRow } from './dialog-rows';
export interface EthereumTransactionDialogProps {
title: string;
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
requiredConfirmations?: number;
}
export const EthereumTransactionDialog = ({
onChange,
title,
transaction,
requiredConfirmations = 1,
}: EthereumTransactionDialogProps) => {
const { status, error, confirmations, txHash } = transaction;
return (
<Dialog
open={transaction.dialogOpen}
onChange={onChange}
size="small"
{...getWrapperProps(title, status)}
>
<TransactionContent
status={status}
error={error}
txHash={txHash}
confirmations={confirmations}
requiredConfirmations={requiredConfirmations}
/>
</Dialog>
);
};
export const TransactionContent = ({
status,
error,
txHash,
confirmations,
requiredConfirmations = 1,
}: {
status: EthTxStatus;
error: TxError | null;
txHash: string | null;
confirmations: number;
requiredConfirmations?: number;
}) => {
if (status === EthTxStatus.Error) {
let errorMessage = '';
if (isEthereumError(error)) {
errorMessage = error.reason;
} else if (error instanceof Error) {
errorMessage = error.message;
}
return (
<p className="break-all">
{t('Error')}: {errorMessage}
</p>
);
}
return (
<div className="text-sm">
<ConfirmRow status={status} />
<TxRow
status={status}
txHash={txHash}
confirmations={confirmations}
requiredConfirmations={requiredConfirmations}
highlightComplete={false}
/>
<ConfirmationEventRow status={status} />
</div>
);
};
export const getWrapperProps = (title: string, status: EthTxStatus) => {
const propsMap = {
[EthTxStatus.Default]: {
title: '',
icon: null,
intent: undefined,
},
[EthTxStatus.Error]: {
title: t(`${title} failed`),
icon: <Icon name="warning-sign" />,
intent: Intent.Danger,
},
[EthTxStatus.Requested]: {
title: t('Confirm transaction'),
icon: <Icon name="hand-up" />,
intent: Intent.Warning,
},
[EthTxStatus.Pending]: {
title: t(`${title} pending`),
icon: (
<span className="mt-1">
<Loader size="small" />
</span>
),
intent: Intent.None,
},
[EthTxStatus.Complete]: {
title: t(`${title} pending`),
icon: <Loader size="small" />,
intent: Intent.None,
},
[EthTxStatus.Confirmed]: {
title: t(`${title} complete`),
icon: <Icon name="tick" />,
intent: Intent.Success,
},
};
return propsMap[status];
};

View File

@ -0,0 +1 @@
export * from './ethereum-transaction-dialog';

View File

@ -1 +0,0 @@
export * from './transaction-dialog';

View File

@ -1,115 +0,0 @@
import { t } from '@vegaprotocol/react-helpers';
import { Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit';
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';
export interface TransactionDialogProps {
name: string;
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
requiredConfirmations?: number;
}
export const TransactionDialog = ({
onChange,
name,
transaction,
requiredConfirmations = 1,
}: TransactionDialogProps) => {
const { status, error, confirmations, txHash } = transaction;
const renderContent = () => {
if (status === EthTxStatus.Error) {
const classNames = 'break-all text-black dark:text-white';
if (isEthereumError(error)) {
return (
<p className={classNames}>
{t('Error')}: {error.reason}
</p>
);
}
if (error instanceof Error) {
return (
<p className={classNames}>
{t('Error')}: {error.message}
</p>
);
}
return (
<p className={classNames}>
{t('Error')}: {t('Unknown error')}
</p>
);
}
return (
<>
<ConfirmRow status={status} />
<TxRow
status={status}
txHash={txHash}
confirmations={confirmations}
requiredConfirmations={requiredConfirmations}
highlightComplete={false}
/>
<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" />,
intent: Intent.Danger,
},
[EthTxStatus.Requested]: {
title: t('Confirm transaction'),
icon: <Icon name="hand-up" />,
intent: Intent.Warning,
},
[EthTxStatus.Pending]: {
title: t(`${name} pending`),
icon: <Loader size="small" />,
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,
},
};
return propsMap[status];
};
const { intent, title, icon } = getWrapperProps();
return (
<Dialog
open={transaction.dialogOpen}
onChange={onChange}
intent={intent}
title={title}
icon={icon}
>
{renderContent()}
</Dialog>
);
};

View File

@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react';
import type { EthereumError } from './ethereum-error'; import type { EthereumError } from './ethereum-error';
import { isExpectedEthereumError } from './ethereum-error'; import { isExpectedEthereumError } from './ethereum-error';
import { isEthereumError } from './ethereum-error'; import { isEthereumError } from './ethereum-error';
import { TransactionDialog } from './transaction-dialog'; import { EthereumTransactionDialog } from './ethereum-transaction-dialog';
export enum EthTxStatus { export enum EthTxStatus {
Default = 'Default', Default = 'Default',
@ -152,8 +152,8 @@ export const useEthereumTransaction = <
const Dialog = useMemo(() => { const Dialog = useMemo(() => {
return () => ( return () => (
<TransactionDialog <EthereumTransactionDialog
name={formatLabel(methodName as string)} title={formatLabel(methodName as string)}
onChange={() => { onChange={() => {
reset(); reset();
}} }}

View File

@ -1,7 +1,12 @@
export * from './lib/withdrawal-dialogs';
export * from './lib/withdraw-form'; export * from './lib/withdraw-form';
export * from './lib/withdraw-form-container';
export * from './lib/withdraw-manager'; export * from './lib/withdraw-manager';
export * from './lib/withdrawals-table'; export * from './lib/withdrawals-table';
export * from './lib/withdrawal-feedback';
export * from './lib/use-complete-withdraw'; export * from './lib/use-complete-withdraw';
export * from './lib/use-withdraw'; export * from './lib/use-create-withdraw';
export * from './lib/use-verify-withdrawal';
export * from './lib/use-withdrawals'; export * from './lib/use-withdrawals';
export * from './lib/__generated__/Withdrawals'; export * from './lib/__generated__/Withdrawals';
export * from './lib/__generated__/WithdrawalFields';

View File

@ -0,0 +1,46 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL fragment: AssetFields
// ====================================================
export interface AssetFields_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface AssetFields_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
*/
contractAddress: string;
}
export type AssetFields_source = AssetFields_source_BuiltinAsset | AssetFields_source_ERC20;
export interface AssetFields {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: AssetFields_source;
}

View File

@ -6,10 +6,10 @@
import { AccountType } from "@vegaprotocol/types"; import { AccountType } from "@vegaprotocol/types";
// ==================================================== // ====================================================
// GraphQL query operation: WithdrawPageQuery // GraphQL query operation: WithdrawFormQuery
// ==================================================== // ====================================================
export interface WithdrawPageQuery_party_withdrawals { export interface WithdrawFormQuery_party_withdrawals {
__typename: "Withdrawal"; __typename: "Withdrawal";
/** /**
* The Vega internal ID of the withdrawal * The Vega internal ID of the withdrawal
@ -21,7 +21,7 @@ export interface WithdrawPageQuery_party_withdrawals {
txHash: string | null; txHash: string | null;
} }
export interface WithdrawPageQuery_party_accounts_asset { export interface WithdrawFormQuery_party_accounts_asset {
__typename: "Asset"; __typename: "Asset";
/** /**
* The ID of the asset * The ID of the asset
@ -33,7 +33,7 @@ export interface WithdrawPageQuery_party_accounts_asset {
symbol: string; symbol: string;
} }
export interface WithdrawPageQuery_party_accounts { export interface WithdrawFormQuery_party_accounts {
__typename: "Account"; __typename: "Account";
/** /**
* Account type (General, Margin, etc) * Account type (General, Margin, etc)
@ -46,10 +46,10 @@ export interface WithdrawPageQuery_party_accounts {
/** /**
* Asset, the 'currency' * Asset, the 'currency'
*/ */
asset: WithdrawPageQuery_party_accounts_asset; asset: WithdrawFormQuery_party_accounts_asset;
} }
export interface WithdrawPageQuery_party { export interface WithdrawFormQuery_party {
__typename: "Party"; __typename: "Party";
/** /**
* Party identifier * Party identifier
@ -58,18 +58,18 @@ export interface WithdrawPageQuery_party {
/** /**
* The list of all withdrawals initiated by the party * The list of all withdrawals initiated by the party
*/ */
withdrawals: WithdrawPageQuery_party_withdrawals[] | null; withdrawals: WithdrawFormQuery_party_withdrawals[] | null;
/** /**
* Collateral accounts relating to a party * Collateral accounts relating to a party
*/ */
accounts: WithdrawPageQuery_party_accounts[] | null; accounts: WithdrawFormQuery_party_accounts[] | null;
} }
export interface WithdrawPageQuery_assetsConnection_edges_node_source_BuiltinAsset { export interface WithdrawFormQuery_assetsConnection_edges_node_source_BuiltinAsset {
__typename: "BuiltinAsset"; __typename: "BuiltinAsset";
} }
export interface WithdrawPageQuery_assetsConnection_edges_node_source_ERC20 { export interface WithdrawFormQuery_assetsConnection_edges_node_source_ERC20 {
__typename: "ERC20"; __typename: "ERC20";
/** /**
* The address of the ERC20 contract * The address of the ERC20 contract
@ -77,9 +77,9 @@ export interface WithdrawPageQuery_assetsConnection_edges_node_source_ERC20 {
contractAddress: string; contractAddress: string;
} }
export type WithdrawPageQuery_assetsConnection_edges_node_source = WithdrawPageQuery_assetsConnection_edges_node_source_BuiltinAsset | WithdrawPageQuery_assetsConnection_edges_node_source_ERC20; export type WithdrawFormQuery_assetsConnection_edges_node_source = WithdrawFormQuery_assetsConnection_edges_node_source_BuiltinAsset | WithdrawFormQuery_assetsConnection_edges_node_source_ERC20;
export interface WithdrawPageQuery_assetsConnection_edges_node { export interface WithdrawFormQuery_assetsConnection_edges_node {
__typename: "Asset"; __typename: "Asset";
/** /**
* The ID of the asset * The ID of the asset
@ -100,33 +100,33 @@ export interface WithdrawPageQuery_assetsConnection_edges_node {
/** /**
* The origin source of the asset (e.g: an ERC20 asset) * The origin source of the asset (e.g: an ERC20 asset)
*/ */
source: WithdrawPageQuery_assetsConnection_edges_node_source; source: WithdrawFormQuery_assetsConnection_edges_node_source;
} }
export interface WithdrawPageQuery_assetsConnection_edges { export interface WithdrawFormQuery_assetsConnection_edges {
__typename: "AssetEdge"; __typename: "AssetEdge";
node: WithdrawPageQuery_assetsConnection_edges_node; node: WithdrawFormQuery_assetsConnection_edges_node;
} }
export interface WithdrawPageQuery_assetsConnection { export interface WithdrawFormQuery_assetsConnection {
__typename: "AssetsConnection"; __typename: "AssetsConnection";
/** /**
* The assets * The assets
*/ */
edges: (WithdrawPageQuery_assetsConnection_edges | null)[] | null; edges: (WithdrawFormQuery_assetsConnection_edges | null)[] | null;
} }
export interface WithdrawPageQuery { export interface WithdrawFormQuery {
/** /**
* An entity that is trading on the Vega network * An entity that is trading on the Vega network
*/ */
party: WithdrawPageQuery_party | null; party: WithdrawFormQuery_party | null;
/** /**
* The list of all assets in use in the Vega network or the specified asset if ID is provided * The list of all assets in use in the Vega network or the specified asset if ID is provided
*/ */
assetsConnection: WithdrawPageQuery_assetsConnection; assetsConnection: WithdrawFormQuery_assetsConnection;
} }
export interface WithdrawPageQueryVariables { export interface WithdrawFormQueryVariables {
partyId: string; partyId: string;
} }

View File

@ -13,12 +13,30 @@ export interface WithdrawalEvent_busEvents_event_TimeUpdate {
__typename: "TimeUpdate" | "MarketEvent" | "TransferResponses" | "PositionResolution" | "Order" | "Trade" | "Account" | "Party" | "MarginLevels" | "Proposal" | "Vote" | "MarketData" | "NodeSignature" | "LossSocialization" | "SettlePosition" | "Market" | "Asset" | "MarketTick" | "SettleDistressed" | "AuctionEvent" | "RiskFactor" | "Deposit" | "OracleSpec" | "LiquidityProvision"; __typename: "TimeUpdate" | "MarketEvent" | "TransferResponses" | "PositionResolution" | "Order" | "Trade" | "Account" | "Party" | "MarginLevels" | "Proposal" | "Vote" | "MarketData" | "NodeSignature" | "LossSocialization" | "SettlePosition" | "Market" | "Asset" | "MarketTick" | "SettleDistressed" | "AuctionEvent" | "RiskFactor" | "Deposit" | "OracleSpec" | "LiquidityProvision";
} }
export interface WithdrawalEvent_busEvents_event_Withdrawal_asset_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface WithdrawalEvent_busEvents_event_Withdrawal_asset_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
*/
contractAddress: string;
}
export type WithdrawalEvent_busEvents_event_Withdrawal_asset_source = WithdrawalEvent_busEvents_event_Withdrawal_asset_source_BuiltinAsset | WithdrawalEvent_busEvents_event_Withdrawal_asset_source_ERC20;
export interface WithdrawalEvent_busEvents_event_Withdrawal_asset { export interface WithdrawalEvent_busEvents_event_Withdrawal_asset {
__typename: "Asset"; __typename: "Asset";
/** /**
* The ID of the asset * The ID of the asset
*/ */
id: string; id: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/** /**
* The symbol of the asset (e.g: GBP) * The symbol of the asset (e.g: GBP)
*/ */
@ -27,6 +45,10 @@ export interface WithdrawalEvent_busEvents_event_Withdrawal_asset {
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18 * The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/ */
decimals: number; decimals: number;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: WithdrawalEvent_busEvents_event_Withdrawal_asset_source;
} }
export interface WithdrawalEvent_busEvents_event_Withdrawal_details { export interface WithdrawalEvent_busEvents_event_Withdrawal_details {

View File

@ -9,12 +9,30 @@ import { WithdrawalStatus } from "@vegaprotocol/types";
// GraphQL fragment: WithdrawalFields // GraphQL fragment: WithdrawalFields
// ==================================================== // ====================================================
export interface WithdrawalFields_asset_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface WithdrawalFields_asset_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
*/
contractAddress: string;
}
export type WithdrawalFields_asset_source = WithdrawalFields_asset_source_BuiltinAsset | WithdrawalFields_asset_source_ERC20;
export interface WithdrawalFields_asset { export interface WithdrawalFields_asset {
__typename: "Asset"; __typename: "Asset";
/** /**
* The ID of the asset * The ID of the asset
*/ */
id: string; id: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/** /**
* The symbol of the asset (e.g: GBP) * The symbol of the asset (e.g: GBP)
*/ */
@ -23,6 +41,10 @@ export interface WithdrawalFields_asset {
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18 * The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/ */
decimals: number; decimals: number;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: WithdrawalFields_asset_source;
} }
export interface WithdrawalFields_details { export interface WithdrawalFields_details {

View File

@ -9,12 +9,30 @@ import { WithdrawalStatus } from "@vegaprotocol/types";
// GraphQL query operation: Withdrawals // GraphQL query operation: Withdrawals
// ==================================================== // ====================================================
export interface Withdrawals_party_withdrawals_asset { export interface Withdrawals_party_withdrawalsConnection_edges_node_asset_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface Withdrawals_party_withdrawalsConnection_edges_node_asset_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
*/
contractAddress: string;
}
export type Withdrawals_party_withdrawalsConnection_edges_node_asset_source = Withdrawals_party_withdrawalsConnection_edges_node_asset_source_BuiltinAsset | Withdrawals_party_withdrawalsConnection_edges_node_asset_source_ERC20;
export interface Withdrawals_party_withdrawalsConnection_edges_node_asset {
__typename: "Asset"; __typename: "Asset";
/** /**
* The ID of the asset * The ID of the asset
*/ */
id: string; id: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/** /**
* The symbol of the asset (e.g: GBP) * The symbol of the asset (e.g: GBP)
*/ */
@ -23,9 +41,13 @@ export interface Withdrawals_party_withdrawals_asset {
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18 * The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/ */
decimals: number; decimals: number;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: Withdrawals_party_withdrawalsConnection_edges_node_asset_source;
} }
export interface Withdrawals_party_withdrawals_details { export interface Withdrawals_party_withdrawalsConnection_edges_node_details {
__typename: "Erc20WithdrawalDetails"; __typename: "Erc20WithdrawalDetails";
/** /**
* The ethereum address of the receiver of the asset funds * The ethereum address of the receiver of the asset funds
@ -33,7 +55,7 @@ export interface Withdrawals_party_withdrawals_details {
receiverAddress: string; receiverAddress: string;
} }
export interface Withdrawals_party_withdrawals { export interface Withdrawals_party_withdrawalsConnection_edges_node {
__typename: "Withdrawal"; __typename: "Withdrawal";
/** /**
* The Vega internal ID of the withdrawal * The Vega internal ID of the withdrawal
@ -50,7 +72,7 @@ export interface Withdrawals_party_withdrawals {
/** /**
* The asset to be withdrawn * The asset to be withdrawn
*/ */
asset: Withdrawals_party_withdrawals_asset; asset: Withdrawals_party_withdrawalsConnection_edges_node_asset;
/** /**
* RFC3339Nano time at which the withdrawal was created * RFC3339Nano time at which the withdrawal was created
*/ */
@ -66,13 +88,26 @@ export interface Withdrawals_party_withdrawals {
/** /**
* Foreign chain specific details about the withdrawal * Foreign chain specific details about the withdrawal
*/ */
details: Withdrawals_party_withdrawals_details | null; details: Withdrawals_party_withdrawalsConnection_edges_node_details | null;
/** /**
* Whether or the not the withdrawal is being processed on Ethereum * Whether or the not the withdrawal is being processed on Ethereum
*/ */
pendingOnForeignChain: boolean; pendingOnForeignChain: boolean;
} }
export interface Withdrawals_party_withdrawalsConnection_edges {
__typename: "WithdrawalEdge";
node: Withdrawals_party_withdrawalsConnection_edges_node;
}
export interface Withdrawals_party_withdrawalsConnection {
__typename: "WithdrawalsConnection";
/**
* The withdrawals
*/
edges: (Withdrawals_party_withdrawalsConnection_edges | null)[] | null;
}
export interface Withdrawals_party { export interface Withdrawals_party {
__typename: "Party"; __typename: "Party";
/** /**
@ -82,7 +117,7 @@ export interface Withdrawals_party {
/** /**
* The list of all withdrawals initiated by the party * The list of all withdrawals initiated by the party
*/ */
withdrawals: Withdrawals_party_withdrawals[] | null; withdrawalsConnection: Withdrawals_party_withdrawalsConnection;
} }
export interface Withdrawals { export interface Withdrawals {

View File

@ -3,7 +3,7 @@ import { AccountType, WithdrawalStatus } from '@vegaprotocol/types';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
import type { Account } from './types'; import type { Account } from './types';
import type { Withdrawals_party_withdrawals } from './__generated__/Withdrawals'; import type { Withdrawals_party_withdrawalsConnection_edges_node } from './__generated__/Withdrawals';
export const generateAsset = (override?: PartialDeep<Asset>) => { export const generateAsset = (override?: PartialDeep<Asset>) => {
const defaultAsset: Asset = { const defaultAsset: Asset = {
@ -33,8 +33,8 @@ export const generateAccount = (override?: PartialDeep<Account>) => {
}; };
export const generateWithdrawal = ( export const generateWithdrawal = (
override?: PartialDeep<Withdrawals_party_withdrawals> override?: PartialDeep<Withdrawals_party_withdrawalsConnection_edges_node>
): Withdrawals_party_withdrawals => { ): Withdrawals_party_withdrawalsConnection_edges_node => {
return merge( return merge(
{ {
__typename: 'Withdrawal', __typename: 'Withdrawal',
@ -43,9 +43,14 @@ export const generateWithdrawal = (
amount: '100', amount: '100',
asset: { asset: {
__typename: 'Asset', __typename: 'Asset',
name: 'asset-name',
id: 'asset-id', id: 'asset-id',
symbol: 'asset-symbol', symbol: 'asset-symbol',
decimals: 2, decimals: 2,
source: {
__typename: 'ERC20',
contractAddress: '0x123',
},
}, },
createdTimestamp: '2022-04-20T00:00:00', createdTimestamp: '2022-04-20T00:00:00',
withdrawnTimestamp: null, withdrawnTimestamp: null,

View File

@ -10,13 +10,17 @@ import * as web3 from '@vegaprotocol/web3';
import * as sentry from '@sentry/react'; import * as sentry from '@sentry/react';
import type { Erc20Approval_erc20WithdrawalApproval } from './__generated__/Erc20Approval'; import type { Erc20Approval_erc20WithdrawalApproval } from './__generated__/Erc20Approval';
jest.mock('@vegaprotocol/web3', () => ({ jest.mock('@vegaprotocol/web3', () => {
useBridgeContract: jest.fn().mockReturnValue({ const orig = jest.requireActual('@vegaprotocol/web3');
withdraw_asset: jest.fn(), return {
isNewContract: true, ...orig,
}), useBridgeContract: jest.fn().mockReturnValue({
useEthereumTransaction: jest.fn(), withdraw_asset: jest.fn(),
})); isNewContract: true,
}),
useEthereumTransaction: jest.fn(),
};
});
function setup(mocks?: MockedResponse[]) { function setup(mocks?: MockedResponse[]) {
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper = ({ children }: { children: ReactNode }) => (

View File

@ -1,7 +1,11 @@
import { gql, useApolloClient } from '@apollo/client'; import { gql, useApolloClient } from '@apollo/client';
import { captureException } from '@sentry/react'; import { captureException } from '@sentry/react';
import type { CollateralBridge } from '@vegaprotocol/smart-contracts'; import type { CollateralBridge } from '@vegaprotocol/smart-contracts';
import { useBridgeContract, useEthereumTransaction } from '@vegaprotocol/web3'; import {
EthTxStatus,
useBridgeContract,
useEthereumTransaction,
} from '@vegaprotocol/web3';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { ERC20_APPROVAL_QUERY } from './queries'; import { ERC20_APPROVAL_QUERY } from './queries';
import type { import type {
@ -21,7 +25,7 @@ export const useCompleteWithdraw = () => {
const { query, cache } = useApolloClient(); const { query, cache } = useApolloClient();
const contract = useBridgeContract(); const contract = useBridgeContract();
const [id, setId] = useState(''); const [id, setId] = useState('');
const { transaction, perform, Dialog } = useEthereumTransaction< const { transaction, perform, reset, Dialog } = useEthereumTransaction<
CollateralBridge, CollateralBridge,
'withdraw_asset' 'withdraw_asset'
>(contract, 'withdraw_asset'); >(contract, 'withdraw_asset');
@ -29,6 +33,7 @@ export const useCompleteWithdraw = () => {
const submit = useCallback( const submit = useCallback(
async (withdrawalId: string) => { async (withdrawalId: string) => {
setId(withdrawalId); setId(withdrawalId);
try { try {
if (!contract) { if (!contract) {
return; return;
@ -39,6 +44,7 @@ export const useCompleteWithdraw = () => {
}); });
const approval = res.data.erc20WithdrawalApproval; const approval = res.data.erc20WithdrawalApproval;
if (!approval) { if (!approval) {
throw new Error('Could not retrieve withdrawal approval'); throw new Error('Could not retrieve withdrawal approval');
} }
@ -65,12 +71,13 @@ export const useCompleteWithdraw = () => {
fragment: PENDING_WITHDRAWAL_FRAGMMENT, fragment: PENDING_WITHDRAWAL_FRAGMMENT,
data: { data: {
__typename: 'Withdrawal', __typename: 'Withdrawal',
pendingOnForeignChain: true, pendingOnForeignChain:
transaction.status === EthTxStatus.Pending ? true : false,
txHash: transaction.txHash, txHash: transaction.txHash,
}, },
}); });
} }
}, [cache, transaction.txHash, id]); }, [cache, transaction.status, transaction.txHash, id]);
return { transaction, Dialog, submit, withdrawalId: id }; return { transaction, reset, Dialog, submit, withdrawalId: id };
}; };

View File

@ -0,0 +1,197 @@
import { act, renderHook } from '@testing-library/react';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { ReactNode } from 'react';
import { ERC20_APPROVAL_QUERY } from './queries';
import type { WithdrawalArgs } from './use-create-withdraw';
import { useCreateWithdraw } from './use-create-withdraw';
import type { Erc20Approval } from './__generated__/Erc20Approval';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import {
initialState,
VegaTxStatus,
VegaWalletContext,
} from '@vegaprotocol/wallet';
import { waitFor } from '@testing-library/react';
import { determineId } from '@vegaprotocol/react-helpers';
import type {
WithdrawalEvent,
WithdrawalEvent_busEvents_event_Withdrawal,
} from './__generated__/WithdrawalEvent';
import { WITHDRAWAL_BUS_EVENT_SUB } from './use-withdrawals';
import { WithdrawalStatus } from '@vegaprotocol/types';
function setup(
vegaWalletContext: Partial<VegaWalletContextShape>,
mocks?: MockedResponse[]
) {
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks}>
<VegaWalletContext.Provider
value={vegaWalletContext as VegaWalletContextShape}
>
{children}
</VegaWalletContext.Provider>
</MockedProvider>
);
return renderHook(() => useCreateWithdraw(), { wrapper });
}
const txHash = 'tx-hash';
const signature =
'cfe592d169f87d0671dd447751036d0dddc165b9c4b65e5a5060e2bbadd1aa726d4cbe9d3c3b327bcb0bff4f83999592619a2493f9bbd251fae99ce7ce766909';
const derivedWithdrawalId = determineId(signature);
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
const pubkey = '0x123';
let mockSend: jest.Mock;
let withdrawalInput: WithdrawalArgs;
let withdrawalEvent: WithdrawalEvent_busEvents_event_Withdrawal;
let mockERC20Approval: MockedResponse<Erc20Approval>;
let mockWithdrawalEvent: MockedResponse<WithdrawalEvent>;
beforeEach(() => {
mockSend = jest
.fn()
.mockReturnValue(
Promise.resolve({ txHash, tx: { signature: { value: signature } } })
);
withdrawalEvent = {
id: '2fca514cebf9f465ae31ecb4c5721e3a6f5f260425ded887ca50ba15b81a5d50',
status: WithdrawalStatus.STATUS_OPEN,
amount: '100',
asset: {
__typename: 'Asset',
id: 'asset-id',
name: 'asset-name',
symbol: 'asset-symbol',
decimals: 2,
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',
};
withdrawalInput = {
amount: '100',
asset: 'asset-id',
receiverAddress: 'receiver-address',
availableTimestamp: null,
};
mockERC20Approval = {
request: {
query: ERC20_APPROVAL_QUERY,
variables: { withdrawalId: derivedWithdrawalId },
},
result: {
data: {
erc20WithdrawalApproval: {
__typename: 'Erc20WithdrawalApproval',
assetSource: 'asset-source',
amount: '100',
nonce: '1',
signatures: 'signatures',
targetAddress: 'targetAddress',
expiry: 'expiry',
creation: '1',
},
},
},
delay: 2000,
};
mockWithdrawalEvent = {
request: {
query: WITHDRAWAL_BUS_EVENT_SUB,
variables: { partyId: pubkey },
},
result: {
data: {
busEvents: [
{
event: withdrawalEvent,
__typename: 'BusEvent',
},
],
},
},
delay: 1000,
};
});
it('creates withdrawal and waits for approval creation', async () => {
const { result } = setup(
// @ts-ignore only need pub property from keypair
{ sendTx: mockSend, keypair: { pub: pubkey } },
[mockWithdrawalEvent, mockERC20Approval]
);
expect(result.current.transaction).toEqual(initialState);
expect(result.current.submit).toEqual(expect.any(Function));
expect(result.current.reset).toEqual(expect.any(Function));
expect(result.current.approval).toEqual(null);
act(() => {
result.current.submit(withdrawalInput);
});
expect(mockSend).toHaveBeenCalledWith({
pubKey: pubkey,
propagate: true,
withdrawSubmission: {
amount: withdrawalInput.amount,
asset: withdrawalInput.asset,
ext: {
erc20: {
receiverAddress: withdrawalInput.receiverAddress,
},
},
},
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Requested);
await waitFor(() => {
expect(result.current.transaction.status).toEqual(VegaTxStatus.Pending);
expect(result.current.transaction.dialogOpen).toBe(true);
// Withdrawal event should not be found yet
expect(result.current.withdrawal).toEqual(null);
// Poll for erc20Approval should not be complete yet
expect(result.current.approval).toEqual(null);
});
await act(async () => {
// Advance time by delay plus interval length to ensure mock result is triggered
// eslint-disable-next-line
jest.advanceTimersByTime(mockWithdrawalEvent.delay! + 1000);
});
expect(result.current.withdrawal).toEqual(withdrawalEvent);
await act(async () => {
// Advance time by delay plus interval length to ensure mock result is triggered
// eslint-disable-next-line
jest.advanceTimersByTime(mockERC20Approval.delay! + 1000);
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Complete);
expect(result.current.approval).toEqual(
// @ts-ignore MockedRespones types inteferring
mockERC20Approval.result.data.erc20WithdrawalApproval
);
});

View File

@ -0,0 +1,74 @@
import { determineId } from '@vegaprotocol/react-helpers';
import { useVegaTransaction, useVegaWallet } from '@vegaprotocol/wallet';
import { useCallback, useState } from 'react';
import { useWithdrawalApproval } from './use-withdrawal-approval';
import { useWithdrawalEvent } from './use-withdrawal-event';
import type { Erc20Approval_erc20WithdrawalApproval } from './__generated__/Erc20Approval';
import type { WithdrawalFields } from './__generated__/WithdrawalFields';
export interface WithdrawalArgs {
amount: string;
asset: string;
receiverAddress: string;
availableTimestamp: number | null;
}
export const useCreateWithdraw = () => {
const waitForWithdrawalApproval = useWithdrawalApproval();
const waitForWithdrawal = useWithdrawalEvent();
const [approval, setApproval] =
useState<Erc20Approval_erc20WithdrawalApproval | null>(null);
const [withdrawal, setWithdrawal] = useState<WithdrawalFields | null>(null);
const [availableTimestamp, setAvailableTimestamp] = useState<number | null>(
null
);
const { keypair } = useVegaWallet();
const { transaction, send, setComplete, reset, Dialog } =
useVegaTransaction();
const submit = useCallback(
async (withdrawal: WithdrawalArgs) => {
if (!keypair) {
return;
}
setAvailableTimestamp(withdrawal.availableTimestamp);
const res = await send({
pubKey: keypair.pub,
propagate: true,
withdrawSubmission: {
amount: withdrawal.amount,
asset: withdrawal.asset,
ext: {
erc20: {
receiverAddress: withdrawal.receiverAddress,
},
},
},
});
if (res?.signature) {
const id = determineId(res.signature);
const withdrawal = await waitForWithdrawal(id, keypair.pub);
setWithdrawal(withdrawal);
const approval = await waitForWithdrawalApproval(withdrawal.id);
setApproval(approval);
setComplete();
}
},
[keypair, send, waitForWithdrawal, waitForWithdrawalApproval, setComplete]
);
return {
transaction,
submit,
reset,
approval,
withdrawal,
availableTimestamp,
Dialog,
};
};

View File

@ -0,0 +1,21 @@
import * as Sentry from '@sentry/react';
import { useBridgeContract } from '@vegaprotocol/web3';
import { useCallback } from 'react';
/**
* Gets the delay in seconds thats required if the withdrawal amount is
* over the withdrawal threshold (contract.get_withdraw_threshold)
*/
export const useGetWithdrawDelay = () => {
const contract = useBridgeContract();
const getDelay = useCallback(async () => {
try {
const res = await contract?.default_withdraw_delay();
return res.toNumber();
} catch (err) {
Sentry.captureException(err);
}
}, [contract]);
return getDelay;
};

View File

@ -1,30 +0,0 @@
import { useCallback } from 'react';
import { useBridgeContract, useEthereumReadContract } from '@vegaprotocol/web3';
import BigNumber from 'bignumber.js';
import type { Asset } from '@vegaprotocol/react-helpers';
import { addDecimal } from '@vegaprotocol/react-helpers';
export const useGetWithdrawLimits = (asset?: Asset) => {
const contract = useBridgeContract();
const getLimits = useCallback(async () => {
if (!contract || !asset || asset.source.__typename !== 'ERC20') {
return;
}
return contract.get_withdraw_threshold(asset.source.contractAddress);
}, [asset, contract]);
const {
state: { data },
} = useEthereumReadContract(getLimits);
if (!data || !asset) return null;
const value = new BigNumber(addDecimal(data.toString(), asset.decimals));
const max = value.isEqualTo(0)
? new BigNumber(Infinity)
: value.minus(new BigNumber(addDecimal('1', asset.decimals)));
return {
max,
};
};

View File

@ -0,0 +1,32 @@
import { useCallback } from 'react';
import { useBridgeContract } from '@vegaprotocol/web3';
import BigNumber from 'bignumber.js';
import type { Asset } from '@vegaprotocol/react-helpers';
import { addDecimal } from '@vegaprotocol/react-helpers';
/**
* Returns a function to get the threshold amount for a withdrawal. If a withdrawal amount
* is greater than this value it will incur a delay before being able to be completed. The delay is set
* on the smart contract and can be retrieved using contract.default_withdraw_delay
*/
export const useGetWithdrawThreshold = () => {
const contract = useBridgeContract();
const getThreshold = useCallback(
async (asset: Asset | undefined) => {
if (!contract || asset?.source.__typename !== 'ERC20') {
return new BigNumber(Infinity);
}
const res = await contract.get_withdraw_threshold(
asset.source.contractAddress
);
const value = new BigNumber(addDecimal(res.toString(), asset.decimals));
const threshold = value.isEqualTo(0)
? new BigNumber(Infinity)
: value.minus(new BigNumber(addDecimal('1', asset.decimals)));
return threshold;
},
[contract]
);
return getThreshold;
};

View File

@ -0,0 +1,135 @@
import { useCallback, useState } from 'react';
import { captureException } from '@sentry/react';
import type { WithdrawalFields } from './__generated__/WithdrawalFields';
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 { ERC20_APPROVAL_QUERY } from './queries';
import type {
Erc20Approval,
Erc20ApprovalVariables,
} from './__generated__/Erc20Approval';
import { useApolloClient } from '@apollo/client';
export enum ApprovalStatus {
Idle = 'Idle',
Pending = 'Pending',
Delayed = 'Delayed',
Error = 'Error',
Ready = 'Ready',
}
export interface VerifyState {
status: ApprovalStatus;
message: string;
threshold: BigNumber;
completeTimestamp: number | null;
dialogOpen: boolean;
}
const initialState = {
status: ApprovalStatus.Idle,
message: '',
threshold: new BigNumber(Infinity),
completeTimestamp: null,
dialogOpen: false,
};
export const useVerifyWithdrawal = () => {
const client = useApolloClient();
const getThreshold = useGetWithdrawThreshold();
const getDelay = useGetWithdrawDelay();
const [state, _setState] = useState<VerifyState>(initialState);
const setState = useCallback(
(update: Partial<VerifyState>) => {
_setState((curr) => ({
...curr,
...update,
}));
},
[_setState]
);
const reset = useCallback(() => {
setState(initialState);
}, [setState]);
const verify = useCallback(
async (withdrawal: WithdrawalFields) => {
try {
setState({ dialogOpen: true });
if (withdrawal.asset.source.__typename !== 'ERC20') {
setState({
status: ApprovalStatus.Error,
message: t(
`Invalid asset source: ${withdrawal.asset.source.__typename}`
),
});
return false;
}
setState({
status: ApprovalStatus.Pending,
message: t('Verifying withdrawal approval'),
});
const amount = new BigNumber(
addDecimal(withdrawal.amount, withdrawal.asset.decimals)
);
const threshold = await getThreshold(withdrawal.asset);
if (threshold && amount.isGreaterThan(threshold)) {
const delaySecs = await getDelay();
const completeTimestamp =
new Date(withdrawal.createdTimestamp).getTime() + delaySecs * 1000;
if (Date.now() < completeTimestamp) {
setState({
status: ApprovalStatus.Delayed,
threshold,
completeTimestamp,
});
return false;
}
}
const res = await client.query<Erc20Approval, Erc20ApprovalVariables>({
query: ERC20_APPROVAL_QUERY,
variables: { withdrawalId: withdrawal.id },
});
const approval = res.data.erc20WithdrawalApproval;
if (!approval) {
setState({
status: ApprovalStatus.Error,
});
return false;
}
if (approval.signatures.length < 3) {
setState({
status: ApprovalStatus.Error,
});
return false;
}
setState({ status: ApprovalStatus.Ready, dialogOpen: false });
return true;
} catch (err) {
captureException(err);
setState({
status: ApprovalStatus.Error,
});
return false;
}
},
[getThreshold, getDelay, client, setState]
);
return { verify, state, reset };
};

View File

@ -1,189 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { ReactNode } from 'react';
import { ERC20_APPROVAL_QUERY } from './queries';
import * as web3 from '@vegaprotocol/web3';
import * as wallet from '@vegaprotocol/wallet';
import type { WithdrawalFields } from './use-withdraw';
import { useWithdraw } from './use-withdraw';
import type { Erc20Approval } from './__generated__/Erc20Approval';
jest.mock('@vegaprotocol/web3', () => ({
useBridgeContract: jest.fn().mockReturnValue({
withdraw_asset: jest.fn(),
isNewContract: true,
}),
useEthereumTransaction: jest.fn(),
}));
jest.mock('@vegaprotocol/wallet', () => ({
useVegaWallet: jest.fn().mockReturnValue({ keypair: { pub: 'pubkey' } }),
useVegaTransaction: jest.fn().mockReturnValue({
transaction: {},
send: jest.fn(),
reset: jest.fn(),
}),
}));
function setup(mocks?: MockedResponse[], cancelled = false) {
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks}>{children}</MockedProvider>
);
return renderHook(() => useWithdraw(cancelled), { wrapper });
}
const signature =
'cfe592d169f87d0671dd447751036d0dddc165b9c4b65e5a5060e2bbadd1aa726d4cbe9d3c3b327bcb0bff4f83999592619a2493f9bbd251fae99ce7ce766909';
const derivedWithdrawalId =
'2fca514cebf9f465ae31ecb4c5721e3a6f5f260425ded887ca50ba15b81a5d50';
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
let pubkey: string;
let mockSend: jest.Mock;
let mockPerform: jest.Mock;
let mockEthReset: jest.Mock;
let mockVegaReset: jest.Mock;
let withdrawalInput: WithdrawalFields;
let mockERC20Approval: MockedResponse<Erc20Approval>;
beforeEach(() => {
pubkey = 'pubkey';
mockSend = jest.fn().mockReturnValue(Promise.resolve({ signature }));
mockPerform = jest.fn();
mockEthReset = jest.fn();
mockVegaReset = jest.fn();
jest.spyOn(web3, 'useEthereumTransaction').mockReturnValue({
// @ts-ignore allow null transaction as its not used in hook logic
transaction: null,
perform: mockPerform,
reset: mockEthReset,
});
jest
.spyOn(wallet, 'useVegaWallet')
// @ts-ignore only need to mock keypair
.mockReturnValue({ keypair: { pub: pubkey } });
jest.spyOn(wallet, 'useVegaTransaction').mockReturnValue({
// @ts-ignore allow null transaction as its not used in hook logic
transaction: null,
send: mockSend,
reset: mockVegaReset,
});
withdrawalInput = {
amount: '100',
asset: 'asset-id',
receiverAddress: 'receiver-address',
};
mockERC20Approval = {
request: {
query: ERC20_APPROVAL_QUERY,
variables: { withdrawalId: derivedWithdrawalId },
},
result: {
data: {
erc20WithdrawalApproval: {
__typename: 'Erc20WithdrawalApproval',
assetSource: 'asset-source',
amount: '100',
nonce: '1',
signatures: 'signatures',
targetAddress: 'targetAddress',
expiry: 'expiry',
creation: '1',
},
},
},
delay: 5000,
};
});
it('Creates withdrawal and immediately submits Ethereum transaction', async () => {
const { result } = setup([mockERC20Approval]);
await act(async () => {
result.current.submit(withdrawalInput);
});
expect(mockSend).toHaveBeenCalledWith({
pubKey: pubkey,
propagate: true,
withdrawSubmission: {
amount: withdrawalInput.amount,
asset: withdrawalInput.asset,
ext: {
erc20: {
receiverAddress: withdrawalInput.receiverAddress,
},
},
},
});
expect(result.current.withdrawalId).toEqual(derivedWithdrawalId);
// Query 'poll' not returned yet and eth transaction shouldn't have
// started
expect(result.current.approval).toEqual(null);
expect(mockPerform).not.toHaveBeenCalled();
// Advance query delay time so query result is returned and the
// eth transaction should be called with the approval query result
await act(async () => {
jest.advanceTimersByTime(mockERC20Approval.delay || 0);
});
expect(result.current.approval).toEqual(
// @ts-ignore MockedRespones types inteferring
mockERC20Approval.result.data.erc20WithdrawalApproval
);
// @ts-ignore MockedRespones types inteferring
const withdrawal = mockERC20Approval.result.data.erc20WithdrawalApproval;
expect(mockPerform).toHaveBeenCalledWith(
withdrawal.assetSource,
withdrawal.amount,
withdrawal.targetAddress,
withdrawal.creation,
withdrawal.nonce,
withdrawal.signatures
);
});
it('Doesnt perform Ethereum tx if cancelled', async () => {
const { result } = setup([mockERC20Approval], true);
await act(async () => {
result.current.submit(withdrawalInput);
});
await act(async () => {
jest.advanceTimersByTime(mockERC20Approval.delay || 0);
});
expect(result.current.approval).toEqual(
// @ts-ignore MockedRespone types inteferring
mockERC20Approval.result.data.erc20WithdrawalApproval
);
// Approval set, but cancelled flag is set, so the Ethereum
// TX should not be invoked
expect(mockPerform).not.toHaveBeenCalled();
});
it('Reset will reset both transactions', async () => {
const { result } = setup([mockERC20Approval]);
await act(async () => {
result.current.reset();
});
expect(mockEthReset).toHaveBeenCalled();
expect(mockVegaReset).toHaveBeenCalled();
});

View File

@ -1,115 +0,0 @@
import { useQuery } from '@apollo/client';
import { determineId } from '@vegaprotocol/react-helpers';
import { useBridgeContract, useEthereumTransaction } from '@vegaprotocol/web3';
import { useVegaTransaction, useVegaWallet } from '@vegaprotocol/wallet';
import { useCallback, useEffect, useState } from 'react';
import { ERC20_APPROVAL_QUERY } from './queries';
import type {
Erc20Approval,
Erc20Approval_erc20WithdrawalApproval,
Erc20ApprovalVariables,
} from './__generated__/Erc20Approval';
import type { CollateralBridge } from '@vegaprotocol/smart-contracts';
export interface WithdrawalFields {
amount: string;
asset: string;
receiverAddress: string;
}
export const useWithdraw = (cancelled: boolean) => {
const [withdrawalId, setWithdrawalId] = useState<string | null>(null);
const [approval, setApproval] =
useState<Erc20Approval_erc20WithdrawalApproval | null>(null);
const contract = useBridgeContract();
const { keypair } = useVegaWallet();
const {
transaction: vegaTx,
send,
reset: resetVegaTx,
} = useVegaTransaction();
const {
transaction: ethTx,
perform,
reset: resetEthTx,
} = useEthereumTransaction<CollateralBridge, 'withdraw_asset'>(
contract,
'withdraw_asset'
);
const { data, stopPolling } = useQuery<Erc20Approval, Erc20ApprovalVariables>(
ERC20_APPROVAL_QUERY,
{
variables: { withdrawalId: withdrawalId || '' },
skip: !withdrawalId,
pollInterval: 1000,
}
);
const submit = useCallback(
async (withdrawal: WithdrawalFields) => {
if (!keypair) {
return;
}
const res = await send({
pubKey: keypair.pub,
propagate: true,
withdrawSubmission: {
amount: withdrawal.amount,
asset: withdrawal.asset,
ext: {
erc20: {
receiverAddress: withdrawal.receiverAddress,
},
},
},
});
if (res?.signature) {
setWithdrawalId(determineId(res.signature));
}
},
[keypair, send]
);
useEffect(() => {
if (
data?.erc20WithdrawalApproval &&
data.erc20WithdrawalApproval.signatures.length > 2
) {
stopPolling();
setApproval(data.erc20WithdrawalApproval);
}
}, [data, stopPolling]);
useEffect(() => {
if (approval && contract && !cancelled) {
perform(
approval.assetSource,
approval.amount,
approval.targetAddress,
approval.creation,
approval.nonce,
approval.signatures
);
}
// eslint-disable-next-line
}, [approval, contract]);
const reset = useCallback(() => {
resetVegaTx();
resetEthTx();
}, [resetVegaTx, resetEthTx]);
return {
submit,
reset,
approval,
withdrawalId,
vegaTx,
ethTx,
};
};

View File

@ -0,0 +1,58 @@
import { useApolloClient } from '@apollo/client';
import { useCallback, useEffect, useRef } from 'react';
import { ERC20_APPROVAL_QUERY } from './queries';
import type {
Erc20Approval,
Erc20ApprovalVariables,
Erc20Approval_erc20WithdrawalApproval,
} from './__generated__/Erc20Approval';
type WaitForApproval = (
id: string
) => Promise<Erc20Approval_erc20WithdrawalApproval>;
export const useWithdrawalApproval = () => {
const client = useApolloClient();
// eslint-disable-next-line
const intervalRef = useRef<any>();
const waitForWithdrawalApproval = useCallback<WaitForApproval>(
(id) => {
return new Promise((resolve) => {
intervalRef.current = setInterval(async () => {
try {
const res = await client.query<
Erc20Approval,
Erc20ApprovalVariables
>({
query: ERC20_APPROVAL_QUERY,
variables: { withdrawalId: id },
fetchPolicy: 'network-only',
});
if (
res.data.erc20WithdrawalApproval &&
res.data.erc20WithdrawalApproval.signatures.length > 2
) {
clearInterval(intervalRef.current);
resolve(res.data.erc20WithdrawalApproval);
}
} catch (err) {
// no op as the query will error until the approval is created
}
}, 1000);
});
},
[client]
);
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return waitForWithdrawalApproval;
};

View File

@ -0,0 +1,61 @@
import { useApolloClient } from '@apollo/client';
import { useCallback, useEffect, useRef } from 'react';
import type { Subscription } from 'zen-observable-ts';
import { WITHDRAWAL_BUS_EVENT_SUB } from './use-withdrawals';
import type {
WithdrawalEvent,
WithdrawalEventVariables,
WithdrawalEvent_busEvents_event_Withdrawal,
} from './__generated__/WithdrawalEvent';
type WaitForWithdrawalEvent = (
id: string,
partyId: string
) => Promise<WithdrawalEvent_busEvents_event_Withdrawal>;
export const useWithdrawalEvent = () => {
const client = useApolloClient();
const subRef = useRef<Subscription | null>(null);
const waitForWithdrawalEvent = useCallback<WaitForWithdrawalEvent>(
(id, partyId) => {
return new Promise((resolve) => {
subRef.current = client
.subscribe<WithdrawalEvent, WithdrawalEventVariables>({
query: WITHDRAWAL_BUS_EVENT_SUB,
variables: { partyId },
})
.subscribe(({ data }) => {
if (!data?.busEvents?.length) {
return;
}
// No types available for the subscription result
const matchingWithdrawalEvent = data.busEvents.find((e) => {
if (e.event.__typename !== 'Withdrawal') {
return false;
}
return e.event.id === id;
});
if (
matchingWithdrawalEvent &&
matchingWithdrawalEvent.event.__typename === 'Withdrawal'
) {
resolve(matchingWithdrawalEvent.event);
subRef.current?.unsubscribe();
}
});
});
},
[client]
);
useEffect(() => {
return () => {
subRef.current?.unsubscribe();
};
}, []);
return waitForWithdrawalEvent;
};

View File

@ -7,11 +7,11 @@ import type {
} from './__generated__/WithdrawalEvent'; } from './__generated__/WithdrawalEvent';
import type { import type {
Withdrawals, Withdrawals,
Withdrawals_party_withdrawals, Withdrawals_party_withdrawalsConnection_edges_node,
} from './__generated__/Withdrawals'; } from './__generated__/Withdrawals';
describe('updateQuery', () => { describe('updateQuery', () => {
it('Updates existing withdrawals', () => { it('updates existing withdrawals', () => {
const withdrawal = generateWithdrawal({ const withdrawal = generateWithdrawal({
id: '1', id: '1',
status: WithdrawalStatus.STATUS_OPEN, status: WithdrawalStatus.STATUS_OPEN,
@ -27,7 +27,15 @@ describe('updateQuery', () => {
party: { party: {
__typename: 'Party', __typename: 'Party',
id: 'party-id', id: 'party-id',
withdrawals: [withdrawalUpdate], withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges: [
{
__typename: 'WithdrawalEdge',
node: withdrawalUpdate,
},
],
},
}, },
}); });
}); });
@ -48,21 +56,45 @@ describe('updateQuery', () => {
party: { party: {
__typename: 'Party', __typename: 'Party',
id: 'party-id', id: 'party-id',
withdrawals: [withdrawalUpdate, withdrawal], withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges: [
{
__typename: 'WithdrawalEdge',
node: withdrawalUpdate,
},
{
__typename: 'WithdrawalEdge',
node: withdrawal,
},
],
},
}, },
}); });
}); });
it('Creates new party if not present', () => { it('creates new party if not present', () => {
const partyId = 'party-id';
const withdrawalUpdate = generateWithdrawal({ const withdrawalUpdate = generateWithdrawal({
id: '2', id: '2',
}); });
const incoming = mockSub([withdrawalUpdate]); const incoming = mockSub([withdrawalUpdate]);
expect(updateQuery({ party: null }, incoming)).toEqual({ expect(
updateQuery({ party: null }, { ...incoming, variables: { partyId } })
).toEqual({
party: { party: {
__typename: 'Party', __typename: 'Party',
withdrawals: [withdrawalUpdate], id: partyId,
withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges: [
{
__typename: 'WithdrawalEdge',
node: withdrawalUpdate,
},
],
},
}, },
}); });
}); });
@ -89,20 +121,42 @@ describe('updateQuery', () => {
party: { party: {
__typename: 'Party', __typename: 'Party',
id: 'party-id', id: 'party-id',
withdrawals: [withdrawalUpdate, withdrawalNew, withdrawal2], withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges: [
{
__typename: 'WithdrawalEdge',
node: withdrawalUpdate,
},
{
__typename: 'WithdrawalEdge',
node: withdrawalNew,
},
{
__typename: 'WithdrawalEdge',
node: withdrawal2,
},
],
},
}, },
}); });
}); });
}); });
const mockQuery = ( const mockQuery = (
withdrawals: Withdrawals_party_withdrawals[] withdrawals: Withdrawals_party_withdrawalsConnection_edges_node[]
): Withdrawals => { ): Withdrawals => {
return { return {
party: { party: {
__typename: 'Party', __typename: 'Party',
id: 'party-id', id: 'party-id',
withdrawals, withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges: withdrawals.map((w) => ({
__typename: 'WithdrawalEdge',
node: w,
})),
},
}, },
}; };
}; };

View File

@ -1,8 +1,10 @@
import orderBy from 'lodash/orderBy';
import compact from 'lodash/compact';
import { gql, useQuery } from '@apollo/client'; import { gql, useQuery } from '@apollo/client';
import type { UpdateQueryFn } from '@apollo/client/core/watchQueryOptions'; import type { UpdateQueryFn } from '@apollo/client/core/watchQueryOptions';
import { useVegaWallet } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet';
import uniqBy from 'lodash/uniqBy'; import uniqBy from 'lodash/uniqBy';
import { useEffect } from 'react'; import { useEffect, useMemo } from 'react';
import type { import type {
WithdrawalEvent, WithdrawalEvent,
WithdrawalEventVariables, WithdrawalEventVariables,
@ -12,6 +14,7 @@ import type {
import type { import type {
Withdrawals, Withdrawals,
WithdrawalsVariables, WithdrawalsVariables,
Withdrawals_party_withdrawalsConnection_edges,
} from './__generated__/Withdrawals'; } from './__generated__/Withdrawals';
const WITHDRAWAL_FRAGMENT = gql` const WITHDRAWAL_FRAGMENT = gql`
@ -21,8 +24,14 @@ const WITHDRAWAL_FRAGMENT = gql`
amount amount
asset { asset {
id id
name
symbol symbol
decimals decimals
source {
... on ERC20 {
contractAddress
}
}
} }
createdTimestamp createdTimestamp
withdrawnTimestamp withdrawnTimestamp
@ -41,8 +50,12 @@ export const WITHDRAWALS_QUERY = gql`
query Withdrawals($partyId: ID!) { query Withdrawals($partyId: ID!) {
party(id: $partyId) { party(id: $partyId) {
id id
withdrawals { withdrawalsConnection {
...WithdrawalFields edges {
node {
...WithdrawalFields
}
}
} }
} }
} }
@ -63,7 +76,7 @@ export const WITHDRAWAL_BUS_EVENT_SUB = gql`
export const useWithdrawals = () => { export const useWithdrawals = () => {
const { keypair } = useVegaWallet(); const { keypair } = useVegaWallet();
const { subscribeToMore, ...queryResult } = useQuery< const { data, loading, error, subscribeToMore } = useQuery<
Withdrawals, Withdrawals,
WithdrawalsVariables WithdrawalsVariables
>(WITHDRAWALS_QUERY, { >(WITHDRAWALS_QUERY, {
@ -85,19 +98,36 @@ export const useWithdrawals = () => {
}; };
}, [keypair?.pub, subscribeToMore]); }, [keypair?.pub, subscribeToMore]);
return queryResult; const withdrawals = useMemo(() => {
if (!data?.party?.withdrawalsConnection?.edges) {
return [];
}
return orderBy(
compact(data.party.withdrawalsConnection.edges).map((edge) => edge.node),
'createdTimestamp',
'desc'
);
}, [data]);
return {
data,
loading,
error,
withdrawals,
};
}; };
export const updateQuery: UpdateQueryFn< export const updateQuery: UpdateQueryFn<
Withdrawals, Withdrawals,
WithdrawalEventVariables, WithdrawalEventVariables,
WithdrawalEvent WithdrawalEvent
> = (prev, { subscriptionData }) => { > = (prev, { subscriptionData, variables }) => {
if (!subscriptionData.data.busEvents?.length) { if (!subscriptionData.data.busEvents?.length) {
return prev; return prev;
} }
const curr = prev.party?.withdrawals || []; const curr = prev.party?.withdrawalsConnection.edges || [];
const incoming = subscriptionData.data.busEvents const incoming = subscriptionData.data.busEvents
.map((e) => { .map((e) => {
return { return {
@ -105,21 +135,28 @@ export const updateQuery: UpdateQueryFn<
pendingOnForeignChain: false, pendingOnForeignChain: false,
}; };
}) })
.filter( .filter(isWithdrawalEvent)
isWithdrawalEvent .map(
// Need this type cast here, TS can't infer that we've filtered any event types (w) =>
// that arent Withdrawals ({
) as WithdrawalEvent_busEvents_event_Withdrawal[]; __typename: 'WithdrawalEdge',
node: w,
} as Withdrawals_party_withdrawalsConnection_edges)
);
const withdrawals = uniqBy([...incoming, ...curr], 'id'); const edges = uniqBy([...incoming, ...curr], 'node.id');
// Write new party to cache if not present // Write new party to cache if not present
if (!prev.party) { if (!prev.party) {
return { return {
...prev, ...prev,
party: { party: {
id: variables?.partyId,
__typename: 'Party', __typename: 'Party',
withdrawals, withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges,
},
}, },
} as Withdrawals; } as Withdrawals;
} }
@ -128,7 +165,10 @@ export const updateQuery: UpdateQueryFn<
...prev, ...prev,
party: { party: {
...prev.party, ...prev.party,
withdrawals, withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges,
},
}, },
}; };
}; };

View File

@ -1,113 +0,0 @@
import { render, screen } from '@testing-library/react';
import merge from 'lodash/merge';
import {
initialState as vegaTxInitialState,
VegaTxStatus,
} from '@vegaprotocol/wallet';
import {
EthTxStatus,
initialState as ethTxInitialState,
} from '@vegaprotocol/web3';
import type { WithdrawDialogProps } from './withdraw-dialog';
import { WithdrawDialog } from './withdraw-dialog';
import type { Erc20Approval_erc20WithdrawalApproval } from './__generated__/Erc20Approval';
import type { PartialDeep } from 'type-fest';
const approval: Erc20Approval_erc20WithdrawalApproval = {
__typename: 'Erc20WithdrawalApproval',
assetSource: 'asset-source',
amount: '100',
nonce: '1',
signatures: 'signatures',
targetAddress: 'target-address',
expiry: 'expiry',
creation: '1660158537747',
};
const generateJsx = (override?: PartialDeep<WithdrawDialogProps>) => {
const defaultProps = {
vegaTx: vegaTxInitialState,
ethTx: ethTxInitialState,
approval: null,
dialogOpen: true,
onDialogChange: jest.fn(),
};
const props = merge(defaultProps, override);
return <WithdrawDialog {...props} />;
};
it('Dialog transaction states', () => {
const { rerender } = render(
generateJsx({
vegaTx: {
status: VegaTxStatus.Requested,
},
})
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Confirm withdrawal')).toBeInTheDocument();
rerender(
generateJsx({
vegaTx: {
status: VegaTxStatus.Pending,
},
})
);
expect(
screen.getByText('Withdrawal transaction pending')
).toBeInTheDocument();
rerender(
generateJsx({
vegaTx: {
status: VegaTxStatus.Error,
},
})
);
expect(screen.getByText('Withdrawal transaction failed')).toBeInTheDocument();
rerender(
generateJsx({
approval,
vegaTx: {
status: VegaTxStatus.Pending,
},
ethTx: {
status: EthTxStatus.Requested,
},
})
);
expect(screen.getByText('Confirm transaction')).toBeInTheDocument();
const txHash = 'tx-hash';
rerender(
generateJsx({
approval,
ethTx: {
status: EthTxStatus.Pending,
txHash,
},
})
);
expect(screen.getByText('Ethereum transaction pending')).toBeInTheDocument();
expect(screen.getByText('View on Etherscan')).toHaveAttribute(
'href',
expect.stringContaining(txHash)
);
rerender(
generateJsx({
approval,
ethTx: {
status: EthTxStatus.Complete,
},
})
);
expect(screen.getByText('Ethereum transaction complete')).toBeInTheDocument();
expect(screen.getByText('View on Etherscan')).toHaveAttribute(
'href',
expect.stringContaining(txHash)
);
});

View File

@ -1,182 +0,0 @@
import { Link, Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment';
import type { VegaTxState } from '@vegaprotocol/wallet';
import { VegaTxStatus } from '@vegaprotocol/wallet';
import type { ReactNode } from 'react';
import type { EthTxState } from '@vegaprotocol/web3';
import { isEthereumError } from '@vegaprotocol/web3';
import { EthTxStatus } from '@vegaprotocol/web3';
import { t } from '@vegaprotocol/react-helpers';
import type { Erc20Approval_erc20WithdrawalApproval } from './__generated__/Erc20Approval';
export interface WithdrawDialogProps {
vegaTx: VegaTxState;
ethTx: EthTxState;
approval: Erc20Approval_erc20WithdrawalApproval | null;
dialogOpen: boolean;
onDialogChange: (isOpen: boolean) => void;
}
export const WithdrawDialog = ({
vegaTx,
ethTx,
approval,
dialogOpen,
onDialogChange,
}: WithdrawDialogProps) => {
const { ETHERSCAN_URL } = useEnvironment();
const { intent, title, icon, children } = getProps(
approval,
vegaTx,
ethTx,
ETHERSCAN_URL
);
return (
<Dialog
open={dialogOpen}
onChange={onDialogChange}
intent={intent}
title={title}
icon={icon}
>
{children}
</Dialog>
);
};
interface StepProps {
children: ReactNode;
}
const Step = ({ children }: StepProps) => {
return (
<p data-testid="dialog-text" className="flex justify-between">
{children}
</p>
);
};
interface DialogProps {
title: string;
icon: ReactNode;
children: ReactNode;
intent?: Intent;
}
const getProps = (
approval: Erc20Approval_erc20WithdrawalApproval | null,
vegaTx: VegaTxState,
ethTx: EthTxState,
ethUrl: string
) => {
const vegaTxPropsMap: Record<VegaTxStatus, DialogProps> = {
[VegaTxStatus.Default]: {
title: '',
icon: null,
intent: undefined,
children: null,
},
[VegaTxStatus.Error]: {
title: t('Withdrawal transaction failed'),
icon: <Icon name="warning-sign" />,
intent: Intent.Danger,
children: (
<Step>
<p>{vegaTx.error}</p>
</Step>
),
},
[VegaTxStatus.Requested]: {
title: t('Confirm withdrawal'),
icon: <Icon name="hand-up" />,
intent: Intent.Warning,
children: <Step>Confirm withdrawal in Vega wallet</Step>,
},
[VegaTxStatus.Pending]: {
title: t('Withdrawal transaction pending'),
icon: <Loader size="small" />,
intent: Intent.None,
children: <Step>Awaiting transaction</Step>,
},
[VegaTxStatus.Complete]: {
title: t('Withdrawal transaction complete'),
icon: <Icon name="tick" />,
intent: Intent.Success,
children: <Step>Withdrawal created</Step>,
},
};
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: '',
icon: null,
intent: undefined,
children: null,
},
[EthTxStatus.Error]: {
title: t('Ethereum transaction failed'),
icon: <Icon name="warning-sign" />,
intent: Intent.Danger,
children: (
<Step>
{isEthereumError(ethTx.error)
? `Error: ${ethTx.error.reason}`
: ethTx.error instanceof Error
? t(`Error: ${ethTx.error.message}`)
: t('Something went wrong')}
</Step>
),
},
[EthTxStatus.Requested]: {
title: t('Confirm transaction'),
icon: <Icon name="hand-up" />,
intent: Intent.Warning,
children: <Step>{t('Confirm transaction in wallet')}</Step>,
},
[EthTxStatus.Pending]: {
title: t('Ethereum transaction pending'),
icon: <Loader size="small" />,
intent: Intent.None,
children: (
<Step>
<span>
{t(
`Awaiting Ethereum transaction ${ethTx.confirmations}/1 confirmations...`
)}
</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

@ -0,0 +1,90 @@
import compact from 'lodash/compact';
import { gql, useQuery } from '@apollo/client';
import { t } from '@vegaprotocol/react-helpers';
import { useMemo } from 'react';
import type { WithdrawalArgs } from './use-create-withdraw';
import { WithdrawManager } from './withdraw-manager';
import type { WithdrawFormQuery } from './__generated__/WithdrawFormQuery';
export const ASSET_FRAGMENT = gql`
fragment AssetFields on Asset {
id
symbol
name
decimals
source {
... on ERC20 {
contractAddress
}
}
}
`;
const WITHDRAW_FORM_QUERY = gql`
${ASSET_FRAGMENT}
query WithdrawFormQuery($partyId: ID!) {
party(id: $partyId) {
id
withdrawals {
id
txHash
}
accounts {
type
balance
asset {
id
symbol
}
}
}
assetsConnection {
edges {
node {
...AssetFields
}
}
}
}
`;
interface WithdrawFormContainerProps {
partyId?: string;
submit: (args: WithdrawalArgs) => void;
}
export const WithdrawFormContainer = ({
partyId,
submit,
}: WithdrawFormContainerProps) => {
const { data, loading, error } = useQuery<WithdrawFormQuery>(
WITHDRAW_FORM_QUERY,
{
variables: { partyId },
}
);
const assets = useMemo(() => {
if (!data?.assetsConnection.edges) {
return [];
}
return compact(data.assetsConnection.edges).map((e) => e.node);
}, [data]);
if (loading || !data) {
return <div>{t('Loading...')}</div>;
}
if (error) {
return <div>{t('Something went wrong')}</div>;
}
return (
<WithdrawManager
assets={assets}
accounts={data.party?.accounts || []}
submit={submit}
/>
);
};

View File

@ -26,13 +26,9 @@ beforeEach(() => {
props = { props = {
assets, assets,
min: new BigNumber(0.00001), min: new BigNumber(0.00001),
max: { balance: new BigNumber(100),
balance: new BigNumber(100), threshold: new BigNumber(200),
threshold: new BigNumber(200), delay: 100,
},
limits: {
max: new BigNumber(200),
},
selectedAsset: undefined, selectedAsset: undefined,
onSelectAsset: jest.fn(), onSelectAsset: jest.fn(),
submitWithdraw: jest.fn().mockReturnValue(Promise.resolve()), submitWithdraw: jest.fn().mockReturnValue(Promise.resolve()),
@ -112,6 +108,7 @@ describe('Withdrawal form', () => {
asset: props.assets[0].id, asset: props.assets[0].id,
amount: '4000000', amount: '4000000',
receiverAddress: MOCK_ETH_ADDRESS, receiverAddress: MOCK_ETH_ADDRESS,
availableTimestamp: null,
}); });
}); });
}); });
@ -123,7 +120,7 @@ describe('Withdrawal form', () => {
fireEvent.click(screen.getByText('Use maximum')); fireEvent.click(screen.getByText('Use maximum'));
expect(screen.getByLabelText('Amount')).toHaveValue( expect(screen.getByLabelText('Amount')).toHaveValue(
Number(props.max.balance.toFixed(asset.decimals)) Number(props.balance.toFixed(asset.decimals))
); );
}); });
}); });

View File

@ -5,7 +5,6 @@ import {
t, t,
removeDecimal, removeDecimal,
required, required,
maxSafe,
isAssetTypeERC20, isAssetTypeERC20,
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import { import {
@ -15,12 +14,11 @@ import {
InputError, InputError,
Select, Select,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { Web3WalletInput } from '@vegaprotocol/web3';
import { useWeb3React } from '@web3-react/core'; import { useWeb3React } from '@web3-react/core';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller, useWatch } from 'react-hook-form';
import type { WithdrawalFields } from './use-withdraw'; import type { WithdrawalArgs } from './use-create-withdraw';
import { WithdrawLimits } from './withdraw-limits'; import { WithdrawLimits } from './withdraw-limits';
interface FormFields { interface FormFields {
@ -31,25 +29,22 @@ interface FormFields {
export interface WithdrawFormProps { export interface WithdrawFormProps {
assets: Asset[]; assets: Asset[];
max: {
balance: BigNumber;
threshold: BigNumber;
};
min: BigNumber; min: BigNumber;
balance: BigNumber;
selectedAsset?: Asset; selectedAsset?: Asset;
limits: { threshold: BigNumber;
max: BigNumber; delay: number | undefined;
} | null;
onSelectAsset: (assetId: string) => void; onSelectAsset: (assetId: string) => void;
submitWithdraw: (withdrawal: WithdrawalFields) => void; submitWithdraw: (withdrawal: WithdrawalArgs) => void;
} }
export const WithdrawForm = ({ export const WithdrawForm = ({
assets, assets,
max, balance,
min, min,
selectedAsset, selectedAsset,
limits, threshold,
delay,
onSelectAsset, onSelectAsset,
submitWithdraw, submitWithdraw,
}: WithdrawFormProps) => { }: WithdrawFormProps) => {
@ -67,6 +62,9 @@ export const WithdrawForm = ({
to: address, to: address,
}, },
}); });
const amount = useWatch({ name: 'amount', control });
const onSubmit = async (fields: FormFields) => { const onSubmit = async (fields: FormFields) => {
if (!selectedAsset) { if (!selectedAsset) {
throw new Error('Asset not selected'); throw new Error('Asset not selected');
@ -75,103 +73,117 @@ export const WithdrawForm = ({
asset: selectedAsset.id, asset: selectedAsset.id,
amount: removeDecimal(fields.amount, selectedAsset.decimals), amount: removeDecimal(fields.amount, selectedAsset.decimals),
receiverAddress: fields.to, receiverAddress: fields.to,
availableTimestamp:
new BigNumber(fields.amount).isGreaterThan(threshold) && delay
? Date.now() + delay * 1000
: null,
}); });
}; };
return ( return (
<form <>
onSubmit={handleSubmit(onSubmit)} <div className="mb-4 text-sm">
noValidate={true} <p>{t('There are two steps required to make a withdrawal')}</p>
data-testid="withdraw-form" <ol className="list-disc pl-4">
> <li>{t('Step 1 - Release funds from Vega')}</li>
<FormGroup label={t('Asset')} labelFor="asset"> <li>{t('Step 2 - Transfer funds to your Ethereum wallet')}</li>
<Controller </ol>
control={control} </div>
name="asset" <form
rules={{ validate: { required } }} onSubmit={handleSubmit(onSubmit)}
render={({ field }) => ( noValidate={true}
<Select data-testid="withdraw-form"
{...field} >
onChange={(e) => { <FormGroup label={t('Asset')} labelFor="asset">
onSelectAsset(e.target.value); <Controller
field.onChange(e.target.value); control={control}
}} name="asset"
value={selectedAsset?.id || ''} rules={{ validate: { required } }}
id="asset" render={({ field }) => (
> <Select
<option value="">{t('Please select')}</option> {...field}
{assets.filter(isAssetTypeERC20).map((a) => ( onChange={(e) => {
<option key={a.id} value={a.id}> onSelectAsset(e.target.value);
{a.name} field.onChange(e.target.value);
</option> }}
))} value={selectedAsset?.id || ''}
</Select> id="asset"
name="asset"
>
<option value="">{t('Please select')}</option>
{assets.filter(isAssetTypeERC20).map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</Select>
)}
/>
{errors.asset?.message && (
<InputError intent="danger">{errors.asset.message}</InputError>
)} )}
/> </FormGroup>
{errors.asset?.message && ( <FormGroup
<InputError intent="danger">{errors.asset.message}</InputError> label={t('To (Ethereum address)')}
labelFor="ethereum-address"
>
<Input
id="ethereum-address"
{...register('to', { validate: { required, ethereumAddress } })}
/>
{errors.to?.message && (
<InputError intent="danger">{errors.to.message}</InputError>
)}
</FormGroup>
{selectedAsset && threshold && (
<div className="mb-4">
<WithdrawLimits
amount={amount}
threshold={threshold}
delay={delay}
balance={balance}
/>
</div>
)} )}
</FormGroup> <FormGroup label={t('Amount')} labelFor="amount">
<FormGroup label={t('To (Ethereum address)')} labelFor="ethereum-address"> <Input
<Web3WalletInput type="number"
inputProps={{ autoComplete="off"
id: 'ethereum-address', id="amount"
...register('to', { validate: { required, ethereumAddress } }), {...register('amount', {
}} validate: {
/> required,
{errors.to?.message && ( maxSafe: (v) => {
<InputError intent="danger">{errors.to.message}</InputError> const value = new BigNumber(v);
)} if (value.isGreaterThan(balance)) {
</FormGroup> return t('Insufficient amount in account');
{selectedAsset && limits && ( }
<div className="mb-6"> return true;
<WithdrawLimits limits={limits} balance={max.balance} /> },
</div> minSafe: (value) => minSafe(min)(value),
)}
<FormGroup label={t('Amount')} labelFor="amount">
<Input
type="number"
autoComplete="off"
id="amount"
{...register('amount', {
validate: {
required,
maxSafe: (v) => {
const value = new BigNumber(v);
if (value.isGreaterThan(max.balance)) {
return t('Insufficient amount in account');
} else if (value.isGreaterThan(max.threshold)) {
return t('Amount is above temporary withdrawal limit');
}
return maxSafe(BigNumber.minimum(max.balance, max.threshold))(
v
);
}, },
minSafe: (value) => minSafe(min)(value), })}
}, />
})} {errors.amount?.message && (
/> <InputError intent="danger">{errors.amount.message}</InputError>
{errors.amount?.message && ( )}
<InputError intent="danger" className="mt-4"> {selectedAsset && (
{errors.amount.message} <UseButton
</InputError> data-testid="use-maximum"
)} onClick={() => {
{selectedAsset && ( setValue('amount', balance.toFixed(selectedAsset.decimals));
<UseButton clearErrors('amount');
data-testid="use-maximum" }}
onClick={() => { >
setValue('amount', max.balance.toFixed(selectedAsset.decimals)); {t('Use maximum')}
clearErrors('amount'); </UseButton>
}} )}
> </FormGroup>
{t('Use maximum')} <Button data-testid="submit-withdrawal" type="submit" variant="primary">
</UseButton> Release funds
)} </Button>
</FormGroup> </form>
<Button variant="primary" data-testid="submit-withdrawal" type="submit"> </>
Submit
</Button>
</form>
); );
}; };

View File

@ -1,24 +1,35 @@
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import type BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import { formatDistanceToNow } from 'date-fns';
interface WithdrawLimitsProps { interface WithdrawLimitsProps {
limits: { amount: string;
max: BigNumber; threshold: BigNumber;
};
balance: BigNumber; balance: BigNumber;
delay: number | undefined;
} }
export const WithdrawLimits = ({ limits, balance }: WithdrawLimitsProps) => { export const WithdrawLimits = ({
let maxLimit = ''; amount,
threshold,
balance,
delay,
}: WithdrawLimitsProps) => {
let text = '';
if (limits.max.isEqualTo(Infinity)) { if (threshold.isEqualTo(Infinity)) {
maxLimit = t('No limit'); text = t('No limit');
} else if (limits.max.isGreaterThan(1_000_000)) { } else if (threshold.isGreaterThan(1_000_000)) {
maxLimit = t('1m+'); text = t('1m+');
} else { } else {
maxLimit = limits.max.toString(); text = threshold.toString();
} }
const delayTime =
new BigNumber(amount).isGreaterThan(threshold) && delay
? formatDistanceToNow(Date.now() + delay * 1000)
: t('None');
return ( return (
<table className="w-full text-sm"> <table className="w-full text-sm">
<tbody> <tbody>
@ -27,8 +38,14 @@ export const WithdrawLimits = ({ limits, balance }: WithdrawLimitsProps) => {
<td className="text-right">{balance.toString()}</td> <td className="text-right">{balance.toString()}</td>
</tr> </tr>
<tr> <tr>
<th className="text-left font-normal">{t('Maximum withdrawal')}</th> <th className="text-left font-normal">
<td className="text-right">{maxLimit}</td> {t('Delayed withdrawal threshold')}
</th>
<td className="text-right">{text}</td>
</tr>
<tr>
<th className="text-left font-normal">{t('Delay time')}</th>
<td className="text-right">{delayTime}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -1,14 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { generateAccount, generateAsset } from './test-helpers'; import { generateAccount, generateAsset } from './test-helpers';
import type { WithdrawManagerProps } from './withdraw-manager'; import type { WithdrawManagerProps } from './withdraw-manager';
import { WithdrawManager } from './withdraw-manager'; import { WithdrawManager } from './withdraw-manager';
import * as withdrawHook from './use-withdraw';
import { initialState as vegaTxInitialState } from '@vegaprotocol/wallet';
import {
EthereumError,
EthTxStatus,
initialState as ethTxInitialState,
} from '@vegaprotocol/web3';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
const ethereumAddress = '0x72c22822A19D20DE7e426fB84aa047399Ddd8853'; const ethereumAddress = '0x72c22822A19D20DE7e426fB84aa047399Ddd8853';
@ -17,72 +11,46 @@ jest.mock('@web3-react/core', () => ({
useWeb3React: () => ({ account: ethereumAddress }), useWeb3React: () => ({ account: ethereumAddress }),
})); }));
jest.mock('./use-get-withdraw-limits', () => ({ jest.mock('./use-get-withdraw-threshold', () => ({
useGetWithdrawLimits: () => { useGetWithdrawThreshold: () => {
return { max: new BigNumber(1000000) }; return () => Promise.resolve(new BigNumber(100));
},
}));
jest.mock('./use-get-withdraw-delay', () => ({
useGetWithdrawDelay: () => {
return () => Promise.resolve(10000);
}, },
})); }));
let props: WithdrawManagerProps; let props: WithdrawManagerProps;
let useWithdrawValue: ReturnType<typeof withdrawHook.useWithdraw>;
let useWithdraw: jest.SpyInstance;
let mockSubmit: jest.Mock;
let mockReset: jest.Mock;
beforeEach(() => { beforeEach(() => {
props = { props = {
assets: [generateAsset()], assets: [generateAsset()],
accounts: [generateAccount()], accounts: [generateAccount()],
initialAssetId: undefined, submit: jest.fn(),
}; };
mockSubmit = jest.fn();
mockReset = jest.fn();
useWithdrawValue = {
ethTx: ethTxInitialState,
vegaTx: vegaTxInitialState,
approval: null,
withdrawalId: null,
submit: mockSubmit,
reset: mockReset,
};
useWithdraw = jest
.spyOn(withdrawHook, 'useWithdraw')
.mockReturnValue(useWithdrawValue);
}); });
const generateJsx = (props: WithdrawManagerProps) => ( const generateJsx = (props: WithdrawManagerProps) => (
<WithdrawManager {...props} /> <WithdrawManager {...props} />
); );
it('Valid form submission opens transaction dialog', async () => { it('calls submit if valid form submission', async () => {
render(generateJsx(props)); render(generateJsx(props));
submitValid(); await act(async () => {
expect(await screen.findByRole('dialog')).toBeInTheDocument(); await submitValid();
expect(mockReset).toHaveBeenCalled(); });
expect(mockSubmit).toHaveBeenCalledWith({ expect(props.submit).toHaveBeenCalledWith({
amount: '1000', amount: '1000',
asset: props.assets[0].id, asset: props.assets[0].id,
receiverAddress: ethereumAddress, receiverAddress: ethereumAddress,
availableTimestamp: null,
}); });
}); });
it('Expected Ethereum error closes the dialog', async () => { it('validates correctly', async () => {
const { rerender } = render(generateJsx(props));
submitValid();
expect(await screen.findByRole('dialog')).toBeInTheDocument();
useWithdraw.mockReturnValue({
...useWithdrawValue,
ethTx: {
...useWithdrawValue.ethTx,
status: EthTxStatus.Error,
error: new EthereumError('User rejected transaction', 4001, 'reason'),
},
});
rerender(generateJsx(props));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('Correct min max values provided to form', async () => {
render(generateJsx(props)); render(generateJsx(props));
// Set other fields to be valid // Set other fields to be valid
@ -99,7 +67,7 @@ it('Correct min max values provided to form', async () => {
}); });
fireEvent.submit(screen.getByTestId('withdraw-form')); fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(await screen.findByText('Value is below minimum')).toBeInTheDocument(); expect(await screen.findByText('Value is below minimum')).toBeInTheDocument();
expect(mockSubmit).not.toBeCalled(); expect(props.submit).not.toBeCalled();
fireEvent.change(screen.getByLabelText('Amount'), { fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00001' }, target: { value: '0.00001' },
@ -113,19 +81,14 @@ it('Correct min max values provided to form', async () => {
expect( expect(
await screen.findByText('Insufficient amount in account') await screen.findByText('Insufficient amount in account')
).toBeInTheDocument(); ).toBeInTheDocument();
expect(mockSubmit).not.toBeCalled(); expect(props.submit).not.toBeCalled();
}); });
it('Initial asset id can preselect asset', async () => { const submitValid = async () => {
const asset = props.assets[0]; await userEvent.selectOptions(
render(generateJsx({ ...props, initialAssetId: asset.id })); screen.getByLabelText('Asset'),
expect(screen.getByLabelText('Asset')).toHaveValue(asset.id); props.assets[0].id
}); );
const submitValid = () => {
fireEvent.change(screen.getByLabelText('Asset'), {
target: { value: props.assets[0].id },
});
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), { fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress }, target: { value: ethereumAddress },
}); });

View File

@ -1,118 +1,78 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback } from 'react';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import { WithdrawForm } from './withdraw-form'; import { WithdrawForm } from './withdraw-form';
import type { WithdrawalFields } from './use-withdraw'; import type { WithdrawalArgs } from './use-create-withdraw';
import { useWithdraw } from './use-withdraw';
import { WithdrawDialog } from './withdraw-dialog';
import { isExpectedEthereumError, EthTxStatus } from '@vegaprotocol/web3';
import type { Asset } from '@vegaprotocol/react-helpers';
import { addDecimal } from '@vegaprotocol/react-helpers'; import { addDecimal } from '@vegaprotocol/react-helpers';
import { AccountType } from '@vegaprotocol/types'; import { AccountType } from '@vegaprotocol/types';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import type { Account } from './types'; import type { Account } from './types';
import { useGetWithdrawLimits } from './use-get-withdraw-limits'; import { useGetWithdrawThreshold } from './use-get-withdraw-threshold';
import { captureException } from '@sentry/react';
import { useGetWithdrawDelay } from './use-get-withdraw-delay';
import { useWithdrawStore } from './withdraw-store';
import type { AssetFields } from './__generated__/AssetFields';
export interface WithdrawManagerProps { export interface WithdrawManagerProps {
assets: Asset[]; assets: AssetFields[];
accounts: Account[]; accounts: Account[];
initialAssetId?: string; submit: (args: WithdrawalArgs) => void;
} }
export const WithdrawManager = ({ export const WithdrawManager = ({
assets, assets,
accounts, accounts,
initialAssetId, submit,
}: WithdrawManagerProps) => { }: WithdrawManagerProps) => {
const dialogDismissed = useRef(false); const { asset, balance, min, threshold, delay, update } = useWithdrawStore();
const [dialogOpen, setDialogOpen] = useState(false); const getThreshold = useGetWithdrawThreshold();
const [assetId, setAssetId] = useState<string | undefined>(initialAssetId); const getDelay = useGetWithdrawDelay();
const { ethTx, vegaTx, approval, submit, reset } = useWithdraw( // Everytime an asset is selected we need to find the corresponding
dialogDismissed.current // account, balance, min viable amount and delay threshold
); const handleSelectAsset = useCallback(
async (id: string) => {
const asset = assets.find((a) => a.id === id);
const account = accounts.find(
(a) =>
a.type === AccountType.ACCOUNT_TYPE_GENERAL &&
a.asset.id === asset?.id
);
const balance =
account && asset
? new BigNumber(addDecimal(account.balance, asset.decimals))
: new BigNumber(0);
const min = asset
? new BigNumber(addDecimal('1', asset.decimals))
: new BigNumber(0);
// Find the asset object from the select box // Query collateral bridge for threshold for selected asset
const asset = useMemo(() => { // and subsequent delay if withdrawal amount is larger than it
return assets?.find((a) => a.id === assetId); let threshold;
}, [assets, assetId]); let delay;
const account = useMemo(() => { try {
return accounts.find( const result = await Promise.all([getThreshold(asset), getDelay()]);
(a) => threshold = result[0];
a.type === AccountType.ACCOUNT_TYPE_GENERAL && a.asset.id === asset?.id delay = result[1];
); } catch (err) {
}, [asset, accounts]); captureException(err);
}
const limits = useGetWithdrawLimits(asset); update({ asset, balance, min, threshold, delay });
const max = useMemo(() => {
if (!asset) {
return {
balance: new BigNumber(0),
threshold: new BigNumber(0),
};
}
const balance = account
? new BigNumber(addDecimal(account.balance, asset.decimals))
: new BigNumber(0);
return {
balance,
threshold: limits ? limits.max : new BigNumber(Infinity),
};
}, [asset, account, limits]);
const min = useMemo(() => {
return asset
? new BigNumber(addDecimal('1', asset.decimals))
: new BigNumber(0);
}, [asset]);
const handleSubmit = useCallback(
(args: WithdrawalFields) => {
reset();
setDialogOpen(true);
submit(args);
dialogDismissed.current = false;
}, },
[submit, reset] [accounts, assets, update, getThreshold, getDelay]
); );
// Close dialog if error is due to user rejecting the tx
useEffect(() => {
if (
ethTx.status === EthTxStatus.Error &&
isExpectedEthereumError(ethTx.error)
) {
setDialogOpen(false);
}
}, [ethTx.status, ethTx.error]);
return ( return (
<> <WithdrawForm
<WithdrawForm selectedAsset={asset}
selectedAsset={asset} onSelectAsset={handleSelectAsset}
onSelectAsset={(id) => setAssetId(id)} assets={sortBy(assets, 'name')}
assets={sortBy(assets, 'name')} balance={balance}
max={max} min={min}
min={min} submitWithdraw={submit}
submitWithdraw={handleSubmit} threshold={threshold}
limits={limits} delay={delay}
/> />
<WithdrawDialog
vegaTx={vegaTx}
ethTx={ethTx}
approval={approval}
dialogOpen={dialogOpen}
onDialogChange={(isOpen) => {
setDialogOpen(isOpen);
if (!isOpen) {
dialogDismissed.current = true;
}
}}
/>
</>
); );
}; };

View File

@ -0,0 +1,24 @@
import type { Asset } from '@vegaprotocol/react-helpers';
import BigNumber from 'bignumber.js';
import type { SetState } from 'zustand';
import create from 'zustand';
interface WithdrawStore {
asset: Asset | undefined;
balance: BigNumber;
min: BigNumber;
threshold: BigNumber;
delay: number | undefined;
update: (state: Partial<WithdrawStore>) => void;
}
export const useWithdrawStore = create((set: SetState<WithdrawStore>) => ({
asset: undefined,
balance: new BigNumber(0),
min: new BigNumber(0),
threshold: new BigNumber(0),
delay: undefined,
update: (updatedState) => {
set(updatedState);
},
}));

View File

@ -0,0 +1,49 @@
import { t } from '@vegaprotocol/react-helpers';
import { Dialog } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useCompleteWithdraw } from './use-complete-withdraw';
import { useCreateWithdraw } from './use-create-withdraw';
import { WithdrawFormContainer } from './withdraw-form-container';
import { WithdrawalFeedback } from './withdrawal-feedback';
export const WithdrawalDialogs = ({
withdrawDialog,
setWithdrawDialog,
}: {
withdrawDialog: boolean;
setWithdrawDialog: (open: boolean) => void;
}) => {
const { keypair } = useVegaWallet();
const createWithdraw = useCreateWithdraw();
const completeWithdraw = useCompleteWithdraw();
return (
<>
<Dialog
title={t('Withdraw')}
open={withdrawDialog}
onChange={(isOpen) => setWithdrawDialog(isOpen)}
size="small"
>
<WithdrawFormContainer
partyId={keypair?.pub}
submit={(args) => {
setWithdrawDialog(false);
createWithdraw.submit(args);
}}
/>
</Dialog>
<createWithdraw.Dialog>
<WithdrawalFeedback
withdrawal={createWithdraw.withdrawal}
transaction={createWithdraw.transaction}
availableTimestamp={createWithdraw.availableTimestamp}
submitWithdraw={(id) => {
createWithdraw.reset();
completeWithdraw.submit(id);
}}
/>
</createWithdraw.Dialog>
<completeWithdraw.Dialog />
</>
);
};

View File

@ -0,0 +1,96 @@
import { useEnvironment } from '@vegaprotocol/environment';
import {
addDecimalsFormatNumber,
t,
truncateByChars,
} from '@vegaprotocol/react-helpers';
import {
Button,
KeyValueTable,
KeyValueTableRow,
} from '@vegaprotocol/ui-toolkit';
import type { VegaTxState } from '@vegaprotocol/wallet';
import { formatDistanceToNow } from 'date-fns';
import type { WithdrawalFields } from './__generated__/WithdrawalFields';
export const WithdrawalFeedback = ({
transaction,
withdrawal,
submitWithdraw,
availableTimestamp,
}: {
transaction: VegaTxState;
withdrawal: WithdrawalFields | null;
submitWithdraw: (withdrawalId: string) => void;
availableTimestamp: number | null;
}) => {
const { VEGA_EXPLORER_URL } = useEnvironment();
const isAvailable =
availableTimestamp === null || Date.now() > availableTimestamp;
return (
<div>
<p className="mb-2">
{t('Your funds have been unlocked for withdrawal')} -{' '}
<a
className="underline"
data-testid="tx-block-explorer"
href={`${VEGA_EXPLORER_URL}/txs/0x${transaction.txHash}`}
target="_blank"
rel="noreferrer"
>
{t('View in block explorer')}
</a>
</p>
{withdrawal && (
<KeyValueTable>
<KeyValueTableRow>
<span>{t('Asset')}</span>
<span>{withdrawal.asset.symbol}</span>
</KeyValueTableRow>
<KeyValueTableRow>
<span>{t('Amount')}</span>
<span>
{addDecimalsFormatNumber(
withdrawal.amount,
withdrawal.asset.decimals
)}
</span>
</KeyValueTableRow>
{withdrawal.details && (
<KeyValueTableRow>
<span>{t('Recipient')}</span>
<a
target="_blank"
href={`${VEGA_EXPLORER_URL}/address/${withdrawal.details.receiverAddress}`}
rel="noreferrer"
className="underline"
>
{truncateByChars(withdrawal.details.receiverAddress)}
</a>
</KeyValueTableRow>
)}
</KeyValueTable>
)}
{isAvailable ? (
<Button
disabled={withdrawal === null ? true : false}
onClick={() => {
if (withdrawal) {
submitWithdraw(withdrawal.id);
}
}}
>
{t('Withdraw funds')}
</Button>
) : (
<p className="text-danger">
{t(
`Available to withdraw in ${formatDistanceToNow(
availableTimestamp
)}`
)}
</p>
)}
</div>
);
};

View File

@ -4,7 +4,7 @@ import {
addDecimalsFormatNumber, addDecimalsFormatNumber,
getDateTimeFormat, getDateTimeFormat,
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import { WithdrawalStatus } from '@vegaprotocol/types'; import { WithdrawalStatus, WithdrawalStatusMapping } from '@vegaprotocol/types';
import { generateWithdrawal } from './test-helpers'; import { generateWithdrawal } from './test-helpers';
import type { import type {
StatusCellProps, StatusCellProps,
@ -12,7 +12,7 @@ import type {
} from './withdrawals-table'; } from './withdrawals-table';
import { StatusCell } from './withdrawals-table'; import { StatusCell } from './withdrawals-table';
import { WithdrawalsTable } from './withdrawals-table'; import { WithdrawalsTable } from './withdrawals-table';
import type { Withdrawals_party_withdrawals } from './__generated__/Withdrawals'; import type { Withdrawals_party_withdrawalsConnection_edges_node } from './__generated__/Withdrawals';
jest.mock('@web3-react/core', () => ({ jest.mock('@web3-react/core', () => ({
useWeb3React: () => ({ provider: undefined }), useWeb3React: () => ({ provider: undefined }),
@ -24,38 +24,66 @@ const generateJsx = (props: WithdrawalsTableProps) => (
</MockedProvider> </MockedProvider>
); );
it('Renders the correct columns', async () => { describe('renders the correct columns', () => {
const withdrawal = generateWithdrawal(); it('incomplete withdrawal', async () => {
await act(async () => { const withdrawal = generateWithdrawal();
render(generateJsx({ withdrawals: [withdrawal] })); await act(async () => {
render(generateJsx({ withdrawals: [withdrawal] }));
});
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(6);
expect(headers.map((h) => h.textContent?.trim())).toEqual([
'Asset',
'Amount',
'Recipient',
'Created at',
'TX hash',
'Status',
]);
const cells = screen.getAllByRole('gridcell');
const expectedValues = [
'asset-symbol',
addDecimalsFormatNumber(withdrawal.amount, withdrawal.asset.decimals),
'123456\u2026123456',
getDateTimeFormat().format(new Date(withdrawal.createdTimestamp)),
'-',
WithdrawalStatusMapping[withdrawal.status],
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
}); });
const headers = screen.getAllByRole('columnheader'); it('completed withdrawal', async () => {
expect(headers).toHaveLength(5); const withdrawal = generateWithdrawal({
expect(headers.map((h) => h.textContent?.trim())).toEqual([ txHash: '0x1234567891011121314',
'Asset', status: WithdrawalStatus.STATUS_FINALIZED,
'Amount', });
'Recipient',
'Created at',
'Status',
]);
const cells = screen.getAllByRole('gridcell'); await act(async () => {
const expectedValues = [ render(generateJsx({ withdrawals: [withdrawal] }));
'asset-symbol', });
addDecimalsFormatNumber(withdrawal.amount, withdrawal.asset.decimals),
'123456\u2026123456', const cells = screen.getAllByRole('gridcell');
getDateTimeFormat().format(new Date(withdrawal.createdTimestamp)), const expectedValues = [
withdrawal.status, 'asset-symbol',
]; addDecimalsFormatNumber(withdrawal.amount, withdrawal.asset.decimals),
cells.forEach((cell, i) => { '123456…123456',
expect(cell).toHaveTextContent(expectedValues[i]); getDateTimeFormat().format(new Date(withdrawal.createdTimestamp)),
'0x1234…121314',
WithdrawalStatusMapping[withdrawal.status],
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
}); });
}); });
describe('StatusCell', () => { describe('StatusCell', () => {
let props: StatusCellProps; let props: StatusCellProps;
let withdrawal: Withdrawals_party_withdrawals; let withdrawal: Withdrawals_party_withdrawalsConnection_edges_node;
let mockComplete: jest.Mock; let mockComplete: jest.Mock;
beforeEach(() => { beforeEach(() => {
@ -76,9 +104,7 @@ describe('StatusCell', () => {
render(<StatusCell {...props} />); render(<StatusCell {...props} />);
expect(screen.getByText('Open')).toBeInTheDocument(); expect(screen.getByText('Open')).toBeInTheDocument();
fireEvent.click( fireEvent.click(screen.getByText('Complete', { selector: 'button' }));
screen.getByText('Click to complete', { selector: 'button' })
);
expect(mockComplete).toHaveBeenCalled(); expect(mockComplete).toHaveBeenCalled();
}); });
@ -102,18 +128,5 @@ describe('StatusCell', () => {
render(<StatusCell {...props} />); render(<StatusCell {...props} />);
expect(screen.getByText('Finalized')).toBeInTheDocument(); expect(screen.getByText('Finalized')).toBeInTheDocument();
expect(screen.getByText('View on Etherscan')).toHaveAttribute(
'href',
expect.stringContaining(props.data.txHash)
);
});
it('Fallback', () => {
props.value = WithdrawalStatus.STATUS_REJECTED;
props.data.pendingOnForeignChain = false;
props.data.txHash = '0x123';
render(<StatusCell {...props} />);
expect(screen.getByText('STATUS_REJECTED')).toBeInTheDocument();
}); });
}); });

View File

@ -1,7 +1,3 @@
import type {
ICellRendererParams,
ValueFormatterParams,
} from 'ag-grid-community';
import { AgGridColumn } from 'ag-grid-react'; import { AgGridColumn } from 'ag-grid-react';
import { import {
getDateTimeFormat, getDateTimeFormat,
@ -9,19 +5,40 @@ import {
truncateByChars, truncateByChars,
addDecimalsFormatNumber, addDecimalsFormatNumber,
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import { WithdrawalStatus } from '@vegaprotocol/types'; import type {
import { Link, AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit'; VegaICellRendererParams,
VegaValueFormatterParams,
} from '@vegaprotocol/ui-toolkit';
import {
Dialog,
Link,
AgGridDynamic as AgGrid,
Intent,
Loader,
Icon,
} from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment'; import { useEnvironment } from '@vegaprotocol/environment';
import { useCompleteWithdraw } from './use-complete-withdraw'; import { useCompleteWithdraw } from './use-complete-withdraw';
import type { Withdrawals_party_withdrawals } from './__generated__/Withdrawals'; import type { WithdrawalFields } from './__generated__/WithdrawalFields';
import type { VerifyState } from './use-verify-withdrawal';
import { ApprovalStatus, useVerifyWithdrawal } from './use-verify-withdrawal';
export interface WithdrawalsTableProps { export interface WithdrawalsTableProps {
withdrawals: Withdrawals_party_withdrawals[]; withdrawals: WithdrawalFields[];
} }
export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => { export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => {
const { ETHERSCAN_URL } = useEnvironment(); const { ETHERSCAN_URL } = useEnvironment();
const { submit, Dialog } = useCompleteWithdraw(); const {
submit,
reset: resetTx,
Dialog: EthereumTransactionDialog,
} = useCompleteWithdraw();
const {
verify,
state: verifyState,
reset: resetVerification,
} = useVerifyWithdrawal();
return ( return (
<> <>
@ -35,51 +52,105 @@ export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => {
> >
<AgGridColumn headerName="Asset" field="asset.symbol" /> <AgGridColumn headerName="Asset" field="asset.symbol" />
<AgGridColumn <AgGridColumn
headerName="Amount" headerName={t('Amount')}
field="amount" field="amount"
valueFormatter={({ value, data }: ValueFormatterParams) => { valueFormatter={({
value,
data,
}: VegaValueFormatterParams<WithdrawalFields, 'amount'>) => {
return addDecimalsFormatNumber(value, data.asset.decimals); return addDecimalsFormatNumber(value, data.asset.decimals);
}} }}
/> />
<AgGridColumn <AgGridColumn
headerName="Recipient" headerName={t('Recipient')}
field="details.receiverAddress" field="details.receiverAddress"
cellRenderer="RecipientCell" cellRenderer="RecipientCell"
cellRendererParams={{ ethUrl: ETHERSCAN_URL }} cellRendererParams={{ ethUrl: ETHERSCAN_URL }}
valueFormatter={({ value }: ValueFormatterParams) => { valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFields,
'details.receiverAddress'
>) => {
if (!value) return '-';
return truncateByChars(value); return truncateByChars(value);
}} }}
/> />
<AgGridColumn <AgGridColumn
headerName="Created at" headerName={t('Created at')}
field="createdTimestamp" field="createdTimestamp"
valueFormatter={({ value }: ValueFormatterParams) => { valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFields,
'createdTimestamp'
>) => {
return getDateTimeFormat().format(new Date(value)); return getDateTimeFormat().format(new Date(value));
}} }}
/> />
<AgGridColumn <AgGridColumn
headerName="Status" headerName={t('TX hash')}
field="txHash"
cellRenderer={({
value,
}: VegaValueFormatterParams<WithdrawalFields, 'txHash'>) => {
if (!value) return '-';
return (
<Link
title={t('View transaction on Etherscan')}
href={`${ETHERSCAN_URL}/tx/${value}`}
data-testid="etherscan-link"
target="_blank"
>
{truncateByChars(value)}
</Link>
);
}}
/>
<AgGridColumn
headerName={t('Status')}
field="status" field="status"
cellRenderer="StatusCell" cellRenderer="StatusCell"
cellRendererParams={{ complete: submit, ethUrl: ETHERSCAN_URL }} cellRendererParams={{
complete: async (withdrawal: WithdrawalFields) => {
const verified = await verify(withdrawal);
if (!verified) {
return;
}
submit(withdrawal.id);
},
ethUrl: ETHERSCAN_URL,
}}
/> />
</AgGrid> </AgGrid>
<Dialog /> <Dialog
title={t('Withdrawal verification')}
onChange={(isOpen) => {
if (!isOpen) {
resetTx();
resetVerification();
}
}}
open={verifyState.dialogOpen}
size="small"
{...getVerifyDialogProps(verifyState.status)}
>
<VerificationStatus state={verifyState} />
</Dialog>
<EthereumTransactionDialog />
</> </>
); );
}; };
export interface StatusCellProps extends ICellRendererParams { export interface StatusCellProps
extends VegaICellRendererParams<WithdrawalFields, 'status'> {
ethUrl: string; ethUrl: string;
complete: (withdrawalId: string) => void; complete: (withdrawal: WithdrawalFields) => void;
} }
export const StatusCell = ({ export const StatusCell = ({ ethUrl, data, complete }: StatusCellProps) => {
ethUrl,
value,
data,
complete,
}: StatusCellProps) => {
if (data.pendingOnForeignChain) { if (data.pendingOnForeignChain) {
return ( return (
<div className="flex justify-between gap-8"> <div className="flex justify-between gap-8">
@ -98,37 +169,22 @@ export const StatusCell = ({
); );
} }
if (value === WithdrawalStatus.STATUS_FINALIZED) { if (!data.txHash) {
return ( return (
<div className="flex justify-between gap-8"> <div className="flex justify-between gap-8">
{data.txHash ? ( {t('Open')}
<> <button className="underline" onClick={() => complete(data)}>
{t('Finalized')} {t('Complete')}
<Link </button>
title={t('View transaction on Etherscan')}
href={`${ethUrl}/tx/${data.txHash}`}
data-testid="etherscan-link"
target="_blank"
>
{t('View on Etherscan')}
</Link>
</>
) : (
<>
{t('Open')}
<button className="underline" onClick={() => complete(data.id)}>
{t('Click to complete')}
</button>
</>
)}
</div> </div>
); );
} }
return value; return <span>{t('Finalized')}</span>;
}; };
export interface RecipientCellProps extends ICellRendererParams { export interface RecipientCellProps
extends VegaICellRendererParams<WithdrawalFields, 'details.receiverAddress'> {
ethUrl: string; ethUrl: string;
} }
@ -148,3 +204,48 @@ const RecipientCell = ({
</Link> </Link>
); );
}; };
const getVerifyDialogProps = (status: ApprovalStatus) => {
if (status === ApprovalStatus.Error) {
return {
intent: Intent.Danger,
icon: <Icon name="warning-sign" />,
};
}
if (status === ApprovalStatus.Pending) {
return { intent: Intent.None, icon: <Loader size="small" /> };
}
if (status === ApprovalStatus.Delayed) {
return { intent: Intent.Warning, icon: <Icon name="time" /> };
}
return { intent: Intent.None };
};
const VerificationStatus = ({ state }: { state: VerifyState }) => {
if (state.status === ApprovalStatus.Error) {
return <p>{t('Something went wrong')}</p>;
}
if (state.status === ApprovalStatus.Pending) {
return <p>{t('Verifying...')}</p>;
}
if (state.status === ApprovalStatus.Delayed && state.completeTimestamp) {
const formattedTime = getDateTimeFormat().format(
new Date(state.completeTimestamp)
);
return (
<>
<p className="mb-2">
{t("The amount you're withdrawing has triggered a time delay")}
</p>
<p>{t(`Cannot be completed until ${formattedTime}`)}</p>
</>
);
}
return null;
};

View File

@ -15,7 +15,6 @@
"**/*.test.jsx", "**/*.test.jsx",
"**/*.spec.jsx", "**/*.spec.jsx",
"**/*.d.ts", "**/*.d.ts",
"src/lib/withdraw-manager.foo.tsx",
"jest.config.ts" "jest.config.ts"
] ]
} }