Feat/104 withdraws (#263)

* add withdraw page

* add create page and withdraws lib with basic components

* add create withdrawal functionality, add withdrawal dialog

* re arrange pages so we have a pending page with a list of withdrawals and a withdraw page with the form

* handle withdrawal completion

* add withdraw dialog so withdraw is completed in one flow

* rename and arrange prop getter function to avoid use of any

* tidy up and make eth transaction and vega transaction hooks follow similar patterns

* make complete withdraw hook fetch approval, combine hooks for full flow

* delete unused file

* add accounts to query to get use max withdraw button

* use ag grid fro withdrawals list

* format amount in grid

* ensure party has id field for cache

* update to use t i18n function

* change complete status to better match values of WithdrawalStatus

* add asset lib file for type guard

* share queries for withdraws lib

* move web3 related hooks to the web3 lib and make web3 lib buildable

* add unit test for use-complete-withdraw

* add test for use-withdraw

* add e2e scenarios for withdrawals

* don render custom no withdrawals message and use ag grid prop

* make withdraw form take ethereum account as prop

* add test config, update withdraw form to handle min/max amounts, add unit test for withdraw form

* fix rendering 0 if not pending withdrawals

* use controlled component for select box

* tidy up async parts of tests, add test for withdraw-manager

* update withdraw dialog to use map for different tx states

* add fallback etherscan link, add test for withdraw-dialog.spec.tsx

* rename erc20 query in token app, change page structure to /withdraw and /withdrawals

* change linked vegawallet-service-api-client to newly published version

* ignore not implemented withdrawals e2e tests

* make vega tx pending and requested states the same, for now, until tx confirmation work is done

* Add some unit tests for determine-ids and remove0x

Neither are critical, but both are libs expected to be shared between
projects so some unit tests will hopefully ensure projects all have the
same expectations about how the functions act.

- Add 1 determine-ids test
- Add a few remove-0x tests
- Update determine-ids to use remove-0x
- Add a comment describing why i18n is what it is

* modify cache with client only field for tracking foreign chain progress

* move withdrawals-list to lib, add useWithdrawals hook

* add container component for withdrawals page

* add unit tests for withdrawals-table

* add some tests for use-withdrawals hook

* revert changes to generated files in other libs and apps

* update withdrawals scenarios

* resolve pr comments

* update page containers to use render prop rather than children

* delete unused files

* remove transaction dialog story

Co-authored-by: Ditmir-Vega <ditmir@vega.xyz>
Co-authored-by: Edd <edd@vega.xyz>
This commit is contained in:
Matthew Russell 2022-04-26 19:51:39 -07:00 committed by GitHub
parent 5134df02b5
commit 29cd7dd2e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
104 changed files with 3312 additions and 362 deletions

View File

@ -0,0 +1,48 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: Erc20ApprovalPoll
// ====================================================
export interface Erc20ApprovalPoll_erc20WithdrawalApproval {
__typename: "Erc20WithdrawalApproval";
/**
* The source asset in the ethereum network
*/
assetSource: string;
/**
* The amount to be withdrawn
*/
amount: string;
/**
* The nonce to be used in the request
*/
nonce: string;
/**
* Signature aggregate from the nodes, in the following format:
* 0x + sig1 + sig2 + ... + sigN
*/
signatures: string;
/**
* The target address which will receive the funds
*/
targetAddress: string;
/**
* Timestamp in seconds for expiry of the approval
*/
expiry: string;
}
export interface Erc20ApprovalPoll {
/**
* find an erc20 withdrawal approval using its withdrawal id
*/
erc20WithdrawalApproval: Erc20ApprovalPoll_erc20WithdrawalApproval | null;
}
export interface Erc20ApprovalPollVariables {
withdrawalId: string;
}

View File

@ -3,7 +3,7 @@
// @generated
// This file was automatically generated and should not be edited.
import { WithdrawalStatus } from "./../../__generated__/globalTypes";
import { WithdrawalStatus } from "./../../../../../libs/types/src/__generated__/globalTypes";
// ====================================================
// GraphQL query operation: WithdrawalPoll

View File

@ -2,13 +2,13 @@ import React from 'react';
import { gql, useApolloClient } from '@apollo/client';
import type {
Erc20Approval,
Erc20Approval_erc20WithdrawalApproval,
Erc20ApprovalVariables,
} from './__generated__/Erc20Approval';
Erc20ApprovalPoll,
Erc20ApprovalPoll_erc20WithdrawalApproval,
Erc20ApprovalPollVariables,
} from './__generated__/Erc20ApprovalPoll';
const ERC20_APPROVAL_QUERY = gql`
query Erc20Approval($withdrawalId: ID!) {
query Erc20ApprovalPoll($withdrawalId: ID!) {
erc20WithdrawalApproval(withdrawalId: $withdrawalId) {
assetSource
amount
@ -24,10 +24,10 @@ export const usePollERC20Approval = (withdrawalId: string) => {
const mountedRef = React.useRef(true);
const client = useApolloClient();
const [erc20Approval, setErc20Approval] =
React.useState<Erc20Approval_erc20WithdrawalApproval | null>(null);
React.useState<Erc20ApprovalPoll_erc20WithdrawalApproval | null>(null);
const safeSetErc20Approval = (
approval: Erc20Approval_erc20WithdrawalApproval
approval: Erc20ApprovalPoll_erc20WithdrawalApproval
) => {
if (mountedRef.current) {
setErc20Approval(approval);
@ -37,7 +37,10 @@ export const usePollERC20Approval = (withdrawalId: string) => {
React.useEffect(() => {
const interval = setInterval(async () => {
try {
const res = await client.query<Erc20Approval, Erc20ApprovalVariables>({
const res = await client.query<
Erc20ApprovalPoll,
Erc20ApprovalPollVariables
>({
query: ERC20_APPROVAL_QUERY,
variables: { withdrawalId },
});

View File

@ -0,0 +1,76 @@
@ignore
Feature: Withdrawals
Scenario: Can prepare a withdrawal
Given I am on withdraw page
And I connect eth wallet
And I connect vega wallet
And I can see the withdrawals warning info
And I can see the from ethereum address field is automatically populated with current connected key
And I select asset 'tBTC'
And I input '100' tokens to withdraw
And the withdraw button is enabled
When I click the withdraw button
Then The vega transaction is sent
Then The Ethereum transaction is triggered
Scenario: Form field validation if fields are incomplete
Given I am on withdraw page
And I connect eth wallet
And I connect vega wallet
When I click the withdraw button
Then I can see validation errors on incomplete fields
Scenario: Can prepare a withdrawal to send to another eth wallet
Given I am on withdraw page
And I connect eth wallet
And I connect vega wallet
And I select asset 'tBTC'
And I can see an eth address is already filled in the withdraw to field
When I click the enter manually button
Then I can enter a new eth address
And I input '100' tokens to withdraw
And the withdraw button is enabled
When I click the withdraw button
Then The vega transaction is sent
Then The Ethereum transaction is triggered
Scenario: Eth key validation on form
Given I am on withdraw page
And I connect eth wallet
And I connect vega wallet
And I select asset 'tBTC'
When I click the enter manually button
And I enter eth address 'MMMMNNNN'
Then the invalid eth address error is shown
Scenario: Validation error if trying to withdraw more than available
Given I am on withdrawals page
And I have connected
And I select asset 'tBTC'
And I input '1088494949494949940' tokens to withdraw
When I click the withdraw button
Then validation error is shown for token input amount
Scenario: Can see pending / unfinished withdrawals
Given I am on the withdrawals page
And I can see there are unfinished withdrawals
And I can see the complete withdrawals button
# Needs capsule
@manual
Scenario: Finish withdrawal to eth wallet
Given I am on the withdrawals page
And I can see there are unfinished withdrawals
And I click on an unfinished withdrawal button
Then I approve transaction on ethereum
Then I can see the withdrawal button state has changed to pending
When The transaction is complete
Then My balance has been updated
@manual
Scenario: Withdrawals after chain reset
Given I am on the withdrawals page
And I previously had withdrawals
And There has been a chain reset
Then There should be no incomplete withdrawals

View File

@ -0,0 +1,4 @@
extend type Withdrawal {
"Whether or the not the withdrawal is being processed on Ethereum"
pendingOnForeignChain: Boolean!
}

View File

@ -50,14 +50,14 @@ jest.mock('@web3-react/core', () => {
function setup(mock = networkParamsQueryMock) {
return render(
<MockedProvider mocks={[mock]}>
<Web3Container>
{({ ethereumConfig }) => (
<Web3Container
render={({ ethereumConfig }) => (
<div>
<div>Child</div>
<div>{ethereumConfig.collateral_bridge_contract.address}</div>
</div>
)}
</Web3Container>
/>
</MockedProvider>
);
}

View File

@ -40,10 +40,10 @@ export const NETWORK_PARAMS_QUERY = gql`
`;
interface Web3ContainerProps {
children: (params: { ethereumConfig: EthereumConfig }) => ReactNode;
render: (params: { ethereumConfig: EthereumConfig }) => ReactNode;
}
export const Web3Container = ({ children }: Web3ContainerProps) => {
export const Web3Container = ({ render }: Web3ContainerProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<PageQueryContainer<NetworkParamsQuery>
@ -79,7 +79,7 @@ export const Web3Container = ({ children }: Web3ContainerProps) => {
appChainId={Number(ethereumConfig.chain_id)}
setDialogOpen={setDialogOpen}
>
{children({ ethereumConfig })}
{render({ ethereumConfig })}
</Web3Content>
<Web3ConnectDialog
connectors={Connectors}

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
*/
decimals: number;
/**
* The origin source of the asset (e.g: an erc20 asset)
*/
source: AssetFields_source;
}

View File

@ -39,6 +39,13 @@ export function createClient(base?: string) {
Node: {
keyFields: false,
},
Withdrawal: {
fields: {
pendingOnForeignChain: {
read: (isPending = false) => isPending,
},
},
},
},
});

View File

@ -0,0 +1,16 @@
import type { AssetFields } from './__generated__/AssetFields';
export interface ERC20Asset extends AssetFields {
source: {
__typename: 'ERC20';
contractAddress: string;
};
}
// Type guard to ensure an asset is an ERC20 token
export const isERC20Asset = (asset: AssetFields): asset is ERC20Asset => {
if (asset.source.__typename === 'ERC20') {
return true;
}
return false;
};

View File

@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const ASSET_FRAGMENT = gql`
fragment AssetFields on Asset {
id
symbol
name
decimals
source {
... on ERC20 {
contractAddress
}
}
}
`;

View File

@ -1,27 +1,18 @@
import type { EthereumConfig } from '../../../components/web3-container/web3-container';
import { gql } from '@apollo/client';
import { PageQueryContainer } from '../../../components/page-query-container';
import type {
DepositPage,
DepositPage_assets,
} from './__generated__/DepositPage';
import type { Asset } from '@vegaprotocol/deposits';
import type { DepositPage } from './__generated__/DepositPage';
import { DepositManager } from '@vegaprotocol/deposits';
import { t } from '@vegaprotocol/react-helpers';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { ASSET_FRAGMENT } from '../../../lib/query-fragments';
import { isERC20Asset } from '../../../lib/assets';
const DEPOSIT_PAGE_QUERY = gql`
${ASSET_FRAGMENT}
query DepositPage {
assets {
id
symbol
name
decimals
source {
... on ERC20 {
contractAddress
}
}
...AssetFields
}
}
`;
@ -62,10 +53,3 @@ export const DepositContainer = ({
/>
);
};
const isERC20Asset = (asset: DepositPage_assets): asset is Asset => {
if (asset.source.__typename === 'ERC20') {
return true;
}
return false;
};

View File

@ -20,14 +20,14 @@ const Deposit = () => {
}, [query]);
return (
<Web3Container>
{({ ethereumConfig }) => (
<Web3Container
render={({ ethereumConfig }) => (
<div className="max-w-[420px] p-24 mx-auto">
<h1 className="text-h3 mb-12">Deposit</h1>
<DepositContainer ethereumConfig={ethereumConfig} assetId={assetId} />
</div>
)}
</Web3Container>
/>
);
};

View File

@ -1,19 +1,25 @@
import { t } from '@vegaprotocol/react-helpers';
import { AnchorButton } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
const Portfolio = () => {
const { keypair } = useVegaWallet();
return (
<div className="p-24">
<h1 className="text-h3 mb-12">{t('Portfolio')}</h1>
{keypair && <p>{t(`Keypair: ${keypair.name} ${keypair.pub}`)}</p>}
<div className="flex gap-4">
<div className="flex gap-4 mb-12">
<AnchorButton href="/portfolio/deposit">{t('Deposit')}</AnchorButton>
<AnchorButton href="/portfolio/deposit?assetId=8b52d4a3a4b0ffe733cddbc2b67be273816cfeb6ca4c8b339bac03ffba08e4e4">
{t('Deposit tEURO')}
</AnchorButton>
</div>
<div className="flex gap-4">
<AnchorButton href="/portfolio/withdrawals">
{t('View Withdrawals')}
</AnchorButton>
<AnchorButton href="/portfolio/withdraw">{t('Withdraw')}</AnchorButton>
<AnchorButton href="/portfolio/withdraw?assetId=8b52d4a3a4b0ffe733cddbc2b67be273816cfeb6ca4c8b339bac03ffba08e4e4">
{t('Withdraw tEURO')}
</AnchorButton>
</div>
</div>
);
};

View File

@ -0,0 +1,119 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { AccountType } from "./../../../../../../libs/types/src/__generated__/globalTypes";
// ====================================================
// GraphQL query operation: WithdrawPageQuery
// ====================================================
export interface WithdrawPageQuery_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 WithdrawPageQuery_party_accounts_asset {
__typename: "Asset";
/**
* The id of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
}
export interface WithdrawPageQuery_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: WithdrawPageQuery_party_accounts_asset;
}
export interface WithdrawPageQuery_party {
__typename: "Party";
/**
* Party identifier
*/
id: string;
/**
* The list of all withdrawals initiated by the party
*/
withdrawals: WithdrawPageQuery_party_withdrawals[] | null;
/**
* Collateral accounts relating to a party
*/
accounts: WithdrawPageQuery_party_accounts[] | null;
}
export interface WithdrawPageQuery_assets_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface WithdrawPageQuery_assets_source_ERC20 {
__typename: "ERC20";
/**
* The address of the erc20 contract
*/
contractAddress: string;
}
export type WithdrawPageQuery_assets_source = WithdrawPageQuery_assets_source_BuiltinAsset | WithdrawPageQuery_assets_source_ERC20;
export interface WithdrawPageQuery_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
*/
decimals: number;
/**
* The origin source of the asset (e.g: an erc20 asset)
*/
source: WithdrawPageQuery_assets_source;
}
export interface WithdrawPageQuery {
/**
* An entity that is trading on the VEGA network
*/
party: WithdrawPageQuery_party | null;
/**
* The list of all assets in use in the vega network
*/
assets: WithdrawPageQuery_assets[] | null;
}
export interface WithdrawPageQueryVariables {
partyId: string;
}

View File

@ -0,0 +1,35 @@
import { Web3Container } from '../../../components/web3-container';
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import { WithdrawPageContainer } from './withdraw-page-container';
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 (
<Web3Container
render={() => (
<div className="max-w-[420px] p-24 mx-auto">
<h1 className="text-h3 mb-12">{t('Withdraw')}</h1>
<WithdrawPageContainer assetId={assetId} />
</div>
)}
/>
);
};
export default Withdraw;

View File

@ -0,0 +1,97 @@
import { gql } from '@apollo/client';
import { 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 { isERC20Asset } from '../../../lib/assets';
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
}
}
}
assets {
...AssetFields
}
}
`;
interface WithdrawPageContainerProps {
assetId?: string;
}
/**
* Fetches data required for the Deposit page
*/
export const WithdrawPageContainer = ({
assetId,
}: WithdrawPageContainerProps) => {
const { keypair } = useVegaWallet();
if (!keypair) {
return <p>{t('Please connect your Vega wallet')}</p>;
}
return (
<PageQueryContainer<WithdrawPageQuery, WithdrawPageQueryVariables>
query={WITHDRAW_PAGE_QUERY}
options={{
variables: { partyId: keypair?.pub || '' },
skip: !keypair?.pub,
}}
render={(data) => {
if (!data.assets?.length) {
return (
<Splash>
<p>{t('No assets on this network')}</p>
</Splash>
);
}
const hasIncompleteWithdrawals = data.party?.withdrawals?.some(
(w) => w.txHash
);
return (
<>
{hasIncompleteWithdrawals ? (
<p className="mb-12">
{t('You have incomplete withdrawals.')}{' '}
<Link href="/portfolio/withdrawals">
<a className="underline">
{t('Click here to finish withdrawal')}
</a>
</Link>
</p>
) : null}
<WithdrawManager
assets={data.assets.filter(isERC20Asset)}
accounts={data.party?.accounts || []}
initialAssetId={assetId}
/>
</>
);
}}
/>
);
};

View File

@ -0,0 +1,31 @@
import { t } from '@vegaprotocol/react-helpers';
import { AnchorButton, Splash } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { Web3Container } from '../../../components/web3-container';
import { WithdrawalsPageContainer } from './withdrawals-page-container';
const Withdrawals = () => {
const { keypair } = useVegaWallet();
if (!keypair) {
return <Splash>{t('Please connect Vega wallet')}</Splash>;
}
return (
<Web3Container
render={() => (
<div className="h-full grid grid grid-rows-[min-content,1fr]">
<header className="flex justify-between p-24">
<h1 className="text-h3">{t('Withdrawals')}</h1>
<AnchorButton href="/portfolio/withdraw">
{t('Start withdrawal')}
</AnchorButton>
</header>
<WithdrawalsPageContainer />
</div>
)}
/>
);
};
export default Withdrawals;

View File

@ -0,0 +1,23 @@
import orderBy from 'lodash/orderBy';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useWithdrawals, WithdrawalsTable } from '@vegaprotocol/withdraws';
export const WithdrawalsPageContainer = () => {
const { data, loading, error } = useWithdrawals();
return (
<AsyncRenderer
data={data}
loading={loading}
error={error}
render={(data) => {
const withdrawals = orderBy(
data.party?.withdrawals || [],
(w) => new Date(w.createdTimestamp).getTime(),
'desc'
);
return <WithdrawalsTable withdrawals={withdrawals} />;
}}
/>
);
};

View File

@ -32,7 +32,7 @@ export const DealTicketManager = ({ market }: DealTicketManagerProps) => {
return Intent.Danger;
}
if (status === VegaTxStatus.Rejected) {
if (status === VegaTxStatus.Error) {
return Intent.Danger;
}
@ -51,7 +51,7 @@ export const DealTicketManager = ({ market }: DealTicketManagerProps) => {
market={market}
submit={submit}
transactionStatus={
transaction.status === VegaTxStatus.AwaitingConfirmation ||
transaction.status === VegaTxStatus.Requested ||
transaction.status === VegaTxStatus.Pending
? 'pending'
: 'default'

View File

@ -2,11 +2,11 @@ import { Icon, Loader } from '@vegaprotocol/ui-toolkit';
import type { ReactNode } from 'react';
import type { OrderEvent_busEvents_event_Order } from './__generated__/OrderEvent';
import { formatNumber, t } from '@vegaprotocol/react-helpers';
import type { TransactionState } from '@vegaprotocol/wallet';
import type { VegaTxState } from '@vegaprotocol/wallet';
import { VegaTxStatus } from '@vegaprotocol/wallet';
interface OrderDialogProps {
transaction: TransactionState;
transaction: VegaTxState;
finalizedOrder: OrderEvent_busEvents_event_Order | null;
}
@ -17,7 +17,7 @@ export const OrderDialog = ({
// TODO: When wallets support confirming transactions return UI for 'awaiting confirmation' step
// Rejected by wallet
if (transaction.status === VegaTxStatus.Rejected) {
if (transaction.status === VegaTxStatus.Error) {
return (
<OrderDialogWrapper
title="Order rejected by wallet"
@ -39,9 +39,9 @@ export const OrderDialog = ({
title="Awaiting network confirmation"
icon={<Loader size="small" />}
>
{transaction.hash && (
{transaction.txHash && (
<p data-testid="tx-hash" className="break-all">
{t(`Tx hash: ${transaction.hash}`)}
{t(`Tx hash: ${transaction.txHash}`)}
</p>
)}
</OrderDialogWrapper>

View File

@ -41,7 +41,7 @@ test('Has the correct default state', () => {
expect(typeof result.current.submit).toEqual('function');
expect(typeof result.current.reset).toEqual('function');
expect(result.current.transaction.status).toEqual(VegaTxStatus.Default);
expect(result.current.transaction.hash).toEqual(null);
expect(result.current.transaction.txHash).toEqual(null);
expect(result.current.transaction.error).toEqual(null);
});

View File

@ -1,10 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { gql, useSubscription } from '@apollo/client';
import { ethers } from 'ethers';
import { SHA3 } from 'sha3';
import type { Order } from './use-order-state';
import { OrderType, useVegaWallet } from '@vegaprotocol/wallet';
import { removeDecimal } from '@vegaprotocol/react-helpers';
import { determineId, removeDecimal } from '@vegaprotocol/react-helpers';
import { useVegaTransaction } from '@vegaprotocol/wallet';
import type {
OrderEvent,
@ -132,23 +130,3 @@ export const useOrderSubmit = (market: UseOrderSubmitMarket) => {
reset,
};
};
/**
* This function creates an ID in the same way that core does on the backend. This way we
* Can match up the newly created order with incoming orders via a subscription
*/
export const determineId = (sig: string) => {
// Prepend 0x
if (sig.slice(0, 2) !== '0x') {
sig = '0x' + sig;
}
// Create the ID
const hash = new SHA3(256);
const bytes = ethers.utils.arrayify(sig);
hash.update(Buffer.from(bytes));
const id = ethers.utils.hexlify(hash.digest());
// Remove 0x as core doesn't keep them in the API
return id.substring(2);
};

View File

@ -118,7 +118,8 @@ test('Form validation', async () => {
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: amountLessThanMinViable },
});
expect(await screen.findByText('Amount is below permitted minimum'));
expect(await screen.findByText('Value is below minimum'));
rerender(
<DepositForm
@ -130,7 +131,7 @@ test('Form validation', async () => {
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: amountLessThanLimit },
});
expect(await screen.findByText('Amount is below permitted minimum'));
expect(await screen.findByText('Value is below minimum'));
});
test('Approval', () => {

View File

@ -6,13 +6,9 @@ import sortBy from 'lodash/sortBy';
import { useSubmitApproval } from './use-submit-approval';
import { useGetDepositLimits } from './use-get-deposit-limits';
import { useGetAllowance } from './use-get-allowance';
import { TransactionDialog } from '@vegaprotocol/ui-toolkit';
import { useSubmitFaucet } from './use-submit-faucet';
import {
useTokenContract,
useBridgeContract,
TxState,
} from '@vegaprotocol/react-helpers';
import { EthTxStatus, TransactionDialog } from '@vegaprotocol/web3';
import { useTokenContract, useBridgeContract } from '@vegaprotocol/web3';
export interface Asset {
__typename: 'Asset';
@ -76,10 +72,13 @@ export const DepositManager = ({
// Update balance after confirmation event has been received
useEffect(() => {
if (faucet.status === TxState.Complete || confirmationEvent !== null) {
if (
faucet.transaction.status === EthTxStatus.Complete ||
confirmationEvent !== null
) {
refetch();
}
}, [confirmationEvent, refetch, faucet.status]);
}, [confirmationEvent, refetch, faucet.transaction.status]);
return (
<>
@ -94,8 +93,8 @@ export const DepositManager = ({
limits={limits}
allowance={allowance}
/>
<TransactionDialog {...approve} name="approve" />
<TransactionDialog {...faucet} name="faucet" />
<TransactionDialog {...approve.transaction} name="approve" />
<TransactionDialog {...faucet.transaction} name="faucet" />
<TransactionDialog
{...deposit}
name="deposit"

View File

@ -1,7 +1,7 @@
import type { ERC20Token } from '@vegaprotocol/smart-contracts-sdk';
import { useWeb3React } from '@web3-react/core';
import { useCallback } from 'react';
import { useEthereumReadContract } from '@vegaprotocol/react-helpers';
import { useEthereumReadContract } from '@vegaprotocol/web3';
export const useGetAllowance = (
contract: ERC20Token | null,

View File

@ -1,4 +1,4 @@
import { useEthereumReadContract } from '@vegaprotocol/react-helpers';
import { useEthereumReadContract } from '@vegaprotocol/web3';
import type { ERC20Token } from '@vegaprotocol/smart-contracts-sdk';
import { useWeb3React } from '@web3-react/core';
import { useCallback } from 'react';

View File

@ -2,7 +2,7 @@ import type BigNumber from 'bignumber.js';
import { useCallback } from 'react';
import type { VegaErc20Bridge } from '@vegaprotocol/smart-contracts-sdk';
import type { Asset } from './deposit-manager';
import { useEthereumReadContract } from '@vegaprotocol/react-helpers';
import { useEthereumReadContract } from '@vegaprotocol/web3';
interface Limits {
min: BigNumber;

View File

@ -1,5 +1,5 @@
import type { ERC20Token } from '@vegaprotocol/smart-contracts-sdk';
import { useEthereumTransaction } from '@vegaprotocol/react-helpers';
import { useEthereumTransaction } from '@vegaprotocol/web3';
export const useSubmitApproval = (
contract: ERC20Token | null,

View File

@ -6,7 +6,8 @@ import type {
} from './__generated__/DepositEvent';
import { DepositStatus } from '@vegaprotocol/types';
import { useState } from 'react';
import { remove0x, useEthereumTransaction } from '@vegaprotocol/react-helpers';
import { remove0x } from '@vegaprotocol/react-helpers';
import { useEthereumTransaction } from '@vegaprotocol/web3';
import type { VegaErc20Bridge } from '@vegaprotocol/smart-contracts-sdk';
const DEPOSIT_EVENT_SUB = gql`
@ -33,7 +34,7 @@ export const useSubmitDeposit = (
// NOTE: it may be different from the users connected key
const [partyId, setPartyId] = useState<string | null>(null);
const transaction = useEthereumTransaction<{
const { transaction, perform } = useEthereumTransaction<{
assetSource: string;
amount: string;
vegaPublicKey: string;
@ -87,6 +88,7 @@ export const useSubmitDeposit = (
return {
...transaction,
perform,
confirmationEvent,
};
};

View File

@ -1,5 +1,5 @@
import type { ERC20Token } from '@vegaprotocol/smart-contracts-sdk';
import { useEthereumTransaction } from '@vegaprotocol/react-helpers';
import { useEthereumTransaction } from '@vegaprotocol/web3';
export const useSubmitFaucet = (contract: ERC20Token | null) => {
const transaction = useEthereumTransaction(() => {

View File

@ -1,7 +1,3 @@
export * from './use-apply-grid-transaction';
export * from './use-ethereum-transaction';
export * from './use-ethereum-read-contract';
export * from './use-data-provider';
export * from './use-theme-switcher';
export * from './use-bridge-contract';
export * from './use-token-contract';

View File

@ -1,72 +0,0 @@
import type { ethers } from 'ethers';
import { useCallback, useState } from 'react';
import { EthereumError, isEthereumError } from '../lib/ethereum-error';
export enum TxState {
Default = 'Default',
Requested = 'Requested',
Pending = 'Pending',
Complete = 'Complete',
Error = 'Error',
}
export type TxError = Error | EthereumError;
export const useEthereumTransaction = <TArgs = void>(
performTransaction: (
args: TArgs
) => Promise<ethers.ContractTransaction> | null,
requiredConfirmations = 1
) => {
const [confirmations, setConfirmations] = useState(0);
const [status, setStatus] = useState(TxState.Default);
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<TxError | null>(null);
const perform = useCallback(
async (args: TArgs) => {
setConfirmations(0);
setStatus(TxState.Requested);
setError(null);
try {
const res = performTransaction(args);
if (res === null) {
setStatus(TxState.Default);
return;
}
const tx = await res;
let receipt: ethers.ContractReceipt | null = null;
setTxHash(tx.hash);
setStatus(TxState.Pending);
for (let i = 1; i <= requiredConfirmations; i++) {
receipt = await tx.wait(i);
setConfirmations(receipt.confirmations);
}
if (!receipt) {
throw new Error('No receipt after confirmations are met');
}
setStatus(TxState.Complete);
} catch (err) {
if (err instanceof Error) {
setError(err);
} else if (isEthereumError(err)) {
setError(new EthereumError(err.message, err.code));
} else {
setError(new Error('Something went wrong'));
}
setStatus(TxState.Error);
}
},
[performTransaction, requiredConfirmations]
);
return { perform, status, error, confirmations, txHash };
};

View File

@ -1,6 +1,5 @@
export * from './hooks';
export * from './lib/context';
export * from './lib/ethereum-error';
export * from './lib/format';
export * from './lib/generic-data-provider';
export * from './lib/grid';
@ -8,3 +7,4 @@ export * from './lib/i18n';
export * from './lib/remove-0x';
export * from './lib/storage';
export * from './lib/validate';
export * from './lib/determine-id';

View File

@ -0,0 +1,23 @@
import { ethers } from 'ethers';
import { SHA3 } from 'sha3';
import { remove0x } from './remove-0x';
/**
* This function creates an ID in the same way that core does on the backend. This way we
* Can match up the newly created order with incoming orders via a subscription
*/
export const determineId = (sig: string) => {
// Prepend 0x
if (sig.slice(0, 2) !== '0x') {
sig = '0x' + sig;
}
// Create the ID
const hash = new SHA3(256);
const bytes = ethers.utils.arrayify(sig);
hash.update(Buffer.from(bytes));
const id = ethers.utils.hexlify(hash.digest());
// Remove 0x as core doesn't keep them in the API
return remove0x(id);
};

View File

@ -0,0 +1,10 @@
import { determineId } from './determine-id';
test('It produces a known result for an ID', () => {
const res = determineId(
'cfe592d169f87d0671dd447751036d0dddc165b9c4b65e5a5060e2bbadd1aa726d4cbe9d3c3b327bcb0bff4f83999592619a2493f9bbd251fae99ce7ce766909'
);
expect(res).toStrictEqual(
'2fca514cebf9f465ae31ecb4c5721e3a6f5f260425ded887ca50ba15b81a5d50'
);
});

View File

@ -1 +1,9 @@
/**
* A stand in for real i18n - this function should
* be used wherever text is placed in the UI so that
* in future they can easily be extracted
*
* @param str A
* @returns str A
*/
export const t = (str: string) => str;

View File

@ -0,0 +1,38 @@
import { remove0x } from './remove-0x';
it('Removes 0x from the start of a transaction hash', () => {
const randomEthereumTx =
'0xfc1fe28cb9cb255c93beaa6ceb3313d41ba17475489e0874fc46d64376d60088';
const res = remove0x(randomEthereumTx);
expect(res).toStrictEqual(
'fc1fe28cb9cb255c93beaa6ceb3313d41ba17475489e0874fc46d64376d60088'
);
});
it('Does not check if the string is valid hex', () => {
const invalidData = '**Not-Hex-At-All!!**';
const res = remove0x(`0x${invalidData}`);
expect(res).toStrictEqual(invalidData);
});
it('Does not remove 0x in the middle of the string', () => {
const unprefixedString = 'test-0x-test';
const res = remove0x(unprefixedString);
expect(res).toStrictEqual(unprefixedString);
});
it('Does not remove all 0xs, just one at the start', () => {
const weirdString = '0x0x0x';
const res = remove0x(weirdString);
expect(res).toStrictEqual('0x0x');
});
it('Handles non strings by returning them untouched', () => {
const res = remove0x(NaN as unknown as string);
expect(res).toStrictEqual(NaN);
});

View File

@ -1,3 +1,16 @@
import isString from 'lodash/isString';
/**
* Removes a hex prefix from the start of a string
*
* @param str Hex string
* @returns string
*/
export const remove0x = (str: string) => {
// Should be prevented by typescript, but just in case...
if (!isString(str)) {
return str;
}
return str.replace(/^0x/, '');
};

View File

@ -25,14 +25,14 @@ export const vegaPublicKey = (value: string) => {
export const minSafe = (min: BigNumber) => (value: string) => {
if (new BigNumber(value).isLessThan(min)) {
return t('Amount is below permitted minimum');
return t('Value is below minimum');
}
return true;
};
export const maxSafe = (max: BigNumber) => (value: string) => {
if (new BigNumber(value).isGreaterThan(max)) {
return t('Amount is above permitted maximum');
return t('Value is above maximum');
}
return true;
};

View File

@ -79,7 +79,7 @@ export enum DepositStatus {
/**
* The interval for trade candles when subscribing via VEGA graphql, default is I15M
*/
export enum Interval {
export enum Interval {
I15M = "I15M",
I1D = "I1D",
I1H = "I1H",
@ -265,11 +265,29 @@ export enum Side {
Sell = "Sell",
}
/**
* The status of the stake linking
*/
export enum StakeLinkingStatus {
Accepted = "Accepted",
Pending = "Pending",
Rejected = "Rejected",
}
export enum VoteValue {
No = "No",
Yes = "Yes",
}
/**
* The status of a withdrawal
*/
export enum WithdrawalStatus {
Finalized = "Finalized",
Open = "Open",
Rejected = "Rejected",
}
//==============================================================
// END Enums and Input Objects
//==============================================================

View File

@ -1,5 +1,6 @@
import { Splash } from '../splash';
import type { ReactElement, ReactNode } from 'react';
import { t } from '@vegaprotocol/react-helpers';
interface AsyncRendererProps<T> {
loading: boolean;
@ -17,15 +18,15 @@ export function AsyncRenderer<T = object>({
render,
}: AsyncRendererProps<T>) {
if (error) {
return <Splash>Something went wrong: {error.message}</Splash>;
return <Splash>{t(`Something went wrong: ${error.message}`)}</Splash>;
}
if (loading) {
return <Splash>Loading...</Splash>;
return <Splash>{t('Loading...')}</Splash>;
}
if (!data) {
return <Splash>No data</Splash>;
return <Splash>{t('No data')}</Splash>;
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{render ? render(data) : children}</>;

View File

@ -1,6 +1,5 @@
import classNames from 'classnames';
import type { AnchorHTMLAttributes } from 'react';
import React from 'react';
const ETHERSCAN_URL = process.env['NX_ETHERSCAN_URL'] as string;

View File

@ -1,62 +0,0 @@
import type { ComponentStory, ComponentMeta } from '@storybook/react';
import { TransactionDialog } from './transaction-dialog';
import { TxState } from '@vegaprotocol/react-helpers';
export default {
component: TransactionDialog,
title: 'TransactionDialog',
parameters: {
themes: false,
},
} as ComponentMeta<typeof TransactionDialog>;
const Template: ComponentStory<typeof TransactionDialog> = (args) => (
<TransactionDialog {...args} />
);
export const Requested = Template.bind({});
Requested.args = {
name: 'Some tx',
status: TxState.Requested,
error: null,
confirmations: 0,
txHash: null,
requiredConfirmations: 1,
confirmed: false,
};
export const Pending = Template.bind({});
Pending.args = {
name: 'Some tx',
status: TxState.Pending,
error: null,
confirmations: 1,
txHash: '0x123123',
requiredConfirmations: 3,
confirmed: false,
};
export const Error = Template.bind({});
Error.args = {
name: 'Some tx',
status: TxState.Error,
error: {
name: 'Error',
message:
'Some very long error message with text that should wrap, here is some error data: {"chain":3,"data":"0x08c379000000000000000000000000000000000000000000000000000000000"}',
},
confirmations: 0,
txHash: null,
requiredConfirmations: 1,
confirmed: false,
};
export const Complete = Template.bind({});
Complete.args = {
name: 'Some tx',
status: TxState.Complete,
error: null,
confirmations: 3,
txHash: '0x123123',
requiredConfirmations: 3,
confirmed: true,
};

View File

@ -21,7 +21,6 @@ export { VegaLogo } from './components/vega-logo';
export { Tooltip } from './components/tooltip';
export { Indicator } from './components/indicator';
export { Card } from './components/card';
export { TransactionDialog } from './components/transaction-dialog';
// Utils
export * from './utils/intent';

View File

@ -2,6 +2,7 @@ import type {
VegaKey,
TransactionResponse,
OrderSubmissionBody,
WithdrawSubmissionBody,
} from '@vegaprotocol/vegawallet-service-api-client';
export { RestConnector } from './rest-connector';
@ -25,6 +26,6 @@ export interface VegaConnector {
/** Send a TX to the network. Only support order submission for now */
sendTx: (
body: OrderSubmissionBody
body: OrderSubmissionBody | WithdrawSubmissionBody
) => Promise<TransactionResponse | ErrorResponse>;
}

View File

@ -1,6 +1,7 @@
import type {
Configuration,
OrderSubmissionBody,
WithdrawSubmissionBody,
} from '@vegaprotocol/vegawallet-service-api-client';
import {
createConfiguration,
@ -87,7 +88,7 @@ export class RestConnector implements VegaConnector {
}
}
async sendTx(body: OrderSubmissionBody) {
async sendTx(body: OrderSubmissionBody | WithdrawSubmissionBody) {
try {
return await this.service.commandSyncPost(body);
} catch (err) {

View File

@ -4,7 +4,7 @@ import type {
} from '@vegaprotocol/vegawallet-service-api-client';
import { createContext } from 'react';
import type { VegaConnector } from './connectors';
import type { Transaction } from './types';
import type { TransactionSubmission } from './types';
export type SendTxError =
| {
@ -39,7 +39,7 @@ export interface VegaWalletContextShape {
/** Send a transaction to the network, only order submissions for now */
sendTx: (
tx: Transaction
tx: TransactionSubmission
) => Promise<TransactionResponse | SendTxError> | null;
}

View File

@ -5,7 +5,10 @@ import type { VegaKeyExtended, VegaWalletContextShape } from '.';
import type { VegaConnector } from './connectors';
import { VegaWalletContext } from './context';
import { WALLET_KEY } from './storage-keys';
import type { OrderSubmissionBody } from '@vegaprotocol/vegawallet-service-api-client';
import type {
OrderSubmissionBody,
WithdrawSubmissionBody,
} from '@vegaprotocol/vegawallet-service-api-client';
interface VegaWalletProviderProps {
children: ReactNode;
@ -69,13 +72,16 @@ export const VegaWalletProvider = ({ children }: VegaWalletProviderProps) => {
}
}, []);
const sendTx = useCallback((body: OrderSubmissionBody) => {
if (!connector.current) {
return null;
}
const sendTx = useCallback(
(body: OrderSubmissionBody | WithdrawSubmissionBody) => {
if (!connector.current) {
return null;
}
return connector.current.sendTx(body);
}, []);
return connector.current.sendTx(body);
},
[]
);
// Current selected keypair derived from publicKey state
const keypair = useMemo(() => {

View File

@ -1,3 +1,8 @@
import type {
OrderSubmissionBody,
WithdrawSubmissionBody,
} from '@vegaprotocol/vegawallet-service-api-client';
export enum OrderType {
Market = 'TYPE_MARKET',
Limit = 'TYPE_LIMIT',
@ -17,20 +22,7 @@ export enum OrderTimeInForce {
GFA = 'TIME_IN_FORCE_GFA',
}
export interface OrderSubmission {
pubKey: string;
propagate: boolean;
orderSubmission: {
marketId: string;
size: string;
type: OrderType;
side: OrderSide;
timeInForce: OrderTimeInForce;
price?: string;
expiresAt?: string;
reference?: string;
};
}
// Will make Transaction a union type as other transactions are added
export type Transaction = OrderSubmission;
export type TransactionSubmission =
| OrderSubmissionBody
| WithdrawSubmissionBody;

View File

@ -27,7 +27,7 @@ function setup(context?: Partial<VegaWalletContextShape>) {
test('Has the correct default state', () => {
const { result } = setup();
expect(result.current.transaction.status).toEqual(VegaTxStatus.Default);
expect(result.current.transaction.hash).toEqual(null);
expect(result.current.transaction.txHash).toEqual(null);
expect(result.current.transaction.signature).toEqual(null);
expect(result.current.transaction.error).toEqual(null);
expect(typeof result.current.reset).toEqual('function');
@ -52,7 +52,7 @@ test('Handles a single error', async () => {
await act(async () => {
result.current.send({} as OrderSubmission);
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Rejected);
expect(result.current.transaction.status).toEqual(VegaTxStatus.Error);
expect(result.current.transaction.error).toEqual({ error: errorMessage });
});
@ -67,7 +67,7 @@ test('Handles multiple errors', async () => {
await act(async () => {
result.current.send({} as OrderSubmission);
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Rejected);
expect(result.current.transaction.status).toEqual(VegaTxStatus.Error);
expect(result.current.transaction.error).toEqual(errorObj);
});
@ -89,7 +89,7 @@ test('Returns the signature if successful', async () => {
result.current.send({} as OrderSubmission);
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Pending);
expect(result.current.transaction.hash).toEqual(successObj.txHash);
expect(result.current.transaction.txHash).toEqual(successObj.txHash);
expect(result.current.transaction.signature).toEqual(
successObj.tx.signature.value
);

View File

@ -1,32 +1,36 @@
import { useCallback, useState } from 'react';
import type { Transaction } from './types';
import type { TransactionSubmission } from './types';
import { useVegaWallet } from './hooks';
import type { SendTxError } from './context';
export enum VegaTxStatus {
Default = 'Default',
AwaitingConfirmation = 'AwaitingConfirmation',
Rejected = 'Rejected',
Requested = 'Requested',
Pending = 'Pending',
Error = 'Error',
// Note no complete state as we have to use api calls/subs to check if
// our transaction was completed
}
export interface TransactionState {
export interface VegaTxState {
status: VegaTxStatus;
error: object | null;
hash: string | null;
txHash: string | null;
signature: string | null;
}
export const initialState = {
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
};
export const useVegaTransaction = () => {
const { sendTx } = useVegaWallet();
const [transaction, _setTransaction] = useState<TransactionState>({
status: VegaTxStatus.Default,
error: null,
hash: null,
signature: null,
});
const [transaction, _setTransaction] = useState<VegaTxState>(initialState);
const setTransaction = useCallback((update: Partial<TransactionState>) => {
const setTransaction = useCallback((update: Partial<VegaTxState>) => {
_setTransaction((curr) => ({
...curr,
...update,
@ -35,18 +39,18 @@ export const useVegaTransaction = () => {
const handleError = useCallback(
(error: SendTxError) => {
setTransaction({ error, status: VegaTxStatus.Rejected });
setTransaction({ error, status: VegaTxStatus.Error });
},
[setTransaction]
);
const send = useCallback(
async (tx: Transaction) => {
async (tx: TransactionSubmission) => {
setTransaction({
error: null,
hash: null,
txHash: null,
signature: null,
status: VegaTxStatus.AwaitingConfirmation,
status: VegaTxStatus.Requested,
});
const res = await sendTx(tx);
@ -65,7 +69,7 @@ export const useVegaTransaction = () => {
} else if (res.tx?.signature?.value && res.txHash) {
setTransaction({
status: VegaTxStatus.Pending,
hash: res.txHash,
txHash: res.txHash,
signature: res.tx.signature.value,
});
return {
@ -79,12 +83,7 @@ export const useVegaTransaction = () => {
);
const reset = useCallback(() => {
setTransaction({
error: null,
hash: null,
signature: null,
status: VegaTxStatus.Default,
});
setTransaction(initialState);
}, [setTransaction]);
return { send, transaction, reset };

View File

@ -1,6 +1,6 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"ignorePatterns": ["!**/*", "__generated__"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],

4
libs/web3/package.json Normal file
View File

@ -0,0 +1,4 @@
{
"name": "@vegaprotocol/web3",
"version": "0.0.1"
}

View File

@ -4,6 +4,26 @@
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nrwl/web:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/web3",
"tsConfig": "libs/web3/tsconfig.lib.json",
"project": "libs/web3/package.json",
"entryFile": "libs/web3/src/index.ts",
"external": ["react/jsx-runtime"],
"rollupConfig": "@nrwl/react/plugins/bundle-rollup",
"compiler": "babel",
"assets": [
{
"glob": "libs/web3/README.md",
"input": ".",
"output": "."
}
]
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],

View File

@ -1,2 +1,8 @@
export * from './lib/web3-provider';
export * from './lib/web3-connect-dialog';
export * from './lib/ethereum-error';
export * from './lib/use-bridge-contract';
export * from './lib/use-token-contract';
export * from './lib/use-ethereum-read-contract';
export * from './lib/use-ethereum-transaction';
export * from './lib/transaction-dialog';

View File

@ -1,10 +1,11 @@
import { EtherscanLink } from '../etherscan-link';
import { t, TxState } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers';
import { EtherscanLink } from '@vegaprotocol/ui-toolkit';
import { EthTxStatus } from '../use-ethereum-transaction';
const ACTIVE_CLASSES = 'text-black dark:text-white';
export const ConfirmRow = ({ status }: { status: TxState }) => {
if (status === TxState.Requested) {
export const ConfirmRow = ({ status }: { status: EthTxStatus }) => {
if (status === EthTxStatus.Requested) {
return (
<p className="text-black dark:text-white">
{t('Confirm transaction in wallet')}
@ -16,7 +17,7 @@ export const ConfirmRow = ({ status }: { status: TxState }) => {
};
interface TxRowProps {
status: TxState;
status: EthTxStatus;
txHash: string | null;
confirmations: number;
requiredConfirmations: number;
@ -30,7 +31,7 @@ export const TxRow = ({
requiredConfirmations,
highlightComplete = true,
}: TxRowProps) => {
if (status === TxState.Pending) {
if (status === EthTxStatus.Pending) {
return (
<p className={`flex justify-between ${ACTIVE_CLASSES}`}>
<span>
@ -47,7 +48,7 @@ export const TxRow = ({
);
}
if (status === TxState.Complete) {
if (status === EthTxStatus.Complete) {
return (
<p
className={`flex justify-between ${
@ -68,7 +69,7 @@ export const TxRow = ({
};
interface ConfirmationEventRowProps {
status: TxState;
status: EthTxStatus;
confirmed: boolean;
}
@ -76,7 +77,7 @@ export const ConfirmationEventRow = ({
status,
confirmed,
}: ConfirmationEventRowProps) => {
if (status !== TxState.Complete) {
if (status !== EthTxStatus.Complete) {
return <p>{t('Vega confirmation')}</p>;
}

View File

@ -1,5 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { EthereumError, TxState } from '@vegaprotocol/react-helpers';
import { EthereumError } from '../ethereum-error';
import { EthTxStatus } from '../use-ethereum-transaction';
import type { TransactionDialogProps } from './transaction-dialog';
import { TransactionDialog } from './transaction-dialog';
@ -14,7 +15,7 @@ let props: TransactionDialogProps;
beforeEach(() => {
props = {
name: 'test',
status: TxState.Default,
status: EthTxStatus.Default,
txHash: null,
error: null,
confirmations: 1,
@ -31,14 +32,14 @@ test('Opens when tx starts and closes if the user rejects the tx', () => {
// Dialog closed by default
expect(container).toBeEmptyDOMElement();
rerender(generateJsx({ status: TxState.Pending }));
rerender(generateJsx({ status: EthTxStatus.Pending }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// User rejecting the tx closes the dialog
rerender(
generateJsx({
status: TxState.Error,
status: EthTxStatus.Error,
error: new EthereumError('User rejected', 4001),
})
);
@ -48,26 +49,26 @@ test('Opens when tx starts and closes if the user rejects the tx', () => {
test('Doesn\t repoen if user dismissed the dialog', () => {
const { container, rerender } = render(
generateJsx({ status: TxState.Pending })
generateJsx({ status: EthTxStatus.Pending })
);
fireEvent.click(screen.getByTestId('dialog-close'));
expect(container).toBeEmptyDOMElement();
rerender(generateJsx({ status: TxState.Complete }));
rerender(generateJsx({ status: EthTxStatus.Complete }));
// Should still be closed even though tx updated
expect(container).toBeEmptyDOMElement();
});
test('Dialog states', () => {
const { rerender } = render(generateJsx({ status: TxState.Requested }));
const { rerender } = render(generateJsx({ status: EthTxStatus.Requested }));
expect(screen.getByText('Confirm transaction')).toBeInTheDocument();
expect(screen.getByText('Confirm transaction in wallet')).toBeInTheDocument();
expect(screen.getByText('Await Ethereum transaction')).toBeInTheDocument();
rerender(generateJsx({ status: TxState.Pending, confirmations: 0 }));
rerender(generateJsx({ status: EthTxStatus.Pending, confirmations: 0 }));
expect(screen.getByText(`${props.name} pending`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
expect(
@ -75,20 +76,22 @@ test('Dialog states', () => {
).toBeInTheDocument();
expect(screen.getByTestId('etherscan-link')).toBeInTheDocument();
rerender(generateJsx({ status: TxState.Complete, confirmations: 1 }));
rerender(generateJsx({ status: EthTxStatus.Complete, confirmations: 1 }));
expect(screen.getByText(`${props.name} complete`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
expect(screen.getByText('Ethereum transaction complete')).toBeInTheDocument();
const errorMsg = 'Something went wrong';
rerender(generateJsx({ status: TxState.Error, error: new Error(errorMsg) }));
rerender(
generateJsx({ status: EthTxStatus.Error, error: new Error(errorMsg) })
);
expect(screen.getByText(`${props.name} failed`)).toBeInTheDocument();
expect(screen.getByText(errorMsg)).toBeInTheDocument();
});
test('Success state waits for confirmation event if provided', () => {
const { rerender } = render(
generateJsx({ status: TxState.Complete, confirmed: false })
generateJsx({ status: EthTxStatus.Complete, confirmed: false })
);
expect(screen.getByText(`${props.name} pending`)).toBeInTheDocument();
expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument();
@ -98,7 +101,7 @@ test('Success state waits for confirmation event if provided', () => {
).toBeInTheDocument();
// @ts-ignore enforce truthy on confirmation event
rerender(generateJsx({ confirmed: true, status: TxState.Complete }));
rerender(generateJsx({ confirmed: true, status: EthTxStatus.Complete }));
expect(
screen.queryByText('Vega is confirming your transaction...')
).not.toBeInTheDocument();

View File

@ -1,19 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import {
t,
TxState,
isExpectedEthereumError,
} from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers';
import { Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit';
import { isExpectedEthereumError } from '../ethereum-error';
import { EthTxStatus } from '../use-ethereum-transaction';
import { ConfirmRow, TxRow, ConfirmationEventRow } from './dialog-rows';
import { DialogWrapper } from './dialog-wrapper';
import { Loader } from '../loader';
import { Intent } from '../../utils/intent';
import { Dialog } from '../dialog';
import { Icon } from '../icon';
export interface TransactionDialogProps {
name: string;
status: TxState;
status: EthTxStatus;
error: Error | null;
confirmations: number;
txHash: string | null;
@ -36,7 +31,7 @@ export const TransactionDialog = ({
const dialogDismissed = useRef(false);
const renderContent = () => {
if (status === TxState.Error) {
if (status === EthTxStatus.Error) {
return (
<p className="break-all text-black dark:text-white">
{error && error.message}
@ -63,22 +58,22 @@ export const TransactionDialog = ({
const getWrapperProps = () => {
const propsMap = {
[TxState.Error]: {
[EthTxStatus.Error]: {
title: t(`${name} failed`),
icon: <Icon name="warning-sign" size={20} />,
intent: Intent.Danger,
},
[TxState.Requested]: {
[EthTxStatus.Requested]: {
title: t('Confirm transaction'),
icon: <Icon name="hand-up" size={20} />,
intent: Intent.Prompt,
},
[TxState.Pending]: {
[EthTxStatus.Pending]: {
title: t(`${name} pending`),
icon: <Loader size="small" />,
intent: Intent.Progress,
},
[TxState.Complete]: {
[EthTxStatus.Complete]: {
title: t(`${name} complete`),
icon: <Icon name="tick" />,
intent: Intent.Success,
@ -86,7 +81,7 @@ export const TransactionDialog = ({
};
// Dialog not showing
if (status === TxState.Default) {
if (status === EthTxStatus.Default) {
return { intent: undefined, title: '', icon: null };
}
@ -94,11 +89,11 @@ export const TransactionDialog = ({
if (confirmed !== undefined) {
// Vega has confirmed Tx
if (confirmed === true) {
return propsMap[TxState.Complete];
return propsMap[EthTxStatus.Complete];
}
// Tx is complete but still awaiting for Vega to confirm
else if (status === TxState.Complete) {
return propsMap[TxState.Pending];
else if (status === EthTxStatus.Complete) {
return propsMap[EthTxStatus.Pending];
}
}
@ -107,12 +102,12 @@ export const TransactionDialog = ({
useEffect(() => {
// Close dialog if error is due to user rejecting the tx
if (status === TxState.Error && isExpectedEthereumError(error)) {
if (status === EthTxStatus.Error && isExpectedEthereumError(error)) {
setDialogOpen(false);
return;
}
if (status !== TxState.Default && !dialogDismissed.current) {
if (status !== EthTxStatus.Default && !dialogDismissed.current) {
setDialogOpen(true);
return;
}

View File

@ -1,7 +1,7 @@
import { MockedProvider } from '@apollo/client/testing';
import { waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react-hooks/dom';
import { TxState } from './use-ethereum-transaction';
import { EthTxStatus } from './use-ethereum-transaction';
import type { ReactNode } from 'react';
import { useEthereumTransaction } from './use-ethereum-transaction';
import type { ethers } from 'ethers';
@ -31,6 +31,7 @@ class MockContract {
setTimeout(
() =>
resolve({
from: 'foo',
confirmations: this.confirmations,
} as ethers.ContractReceipt),
100
@ -65,44 +66,52 @@ test('Ethereum transaction flow', async () => {
});
expect(result.current).toEqual({
status: TxState.Default,
txHash: null,
error: null,
confirmations: 0,
transaction: {
status: EthTxStatus.Default,
txHash: null,
error: null,
confirmations: 0,
receipt: null,
},
perform: expect.any(Function),
reset: expect.any(Function),
});
act(() => {
result.current.perform();
});
expect(result.current.status).toEqual(TxState.Requested);
expect(result.current.confirmations).toBe(0);
expect(result.current.transaction.status).toEqual(EthTxStatus.Requested);
expect(result.current.transaction.confirmations).toBe(0);
await waitFor(() => {
expect(result.current.status).toEqual(TxState.Pending);
expect(result.current.txHash).toEqual(MockContract.txHash);
expect(result.current.transaction.status).toEqual(EthTxStatus.Pending);
expect(result.current.transaction.txHash).toEqual(MockContract.txHash);
});
await act(async () => {
jest.advanceTimersByTime(100);
});
expect(result.current.confirmations).toBe(1);
expect(result.current.status).toEqual(TxState.Pending);
expect(result.current.transaction.confirmations).toBe(1);
expect(result.current.transaction.status).toEqual(EthTxStatus.Pending);
await act(async () => {
jest.advanceTimersByTime(100);
});
expect(result.current.confirmations).toBe(2);
expect(result.current.status).toEqual(TxState.Pending);
expect(result.current.transaction.confirmations).toBe(2);
expect(result.current.transaction.status).toEqual(EthTxStatus.Pending);
await act(async () => {
jest.advanceTimersByTime(100);
});
expect(result.current.confirmations).toBe(3);
expect(result.current.transaction.confirmations).toBe(3);
// Now complete as required confirmations have been surpassed
expect(result.current.status).toEqual(TxState.Complete);
expect(result.current.transaction.status).toEqual(EthTxStatus.Complete);
expect(result.current.transaction.receipt).toEqual({
from: 'foo',
confirmations: result.current.transaction.confirmations,
});
});
test('Error handling', async () => {
@ -116,7 +125,7 @@ test('Error handling', async () => {
result.current.perform();
});
expect(result.current.status).toEqual(TxState.Error);
expect(result.current.error instanceof EthereumError).toBe(true);
expect(result.current.error?.message).toBe(errorMsg);
expect(result.current.transaction.status).toEqual(EthTxStatus.Error);
expect(result.current.transaction.error instanceof EthereumError).toBe(true);
expect(result.current.transaction.error?.message).toBe(errorMsg);
});

View File

@ -0,0 +1,102 @@
import type { ethers } from 'ethers';
import { useCallback, useState } from 'react';
import { EthereumError, isEthereumError } from './ethereum-error';
export enum EthTxStatus {
Default = 'Default',
Requested = 'Requested',
Pending = 'Pending',
Complete = 'Complete',
Error = 'Error',
}
export type TxError = Error | EthereumError;
export interface EthTxState {
status: EthTxStatus;
error: TxError | null;
txHash: string | null;
receipt: ethers.ContractReceipt | null;
confirmations: number;
}
export const initialState = {
status: EthTxStatus.Default,
error: null,
txHash: null,
receipt: null,
confirmations: 0,
};
export const useEthereumTransaction = <TArgs = void>(
performTransaction: (
args: TArgs
) => Promise<ethers.ContractTransaction> | null,
requiredConfirmations = 1
) => {
const [transaction, _setTransaction] = useState<EthTxState>(initialState);
const setTransaction = useCallback((update: Partial<EthTxState>) => {
_setTransaction((curr) => ({
...curr,
...update,
}));
}, []);
const perform = useCallback(
async (args: TArgs) => {
setTransaction({
status: EthTxStatus.Requested,
error: null,
confirmations: 0,
});
try {
const res = performTransaction(args);
if (res === null) {
setTransaction({ status: EthTxStatus.Default });
return;
}
const tx = await res;
let receipt: ethers.ContractReceipt | null = null;
setTransaction({ status: EthTxStatus.Pending, txHash: tx.hash });
for (let i = 1; i <= requiredConfirmations; i++) {
receipt = await tx.wait(i);
setTransaction({ confirmations: receipt.confirmations });
}
if (!receipt) {
throw new Error('No receipt after confirmations are met');
}
setTransaction({ status: EthTxStatus.Complete, receipt });
} catch (err) {
if (err instanceof Error) {
setTransaction({ status: EthTxStatus.Error, error: err });
} else if (isEthereumError(err)) {
setTransaction({
status: EthTxStatus.Error,
error: new EthereumError(err.message, err.code),
});
} else {
setTransaction({
status: EthTxStatus.Error,
error: new Error('Something went wrong'),
});
}
}
},
[performTransaction, requiredConfirmations, setTransaction]
);
const reset = useCallback(() => {
setTransaction(initialState);
}, [setTransaction]);
return { perform, transaction, reset };
};

12
libs/withdraws/.babelrc Normal file
View File

@ -0,0 +1,12 @@
{
"presets": [
[
"@nrwl/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@ -0,0 +1,18 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "__generated__"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

7
libs/withdraws/README.md Normal file
View File

@ -0,0 +1,7 @@
# withdraws
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test withdraws` to execute the unit tests via [Jest](https://jestjs.io).

View File

@ -0,0 +1,10 @@
module.exports = {
displayName: 'withdraws',
preset: '../../jest.preset.js',
transform: {
'^.+\\.[tj]sx?$': 'babel-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/withdraws',
setupFilesAfterEnv: ['./src/setup-tests.ts'],
};

View File

@ -0,0 +1,4 @@
{
"name": "@vegaprotocol/withdraws",
"version": "0.0.1"
}

View File

@ -0,0 +1,43 @@
{
"root": "libs/withdraws",
"sourceRoot": "libs/withdraws/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nrwl/web:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/withdraws",
"tsConfig": "libs/withdraws/tsconfig.lib.json",
"project": "libs/withdraws/package.json",
"entryFile": "libs/withdraws/src/index.ts",
"external": ["react/jsx-runtime"],
"rollupConfig": "@nrwl/react/plugins/bundle-rollup",
"compiler": "babel",
"assets": [
{
"glob": "libs/withdraws/README.md",
"input": ".",
"output": "."
}
]
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/withdraws/**/*.{ts,tsx,js,jsx}"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["coverage/libs/withdraws"],
"options": {
"jestConfig": "libs/withdraws/jest.config.js",
"passWithNoTests": true
}
}
}
}

View File

@ -0,0 +1,6 @@
export * from './lib/withdraw-form';
export * from './lib/withdraw-manager';
export * from './lib/withdrawals-table';
export * from './lib/use-complete-withdraw';
export * from './lib/use-withdraw';
export * from './lib/use-withdrawals';

View File

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL fragment: PendingWithdrawal
// ====================================================
export interface PendingWithdrawal {
__typename: "Withdrawal";
/**
* Whether or the not the withdrawal is being processed on Ethereum
*/
pendingOnForeignChain: boolean;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
}

View File

@ -0,0 +1,99 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { WithdrawalStatus } from "./../../../../types/src/__generated__/globalTypes";
// ====================================================
// GraphQL subscription operation: WithdrawalEvent
// ====================================================
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 {
__typename: "Asset";
/**
* The id of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The precision of the asset
*/
decimals: number;
}
export interface WithdrawalEvent_busEvents_event_Withdrawal_details {
__typename: "Erc20WithdrawalDetails";
/**
* The ethereum address of the receiver of the asset funds
*/
receiverAddress: string;
}
export interface WithdrawalEvent_busEvents_event_Withdrawal {
__typename: "Withdrawal";
/**
* The Vega internal id of the withdrawal
*/
id: string;
/**
* The current status of the withdrawal
*/
status: WithdrawalStatus;
/**
* The amount to be withdrawn
*/
amount: string;
/**
* The asset to be withdrawn
*/
asset: WithdrawalEvent_busEvents_event_Withdrawal_asset;
/**
* RFC3339Nano time at which the withdrawal was created
*/
createdTimestamp: string;
/**
* RFC3339Nano time at which the withdrawal was finalized
*/
withdrawnTimestamp: string | null;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
/**
* Foreign chain specific details about the withdrawal
*/
details: WithdrawalEvent_busEvents_event_Withdrawal_details | null;
/**
* Whether or the not the withdrawal is being processed on Ethereum
*/
pendingOnForeignChain: boolean;
}
export type WithdrawalEvent_busEvents_event = WithdrawalEvent_busEvents_event_TimeUpdate | WithdrawalEvent_busEvents_event_Withdrawal;
export interface WithdrawalEvent_busEvents {
__typename: "BusEvent";
/**
* the payload - the wrapped event
*/
event: WithdrawalEvent_busEvents_event;
}
export interface WithdrawalEvent {
/**
* Subscribe to event data from the event bus
*/
busEvents: WithdrawalEvent_busEvents[] | null;
}
export interface WithdrawalEventVariables {
partyId: string;
}

View File

@ -0,0 +1,74 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { WithdrawalStatus } from "./../../../../types/src/__generated__/globalTypes";
// ====================================================
// GraphQL fragment: WithdrawalFields
// ====================================================
export interface WithdrawalFields_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
*/
decimals: number;
}
export interface WithdrawalFields_details {
__typename: "Erc20WithdrawalDetails";
/**
* The ethereum address of the receiver of the asset funds
*/
receiverAddress: string;
}
export interface WithdrawalFields {
__typename: "Withdrawal";
/**
* The Vega internal id of the withdrawal
*/
id: string;
/**
* The current status of the withdrawal
*/
status: WithdrawalStatus;
/**
* The amount to be withdrawn
*/
amount: string;
/**
* The asset to be withdrawn
*/
asset: WithdrawalFields_asset;
/**
* RFC3339Nano time at which the withdrawal was created
*/
createdTimestamp: string;
/**
* RFC3339Nano time at which the withdrawal was finalized
*/
withdrawnTimestamp: string | null;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
/**
* Foreign chain specific details about the withdrawal
*/
details: WithdrawalFields_details | null;
/**
* Whether or the not the withdrawal is being processed on Ethereum
*/
pendingOnForeignChain: boolean;
}

View File

@ -0,0 +1,97 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { WithdrawalStatus } from "./../../../../types/src/__generated__/globalTypes";
// ====================================================
// GraphQL query operation: Withdrawals
// ====================================================
export interface Withdrawals_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
*/
decimals: number;
}
export interface Withdrawals_party_withdrawals_details {
__typename: "Erc20WithdrawalDetails";
/**
* The ethereum address of the receiver of the asset funds
*/
receiverAddress: string;
}
export interface Withdrawals_party_withdrawals {
__typename: "Withdrawal";
/**
* The Vega internal id of the withdrawal
*/
id: string;
/**
* The current status of the withdrawal
*/
status: WithdrawalStatus;
/**
* The amount to be withdrawn
*/
amount: string;
/**
* The asset to be withdrawn
*/
asset: Withdrawals_party_withdrawals_asset;
/**
* RFC3339Nano time at which the withdrawal was created
*/
createdTimestamp: string;
/**
* RFC3339Nano time at which the withdrawal was finalized
*/
withdrawnTimestamp: string | null;
/**
* Hash of the transaction on the foreign chain
*/
txHash: string | null;
/**
* Foreign chain specific details about the withdrawal
*/
details: Withdrawals_party_withdrawals_details | null;
/**
* Whether or the not the withdrawal is being processed on Ethereum
*/
pendingOnForeignChain: boolean;
}
export interface Withdrawals_party {
__typename: "Party";
/**
* Party identifier
*/
id: string;
/**
* The list of all withdrawals initiated by the party
*/
withdrawals: Withdrawals_party_withdrawals[] | null;
}
export interface Withdrawals {
/**
* An entity that is trading on the VEGA network
*/
party: Withdrawals_party | null;
}
export interface WithdrawalsVariables {
partyId: string;
}

View File

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const ERC20_APPROVAL_QUERY = gql`
query Erc20Approval($withdrawalId: ID!) {
erc20WithdrawalApproval(withdrawalId: $withdrawalId) {
assetSource
amount
nonce
signatures
targetAddress
expiry
}
}
`;

View File

@ -0,0 +1,58 @@
import { AccountType, WithdrawalStatus } from '@vegaprotocol/types';
import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest';
import type { Asset, Account } from './types';
import type { Withdrawals_party_withdrawals } from './__generated__/Withdrawals';
export const generateAsset = (override?: PartialDeep<Asset>) => {
const defaultAsset: Asset = {
id: 'asset-id',
symbol: 'asset-symbol',
name: 'asset-name',
decimals: 5,
source: {
contractAddress: 'contract-address',
},
};
return merge(defaultAsset, override);
};
export const generateAccount = (override?: PartialDeep<Account>) => {
const defaultAccount: Account = {
type: AccountType.General,
balance: '100000',
asset: {
id: 'asset-id',
symbol: 'asset-symbol',
},
};
return merge(defaultAccount, override);
};
export const generateWithdrawal = (
override?: PartialDeep<Withdrawals_party_withdrawals>
): Withdrawals_party_withdrawals => {
return merge(
{
__typename: 'Withdrawal',
id: 'withdrawal-id',
status: WithdrawalStatus.Open,
amount: '100',
asset: {
__typename: 'Asset',
id: 'asset-id',
symbol: 'asset-symbol',
decimals: 2,
},
createdTimestamp: '2022-04-20T00:00:00',
withdrawnTimestamp: null,
txHash: null,
details: {
__typename: 'Erc20WithdrawalDetails',
receiverAddress: '123456___123456',
},
pendingOnForeignChain: false,
},
override
);
};

View File

@ -0,0 +1,20 @@
import type { AccountType } from '@vegaprotocol/types';
export interface Asset {
id: string;
symbol: string;
name: string;
decimals: number;
source: {
contractAddress: string;
};
}
export interface Account {
type: AccountType;
balance: string;
asset: {
id: string;
symbol: string;
};
}

View File

@ -0,0 +1,121 @@
import { act, renderHook } from '@testing-library/react-hooks';
import { waitFor } from '@testing-library/react';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { ReactNode } from 'react';
import { useCompleteWithdraw } from './use-complete-withdraw';
import type {
Erc20Approval,
Erc20Approval_erc20WithdrawalApproval,
} from './__generated__/Erc20Approval';
import { ERC20_APPROVAL_QUERY } from './queries';
import * as web3 from '@vegaprotocol/web3';
import * as sentry from '@sentry/nextjs';
jest.mock('@vegaprotocol/web3', () => ({
useBridgeContract: jest.fn(),
useEthereumTransaction: jest.fn(),
}));
function setup(mocks?: MockedResponse[]) {
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks}>{children}</MockedProvider>
);
return renderHook(() => useCompleteWithdraw(), { wrapper });
}
test('Should perform the Ethereum transaction with the fetched approval', async () => {
const withdrawalId = 'withdrawal-id';
const erc20WithdrawalApproval: Erc20Approval_erc20WithdrawalApproval = {
__typename: 'Erc20WithdrawalApproval',
assetSource: 'asset-source',
amount: '100',
nonce: '1',
signatures: 'signatures',
targetAddress: 'target-address',
expiry: 'expiry',
};
const mockERC20Approval: MockedResponse<Erc20Approval> = {
request: {
query: ERC20_APPROVAL_QUERY,
variables: { withdrawalId },
},
result: {
data: {
erc20WithdrawalApproval,
},
},
};
const mockPerform = jest.fn();
jest.spyOn(web3, 'useEthereumTransaction').mockReturnValue({
// @ts-ignore allow null transaction as its not used in hook logic
transaction: { txHash: 'tx-hash' },
perform: mockPerform,
});
const { result } = setup([mockERC20Approval]);
act(() => {
result.current.submit(withdrawalId);
});
await waitFor(() => {
expect(mockPerform).toHaveBeenCalledWith(erc20WithdrawalApproval);
expect(result.current.withdrawalId).toBe(withdrawalId);
});
});
test('Captures an error if the erc20Withdrawal is not found', async () => {
const withdrawalId = 'withdrawal-id';
const mockERC20Approval: MockedResponse<Erc20Approval> = {
request: {
query: ERC20_APPROVAL_QUERY,
variables: { withdrawalId },
},
result: {
data: {
erc20WithdrawalApproval: null,
},
},
};
const mockPerform = jest.fn();
const spyOnCaptureException = jest.spyOn(sentry, 'captureException');
jest.spyOn(web3, 'useEthereumTransaction').mockReturnValue({
// @ts-ignore allow null transaction as its not used in hook logic
transaction: { txHash: 'tx-hash' },
perform: mockPerform,
});
const { result } = setup([mockERC20Approval]);
act(() => {
result.current.submit(withdrawalId);
});
await waitFor(() => {
expect(mockPerform).not.toHaveBeenCalled();
expect(spyOnCaptureException).toHaveBeenCalled();
expect(result.current.withdrawalId).toBe(withdrawalId);
});
});
test('Captures an error if erc20 approval query fails', async () => {
const withdrawalId = 'withdrawal-id';
const mockERC20Approval: MockedResponse<Erc20Approval> = {
request: {
query: ERC20_APPROVAL_QUERY,
variables: { withdrawalId },
},
error: new Error('query failed'),
};
const mockPerform = jest.fn();
const spyOnCaptureException = jest.spyOn(sentry, 'captureException');
jest.spyOn(web3, 'useEthereumTransaction').mockReturnValue({
// @ts-ignore allow null transaction as its not used in hook logic
transaction: { txHash: 'tx-hash' },
perform: mockPerform,
});
const { result } = setup([mockERC20Approval]);
act(() => {
result.current.submit(withdrawalId);
});
await waitFor(() => {
expect(mockPerform).not.toHaveBeenCalled();
expect(spyOnCaptureException).toHaveBeenCalled();
expect(result.current.withdrawalId).toBe(withdrawalId);
});
});

View File

@ -0,0 +1,75 @@
import { gql, useApolloClient } from '@apollo/client';
import { captureException } from '@sentry/nextjs';
import { useBridgeContract, useEthereumTransaction } from '@vegaprotocol/web3';
import { useCallback, useEffect, useState } from 'react';
import { ERC20_APPROVAL_QUERY } from './queries';
import type {
Erc20Approval,
Erc20ApprovalVariables,
} from './__generated__/Erc20Approval';
import type { PendingWithdrawal } from './__generated__/PendingWithdrawal';
export const PENDING_WITHDRAWAL_FRAGMMENT = gql`
fragment PendingWithdrawal on Withdrawal {
pendingOnForeignChain @client
txHash
}
`;
export interface WithdrawTransactionArgs {
assetSource: string;
amount: string;
nonce: string;
signatures: string;
targetAddress: string;
}
export const useCompleteWithdraw = () => {
const { query, cache } = useApolloClient();
const contract = useBridgeContract();
const [id, setId] = useState('');
const { transaction, perform } =
useEthereumTransaction<WithdrawTransactionArgs>((args) => {
if (!contract) {
return null;
}
return contract.withdraw(args);
});
const submit = useCallback(
async (withdrawalId: string) => {
setId(withdrawalId);
try {
const res = await query<Erc20Approval, Erc20ApprovalVariables>({
query: ERC20_APPROVAL_QUERY,
variables: { withdrawalId },
});
if (!res.data.erc20WithdrawalApproval) {
throw new Error('Could not retrieve withdrawal approval');
}
perform(res.data.erc20WithdrawalApproval);
} catch (err) {
captureException(err);
}
},
[query, perform]
);
useEffect(() => {
if (id && transaction.txHash) {
cache.writeFragment<PendingWithdrawal>({
id: `Withdrawal:${id}`,
fragment: PENDING_WITHDRAWAL_FRAGMMENT,
data: {
__typename: 'Withdrawal',
pendingOnForeignChain: true,
txHash: transaction.txHash,
},
});
}
}, [cache, transaction.txHash, id]);
return { transaction, submit, withdrawalId: id };
};

View File

@ -0,0 +1,179 @@
import { act, renderHook } from '@testing-library/react-hooks';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { ReactNode } from 'react';
import type { Erc20Approval } from './__generated__/Erc20Approval';
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';
jest.mock('@vegaprotocol/web3', () => ({
useBridgeContract: jest.fn(),
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',
},
},
},
delay: 5000,
};
});
test('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
);
expect(mockPerform).toHaveBeenCalledWith(
// @ts-ignore MockedRespones types inteferring
mockERC20Approval.result.data.erc20WithdrawalApproval
);
});
test('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();
});
test('Reset will reset both transactions', async () => {
const { result } = setup([mockERC20Approval]);
await act(async () => {
result.current.reset();
});
expect(mockEthReset).toHaveBeenCalled();
expect(mockVegaReset).toHaveBeenCalled();
});

View File

@ -0,0 +1,107 @@
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 { WithdrawTransactionArgs } from './use-complete-withdraw';
import type {
Erc20Approval,
Erc20ApprovalVariables,
Erc20Approval_erc20WithdrawalApproval,
} from './__generated__/Erc20Approval';
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<WithdrawTransactionArgs>((args) => {
if (!contract) {
return null;
}
return contract.withdraw(args);
});
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) {
stopPolling();
setApproval(data.erc20WithdrawalApproval);
}
}, [data, stopPolling]);
useEffect(() => {
if (approval && !cancelled) {
perform(approval);
}
// eslint-disable-next-line
}, [approval]);
const reset = useCallback(() => {
resetVegaTx();
resetEthTx();
}, [resetVegaTx, resetEthTx]);
return {
submit,
reset,
approval,
withdrawalId,
vegaTx,
ethTx,
};
};

View File

@ -0,0 +1,127 @@
import { WithdrawalStatus } from '@vegaprotocol/types';
import { generateWithdrawal } from './test-helpers';
import { updateQuery } from './use-withdrawals';
import type {
WithdrawalEvent,
WithdrawalEvent_busEvents_event_Withdrawal,
} from './__generated__/WithdrawalEvent';
import type {
Withdrawals,
Withdrawals_party_withdrawals,
} from './__generated__/Withdrawals';
describe('updateQuery', () => {
test('Updates existing withdrawals', () => {
const withdrawal = generateWithdrawal({
id: '1',
status: WithdrawalStatus.Open,
});
const withdrawalUpdate = generateWithdrawal({
id: '1',
status: WithdrawalStatus.Finalized,
});
const prev = mockQuery([withdrawal]);
const incoming = mockSub([withdrawalUpdate]);
expect(updateQuery(prev, incoming)).toEqual({
party: {
__typename: 'Party',
id: 'party-id',
withdrawals: [withdrawalUpdate],
},
});
});
test('Adds new withdrawals', () => {
const withdrawal = generateWithdrawal({
id: '1',
amount: '100',
});
const withdrawalUpdate = generateWithdrawal({
id: '2',
amount: '200',
});
const prev = mockQuery([withdrawal]);
const incoming = mockSub([withdrawalUpdate]);
expect(updateQuery(prev, incoming)).toEqual({
party: {
__typename: 'Party',
id: 'party-id',
withdrawals: [withdrawalUpdate, withdrawal],
},
});
});
test('Creates new party if not present', () => {
const withdrawalUpdate = generateWithdrawal({
id: '2',
});
const incoming = mockSub([withdrawalUpdate]);
expect(updateQuery({ party: null }, incoming)).toEqual({
party: {
__typename: 'Party',
withdrawals: [withdrawalUpdate],
},
});
});
test('Handles updates and inserts simultaneously', () => {
const withdrawal1 = generateWithdrawal({
id: '1',
status: WithdrawalStatus.Open,
});
const withdrawal2 = generateWithdrawal({
id: '2',
});
const withdrawalUpdate = generateWithdrawal({
id: '1',
status: WithdrawalStatus.Finalized,
});
const withdrawalNew = generateWithdrawal({
id: '3',
});
const prev = mockQuery([withdrawal1, withdrawal2]);
const incoming = mockSub([withdrawalUpdate, withdrawalNew]);
expect(updateQuery(prev, incoming)).toEqual({
party: {
__typename: 'Party',
id: 'party-id',
withdrawals: [withdrawalUpdate, withdrawalNew, withdrawal2],
},
});
});
});
const mockQuery = (
withdrawals: Withdrawals_party_withdrawals[]
): Withdrawals => {
return {
party: {
__typename: 'Party',
id: 'party-id',
withdrawals,
},
};
};
const mockSub = (
withdrawals: WithdrawalEvent_busEvents_event_Withdrawal[]
): {
subscriptionData: {
data: WithdrawalEvent;
};
} => {
return {
subscriptionData: {
data: {
busEvents: withdrawals.map((w) => ({
__typename: 'BusEvent',
event: w,
})),
},
},
};
};

View File

@ -0,0 +1,144 @@
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 type {
WithdrawalEvent,
WithdrawalEventVariables,
WithdrawalEvent_busEvents_event,
WithdrawalEvent_busEvents_event_Withdrawal,
} from './__generated__/WithdrawalEvent';
import type {
Withdrawals,
WithdrawalsVariables,
} from './__generated__/Withdrawals';
const WITHDRAWAL_FRAGMENT = gql`
fragment WithdrawalFields on Withdrawal {
id
status
amount
asset {
id
symbol
decimals
}
createdTimestamp
withdrawnTimestamp
txHash
details {
... on Erc20WithdrawalDetails {
receiverAddress
}
}
pendingOnForeignChain @client
}
`;
export const WITHDRAWALS_QUERY = gql`
${WITHDRAWAL_FRAGMENT}
query Withdrawals($partyId: ID!) {
party(id: $partyId) {
id
withdrawals {
...WithdrawalFields
}
}
}
`;
export const WITHDRAWAL_BUS_EVENT_SUB = gql`
${WITHDRAWAL_FRAGMENT}
subscription WithdrawalEvent($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [Withdrawal]) {
event {
... on Withdrawal {
...WithdrawalFields
}
}
}
}
`;
export const useWithdrawals = () => {
const { keypair } = useVegaWallet();
const { subscribeToMore, ...queryResult } = useQuery<
Withdrawals,
WithdrawalsVariables
>(WITHDRAWALS_QUERY, {
variables: { partyId: keypair?.pub || '' },
skip: !keypair?.pub,
});
useEffect(() => {
if (!keypair?.pub) return;
const unsub = subscribeToMore<WithdrawalEvent, WithdrawalEventVariables>({
document: WITHDRAWAL_BUS_EVENT_SUB,
variables: { partyId: keypair.pub },
updateQuery,
});
return () => {
unsub();
};
}, [keypair?.pub, subscribeToMore]);
return queryResult;
};
export const updateQuery: UpdateQueryFn<
Withdrawals,
WithdrawalEventVariables,
WithdrawalEvent
> = (prev, { subscriptionData }) => {
if (!subscriptionData.data.busEvents?.length) {
return prev;
}
const curr = prev.party?.withdrawals || [];
const incoming = subscriptionData.data.busEvents
.map((e) => {
return {
...e.event,
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[];
const withdrawals = uniqBy([...incoming, ...curr], 'id');
// Write new party to cache if not present
if (!prev.party) {
return {
...prev,
party: {
__typename: 'Party',
withdrawals,
},
} as Withdrawals;
}
return {
...prev,
party: {
...prev.party,
withdrawals,
},
};
};
const isWithdrawalEvent = (
event: WithdrawalEvent_busEvents_event
): event is WithdrawalEvent_busEvents_event_Withdrawal => {
if (event.__typename === 'Withdrawal') {
return true;
}
return false;
};

View File

@ -0,0 +1,112 @@
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',
};
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} />;
};
test('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

@ -0,0 +1,178 @@
import {
Dialog,
EtherscanLink,
Icon,
Intent,
Loader,
} from '@vegaprotocol/ui-toolkit';
import type { VegaTxState } from '@vegaprotocol/wallet';
import { VegaTxStatus } from '@vegaprotocol/wallet';
import type { ReactNode } from 'react';
import type { EthTxState } 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 { intent, ...props } = getProps(approval, vegaTx, ethTx);
return (
<Dialog open={dialogOpen} intent={intent} onChange={onDialogChange}>
<DialogWrapper {...props} />
</Dialog>
);
};
interface DialogWrapperProps {
children: ReactNode;
icon: ReactNode;
title: string;
}
export const DialogWrapper = ({
children,
icon,
title,
}: DialogWrapperProps) => {
return (
<div className="flex gap-12 max-w-full text-ui">
<div className="pt-8 fill-current">{icon}</div>
<div className="flex-1">
<h1 className="text-h4 text-black dark:text-white capitalize mb-12">
{title}
</h1>
{children}
</div>
</div>
);
};
interface StepProps {
children: ReactNode;
}
const Step = ({ children }: StepProps) => {
return <p 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
) => {
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" size={20} />,
intent: Intent.Danger,
children: (
<Step>
{vegaTx.error && (
<pre className="text-ui break-all whitespace-pre-wrap">
{JSON.stringify(vegaTx.error, null, 2)}
</pre>
)}
</Step>
),
},
[VegaTxStatus.Requested]: {
title: t('Confirm withdrawal'),
icon: <Icon name="hand-up" size={20} />,
intent: Intent.Prompt,
children: <Step>Confirm withdrawal in Vega wallet</Step>,
},
[VegaTxStatus.Pending]: {
title: t('Withdrawal transaction pending'),
icon: <Loader size="small" />,
intent: Intent.Progress,
children: <Step>Awaiting transaction</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" size={20} />,
intent: Intent.Danger,
children: (
<Step>
{ethTx.error ? ethTx.error.message : t('Something went wrong')}
</Step>
),
},
[EthTxStatus.Requested]: {
title: t('Confirm transaction'),
icon: <Icon name="hand-up" size={20} />,
intent: Intent.Prompt,
children: <Step>{t('Confirm transaction in wallet')}</Step>,
},
[EthTxStatus.Pending]: {
title: t('Ethereum transaction pending'),
icon: <Loader size="small" />,
intent: Intent.Progress,
children: (
<Step>
<span>
{t(
`Awaiting Ethereum transaction ${ethTx.confirmations}/1 confirmations...`
)}
</span>
<EtherscanLink
tx={ethTx.txHash || ''}
className="text-vega-pink dark:text-vega-yellow"
text={t('View on Etherscan')}
/>
</Step>
),
},
[EthTxStatus.Complete]: {
title: t('Withdrawal complete'),
icon: <Icon name="tick" />,
intent: Intent.Success,
children: (
<Step>
<span>{t('Ethereum transaction complete')}</span>
<EtherscanLink
tx={ethTx.txHash || ''}
className="text-vega-pink dark:text-vega-yellow"
text={t('View on Etherscan')}
/>
</Step>
),
},
};
return approval ? ethTxPropsMap[ethTx.status] : vegaTxPropsMap[vegaTx.status];
};

View File

@ -0,0 +1,108 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import BigNumber from 'bignumber.js';
import { WithdrawForm } from './withdraw-form';
import type { WithdrawFormProps } from './withdraw-form';
import { generateAsset } from './test-helpers';
const ethereumAddress = '0x72c22822A19D20DE7e426fB84aa047399Ddd8853';
let props: WithdrawFormProps;
beforeEach(() => {
const assets = [
generateAsset(),
generateAsset({
id: 'asset-id-2',
symbol: 'asset-symbol-2',
name: 'asset-name-2',
}),
];
props = {
assets,
min: new BigNumber(0.00001),
max: new BigNumber(100),
ethereumAccount: undefined,
selectedAsset: undefined,
onSelectAsset: jest.fn(),
submitWithdraw: jest.fn().mockReturnValue(Promise.resolve()),
};
});
const generateJsx = (props: WithdrawFormProps) => <WithdrawForm {...props} />;
test('Validation', async () => {
const { rerender } = render(generateJsx(props));
fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(await screen.findAllByRole('alert')).toHaveLength(3);
expect(screen.getAllByText('Required')).toHaveLength(3);
// Selected asset state lives in state so rerender with it now selected
rerender(generateJsx({ ...props, selectedAsset: props.assets[0] }));
fireEvent.change(screen.getByLabelText('Asset'), {
target: { value: props.assets[0].id },
});
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: 'invalid-address' },
});
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '101' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(
await screen.findByText('Invalid Ethereum address')
).toBeInTheDocument();
expect(screen.getByText('Value is above maximum')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress },
});
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.000000000001' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(await screen.findByText('Value is below minimum')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '40' },
});
await act(async () => {
fireEvent.submit(screen.getByTestId('withdraw-form'));
});
expect(props.submitWithdraw).toHaveBeenCalledWith({
asset: props.assets[0].id,
amount: '4000000',
receiverAddress: ethereumAddress,
});
});
test('Use max button', () => {
const asset = props.assets[0];
render(generateJsx({ ...props, selectedAsset: asset }));
fireEvent.click(screen.getByText('Use maximum'));
expect(screen.getByLabelText('Amount')).toHaveValue(
Number(props.max.toFixed(asset.decimals))
);
});
test('Use connected Ethereum account', () => {
render(generateJsx({ ...props, ethereumAccount: ethereumAddress }));
fireEvent.click(screen.getByText('Use connected'));
expect(screen.getByLabelText('To (Ethereum address)')).toHaveValue(
ethereumAddress
);
});

View File

@ -0,0 +1,184 @@
import {
ethereumAddress,
maxSafe,
minSafe,
t,
removeDecimal,
required,
} from '@vegaprotocol/react-helpers';
import {
Button,
FormGroup,
Input,
InputError,
Select,
} from '@vegaprotocol/ui-toolkit';
import type BigNumber from 'bignumber.js';
import type { ReactNode } from 'react';
import { useForm, Controller } from 'react-hook-form';
import type { WithdrawalFields } from './use-withdraw';
import type { Asset } from './types';
interface FormFields {
asset: string;
to: string;
amount: string;
}
export interface WithdrawFormProps {
assets: Asset[];
max: BigNumber;
min: BigNumber;
selectedAsset?: Asset;
ethereumAccount?: string;
onSelectAsset: (assetId: string) => void;
submitWithdraw: (withdrawal: WithdrawalFields) => void;
}
export const WithdrawForm = ({
assets,
max,
min,
selectedAsset,
ethereumAccount,
onSelectAsset,
submitWithdraw,
}: WithdrawFormProps) => {
const {
register,
handleSubmit,
setValue,
clearErrors,
control,
formState: { errors },
} = useForm<FormFields>({
defaultValues: {
asset: selectedAsset?.id,
to: ethereumAccount,
},
});
const onSubmit = async (fields: FormFields) => {
if (!selectedAsset) {
throw new Error('Asset not selected');
}
submitWithdraw({
asset: selectedAsset.id,
amount: removeDecimal(fields.amount, selectedAsset.decimals),
receiverAddress: fields.to,
});
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate={true}
data-testid="withdraw-form"
>
<FormGroup label={t('Asset')} labelFor="asset" className="relative">
<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.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</Select>
)}
/>
{errors.asset?.message && (
<InputError intent="danger" className="mt-4">
{errors.asset.message}
</InputError>
)}
</FormGroup>
<FormGroup
label={t('To (Ethereum address)')}
labelFor="ethereum-address"
className="relative"
>
<Input
{...register('to', { validate: { required, ethereumAddress } })}
id="ethereum-address"
/>
{errors.to?.message && (
<InputError intent="danger" className="mt-4">
{errors.to.message}
</InputError>
)}
{ethereumAccount && (
<UseButton
onClick={() => {
setValue('to', ethereumAccount);
clearErrors('to');
}}
>
{t('Use connected')}
</UseButton>
)}
</FormGroup>
<FormGroup label={t('Amount')} labelFor="amount" className="relative">
<Input
type="number"
autoComplete="off"
id="amount"
{...register('amount', {
validate: {
required,
maxSafe: (value) => maxSafe(max)(value),
minSafe: (value) => minSafe(min)(value),
},
})}
/>
{errors.amount?.message && (
<InputError intent="danger" className="mt-4">
{errors.amount.message}
</InputError>
)}
{selectedAsset && (
<UseButton
onClick={() => {
setValue('amount', max.toFixed(selectedAsset.decimals));
clearErrors('amount');
}}
>
{t('Use maximum')}
</UseButton>
)}
</FormGroup>
<Button type="submit">Submit</Button>
</form>
);
};
interface UseButtonProps {
children: ReactNode;
onClick: () => void;
}
const UseButton = ({ children, onClick }: UseButtonProps) => {
return (
<button
type="button"
className="ml-auto text-ui absolute top-0 right-0 underline"
onClick={onClick}
>
{children}
</button>
);
};

View File

@ -0,0 +1,127 @@
import { fireEvent, render, screen } from '@testing-library/react';
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';
const ethereumAddress = '0x72c22822A19D20DE7e426fB84aa047399Ddd8853';
jest.mock('@web3-react/core', () => ({
useWeb3React: () => ({ account: ethereumAddress }),
}));
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,
};
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} />
);
test('Valid form submission opens transaction dialog', async () => {
render(generateJsx(props));
submitValid();
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(mockReset).toHaveBeenCalled();
expect(mockSubmit).toHaveBeenCalledWith({
amount: '1000',
asset: props.assets[0].id,
receiverAddress: ethereumAddress,
});
});
test('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),
},
});
rerender(generateJsx(props));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('Correct min max values provided to form', async () => {
render(generateJsx(props));
// Set other fields to be valid
fireEvent.change(screen.getByLabelText('Asset'), {
target: { value: props.assets[0].id },
});
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress },
});
// Min amount
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00000001' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(await screen.findByText('Value is below minimum')).toBeInTheDocument();
expect(mockSubmit).not.toBeCalled();
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00001' },
});
// Max amount (balance is 1)
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '2' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(await screen.findByText('Value is above maximum'));
expect(mockSubmit).not.toBeCalled();
});
test('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 },
});
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress },
});
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.01' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
};

View File

@ -0,0 +1,108 @@
import { useCallback, useEffect, useMemo, useRef, useState } 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 { addDecimal } from '@vegaprotocol/react-helpers';
import { AccountType } from '@vegaprotocol/types';
import BigNumber from 'bignumber.js';
import type { Account, Asset } from './types';
import { useWeb3React } from '@web3-react/core';
export interface WithdrawManagerProps {
assets: Asset[];
accounts: Account[];
initialAssetId?: string;
}
export const WithdrawManager = ({
assets,
accounts,
initialAssetId,
}: WithdrawManagerProps) => {
const dialogDismissed = useRef(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [assetId, setAssetId] = useState<string | undefined>(initialAssetId);
const { account: ethereumAccount } = useWeb3React();
const { ethTx, vegaTx, approval, submit, reset } = useWithdraw(
dialogDismissed.current
);
// Find the asset object from the select box
const asset = useMemo(() => {
return assets?.find((a) => a.id === assetId);
}, [assets, assetId]);
const max = useMemo(() => {
if (!asset) {
return new BigNumber(0);
}
const account = accounts.find(
(a) => a.type === AccountType.General && a.asset.id === asset.id
);
if (!account) {
return new BigNumber(0);
}
const v = new BigNumber(addDecimal(account.balance, asset.decimals));
return v;
}, [asset, accounts]);
const min = useMemo(() => {
return asset
? new BigNumber(addDecimal('1', asset.decimals))
: new BigNumber(0);
}, [asset]);
const handleSubmit = useCallback(
(args: WithdrawalFields) => {
reset();
setDialogOpen(true);
submit(args);
dialogDismissed.current = false;
},
[submit, reset]
);
// 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
ethereumAccount={ethereumAccount}
selectedAsset={asset}
onSelectAsset={(id) => setAssetId(id)}
assets={sortBy(assets, 'name')}
max={max}
min={min}
submitWithdraw={handleSubmit}
/>
<WithdrawDialog
vegaTx={vegaTx}
ethTx={ethTx}
approval={approval}
dialogOpen={dialogOpen}
onDialogChange={(isOpen) => {
setDialogOpen(isOpen);
if (!isOpen) {
dialogDismissed.current = true;
}
}}
/>
</>
);
};

View File

@ -0,0 +1,114 @@
import { MockedProvider } from '@apollo/client/testing';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { formatNumber, getDateTimeFormat } from '@vegaprotocol/react-helpers';
import { WithdrawalStatus } from '@vegaprotocol/types';
import { generateWithdrawal } from './test-helpers';
import type {
StatusCellProps,
WithdrawalsTableProps,
} from './withdrawals-table';
import { StatusCell } from './withdrawals-table';
import { WithdrawalsTable } from './withdrawals-table';
import type { Withdrawals_party_withdrawals } from './__generated__/Withdrawals';
jest.mock('@web3-react/core', () => ({
useWeb3React: () => ({ provider: undefined }),
}));
const generateJsx = (props: WithdrawalsTableProps) => (
<MockedProvider>
<WithdrawalsTable {...props} />
</MockedProvider>
);
test('Renders the correct columns', async () => {
const withdrawal = generateWithdrawal();
await act(async () => {
render(generateJsx({ withdrawals: [withdrawal] }));
});
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(5);
expect(headers.map((h) => h.textContent?.trim())).toEqual([
'Asset',
'Amount',
'Recipient',
'Created at',
'Status',
]);
const cells = screen.getAllByRole('gridcell');
const expectedValues = [
'asset-symbol',
formatNumber(withdrawal.amount, withdrawal.asset.decimals),
'123456\u2026123456',
getDateTimeFormat().format(new Date(withdrawal.createdTimestamp)),
withdrawal.status,
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
});
describe('StatusCell', () => {
let props: StatusCellProps;
let withdrawal: Withdrawals_party_withdrawals;
let mockComplete: jest.Mock;
beforeEach(() => {
withdrawal = generateWithdrawal();
mockComplete = jest.fn();
// @ts-ignore dont need full ICellRendererParams
props = {
value: withdrawal.status,
data: withdrawal,
complete: mockComplete,
};
});
test('Open', () => {
props.value = WithdrawalStatus.Finalized;
props.data.pendingOnForeignChain = false;
props.data.txHash = null;
render(<StatusCell {...props} />);
expect(screen.getByText('Open'));
fireEvent.click(screen.getByText('Complete', { selector: 'button' }));
expect(mockComplete).toHaveBeenCalled();
});
test('Pending', () => {
props.value = WithdrawalStatus.Finalized;
props.data.pendingOnForeignChain = true;
props.data.txHash = '0x123';
render(<StatusCell {...props} />);
expect(screen.getByText('Pending'));
expect(screen.getByText('View on Etherscan')).toHaveAttribute(
'href',
expect.stringContaining(props.data.txHash)
);
});
test('Finalized', () => {
props.value = WithdrawalStatus.Finalized;
props.data.pendingOnForeignChain = false;
props.data.txHash = '0x123';
render(<StatusCell {...props} />);
expect(screen.getByText('Finalized'));
expect(screen.getByText('View on Etherscan')).toHaveAttribute(
'href',
expect.stringContaining(props.data.txHash)
);
});
test('Fallback', () => {
props.value = WithdrawalStatus.Rejected;
props.data.pendingOnForeignChain = false;
props.data.txHash = '0x123';
render(<StatusCell {...props} />);
expect(screen.getByText('Rejected'));
});
});

View File

@ -0,0 +1,114 @@
import type {
ICellRendererParams,
ValueFormatterParams,
} from 'ag-grid-community';
import { AgGridColumn } from 'ag-grid-react';
import {
getDateTimeFormat,
t,
truncateByChars,
formatNumber,
} from '@vegaprotocol/react-helpers';
import { WithdrawalStatus } from '@vegaprotocol/types';
import {
EtherscanLink,
AgGridDynamic as AgGrid,
} from '@vegaprotocol/ui-toolkit';
import { TransactionDialog } from '@vegaprotocol/web3';
import { useCompleteWithdraw } from './use-complete-withdraw';
import type { Withdrawals_party_withdrawals } from './__generated__/Withdrawals';
export interface WithdrawalsTableProps {
withdrawals: Withdrawals_party_withdrawals[];
}
export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => {
const { transaction, submit } = useCompleteWithdraw();
return (
<>
<AgGrid
rowData={withdrawals}
overlayNoRowsTemplate={t('No withdrawals')}
defaultColDef={{ flex: 1, resizable: true }}
style={{ width: '100%', height: '100%' }}
components={{ StatusCell, RecipientCell }}
suppressCellFocus={true}
>
<AgGridColumn headerName="Asset" field="asset.symbol" />
<AgGridColumn
headerName="Amount"
field="amount"
valueFormatter={({ value, data }: ValueFormatterParams) => {
return formatNumber(value, data.asset.decimals);
}}
/>
<AgGridColumn
headerName="Recipient"
field="details.receiverAddress"
cellRenderer="RecipientCell"
valueFormatter={({ value }: ValueFormatterParams) => {
return truncateByChars(value);
}}
/>
<AgGridColumn
headerName="Created at"
field="createdTimestamp"
valueFormatter={({ value }: ValueFormatterParams) => {
return getDateTimeFormat().format(new Date(value));
}}
/>
<AgGridColumn
headerName="Status"
field="status"
cellRenderer="StatusCell"
cellRendererParams={{ complete: submit }}
/>
</AgGrid>
<TransactionDialog name="withdraw" {...transaction} />
</>
);
};
export interface StatusCellProps extends ICellRendererParams {
complete: (withdrawalId: string) => void;
}
export const StatusCell = ({ value, data, complete }: StatusCellProps) => {
if (data.pendingOnForeignChain) {
return (
<div className="flex justify-between gap-8">
{t('Pending')}
{data.txHash && (
<EtherscanLink tx={data.txHash} text={t('View on Etherscan')} />
)}
</div>
);
}
if (value === WithdrawalStatus.Finalized) {
return (
<div className="flex justify-between gap-8">
{data.txHash ? (
<>
{t('Finalized')}
<EtherscanLink tx={data.txHash} text={t('View on Etherscan')} />
</>
) : (
<>
{t('Open')}
<button className="underline" onClick={() => complete(data.id)}>
{t('Complete')}
</button>
</>
)}
</div>
);
}
return value;
};
const RecipientCell = ({ value, valueFormatted }: ICellRendererParams) => {
return <EtherscanLink address={value} text={valueFormatted} />;
};

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,22 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
},
"files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node", "@testing-library/jest-dom"]
},
"include": [
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts",
"src/lib/withdraw-manager.foo.tsx"
]
}

Some files were not shown because too many files have changed in this diff Show More