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

View File

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

View File

@ -1,18 +1,17 @@
const connectToVegaBtn = '[data-testid="connect-to-vega-wallet-btn"]';
const warning = '[data-testid="callout"]';
context('Withdraw Page - verify elements on page', function () {
before('navigate to withdraw page', function () {
cy.visit('/').navigate_to('withdraw');
before('navigate to withdrawals page', function () {
cy.visit('/').navigate_to('withdrawals');
});
describe('with wallets disconnected', 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 () {
cy.verify_page_header('Withdraw');
cy.verify_page_header('Withdrawals');
});
it('should have connect Vega wallet button', function () {
@ -20,9 +19,5 @@ context('Withdraw Page - verify elements on page', function () {
.should('be.visible')
.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"]',
staking: '[href="/staking"]',
rewards: '[href="/rewards"]',
withdraw: '[href="/withdraw"]',
withdrawals: '[href="/withdrawals"]',
governance: '[href="/governance"]',
pageSpinner: '[data-testid="splash-loader"]',
};

View File

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

View File

@ -452,7 +452,7 @@
"withdrawFormAmountLabel": "How much would you like to withdraw?",
"withdrawFormSubmitButtonIdle": "Withdraw {{amount}} {{symbol}} tokens",
"withdrawFormSubmitButtonPending": "Preparing",
"withdrawalsTitle": "Incomplete withdrawals",
"withdrawalsTitle": "Withdrawals",
"withdrawalsText": "These withdrawals need to be completed with an Ethereum transaction.",
"withdrawalsNone": "You don't have any pending withdrawals.",
"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(
() =>
import(
@ -201,11 +194,6 @@ const routerConfig = [
name: 'Rewards',
component: LazyRewards,
},
{
path: Routes.WITHDRAW,
name: 'Withdraw',
component: LazyWithdraw,
},
{
path: Routes.WITHDRAWALS,
name: 'Withdrawals',

View File

@ -4,7 +4,6 @@ export default {
CLAIM: '/claim',
STAKING: '/staking',
REWARDS: '/rewards',
WITHDRAW: '/withdraw',
WITHDRAWALS: '/withdrawals',
GOVERNANCE: '/governance',
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 { format } from 'date-fns';
import orderBy from 'lodash/orderBy';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment';
import { Heading } from '../../components/heading';
import { KeyValueTable, KeyValueTableRow } from '@vegaprotocol/ui-toolkit';
import { SplashLoader } from '../../components/splash-loader';
import { VegaWalletContainer } from '../../components/vega-wallet-container';
import { BigNumber } from '../../lib/bignumber';
import { DATE_FORMAT_DETAILED } from '../../lib/date-formats';
import { addDecimal } from '../../lib/decimals';
import { truncateMiddle } from '../../lib/truncate-middle';
import type { Withdrawals_party_withdrawals } from '@vegaprotocol/withdraws';
import { useCompleteWithdraw, useWithdrawals } from '@vegaprotocol/withdraws';
import { WithdrawalStatus } from '@vegaprotocol/types';
import {
useWithdrawals,
WithdrawalDialogs,
WithdrawalsTable,
} from '@vegaprotocol/withdraws';
import { useState } from 'react';
const Withdrawals = () => {
const { t } = useTranslation();
@ -32,19 +25,9 @@ const Withdrawals = () => {
};
const WithdrawPendingContainer = () => {
const [withdrawDialog, setWithdrawDialog] = useState(false);
const { t } = useTranslation();
const { submit, Dialog } = useCompleteWithdraw();
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]);
const { withdrawals, loading, error } = useWithdrawals();
if (error) {
return (
@ -55,7 +38,7 @@ const WithdrawPendingContainer = () => {
);
}
if (loading || !data) {
if (loading) {
return (
<Splash>
<SplashLoader />
@ -63,122 +46,23 @@ const WithdrawPendingContainer = () => {
);
}
if (!withdrawals.length) {
return <p>{t('withdrawalsNone')}</p>;
}
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 className="mb-8">{t('withdrawalsPreparedWarningText')}</p>
<ul role="list">
{withdrawals.map((w) => (
<li key={w.id} className="mb-10 last:mb-0">
<Withdrawal withdrawal={w} complete={submit} />
</li>
))}
</ul>
<Dialog />
<div className="w-full h-[500px]">
<WithdrawalsTable withdrawals={withdrawals} />
</div>
<WithdrawalDialogs
withdrawDialog={withdrawDialog}
setWithdrawDialog={setWithdrawDialog}
/>
</>
);
};
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;

View File

@ -1,7 +1,8 @@
import { aliasQuery } from '@vegaprotocol/cypress';
import { connectEthereumWallet } from '../support/ethereum-wallet';
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';
describe('withdraw', () => {
@ -16,10 +17,13 @@ describe('withdraw', () => {
beforeEach(() => {
cy.mockWeb3Provider();
cy.mockGQL((req) => {
aliasQuery(req, 'WithdrawPageQuery', generateWithdrawPageQuery());
aliasQuery(req, 'Withdrawals', generateWithdrawals());
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
connectVegaWallet();
@ -27,15 +31,14 @@ describe('withdraw', () => {
// It also requires connection Ethereum wallet
connectEthereumWallet();
cy.wait('@WithdrawPageQuery');
cy.contains('Withdraw');
cy.mockGQL((req) => {
aliasQuery(req, 'WithdrawFormQuery', generateWithdrawFormQuery());
});
cy.getByTestId('withdraw-dialog-button').click();
cy.wait('@WithdrawFormQuery');
});
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(formFieldError).should('contain.text', 'Required');
@ -82,9 +85,8 @@ describe('withdraw', () => {
cy.getByTestId(submitWithdrawBtn).click();
cy.getByTestId('dialog-title').should(
'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

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 type { PartialDeep } from 'type-fest';
export const generateWithdrawPageQuery = (
export const generateWithdrawFormQuery = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override?: PartialDeep<any>
) => {

View File

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

View File

@ -1,6 +1,6 @@
# App configuration variables
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_ETHERSCAN_URL=https://ropsten.etherscan.io
NX_VEGA_NETWORKS={\"MAINNET\":\"https://alpha.console.vega.xyz\"}

View File

@ -1,6 +1,6 @@
# App configuration variables
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_ETHEREUM_PROVIDER_URL=https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8
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 { Button } from '@vegaprotocol/ui-toolkit';
import Link from 'next/link';
export const DepositsContainer = () => {
const { deposits, loading, error } = useDeposits();
return (
<div className="grid grid-cols-[1fr_min-content] gap-4 h-full">
<div />
<div className="p-4">
<div className="h-full grid grid-rows-[min-content_1fr]">
<header className="flex justify-between items-center p-4">
<h4 className="text-lg text-black dark:text-white">{t('Deposits')}</h4>
<Link href="/portfolio/deposit" passHref={true}>
<Button size="md" data-testid="deposit">
{t('Deposit')}
</Button>
<Button>Deposit</Button>
</Link>
</header>
<div>
<AsyncRenderer
data={deposits}
loading={loading}
error={error}
render={(data) => {
return <DepositsTable deposits={data} />;
}}
/>
</div>
</div>
);

View File

@ -1,4 +1,3 @@
import { Web3Container } from '@vegaprotocol/web3';
import { t } from '@vegaprotocol/react-helpers';
import { PositionsContainer } from '@vegaprotocol/positions';
import { OrderListContainer } from '@vegaprotocol/orders';
@ -67,14 +66,12 @@ const Portfolio = () => {
</VegaWalletContainer>
</Tab>
<Tab id="deposits" name={t('Deposits')}>
<DepositsContainer />
<VegaWalletContainer>
<DepositsContainer />
</VegaWalletContainer>
</Tab>
<Tab id="withdrawals" name={t('Withdrawals')}>
<Web3Container>
<VegaWalletContainer>
<WithdrawalsContainer />
</VegaWalletContainer>
</Web3Container>
<WithdrawalsContainer />
</Tab>
</Tabs>
</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 { useWithdrawals, WithdrawalsTable } from '@vegaprotocol/withdraws';
import Link from 'next/link';
import {
useWithdrawals,
WithdrawalDialogs,
WithdrawalsTable,
} from '@vegaprotocol/withdraws';
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 = () => {
const { data, loading, error } = useWithdrawals();
const { withdrawals, loading, error } = useWithdrawals();
const [withdrawDialog, setWithdrawDialog] = useState(false);
console.log('render');
return (
<AsyncRenderer
data={data}
loading={loading}
error={error}
render={(data) => {
const withdrawals = orderBy(
data.party?.withdrawals || [],
(w) => new Date(w.createdTimestamp).getTime(),
'desc'
);
return (
<div className="grid grid-cols-[1fr_min-content] gap-4 h-full">
<WithdrawalsTable withdrawals={withdrawals} />
<div className="p-4">
<Link href="/portfolio/withdraw" passHref={true}>
<Button size="md" data-testid="start-withdrawal">
{t('Withdraw')}
</Button>
</Link>
</div>
<Web3Container>
<VegaWalletContainer>
<div className="h-full grid grid-rows-[min-content_1fr]">
<header className="flex justify-between items-center p-4">
<h4 className="text-lg text-black dark:text-white">
{t('Withdrawals')}
</h4>
<Button
onClick={() => setWithdrawDialog(true)}
data-testid="withdraw-dialog-button"
>
{t('Withdraw')}
</Button>
</header>
<div>
<AsyncRenderer
data={withdrawals}
loading={loading}
error={error}
render={(data) => {
return <WithdrawalsTable withdrawals={data} />;
}}
/>
</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,
children,
}: DealTicketManagerProps) => {
const { submit, transaction, finalizedOrder, TransactionDialog } =
useOrderSubmit();
const { submit, transaction, finalizedOrder, Dialog } = useOrderSubmit();
return (
<>
@ -33,13 +32,13 @@ export const DealTicketManager = ({
}
/>
)}
<TransactionDialog
<Dialog
title={getOrderDialogTitle(finalizedOrder?.status)}
intent={getOrderDialogIntent(finalizedOrder?.status)}
icon={getOrderDialogIcon(finalizedOrder?.status)}
>
<OrderFeedback transaction={transaction} order={finalizedOrder} />
</TransactionDialog>
</Dialog>
</>
);
};

View File

@ -1 +1,3 @@
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(
'Proposal rejected'
'Proposal submitted'
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,6 +34,9 @@ export class CollateralBridge {
get_withdraw_threshold(assetSource: string) {
return this.contract.get_withdraw_threshold(assetSource);
}
default_withdraw_delay() {
return this.contract.default_withdraw_delay();
}
withdraw_asset(
assetSource: 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-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]
);
const TransactionDialog = useMemo(() => {
const Dialog = useMemo(() => {
return (props: DialogProps) => (
<VegaTransactionDialog
{...props}
@ -122,7 +122,7 @@ export const useVegaTransaction = () => {
reset,
setComplete,
setTransaction,
TransactionDialog,
Dialog,
};
};

View File

@ -43,6 +43,7 @@ export const VegaTransactionDialog = ({
intent={computedIntent}
title={computedTitle}
icon={computedIcon}
size="small"
>
{content}
</Dialog>
@ -59,8 +60,9 @@ interface VegaDialogProps {
export const VegaDialog = ({ transaction }: VegaDialogProps) => {
const { VEGA_EXPLORER_URL } = useEnvironment();
let content = null;
if (transaction.status === VegaTxStatus.Requested) {
return (
content = (
<p data-testid={transaction.status}>
{t(
'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) {
return (
content = (
<div data-testid={transaction.status}>
<p>{transaction.error && formatLabel(transaction.error)}</p>
{transaction.details && (
@ -81,7 +83,7 @@ export const VegaDialog = ({ transaction }: VegaDialogProps) => {
}
if (transaction.status === VegaTxStatus.Pending) {
return (
content = (
<div data-testid={transaction.status}>
<p className="break-all">
{t('Please wait for your transaction to be confirmed')} - &nbsp;
@ -102,7 +104,7 @@ export const VegaDialog = ({ transaction }: VegaDialogProps) => {
}
if (transaction.status === VegaTxStatus.Complete) {
return (
content = (
<div data-testid={transaction.status}>
<p className="break-all">
{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) => {
@ -160,7 +162,11 @@ const getIcon = (transaction: VegaTxState) => {
case VegaTxStatus.Requested:
return <Icon name="hand-up" />;
case VegaTxStatus.Pending:
return <Loader size="small" />;
return (
<span className="mt-1">
<Loader size="small" />
</span>
);
case VegaTxStatus.Error:
return <Icon name="warning-sign" />;
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-read-contract';
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-connect-dialog';
export * from './lib/web3-wallet-input';

View File

@ -3,8 +3,8 @@ import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest';
import { EthereumError } from '../ethereum-error';
import { EthTxStatus } from '../use-ethereum-transaction';
import type { TransactionDialogProps } from './transaction-dialog';
import { TransactionDialog } from './transaction-dialog';
import type { EthereumTransactionDialogProps } from './ethereum-transaction-dialog';
import { EthereumTransactionDialog } from './ethereum-transaction-dialog';
jest.mock('@web3-react/core', () => ({
useWeb3React: () => ({
@ -12,11 +12,11 @@ jest.mock('@web3-react/core', () => ({
}),
}));
let props: TransactionDialogProps;
let props: EthereumTransactionDialogProps;
beforeEach(() => {
props = {
name: 'test',
title: 'test',
onChange: jest.fn(),
transaction: {
status: EthTxStatus.Default,
@ -29,9 +29,11 @@ beforeEach(() => {
};
});
const generateJsx = (moreProps?: PartialDeep<TransactionDialogProps>) => {
const generateJsx = (
moreProps?: PartialDeep<EthereumTransactionDialogProps>
) => {
const mergedProps = merge(props, moreProps);
return <TransactionDialog {...mergedProps} />;
return <EthereumTransactionDialog {...mergedProps} />;
};
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();
});
it('Doesn\t repoen if user dismissed the dialog', () => {
it("Doesn't repoen if user dismissed the dialog", () => {
const { container, rerender } = render(
generateJsx({ transaction: { status: EthTxStatus.Pending } })
);
@ -87,7 +89,7 @@ it('Dialog states', () => {
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('Awaiting Ethereum transaction 0/1 confirmations...')
@ -100,7 +102,7 @@ it('Dialog states', () => {
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('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('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();
});

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

View File

@ -1,7 +1,12 @@
export * from './lib/withdrawal-dialogs';
export * from './lib/withdraw-form';
export * from './lib/withdraw-form-container';
export * from './lib/withdraw-manager';
export * from './lib/withdrawals-table';
export * from './lib/withdrawal-feedback';
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/__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";
// ====================================================
// GraphQL query operation: WithdrawPageQuery
// GraphQL query operation: WithdrawFormQuery
// ====================================================
export interface WithdrawPageQuery_party_withdrawals {
export interface WithdrawFormQuery_party_withdrawals {
__typename: "Withdrawal";
/**
* The Vega internal ID of the withdrawal
@ -21,7 +21,7 @@ export interface WithdrawPageQuery_party_withdrawals {
txHash: string | null;
}
export interface WithdrawPageQuery_party_accounts_asset {
export interface WithdrawFormQuery_party_accounts_asset {
__typename: "Asset";
/**
* The ID of the asset
@ -33,7 +33,7 @@ export interface WithdrawPageQuery_party_accounts_asset {
symbol: string;
}
export interface WithdrawPageQuery_party_accounts {
export interface WithdrawFormQuery_party_accounts {
__typename: "Account";
/**
* Account type (General, Margin, etc)
@ -46,10 +46,10 @@ export interface WithdrawPageQuery_party_accounts {
/**
* Asset, the 'currency'
*/
asset: WithdrawPageQuery_party_accounts_asset;
asset: WithdrawFormQuery_party_accounts_asset;
}
export interface WithdrawPageQuery_party {
export interface WithdrawFormQuery_party {
__typename: "Party";
/**
* Party identifier
@ -58,18 +58,18 @@ export interface WithdrawPageQuery_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
*/
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";
}
export interface WithdrawPageQuery_assetsConnection_edges_node_source_ERC20 {
export interface WithdrawFormQuery_assetsConnection_edges_node_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
@ -77,9 +77,9 @@ export interface WithdrawPageQuery_assetsConnection_edges_node_source_ERC20 {
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";
/**
* 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)
*/
source: WithdrawPageQuery_assetsConnection_edges_node_source;
source: WithdrawFormQuery_assetsConnection_edges_node_source;
}
export interface WithdrawPageQuery_assetsConnection_edges {
export interface WithdrawFormQuery_assetsConnection_edges {
__typename: "AssetEdge";
node: WithdrawPageQuery_assetsConnection_edges_node;
node: WithdrawFormQuery_assetsConnection_edges_node;
}
export interface WithdrawPageQuery_assetsConnection {
export interface WithdrawFormQuery_assetsConnection {
__typename: "AssetsConnection";
/**
* 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
*/
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
*/
assetsConnection: WithdrawPageQuery_assetsConnection;
assetsConnection: WithdrawFormQuery_assetsConnection;
}
export interface WithdrawPageQueryVariables {
export interface WithdrawFormQueryVariables {
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";
}
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 {
__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)
*/
@ -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
*/
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 {

View File

@ -9,12 +9,30 @@ import { WithdrawalStatus } from "@vegaprotocol/types";
// 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 {
__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)
*/
@ -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
*/
decimals: number;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: WithdrawalFields_asset_source;
}
export interface WithdrawalFields_details {

View File

@ -9,12 +9,30 @@ import { WithdrawalStatus } from "@vegaprotocol/types";
// 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";
/**
* 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)
*/
@ -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
*/
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";
/**
* The ethereum address of the receiver of the asset funds
@ -33,7 +55,7 @@ export interface Withdrawals_party_withdrawals_details {
receiverAddress: string;
}
export interface Withdrawals_party_withdrawals {
export interface Withdrawals_party_withdrawalsConnection_edges_node {
__typename: "Withdrawal";
/**
* The Vega internal ID of the withdrawal
@ -50,7 +72,7 @@ export interface Withdrawals_party_withdrawals {
/**
* 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
*/
@ -66,13 +88,26 @@ export interface Withdrawals_party_withdrawals {
/**
* 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
*/
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 {
__typename: "Party";
/**
@ -82,7 +117,7 @@ export interface Withdrawals_party {
/**
* The list of all withdrawals initiated by the party
*/
withdrawals: Withdrawals_party_withdrawals[] | null;
withdrawalsConnection: Withdrawals_party_withdrawalsConnection;
}
export interface Withdrawals {

View File

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

View File

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

View File

@ -1,7 +1,11 @@
import { gql, useApolloClient } from '@apollo/client';
import { captureException } from '@sentry/react';
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 { ERC20_APPROVAL_QUERY } from './queries';
import type {
@ -21,7 +25,7 @@ export const useCompleteWithdraw = () => {
const { query, cache } = useApolloClient();
const contract = useBridgeContract();
const [id, setId] = useState('');
const { transaction, perform, Dialog } = useEthereumTransaction<
const { transaction, perform, reset, Dialog } = useEthereumTransaction<
CollateralBridge,
'withdraw_asset'
>(contract, 'withdraw_asset');
@ -29,6 +33,7 @@ export const useCompleteWithdraw = () => {
const submit = useCallback(
async (withdrawalId: string) => {
setId(withdrawalId);
try {
if (!contract) {
return;
@ -39,6 +44,7 @@ export const useCompleteWithdraw = () => {
});
const approval = res.data.erc20WithdrawalApproval;
if (!approval) {
throw new Error('Could not retrieve withdrawal approval');
}
@ -65,12 +71,13 @@ export const useCompleteWithdraw = () => {
fragment: PENDING_WITHDRAWAL_FRAGMMENT,
data: {
__typename: 'Withdrawal',
pendingOnForeignChain: true,
pendingOnForeignChain:
transaction.status === EthTxStatus.Pending ? true : false,
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';
import type {
Withdrawals,
Withdrawals_party_withdrawals,
Withdrawals_party_withdrawalsConnection_edges_node,
} from './__generated__/Withdrawals';
describe('updateQuery', () => {
it('Updates existing withdrawals', () => {
it('updates existing withdrawals', () => {
const withdrawal = generateWithdrawal({
id: '1',
status: WithdrawalStatus.STATUS_OPEN,
@ -27,7 +27,15 @@ describe('updateQuery', () => {
party: {
__typename: 'Party',
id: 'party-id',
withdrawals: [withdrawalUpdate],
withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges: [
{
__typename: 'WithdrawalEdge',
node: withdrawalUpdate,
},
],
},
},
});
});
@ -48,21 +56,45 @@ describe('updateQuery', () => {
party: {
__typename: 'Party',
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({
id: '2',
});
const incoming = mockSub([withdrawalUpdate]);
expect(updateQuery({ party: null }, incoming)).toEqual({
expect(
updateQuery({ party: null }, { ...incoming, variables: { partyId } })
).toEqual({
party: {
__typename: 'Party',
withdrawals: [withdrawalUpdate],
id: partyId,
withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges: [
{
__typename: 'WithdrawalEdge',
node: withdrawalUpdate,
},
],
},
},
});
});
@ -89,20 +121,42 @@ describe('updateQuery', () => {
party: {
__typename: 'Party',
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 = (
withdrawals: Withdrawals_party_withdrawals[]
withdrawals: Withdrawals_party_withdrawalsConnection_edges_node[]
): Withdrawals => {
return {
party: {
__typename: 'Party',
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 type { UpdateQueryFn } from '@apollo/client/core/watchQueryOptions';
import { useVegaWallet } from '@vegaprotocol/wallet';
import uniqBy from 'lodash/uniqBy';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import type {
WithdrawalEvent,
WithdrawalEventVariables,
@ -12,6 +14,7 @@ import type {
import type {
Withdrawals,
WithdrawalsVariables,
Withdrawals_party_withdrawalsConnection_edges,
} from './__generated__/Withdrawals';
const WITHDRAWAL_FRAGMENT = gql`
@ -21,8 +24,14 @@ const WITHDRAWAL_FRAGMENT = gql`
amount
asset {
id
name
symbol
decimals
source {
... on ERC20 {
contractAddress
}
}
}
createdTimestamp
withdrawnTimestamp
@ -41,8 +50,12 @@ export const WITHDRAWALS_QUERY = gql`
query Withdrawals($partyId: ID!) {
party(id: $partyId) {
id
withdrawals {
...WithdrawalFields
withdrawalsConnection {
edges {
node {
...WithdrawalFields
}
}
}
}
}
@ -63,7 +76,7 @@ export const WITHDRAWAL_BUS_EVENT_SUB = gql`
export const useWithdrawals = () => {
const { keypair } = useVegaWallet();
const { subscribeToMore, ...queryResult } = useQuery<
const { data, loading, error, subscribeToMore } = useQuery<
Withdrawals,
WithdrawalsVariables
>(WITHDRAWALS_QUERY, {
@ -85,19 +98,36 @@ export const useWithdrawals = () => {
};
}, [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<
Withdrawals,
WithdrawalEventVariables,
WithdrawalEvent
> = (prev, { subscriptionData }) => {
> = (prev, { subscriptionData, variables }) => {
if (!subscriptionData.data.busEvents?.length) {
return prev;
}
const curr = prev.party?.withdrawals || [];
const curr = prev.party?.withdrawalsConnection.edges || [];
const incoming = subscriptionData.data.busEvents
.map((e) => {
return {
@ -105,21 +135,28 @@ export const updateQuery: UpdateQueryFn<
pendingOnForeignChain: false,
};
})
.filter(
isWithdrawalEvent
// Need this type cast here, TS can't infer that we've filtered any event types
// that arent Withdrawals
) as WithdrawalEvent_busEvents_event_Withdrawal[];
.filter(isWithdrawalEvent)
.map(
(w) =>
({
__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
if (!prev.party) {
return {
...prev,
party: {
id: variables?.partyId,
__typename: 'Party',
withdrawals,
withdrawalsConnection: {
__typename: 'WithdrawalsConnection',
edges,
},
},
} as Withdrawals;
}
@ -128,7 +165,10 @@ export const updateQuery: UpdateQueryFn<
...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 = {
assets,
min: new BigNumber(0.00001),
max: {
balance: new BigNumber(100),
threshold: new BigNumber(200),
},
limits: {
max: new BigNumber(200),
},
balance: new BigNumber(100),
threshold: new BigNumber(200),
delay: 100,
selectedAsset: undefined,
onSelectAsset: jest.fn(),
submitWithdraw: jest.fn().mockReturnValue(Promise.resolve()),
@ -112,6 +108,7 @@ describe('Withdrawal form', () => {
asset: props.assets[0].id,
amount: '4000000',
receiverAddress: MOCK_ETH_ADDRESS,
availableTimestamp: null,
});
});
});
@ -123,7 +120,7 @@ describe('Withdrawal form', () => {
fireEvent.click(screen.getByText('Use maximum'));
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,
removeDecimal,
required,
maxSafe,
isAssetTypeERC20,
} from '@vegaprotocol/react-helpers';
import {
@ -15,12 +14,11 @@ import {
InputError,
Select,
} from '@vegaprotocol/ui-toolkit';
import { Web3WalletInput } from '@vegaprotocol/web3';
import { useWeb3React } from '@web3-react/core';
import BigNumber from 'bignumber.js';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { useForm, Controller } from 'react-hook-form';
import type { WithdrawalFields } from './use-withdraw';
import { useForm, Controller, useWatch } from 'react-hook-form';
import type { WithdrawalArgs } from './use-create-withdraw';
import { WithdrawLimits } from './withdraw-limits';
interface FormFields {
@ -31,25 +29,22 @@ interface FormFields {
export interface WithdrawFormProps {
assets: Asset[];
max: {
balance: BigNumber;
threshold: BigNumber;
};
min: BigNumber;
balance: BigNumber;
selectedAsset?: Asset;
limits: {
max: BigNumber;
} | null;
threshold: BigNumber;
delay: number | undefined;
onSelectAsset: (assetId: string) => void;
submitWithdraw: (withdrawal: WithdrawalFields) => void;
submitWithdraw: (withdrawal: WithdrawalArgs) => void;
}
export const WithdrawForm = ({
assets,
max,
balance,
min,
selectedAsset,
limits,
threshold,
delay,
onSelectAsset,
submitWithdraw,
}: WithdrawFormProps) => {
@ -67,6 +62,9 @@ export const WithdrawForm = ({
to: address,
},
});
const amount = useWatch({ name: 'amount', control });
const onSubmit = async (fields: FormFields) => {
if (!selectedAsset) {
throw new Error('Asset not selected');
@ -75,103 +73,117 @@ export const WithdrawForm = ({
asset: selectedAsset.id,
amount: removeDecimal(fields.amount, selectedAsset.decimals),
receiverAddress: fields.to,
availableTimestamp:
new BigNumber(fields.amount).isGreaterThan(threshold) && delay
? Date.now() + delay * 1000
: null,
});
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate={true}
data-testid="withdraw-form"
>
<FormGroup label={t('Asset')} labelFor="asset">
<Controller
control={control}
name="asset"
rules={{ validate: { required } }}
render={({ field }) => (
<Select
{...field}
onChange={(e) => {
onSelectAsset(e.target.value);
field.onChange(e.target.value);
}}
value={selectedAsset?.id || ''}
id="asset"
>
<option value="">{t('Please select')}</option>
{assets.filter(isAssetTypeERC20).map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</Select>
<>
<div className="mb-4 text-sm">
<p>{t('There are two steps required to make a withdrawal')}</p>
<ol className="list-disc pl-4">
<li>{t('Step 1 - Release funds from Vega')}</li>
<li>{t('Step 2 - Transfer funds to your Ethereum wallet')}</li>
</ol>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
noValidate={true}
data-testid="withdraw-form"
>
<FormGroup label={t('Asset')} labelFor="asset">
<Controller
control={control}
name="asset"
rules={{ validate: { required } }}
render={({ field }) => (
<Select
{...field}
onChange={(e) => {
onSelectAsset(e.target.value);
field.onChange(e.target.value);
}}
value={selectedAsset?.id || ''}
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>
)}
/>
{errors.asset?.message && (
<InputError intent="danger">{errors.asset.message}</InputError>
</FormGroup>
<FormGroup
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('To (Ethereum address)')} labelFor="ethereum-address">
<Web3WalletInput
inputProps={{
id: 'ethereum-address',
...register('to', { validate: { required, ethereumAddress } }),
}}
/>
{errors.to?.message && (
<InputError intent="danger">{errors.to.message}</InputError>
)}
</FormGroup>
{selectedAsset && limits && (
<div className="mb-6">
<WithdrawLimits limits={limits} balance={max.balance} />
</div>
)}
<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
);
<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(balance)) {
return t('Insufficient amount in account');
}
return true;
},
minSafe: (value) => minSafe(min)(value),
},
minSafe: (value) => minSafe(min)(value),
},
})}
/>
{errors.amount?.message && (
<InputError intent="danger" className="mt-4">
{errors.amount.message}
</InputError>
)}
{selectedAsset && (
<UseButton
data-testid="use-maximum"
onClick={() => {
setValue('amount', max.balance.toFixed(selectedAsset.decimals));
clearErrors('amount');
}}
>
{t('Use maximum')}
</UseButton>
)}
</FormGroup>
<Button variant="primary" data-testid="submit-withdrawal" type="submit">
Submit
</Button>
</form>
})}
/>
{errors.amount?.message && (
<InputError intent="danger">{errors.amount.message}</InputError>
)}
{selectedAsset && (
<UseButton
data-testid="use-maximum"
onClick={() => {
setValue('amount', balance.toFixed(selectedAsset.decimals));
clearErrors('amount');
}}
>
{t('Use maximum')}
</UseButton>
)}
</FormGroup>
<Button data-testid="submit-withdrawal" type="submit" variant="primary">
Release funds
</Button>
</form>
</>
);
};

View File

@ -1,24 +1,35 @@
import { t } from '@vegaprotocol/react-helpers';
import type BigNumber from 'bignumber.js';
import BigNumber from 'bignumber.js';
import { formatDistanceToNow } from 'date-fns';
interface WithdrawLimitsProps {
limits: {
max: BigNumber;
};
amount: string;
threshold: BigNumber;
balance: BigNumber;
delay: number | undefined;
}
export const WithdrawLimits = ({ limits, balance }: WithdrawLimitsProps) => {
let maxLimit = '';
export const WithdrawLimits = ({
amount,
threshold,
balance,
delay,
}: WithdrawLimitsProps) => {
let text = '';
if (limits.max.isEqualTo(Infinity)) {
maxLimit = t('No limit');
} else if (limits.max.isGreaterThan(1_000_000)) {
maxLimit = t('1m+');
if (threshold.isEqualTo(Infinity)) {
text = t('No limit');
} else if (threshold.isGreaterThan(1_000_000)) {
text = t('1m+');
} else {
maxLimit = limits.max.toString();
text = threshold.toString();
}
const delayTime =
new BigNumber(amount).isGreaterThan(threshold) && delay
? formatDistanceToNow(Date.now() + delay * 1000)
: t('None');
return (
<table className="w-full text-sm">
<tbody>
@ -27,8 +38,14 @@ export const WithdrawLimits = ({ limits, balance }: WithdrawLimitsProps) => {
<td className="text-right">{balance.toString()}</td>
</tr>
<tr>
<th className="text-left font-normal">{t('Maximum withdrawal')}</th>
<td className="text-right">{maxLimit}</td>
<th className="text-left font-normal">
{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>
</tbody>
</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 type { WithdrawManagerProps } 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';
const ethereumAddress = '0x72c22822A19D20DE7e426fB84aa047399Ddd8853';
@ -17,72 +11,46 @@ jest.mock('@web3-react/core', () => ({
useWeb3React: () => ({ account: ethereumAddress }),
}));
jest.mock('./use-get-withdraw-limits', () => ({
useGetWithdrawLimits: () => {
return { max: new BigNumber(1000000) };
jest.mock('./use-get-withdraw-threshold', () => ({
useGetWithdrawThreshold: () => {
return () => Promise.resolve(new BigNumber(100));
},
}));
jest.mock('./use-get-withdraw-delay', () => ({
useGetWithdrawDelay: () => {
return () => Promise.resolve(10000);
},
}));
let props: WithdrawManagerProps;
let useWithdrawValue: ReturnType<typeof withdrawHook.useWithdraw>;
let useWithdraw: jest.SpyInstance;
let mockSubmit: jest.Mock;
let mockReset: jest.Mock;
beforeEach(() => {
props = {
assets: [generateAsset()],
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) => (
<WithdrawManager {...props} />
);
it('Valid form submission opens transaction dialog', async () => {
it('calls submit if valid form submission', async () => {
render(generateJsx(props));
submitValid();
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(mockReset).toHaveBeenCalled();
expect(mockSubmit).toHaveBeenCalledWith({
await act(async () => {
await submitValid();
});
expect(props.submit).toHaveBeenCalledWith({
amount: '1000',
asset: props.assets[0].id,
receiverAddress: ethereumAddress,
availableTimestamp: null,
});
});
it('Expected Ethereum error closes the dialog', 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 () => {
it('validates correctly', async () => {
render(generateJsx(props));
// 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'));
expect(await screen.findByText('Value is below minimum')).toBeInTheDocument();
expect(mockSubmit).not.toBeCalled();
expect(props.submit).not.toBeCalled();
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00001' },
@ -113,19 +81,14 @@ it('Correct min max values provided to form', async () => {
expect(
await screen.findByText('Insufficient amount in account')
).toBeInTheDocument();
expect(mockSubmit).not.toBeCalled();
expect(props.submit).not.toBeCalled();
});
it('Initial asset id can preselect asset', async () => {
const asset = props.assets[0];
render(generateJsx({ ...props, initialAssetId: asset.id }));
expect(screen.getByLabelText('Asset')).toHaveValue(asset.id);
});
const submitValid = () => {
fireEvent.change(screen.getByLabelText('Asset'), {
target: { value: props.assets[0].id },
});
const submitValid = async () => {
await userEvent.selectOptions(
screen.getByLabelText('Asset'),
props.assets[0].id
);
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
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 { WithdrawForm } from './withdraw-form';
import type { WithdrawalFields } from './use-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 type { WithdrawalArgs } from './use-create-withdraw';
import { addDecimal } from '@vegaprotocol/react-helpers';
import { AccountType } from '@vegaprotocol/types';
import BigNumber from 'bignumber.js';
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 {
assets: Asset[];
assets: AssetFields[];
accounts: Account[];
initialAssetId?: string;
submit: (args: WithdrawalArgs) => void;
}
export const WithdrawManager = ({
assets,
accounts,
initialAssetId,
submit,
}: WithdrawManagerProps) => {
const dialogDismissed = useRef(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [assetId, setAssetId] = useState<string | undefined>(initialAssetId);
const { asset, balance, min, threshold, delay, update } = useWithdrawStore();
const getThreshold = useGetWithdrawThreshold();
const getDelay = useGetWithdrawDelay();
const { ethTx, vegaTx, approval, submit, reset } = useWithdraw(
dialogDismissed.current
);
// Everytime an asset is selected we need to find the corresponding
// 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
const asset = useMemo(() => {
return assets?.find((a) => a.id === assetId);
}, [assets, assetId]);
// Query collateral bridge for threshold for selected asset
// and subsequent delay if withdrawal amount is larger than it
let threshold;
let delay;
const account = useMemo(() => {
return accounts.find(
(a) =>
a.type === AccountType.ACCOUNT_TYPE_GENERAL && a.asset.id === asset?.id
);
}, [asset, accounts]);
try {
const result = await Promise.all([getThreshold(asset), getDelay()]);
threshold = result[0];
delay = result[1];
} catch (err) {
captureException(err);
}
const limits = useGetWithdrawLimits(asset);
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;
update({ asset, balance, min, threshold, delay });
},
[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 (
<>
<WithdrawForm
selectedAsset={asset}
onSelectAsset={(id) => setAssetId(id)}
assets={sortBy(assets, 'name')}
max={max}
min={min}
submitWithdraw={handleSubmit}
limits={limits}
/>
<WithdrawDialog
vegaTx={vegaTx}
ethTx={ethTx}
approval={approval}
dialogOpen={dialogOpen}
onDialogChange={(isOpen) => {
setDialogOpen(isOpen);
if (!isOpen) {
dialogDismissed.current = true;
}
}}
/>
</>
<WithdrawForm
selectedAsset={asset}
onSelectAsset={handleSelectAsset}
assets={sortBy(assets, 'name')}
balance={balance}
min={min}
submitWithdraw={submit}
threshold={threshold}
delay={delay}
/>
);
};

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,
getDateTimeFormat,
} from '@vegaprotocol/react-helpers';
import { WithdrawalStatus } from '@vegaprotocol/types';
import { WithdrawalStatus, WithdrawalStatusMapping } from '@vegaprotocol/types';
import { generateWithdrawal } from './test-helpers';
import type {
StatusCellProps,
@ -12,7 +12,7 @@ import type {
} from './withdrawals-table';
import { StatusCell } 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', () => ({
useWeb3React: () => ({ provider: undefined }),
@ -24,38 +24,66 @@ const generateJsx = (props: WithdrawalsTableProps) => (
</MockedProvider>
);
it('Renders the correct columns', async () => {
const withdrawal = generateWithdrawal();
await act(async () => {
render(generateJsx({ withdrawals: [withdrawal] }));
describe('renders the correct columns', () => {
it('incomplete withdrawal', async () => {
const withdrawal = generateWithdrawal();
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');
expect(headers).toHaveLength(5);
expect(headers.map((h) => h.textContent?.trim())).toEqual([
'Asset',
'Amount',
'Recipient',
'Created at',
'Status',
]);
it('completed withdrawal', async () => {
const withdrawal = generateWithdrawal({
txHash: '0x1234567891011121314',
status: WithdrawalStatus.STATUS_FINALIZED,
});
const cells = screen.getAllByRole('gridcell');
const expectedValues = [
'asset-symbol',
addDecimalsFormatNumber(withdrawal.amount, withdrawal.asset.decimals),
'123456\u2026123456',
getDateTimeFormat().format(new Date(withdrawal.createdTimestamp)),
withdrawal.status,
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
await act(async () => {
render(generateJsx({ withdrawals: [withdrawal] }));
});
const cells = screen.getAllByRole('gridcell');
const expectedValues = [
'asset-symbol',
addDecimalsFormatNumber(withdrawal.amount, withdrawal.asset.decimals),
'123456…123456',
getDateTimeFormat().format(new Date(withdrawal.createdTimestamp)),
'0x1234…121314',
WithdrawalStatusMapping[withdrawal.status],
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
});
});
describe('StatusCell', () => {
let props: StatusCellProps;
let withdrawal: Withdrawals_party_withdrawals;
let withdrawal: Withdrawals_party_withdrawalsConnection_edges_node;
let mockComplete: jest.Mock;
beforeEach(() => {
@ -76,9 +104,7 @@ describe('StatusCell', () => {
render(<StatusCell {...props} />);
expect(screen.getByText('Open')).toBeInTheDocument();
fireEvent.click(
screen.getByText('Click to complete', { selector: 'button' })
);
fireEvent.click(screen.getByText('Complete', { selector: 'button' }));
expect(mockComplete).toHaveBeenCalled();
});
@ -102,18 +128,5 @@ describe('StatusCell', () => {
render(<StatusCell {...props} />);
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 {
getDateTimeFormat,
@ -9,19 +5,40 @@ import {
truncateByChars,
addDecimalsFormatNumber,
} from '@vegaprotocol/react-helpers';
import { WithdrawalStatus } from '@vegaprotocol/types';
import { Link, AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import type {
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 { 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 {
withdrawals: Withdrawals_party_withdrawals[];
withdrawals: WithdrawalFields[];
}
export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => {
const { ETHERSCAN_URL } = useEnvironment();
const { submit, Dialog } = useCompleteWithdraw();
const {
submit,
reset: resetTx,
Dialog: EthereumTransactionDialog,
} = useCompleteWithdraw();
const {
verify,
state: verifyState,
reset: resetVerification,
} = useVerifyWithdrawal();
return (
<>
@ -35,51 +52,105 @@ export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => {
>
<AgGridColumn headerName="Asset" field="asset.symbol" />
<AgGridColumn
headerName="Amount"
headerName={t('Amount')}
field="amount"
valueFormatter={({ value, data }: ValueFormatterParams) => {
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<WithdrawalFields, 'amount'>) => {
return addDecimalsFormatNumber(value, data.asset.decimals);
}}
/>
<AgGridColumn
headerName="Recipient"
headerName={t('Recipient')}
field="details.receiverAddress"
cellRenderer="RecipientCell"
cellRendererParams={{ ethUrl: ETHERSCAN_URL }}
valueFormatter={({ value }: ValueFormatterParams) => {
valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFields,
'details.receiverAddress'
>) => {
if (!value) return '-';
return truncateByChars(value);
}}
/>
<AgGridColumn
headerName="Created at"
headerName={t('Created at')}
field="createdTimestamp"
valueFormatter={({ value }: ValueFormatterParams) => {
valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFields,
'createdTimestamp'
>) => {
return getDateTimeFormat().format(new Date(value));
}}
/>
<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"
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>
<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;
complete: (withdrawalId: string) => void;
complete: (withdrawal: WithdrawalFields) => void;
}
export const StatusCell = ({
ethUrl,
value,
data,
complete,
}: StatusCellProps) => {
export const StatusCell = ({ ethUrl, data, complete }: StatusCellProps) => {
if (data.pendingOnForeignChain) {
return (
<div className="flex justify-between gap-8">
@ -98,37 +169,22 @@ export const StatusCell = ({
);
}
if (value === WithdrawalStatus.STATUS_FINALIZED) {
if (!data.txHash) {
return (
<div className="flex justify-between gap-8">
{data.txHash ? (
<>
{t('Finalized')}
<Link
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>
</>
)}
{t('Open')}
<button className="underline" onClick={() => complete(data)}>
{t('Complete')}
</button>
</div>
);
}
return value;
return <span>{t('Finalized')}</span>;
};
export interface RecipientCellProps extends ICellRendererParams {
export interface RecipientCellProps
extends VegaICellRendererParams<WithdrawalFields, 'details.receiverAddress'> {
ethUrl: string;
}
@ -148,3 +204,48 @@ const RecipientCell = ({
</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",
"**/*.spec.jsx",
"**/*.d.ts",
"src/lib/withdraw-manager.foo.tsx",
"jest.config.ts"
]
}