From f244cd07d402afbbd5d88d5201600031c707e9cd Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Wed, 6 Apr 2022 10:34:51 -0700 Subject: [PATCH] Feat/103 deposits (#143) * add web3 provider using web3-react package * add env setup, add guard for incorrect chain id * add lib for web3-provider * make wallet and ethereum connect dialogs look more consistent * add setup tests file for jest-dom * remove chain id config and just use appChainId prop, add disconnect button to invalid chainId state * switch handling of connect dialog state to the consuming app * rename web3-provider package to just web3 * envs for each environment so we can specify chainId * make web3container enforce connection before rendering childen * add web3 provider using web3-react package * make web3container enforce connection before rendering childen * add container for getting network params * Move ethereum config query to web3 container * add basic deposit form elements * add queries required for deposits, add asset default * add bridge contract and deposit transaction * break txhash * restrict etherscan link props, use etherscan link in transaction dialogs * use smart-contracts-sdk * split hooks and components into different files, fix find deposit logic, add styles and progress for tx dialogs * fix text colors for dark mode * improve tx dialogs, rename deposit query * position use buttons, fix select validation * fix type errors after not being in strict mode, add allowance checking * add deposit-limits component, fix types now that strict mode is enabled * make contract hooks have a single instance * split out dialogs into separate files, fix icon alignment * improve error types for use transaction hook, add number save min and max for the amount input * add validation for ethereum and vega addresses * add unit test for deposit form component * add icons and shared dialog styles so it better matches order transaction dialog * fix underline class, reset finalized deposit * fix type imports, use i18n function, regen types * only pass contract address to token contract hook * add vega env, refactor so retrieving asset contract address logic isn't duplicated * add faucet functionality, combine dialogs into single transaction-dialog * combine rendering logic into single func of transaction dialog, rever contract hooks to just useMemo * use to field rather than connected key * fix props and imports in deposit form test * share faucetable condition, pass it to token contract * pass contracts in as params to hooks to avoid multiple contract instances * refetch balance in wallet after deposit, add comments * use hook state for tracking deposit via partyid, add test for use ethereum transaction hook * add deposits lib * add last smart contract sdk package * fix asset import in test * tidy up ts-ignores * pass arg for faucetable token contract * add provider url to env vars and use in place of infura id, also update web3-connector to only allow the chain permitted by the app * add type guard for erc20 assets * fix intent shadow helper function, use arrow function for isEthereumError * update etherscan link to use env vars for url base * rename deposit related hooks to indicate read vs write calls * move ethereum error class and helpers to react-helpers * add use-ethereum-read-contract hook to contain fetch logic * remove unused import * move validation to lib, add hex check for vega public key * use map for transaction modal states, pass confirmed prop to transaction dialog for deposits * remove unused import for classnames --- apps/trading/.env | 4 +- apps/trading/.env.devent | 3 +- apps/trading/.env.mainnet | 3 +- apps/trading/.env.stagnet1 | 3 +- apps/trading/.env.stagnet2 | 3 +- apps/trading/.env.testnet | 3 +- .../components/page-query-container/index.ts | 1 + .../{index.tsx => page-query-container.tsx} | 0 .../__generated__/NetworkParamsQuery.ts | 27 ++ .../web3-container/web3-container.spec.tsx | 148 ++++++-- .../web3-container/web3-container.tsx | 100 +++++- apps/trading/lib/web3-connectors.ts | 12 +- .../deposit/__generated__/DepositPage.ts | 53 +++ .../portfolio/deposit/deposit-container.tsx | 70 ++++ .../pages/portfolio/deposit/index.page.tsx | 42 ++- apps/trading/pages/portfolio/index.page.tsx | 3 + libs/deposits/.babelrc | 12 + libs/deposits/.eslintrc.json | 18 + libs/deposits/README.md | 7 + libs/deposits/jest.config.js | 10 + libs/deposits/package.json | 4 + libs/deposits/project.json | 43 +++ libs/deposits/src/config/index.ts | 1 + libs/deposits/src/index.ts | 1 + .../src/lib/__generated__/DepositEvent.ts | 63 ++++ libs/deposits/src/lib/deposit-form.spec.tsx | 207 ++++++++++++ libs/deposits/src/lib/deposit-form.tsx | 315 ++++++++++++++++++ libs/deposits/src/lib/deposit-limits.tsx | 33 ++ libs/deposits/src/lib/deposit-manager.tsx | 107 ++++++ libs/deposits/src/lib/use-get-allowance.ts | 24 ++ .../src/lib/use-get-balance-of-erc20-token.ts | 20 ++ .../src/lib/use-get-deposit-limits.ts | 37 ++ libs/deposits/src/lib/use-submit-approval.ts | 16 + libs/deposits/src/lib/use-submit-deposit.ts | 95 ++++++ libs/deposits/src/lib/use-submit-faucet.ts | 13 + libs/deposits/src/setup-tests.ts | 1 + libs/deposits/tsconfig.json | 25 ++ libs/deposits/tsconfig.lib.json | 22 ++ libs/deposits/tsconfig.spec.json | 19 ++ .../src/lib/__generated__/Deposit.ts | 53 +++ .../src/lib/__generated__/DepositEvent.ts | 63 ++++ .../src/lib/__generated__/DepositPage.ts | 53 +++ .../lib/__generated__/NetworkParamsQuery.ts | 27 ++ libs/react-helpers/src/hooks/index.ts | 4 + .../src/hooks/use-bridge-contract.ts | 21 ++ .../src/hooks/use-ethereum-read-contract.ts | 70 ++++ .../hooks/use-ethereum-transaction.test.tsx | 122 +++++++ .../src/hooks/use-ethereum-transaction.ts | 72 ++++ .../src/hooks/use-token-contract.ts | 23 ++ libs/react-helpers/src/index.ts | 2 + libs/react-helpers/src/lib/ethereum-error.ts | 26 ++ libs/react-helpers/src/lib/validate/index.ts | 38 +++ libs/types/src/__generated__/globalTypes.ts | 9 + libs/ui-toolkit/jest.config.js | 1 + .../src/components/dialog/dialog.tsx | 5 +- .../etherscan-link/etherscan-link.stories.tsx | 35 +- .../etherscan-link/etherscan-link.test.tsx | 51 +-- .../etherscan-link/etherscan-link.tsx | 49 +-- libs/ui-toolkit/src/components/icon/icon.tsx | 2 + .../components/input-error/input-error.tsx | 1 + .../ui-toolkit/src/components/input/input.tsx | 1 + .../src/components/loader/loader.tsx | 2 +- .../transaction-dialog/dialog-rows.tsx | 94 ++++++ .../transaction-dialog/dialog-wrapper.tsx | 25 ++ .../components/transaction-dialog/index.ts | 1 + .../transaction-dialog.spec.tsx | 106 ++++++ .../transaction-dialog/transaction-dialog.tsx | 133 ++++++++ libs/ui-toolkit/src/index.ts | 4 +- libs/ui-toolkit/src/setup-test-env.ts | 1 + libs/ui-toolkit/src/utils/intent.tsx | 7 +- libs/ui-toolkit/src/utils/web3.ts | 23 -- package.json | 1 + tsconfig.base.json | 1 + workspace.json | 1 + yarn.lock | 89 +++++ 75 files changed, 2576 insertions(+), 208 deletions(-) create mode 100644 apps/trading/components/page-query-container/index.ts rename apps/trading/components/page-query-container/{index.tsx => page-query-container.tsx} (100%) create mode 100644 apps/trading/components/web3-container/__generated__/NetworkParamsQuery.ts create mode 100644 apps/trading/pages/portfolio/deposit/__generated__/DepositPage.ts create mode 100644 apps/trading/pages/portfolio/deposit/deposit-container.tsx create mode 100644 libs/deposits/.babelrc create mode 100644 libs/deposits/.eslintrc.json create mode 100644 libs/deposits/README.md create mode 100644 libs/deposits/jest.config.js create mode 100644 libs/deposits/package.json create mode 100644 libs/deposits/project.json create mode 100644 libs/deposits/src/config/index.ts create mode 100644 libs/deposits/src/index.ts create mode 100644 libs/deposits/src/lib/__generated__/DepositEvent.ts create mode 100644 libs/deposits/src/lib/deposit-form.spec.tsx create mode 100644 libs/deposits/src/lib/deposit-form.tsx create mode 100644 libs/deposits/src/lib/deposit-limits.tsx create mode 100644 libs/deposits/src/lib/deposit-manager.tsx create mode 100644 libs/deposits/src/lib/use-get-allowance.ts create mode 100644 libs/deposits/src/lib/use-get-balance-of-erc20-token.ts create mode 100644 libs/deposits/src/lib/use-get-deposit-limits.ts create mode 100644 libs/deposits/src/lib/use-submit-approval.ts create mode 100644 libs/deposits/src/lib/use-submit-deposit.ts create mode 100644 libs/deposits/src/lib/use-submit-faucet.ts create mode 100644 libs/deposits/src/setup-tests.ts create mode 100644 libs/deposits/tsconfig.json create mode 100644 libs/deposits/tsconfig.lib.json create mode 100644 libs/deposits/tsconfig.spec.json create mode 100644 libs/market-list/src/lib/__generated__/Deposit.ts create mode 100644 libs/market-list/src/lib/__generated__/DepositEvent.ts create mode 100644 libs/market-list/src/lib/__generated__/DepositPage.ts create mode 100644 libs/market-list/src/lib/__generated__/NetworkParamsQuery.ts create mode 100644 libs/react-helpers/src/hooks/use-bridge-contract.ts create mode 100644 libs/react-helpers/src/hooks/use-ethereum-read-contract.ts create mode 100644 libs/react-helpers/src/hooks/use-ethereum-transaction.test.tsx create mode 100644 libs/react-helpers/src/hooks/use-ethereum-transaction.ts create mode 100644 libs/react-helpers/src/hooks/use-token-contract.ts create mode 100644 libs/react-helpers/src/lib/ethereum-error.ts create mode 100644 libs/react-helpers/src/lib/validate/index.ts create mode 100644 libs/ui-toolkit/src/components/transaction-dialog/dialog-rows.tsx create mode 100644 libs/ui-toolkit/src/components/transaction-dialog/dialog-wrapper.tsx create mode 100644 libs/ui-toolkit/src/components/transaction-dialog/index.ts create mode 100644 libs/ui-toolkit/src/components/transaction-dialog/transaction-dialog.spec.tsx create mode 100644 libs/ui-toolkit/src/components/transaction-dialog/transaction-dialog.tsx create mode 100644 libs/ui-toolkit/src/setup-test-env.ts delete mode 100644 libs/ui-toolkit/src/utils/web3.ts diff --git a/apps/trading/.env b/apps/trading/.env index c84b37750..09d342bd1 100644 --- a/apps/trading/.env +++ b/apps/trading/.env @@ -19,6 +19,8 @@ NX_DEPLOY_URL=$DEPLOY_URL NX_DEPLOY_PRIME_URL=$DEPLOY_PRIME_URL # App configuration variables +NX_VEGA_ENV = "TESTNET" NX_VEGA_URL = "https://lb.testnet.vega.xyz/query" NX_ETHEREUM_CHAIN_ID = 3 -NX_INFURA_ID = "4f846e79e13f44d1b51bbd7ed9edefb8" \ No newline at end of file +NX_ETHEREUM_PROVIDER_URL = "https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8" +NX_ETHERSCAN_URL = "https://ropsten.etherscan.io" diff --git a/apps/trading/.env.devent b/apps/trading/.env.devent index c6c66b03c..43dfa5712 100644 --- a/apps/trading/.env.devent +++ b/apps/trading/.env.devent @@ -2,4 +2,5 @@ NX_VEGA_ENV = "DEVNET" NX_VEGA_URL = "https://n04.d.vega.xyz/query" NX_ETHEREUM_CHAIN_ID = 3 -NX_INFURA_ID = "4f846e79e13f44d1b51bbd7ed9edefb8" \ No newline at end of file +NX_ETHEREUM_PROVIDER_URL = "https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8" +NX_ETHERSCAN_URL = "https://ropsten.etherscan.io" \ No newline at end of file diff --git a/apps/trading/.env.mainnet b/apps/trading/.env.mainnet index 494a3b378..037533823 100644 --- a/apps/trading/.env.mainnet +++ b/apps/trading/.env.mainnet @@ -2,4 +2,5 @@ NX_VEGA_ENV = "MAINNET" NX_VEGA_URL = "https://api.token.vega.xyz/query" NX_ETHEREUM_CHAIN_ID = 1 -NX_INFURA_ID = "4f846e79e13f44d1b51bbd7ed9edefb8" \ No newline at end of file +NX_ETHEREUM_PROVIDER_URL = "https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8" +NX_ETHERSCAN_URL = "https://etherscan.io" \ No newline at end of file diff --git a/apps/trading/.env.stagnet1 b/apps/trading/.env.stagnet1 index 33480ce2f..17d96f986 100644 --- a/apps/trading/.env.stagnet1 +++ b/apps/trading/.env.stagnet1 @@ -2,4 +2,5 @@ NX_VEGA_ENV = "STAGNET" NX_VEGA_URL = "https://n03.s.vega.xyz/query" NX_ETHEREUM_CHAIN_ID = 3 -NX_INFURA_ID = "4f846e79e13f44d1b51bbd7ed9edefb8" +NX_ETHEREUM_PROVIDER_URL = "https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8" +NX_ETHERSCAN_URL = "https://ropsten.etherscan.io" \ No newline at end of file diff --git a/apps/trading/.env.stagnet2 b/apps/trading/.env.stagnet2 index 72ef2d847..b7bcbb54a 100644 --- a/apps/trading/.env.stagnet2 +++ b/apps/trading/.env.stagnet2 @@ -2,4 +2,5 @@ NX_VEGA_ENV = "STAGNET2" NX_VEGA_URL = "https://n03.stagnet2.vega.xyz/query" NX_ETHEREUM_CHAIN_ID = 3 -NX_INFURA_ID = "4f846e79e13f44d1b51bbd7ed9edefb8" \ No newline at end of file +NX_ETHEREUM_PROVIDER_URL = "https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8" +NX_ETHERSCAN_URL = "https://ropsten.etherscan.io" \ No newline at end of file diff --git a/apps/trading/.env.testnet b/apps/trading/.env.testnet index 7d1d004f3..668cc42a7 100644 --- a/apps/trading/.env.testnet +++ b/apps/trading/.env.testnet @@ -2,4 +2,5 @@ NX_VEGA_ENV = "TESTNET" NX_VEGA_URL = "https://lb.testnet.vega.xyz/query" NX_ETHEREUM_CHAIN_ID = 3 -NX_INFURA_ID = "4f846e79e13f44d1b51bbd7ed9edefb8" \ No newline at end of file +NX_ETHEREUM_PROVIDER_URL = "https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8" +NX_ETHERSCAN_URL = "https://ropsten.etherscan.io" \ No newline at end of file diff --git a/apps/trading/components/page-query-container/index.ts b/apps/trading/components/page-query-container/index.ts new file mode 100644 index 000000000..9f9785660 --- /dev/null +++ b/apps/trading/components/page-query-container/index.ts @@ -0,0 +1 @@ +export { PageQueryContainer } from './page-query-container'; diff --git a/apps/trading/components/page-query-container/index.tsx b/apps/trading/components/page-query-container/page-query-container.tsx similarity index 100% rename from apps/trading/components/page-query-container/index.tsx rename to apps/trading/components/page-query-container/page-query-container.tsx diff --git a/apps/trading/components/web3-container/__generated__/NetworkParamsQuery.ts b/apps/trading/components/web3-container/__generated__/NetworkParamsQuery.ts new file mode 100644 index 000000000..f2debc714 --- /dev/null +++ b/apps/trading/components/web3-container/__generated__/NetworkParamsQuery.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: NetworkParamsQuery +// ==================================================== + +export interface NetworkParamsQuery_networkParameters { + __typename: "NetworkParameter"; + /** + * The name of the network parameter + */ + key: string; + /** + * The value of the network parameter + */ + value: string; +} + +export interface NetworkParamsQuery { + /** + * return the full list of network parameters + */ + networkParameters: NetworkParamsQuery_networkParameters[] | null; +} diff --git a/apps/trading/components/web3-container/web3-container.spec.tsx b/apps/trading/components/web3-container/web3-container.spec.tsx index cf120d4c9..e64fda721 100644 --- a/apps/trading/components/web3-container/web3-container.spec.tsx +++ b/apps/trading/components/web3-container/web3-container.spec.tsx @@ -1,5 +1,8 @@ -import { fireEvent, render, screen, act } from '@testing-library/react'; -import { Web3Container } from './web3-container'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { MockedResponse } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing'; +import { NETWORK_PARAMS_QUERY, Web3Container } from './web3-container'; +import type { NetworkParametersQuery } from '@vegaprotocol/graphql'; const defaultHookValue = { isActive: false, @@ -9,6 +12,32 @@ const defaultHookValue = { }; let mockHookValue; +const mockEthereumConfig = { + network_id: '3', + chain_id: '3', + confirmations: 3, + collateral_bridge_contract: { + address: 'bridge address', + }, +}; + +const networkParamsQueryMock: MockedResponse = { + request: { + query: NETWORK_PARAMS_QUERY, + }, + result: { + data: { + networkParameters: [ + { + __typename: 'NetworkParameter', + key: 'blockchains.ethereumConfig', + value: JSON.stringify(mockEthereumConfig), + }, + ], + }, + }, +}; + jest.mock('@web3-react/core', () => { const original = jest.requireActual('@web3-react/core'); return { @@ -17,19 +46,33 @@ jest.mock('@web3-react/core', () => { }; }); +function setup(mock = networkParamsQueryMock) { + return render( + + + {({ ethereumConfig }) => ( +
+
Child
+
{ethereumConfig.collateral_bridge_contract.address}
+
+ )} +
+
+ ); +} + test('Prompt to connect opens dialog', async () => { mockHookValue = defaultHookValue; - await act(async () => { - render( - -
Child
-
- ); - }); + setup(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect( + await screen.findByText('Connect your Ethereum wallet') + ).toBeInTheDocument(); expect(screen.queryByText('Child')).not.toBeInTheDocument(); expect(screen.queryByTestId('web3-connector-list')).not.toBeInTheDocument(); - expect(screen.getByText('Connect your Ethereum wallet')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Connect')); expect(screen.getByTestId('web3-connector-list')).toBeInTheDocument(); }); @@ -37,33 +80,84 @@ test('Prompt to connect opens dialog', async () => { test('Error message is shown', async () => { const message = 'Opps! An error'; mockHookValue = { ...defaultHookValue, error: new Error(message) }; - await act(async () => { - render( - -
Child
-
- ); - }); + setup(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText(`Something went wrong: ${message}`)); expect(screen.queryByText('Child')).not.toBeInTheDocument(); - expect(screen.getByText(`Something went wrong: ${message}`)); }); -test('Chain id matches app configuration', async () => { +test('Checks that chain ID matches app ID', async () => { const expectedChainId = 4; mockHookValue = { ...defaultHookValue, isActive: true, chainId: expectedChainId, }; - await act(async () => { - render( - -
Child
-
- ); - }); + setup(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText(`This app only works on chain ID: 3`)); expect(screen.queryByText('Child')).not.toBeInTheDocument(); - expect(screen.getByText(`This app only works on chain ID: 3`)); +}); + +test('Passes ethereum config to children', async () => { + mockHookValue = { + ...defaultHookValue, + isActive: true, + }; + setup(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect( + await screen.findByText( + mockEthereumConfig.collateral_bridge_contract.address + ) + ).toBeInTheDocument(); +}); + +test('Shows no config found message if the network parameter doesnt exist', async () => { + const mock: MockedResponse = { + request: { + query: NETWORK_PARAMS_QUERY, + }, + result: { + data: { + networkParameters: [ + { + __typename: 'NetworkParameter', + key: 'nope', + value: 'foo', + }, + ], + }, + }, + }; + setup(mock); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect( + await screen.findByText('No ethereum config found') + ).toBeInTheDocument(); +}); + +test('Shows message if ethereum config could not be parsed', async () => { + const mock: MockedResponse = { + request: { + query: NETWORK_PARAMS_QUERY, + }, + result: { + data: { + networkParameters: [ + { + __typename: 'NetworkParameter', + key: 'blockchains.ethereumConfig', + value: '"something invalid }', + }, + ], + }, + }, + }; + setup(mock); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText('Could not parse config')).toBeInTheDocument(); }); diff --git a/apps/trading/components/web3-container/web3-container.tsx b/apps/trading/components/web3-container/web3-container.tsx index a427345d8..2bf76ea01 100644 --- a/apps/trading/components/web3-container/web3-container.tsx +++ b/apps/trading/components/web3-container/web3-container.tsx @@ -1,41 +1,113 @@ -import { t } from '@vegaprotocol/react-helpers'; +import { gql } from '@apollo/client'; +import type { NetworkParamsQuery } from './__generated__/NetworkParamsQuery'; import { Button, Splash } from '@vegaprotocol/ui-toolkit'; import { Web3Provider, Web3ConnectDialog } from '@vegaprotocol/web3'; import { useWeb3React } from '@web3-react/core'; import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { Connectors } from '../../lib/web3-connectors'; +import { PageQueryContainer } from '../page-query-container'; +import { t } from '@vegaprotocol/react-helpers'; + +export interface EthereumConfig { + network_id: string; + chain_id: string; + confirmations: number; + collateral_bridge_contract: { + address: string; + }; + multisig_control_contract: { + address: string; + deployment_block_height: number; + }; + staking_bridge_contract: { + address: string; + deployment_block_height: number; + }; + token_vesting_contract: { + address: string; + deployment_block_height: number; + }; +} + +export const NETWORK_PARAMS_QUERY = gql` + query NetworkParamsQuery { + networkParameters { + key + value + } + } +`; interface Web3ContainerProps { - children: ReactNode; + children: (params: { ethereumConfig: EthereumConfig }) => ReactNode; } export const Web3Container = ({ children }: Web3ContainerProps) => { const [dialogOpen, setDialogOpen] = useState(false); return ( - - {children} - - + query={NETWORK_PARAMS_QUERY}> + {(data) => { + const ethereumConfigParam = data.networkParameters?.find( + (np) => np.key === 'blockchains.ethereumConfig' + ); + + if (!ethereumConfigParam) { + return ( + +

{t('No ethereum config found')}

+
+ ); + } + + let ethereumConfig: EthereumConfig; + + try { + ethereumConfig = JSON.parse(ethereumConfigParam.value); + } catch { + return ( + +

{t('Could not parse config')}

+
+ ); + } + + return ( + + + {children({ ethereumConfig })} + + + + ); + }} + ); }; interface Web3ContentProps { children: ReactNode; + appChainId: number; setDialogOpen: (isOpen: boolean) => void; } -export const Web3Content = ({ children, setDialogOpen }: Web3ContentProps) => { - const appChainId = Number(process.env['NX_ETHEREUM_CHAIN_ID'] || 3); +export const Web3Content = ({ + children, + appChainId, + setDialogOpen, +}: Web3ContentProps) => { const { isActive, error, connector, chainId } = useWeb3React(); useEffect(() => { - if (typeof connector?.connectEagerly === 'function') { + if (connector?.connectEagerly) { connector.connectEagerly(); } }, [connector]); diff --git a/apps/trading/lib/web3-connectors.ts b/apps/trading/lib/web3-connectors.ts index 007c2b457..55ce0ef02 100644 --- a/apps/trading/lib/web3-connectors.ts +++ b/apps/trading/lib/web3-connectors.ts @@ -6,17 +6,21 @@ export const metamask = initializeConnector( (actions) => new MetaMask(actions) ); -const infuraId = process.env['NX_INFURA_ID']; +const CHAIN_ID = Number(process.env['NX_ETHEREUM_CHAIN_ID']); +const PROVIDER_URL = process.env['NX_ETHEREUM_PROVIDER_URL'] as string; + +if (isNaN(CHAIN_ID)) { + throw new Error('Invalid Ethereum chain ID for environment'); +} export const walletconnect = initializeConnector( (actions) => new WalletConnect(actions, { rpc: { - 1: `https://mainnet.infura.io/v3/${infuraId}`, - 3: `https://ropsten.infura.io/v3/${infuraId}`, + [CHAIN_ID]: PROVIDER_URL, }, }), - [1, 3] + [CHAIN_ID] ); export const Connectors = { diff --git a/apps/trading/pages/portfolio/deposit/__generated__/DepositPage.ts b/apps/trading/pages/portfolio/deposit/__generated__/DepositPage.ts new file mode 100644 index 000000000..e2c9a2bf2 --- /dev/null +++ b/apps/trading/pages/portfolio/deposit/__generated__/DepositPage.ts @@ -0,0 +1,53 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: DepositPage +// ==================================================== + +export interface DepositPage_assets_source_BuiltinAsset { + __typename: "BuiltinAsset"; +} + +export interface DepositPage_assets_source_ERC20 { + __typename: "ERC20"; + /** + * The address of the erc20 contract + */ + contractAddress: string; +} + +export type DepositPage_assets_source = DepositPage_assets_source_BuiltinAsset | DepositPage_assets_source_ERC20; + +export interface DepositPage_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: DepositPage_assets_source; +} + +export interface DepositPage { + /** + * The list of all assets in use in the vega network + */ + assets: DepositPage_assets[] | null; +} diff --git a/apps/trading/pages/portfolio/deposit/deposit-container.tsx b/apps/trading/pages/portfolio/deposit/deposit-container.tsx new file mode 100644 index 000000000..1640db50f --- /dev/null +++ b/apps/trading/pages/portfolio/deposit/deposit-container.tsx @@ -0,0 +1,70 @@ +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 { DepositManager } from '@vegaprotocol/deposits'; +import { t } from '@vegaprotocol/react-helpers'; +import { Splash } from '@vegaprotocol/ui-toolkit'; + +const DEPOSIT_PAGE_QUERY = gql` + query DepositPage { + assets { + id + symbol + name + decimals + source { + ... on ERC20 { + contractAddress + } + } + } + } +`; + +interface DepositContainerProps { + ethereumConfig: EthereumConfig; + assetId?: string; +} + +/** + * Fetches data required for the Deposit page + */ +export const DepositContainer = ({ + ethereumConfig, + assetId, +}: DepositContainerProps) => { + return ( + query={DEPOSIT_PAGE_QUERY}> + {(data) => { + if (!data.assets?.length) { + return ( + +

{t('No assets on this network')}

+
+ ); + } + + return ( + + ); + }} + + ); +}; + +const isERC20Asset = (asset: DepositPage_assets): asset is Asset => { + if (asset.source.__typename === 'ERC20') { + return true; + } + return false; +}; diff --git a/apps/trading/pages/portfolio/deposit/index.page.tsx b/apps/trading/pages/portfolio/deposit/index.page.tsx index 4fc2dc129..04b2c79d1 100644 --- a/apps/trading/pages/portfolio/deposit/index.page.tsx +++ b/apps/trading/pages/portfolio/deposit/index.page.tsx @@ -1,28 +1,34 @@ -import { useWeb3React } from '@web3-react/core'; +import { useRouter } from 'next/router'; +import { useMemo } from 'react'; import { Web3Container } from '../../../components/web3-container'; +import { DepositContainer } from './deposit-container'; const Deposit = () => { + 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 ( -
-

Deposit

- -
+ {({ ethereumConfig }) => ( +
+

Deposit

+ +
+ )}
); }; -const Info = () => { - const { isActive, chainId, account } = useWeb3React(); - if (!isActive) { - return
Not active
; - } - return ( -
-

{chainId}

-

{account ? account : 'No account'}

-
- ); -}; - export default Deposit; diff --git a/apps/trading/pages/portfolio/index.page.tsx b/apps/trading/pages/portfolio/index.page.tsx index eb477ed74..e496035d0 100644 --- a/apps/trading/pages/portfolio/index.page.tsx +++ b/apps/trading/pages/portfolio/index.page.tsx @@ -10,6 +10,9 @@ const Portfolio = () => { {keypair &&

{t(`Keypair: ${keypair.name} ${keypair.pub}`)}

}
{t('Deposit')} + + {t('Deposit tEURO')} +
); diff --git a/libs/deposits/.babelrc b/libs/deposits/.babelrc new file mode 100644 index 000000000..ccae900be --- /dev/null +++ b/libs/deposits/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nrwl/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/deposits/.eslintrc.json b/libs/deposits/.eslintrc.json new file mode 100644 index 000000000..db820c5d0 --- /dev/null +++ b/libs/deposits/.eslintrc.json @@ -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": {} + } + ] +} diff --git a/libs/deposits/README.md b/libs/deposits/README.md new file mode 100644 index 000000000..6c51a7c87 --- /dev/null +++ b/libs/deposits/README.md @@ -0,0 +1,7 @@ +# deposits + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test deposits` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/deposits/jest.config.js b/libs/deposits/jest.config.js new file mode 100644 index 000000000..6c7ddd077 --- /dev/null +++ b/libs/deposits/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: 'deposits', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]sx?$': 'babel-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/deposits', + setupFilesAfterEnv: ['./src/setup-tests.ts'], +}; diff --git a/libs/deposits/package.json b/libs/deposits/package.json new file mode 100644 index 000000000..00f48bc12 --- /dev/null +++ b/libs/deposits/package.json @@ -0,0 +1,4 @@ +{ + "name": "@vegaprotocol/deposits", + "version": "0.0.1" +} diff --git a/libs/deposits/project.json b/libs/deposits/project.json new file mode 100644 index 000000000..90a61d438 --- /dev/null +++ b/libs/deposits/project.json @@ -0,0 +1,43 @@ +{ + "root": "libs/deposits", + "sourceRoot": "libs/deposits/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nrwl/web:rollup", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/deposits", + "tsConfig": "libs/deposits/tsconfig.lib.json", + "project": "libs/deposits/package.json", + "entryFile": "libs/deposits/src/index.ts", + "external": ["react/jsx-runtime"], + "rollupConfig": "@nrwl/react/plugins/bundle-rollup", + "compiler": "babel", + "assets": [ + { + "glob": "libs/deposits/README.md", + "input": ".", + "output": "." + } + ] + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/deposits/**/*.{ts,tsx,js,jsx}"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/libs/deposits"], + "options": { + "jestConfig": "libs/deposits/jest.config.js", + "passWithNoTests": true + } + } + } +} diff --git a/libs/deposits/src/config/index.ts b/libs/deposits/src/config/index.ts new file mode 100644 index 000000000..010b6759a --- /dev/null +++ b/libs/deposits/src/config/index.ts @@ -0,0 +1 @@ +export const FAUCETABLE = process.env['NX_VEGA_ENV'] !== 'MAINNET'; diff --git a/libs/deposits/src/index.ts b/libs/deposits/src/index.ts new file mode 100644 index 000000000..d267d759e --- /dev/null +++ b/libs/deposits/src/index.ts @@ -0,0 +1 @@ +export * from './lib/deposit-manager'; diff --git a/libs/deposits/src/lib/__generated__/DepositEvent.ts b/libs/deposits/src/lib/__generated__/DepositEvent.ts new file mode 100644 index 000000000..dfc23953e --- /dev/null +++ b/libs/deposits/src/lib/__generated__/DepositEvent.ts @@ -0,0 +1,63 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { BusEventType, DepositStatus } from "./../../../../types/src/__generated__/globalTypes"; + +// ==================================================== +// GraphQL subscription operation: DepositEvent +// ==================================================== + +export interface DepositEvent_busEvents_event_TimeUpdate { + __typename: "TimeUpdate" | "MarketEvent" | "TransferResponses" | "PositionResolution" | "Order" | "Trade" | "Account" | "Party" | "MarginLevels" | "Proposal" | "Vote" | "MarketData" | "NodeSignature" | "LossSocialization" | "SettlePosition" | "Market" | "Asset" | "MarketTick" | "SettleDistressed" | "AuctionEvent" | "RiskFactor" | "Withdrawal" | "OracleSpec" | "LiquidityProvision"; +} + +export interface DepositEvent_busEvents_event_Deposit { + __typename: "Deposit"; + /** + * The Vega internal id of the deposit + */ + id: string; + /** + * Hash of the transaction on the foreign chain + */ + txHash: string | null; + /** + * The current status of the deposit + */ + status: DepositStatus; +} + +export type DepositEvent_busEvents_event = DepositEvent_busEvents_event_TimeUpdate | DepositEvent_busEvents_event_Deposit; + +export interface DepositEvent_busEvents { + __typename: "BusEvent"; + /** + * the id for this event + */ + eventId: string; + /** + * the block hash + */ + block: string; + /** + * the type of event we're dealing with + */ + type: BusEventType; + /** + * the payload - the wrapped event + */ + event: DepositEvent_busEvents_event; +} + +export interface DepositEvent { + /** + * Subscribe to event data from the event bus + */ + busEvents: DepositEvent_busEvents[] | null; +} + +export interface DepositEventVariables { + partyId: string; +} diff --git a/libs/deposits/src/lib/deposit-form.spec.tsx b/libs/deposits/src/lib/deposit-form.spec.tsx new file mode 100644 index 000000000..1a4765a04 --- /dev/null +++ b/libs/deposits/src/lib/deposit-form.spec.tsx @@ -0,0 +1,207 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import BigNumber from 'bignumber.js'; +import type { DepositFormProps } from './deposit-form'; +import { DepositForm } from './deposit-form'; +import { useVegaWallet } from '@vegaprotocol/wallet'; +import { useWeb3React } from '@web3-react/core'; +import type { Asset } from './deposit-manager'; + +jest.mock('@vegaprotocol/wallet'); +jest.mock('@web3-react/core'); + +function generateAsset(): Asset { + return { + __typename: 'Asset', + id: 'asset-id', + symbol: 'asset-symbol', + name: 'asset-name', + decimals: 2, + source: { + __typename: 'ERC20', + contractAddress: 'contract-address', + }, + }; +} + +let asset: Asset; +let props: DepositFormProps; + +beforeEach(() => { + asset = generateAsset(); + props = { + assets: [asset], + selectedAsset: undefined, + onSelectAsset: jest.fn(), + available: new BigNumber(5), + submitApprove: jest.fn(), + submitDeposit: jest.fn(), + requestFaucet: jest.fn(), + limits: { + min: new BigNumber(0), + max: new BigNumber(20), + }, + allowance: new BigNumber(30), + }; +}); + +test('Form validation', async () => { + const mockUseVegaWallet = useVegaWallet as jest.Mock; + mockUseVegaWallet.mockReturnValue({ keypair: null }); + + const mockUseWeb3React = useWeb3React as jest.Mock; + mockUseWeb3React.mockReturnValue({ account: undefined }); + + const { rerender } = render(); + + // Assert default values (including) from/to provided by useVegaWallet and useWeb3React + expect(screen.getByLabelText('From (Ethereum address)')).toHaveValue(''); + expect(screen.getByLabelText('Asset')).toHaveValue(''); + expect(screen.getByLabelText('To (Vega key)')).toHaveValue(''); + expect(screen.getByLabelText('Amount')).toHaveValue(null); + + await act(async () => { + fireEvent.click( + screen.getByText('Deposit', { selector: '[type="submit"]' }) + ); + }); + + expect(props.submitDeposit).not.toHaveBeenCalled(); + const validationMessages = screen.getAllByRole('alert'); + expect(validationMessages).toHaveLength(4); + validationMessages.forEach((el) => { + expect(el).toHaveTextContent('Required'); + }); + + // Address validation + const invalidEthereumAddress = '123'; + fireEvent.change(screen.getByLabelText('From (Ethereum address)'), { + target: { value: invalidEthereumAddress }, + }); + expect(await screen.findByText('Invalid Ethereum address')); + + const invalidVegaKey = 'abc'; + fireEvent.change(screen.getByLabelText('To (Vega key)'), { + target: { value: invalidVegaKey }, + }); + expect(await screen.findByText('Invalid Vega key')); + + // Max amount validation + const amountMoreThanAvailable = '11'; + fireEvent.change(screen.getByLabelText('Amount'), { + target: { value: amountMoreThanAvailable }, + }); + expect(await screen.findByText('Insufficient amount in Ethereum wallet')); + + const amountMoreThanLimit = '21'; + fireEvent.change(screen.getByLabelText('Amount'), { + target: { value: amountMoreThanLimit }, + }); + expect(await screen.findByText('Amount is above permitted maximum')); + + rerender( + + ); + + const amountMoreThanAllowance = '31'; + fireEvent.change(screen.getByLabelText('Amount'), { + target: { value: amountMoreThanAllowance }, + }); + expect(await screen.findByText('Amount is above approved amount')); + + // Min amount validation + rerender(); // Rerender with selected asset so we have asset.decimals + + const amountLessThanMinViable = '0.00001'; + fireEvent.change(screen.getByLabelText('Amount'), { + target: { value: amountLessThanMinViable }, + }); + expect(await screen.findByText('Amount is below permitted minimum')); + + rerender( + + ); + const amountLessThanLimit = '5'; + fireEvent.change(screen.getByLabelText('Amount'), { + target: { value: amountLessThanLimit }, + }); + expect(await screen.findByText('Amount is below permitted minimum')); +}); + +test('Approval', () => { + const mockUseVegaWallet = useVegaWallet as jest.Mock; + mockUseVegaWallet.mockReturnValue({ keypair: null }); + + const mockUseWeb3React = useWeb3React as jest.Mock; + mockUseWeb3React.mockReturnValue({ account: undefined }); + + render( + + ); + + fireEvent.click( + screen.getByText(`Approve ${asset.symbol}`, { selector: '[type="button"]' }) + ); + + expect(props.submitApprove).toHaveBeenCalled(); +}); + +test('Deposit', async () => { + const vegaKey = + 'f8885edfa7ffdb6ed996ca912e9258998e47bf3515c885cf3c63fb56b15de36f'; + const mockUseVegaWallet = useVegaWallet as jest.Mock; + mockUseVegaWallet.mockReturnValue({ keypair: { pub: vegaKey } }); + + const account = '0x72c22822A19D20DE7e426fB84aa047399Ddd8853'; + const mockUseWeb3React = useWeb3React as jest.Mock; + mockUseWeb3React.mockReturnValue({ account }); + + const limits = { + min: new BigNumber(10), + max: new BigNumber(20), + }; + + render( + + ); + + // Check deposit limits are displayed + expect( + screen.getByText('Minimum', { selector: 'th' }).nextElementSibling + ).toHaveTextContent(limits.min.toString()); + expect( + screen.getByText('Maximum', { selector: 'th' }).nextElementSibling + ).toHaveTextContent(limits.max.toString()); + + fireEvent.change(screen.getByLabelText('Amount'), { + target: { value: '15' }, + }); + + await act(async () => { + fireEvent.click( + screen.getByText('Deposit', { selector: '[type="submit"]' }) + ); + }); + + expect(props.submitDeposit).toHaveBeenCalledWith({ + // @ts-ignore contract address definitely defined + assetSource: asset.source.contractAddress, + amount: '1500', + vegaPublicKey: `0x${vegaKey}`, + }); +}); diff --git a/libs/deposits/src/lib/deposit-form.tsx b/libs/deposits/src/lib/deposit-form.tsx new file mode 100644 index 000000000..b3cfa6665 --- /dev/null +++ b/libs/deposits/src/lib/deposit-form.tsx @@ -0,0 +1,315 @@ +import { + addDecimal, + removeDecimal, + t, + ethereumAddress, + required, + vegaPublicKey, + minSafe, + maxSafe, +} from '@vegaprotocol/react-helpers'; +import { + Button, + FormGroup, + Icon, + Input, + InputError, + Select, +} from '@vegaprotocol/ui-toolkit'; +import { useVegaWallet } from '@vegaprotocol/wallet'; +import { useWeb3React } from '@web3-react/core'; +import BigNumber from 'bignumber.js'; +import type { ReactNode } from 'react'; +import { useMemo } from 'react'; +import { useEffect } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { DepositLimits } from './deposit-limits'; +import { FAUCETABLE } from '../config'; +import type { Asset } from './deposit-manager'; + +interface FormFields { + asset: string; + from: string; + to: string; + amount: string; +} + +export interface DepositFormProps { + assets: Asset[]; + selectedAsset?: Asset; + onSelectAsset: (assetId: string) => void; + available: BigNumber | undefined; + submitApprove: () => Promise; + submitDeposit: (args: { + assetSource: string; + amount: string; + vegaPublicKey: string; + }) => Promise; + requestFaucet: () => Promise; + limits: { + min: BigNumber; + max: BigNumber; + } | null; + allowance: BigNumber | undefined; +} + +export const DepositForm = ({ + assets, + selectedAsset, + onSelectAsset, + available, + submitApprove, + submitDeposit, + requestFaucet, + limits, + allowance, +}: DepositFormProps) => { + const { account } = useWeb3React(); + const { keypair } = useVegaWallet(); + const { + register, + handleSubmit, + setValue, + clearErrors, + control, + formState: { errors }, + } = useForm({ + defaultValues: { + asset: selectedAsset?.id, + from: account, + to: keypair?.pub, + }, + }); + + const onDeposit = async (fields: FormFields) => { + if (!selectedAsset) { + throw new Error('Asset not selected'); + } + + submitDeposit({ + assetSource: selectedAsset.source.contractAddress, + amount: removeDecimal(fields.amount, selectedAsset.decimals), + vegaPublicKey: `0x${fields.to}`, + }); + }; + + const assetId = useWatch({ name: 'asset', control }); + const amount = useWatch({ name: 'amount', control }); + + const min = useMemo(() => { + // Min viable amount given asset decimals EG for WEI 0.000000000000000001 + const minViableAmount = selectedAsset + ? new BigNumber(addDecimal('1', selectedAsset.decimals)) + : new BigNumber(0); + + const min = limits + ? BigNumber.maximum(minViableAmount, limits.min) + : minViableAmount; + + return min; + }, [limits, selectedAsset]); + + const max = useMemo(() => { + const maxApproved = allowance ? allowance : new BigNumber(Infinity); + const maxAvailable = available ? available : new BigNumber(Infinity); + // A max limit of zero indicates that there is no limit + let maxLimit = new BigNumber(Infinity); + if (limits && limits.max.isGreaterThan(0)) { + maxLimit = limits.max; + } + + return { + approved: maxApproved, + available: maxAvailable, + limit: maxLimit, + amount: BigNumber.minimum(maxLimit, maxApproved, maxAvailable), + }; + }, [limits, allowance, available]); + + useEffect(() => { + onSelectAsset(assetId); + }, [assetId, onSelectAsset]); + + return ( +
+ + + {errors.from?.message && ( + + {errors.from.message} + + )} + + + + {errors.asset?.message && ( + + {errors.asset.message} + + )} + {FAUCETABLE && selectedAsset && ( + + {t(`Get ${selectedAsset.symbol}`)} + + )} + + + + {errors.to?.message && ( + + {errors.to.message} + + )} + {keypair?.pub && ( + { + setValue('to', keypair.pub); + clearErrors('to'); + }} + > + {t('Use connected')} + + )} + + {selectedAsset && limits && ( + + + + )} + + minSafe(min)(value), + maxSafe: (v) => { + const value = new BigNumber(v); + if (value.isGreaterThan(max.approved)) { + return t('Amount is above approved amount'); + } else if (value.isGreaterThan(max.limit)) { + return t('Amount is above permitted maximum'); + } else if (value.isGreaterThan(max.available)) { + return t('Insufficient amount in Ethereum wallet'); + } + return maxSafe(max.amount)(v); + }, + }, + })} + /> + {errors.amount?.message && ( + + {errors.amount.message} + + )} + {account && selectedAsset && available && ( + { + setValue('amount', max.amount.toFixed(selectedAsset.decimals)); + clearErrors('amount'); + }} + > + {t('Use maximum')} + + )} + + + + ); +}; + +interface FormButtonProps { + selectedAsset?: Asset; + amount: BigNumber; + allowance: BigNumber | undefined; + onApproveClick: () => void; +} + +const FormButton = ({ + selectedAsset, + amount, + allowance, + onApproveClick, +}: FormButtonProps) => { + const approved = + allowance && allowance.isGreaterThan(0) && amount.isLessThan(allowance); + let button = null; + let message: ReactNode = ''; + + if (!selectedAsset) { + button = ( + + ); + } else if (approved) { + message = ( + <> + {t('Approved')} + + ); + button = ( + + ); + } else { + message = t(`Deposits of ${selectedAsset.symbol} not approved`); + button = ( + + ); + } + + return ( +
+ {message &&

{message}

} + {button} +
+ ); +}; + +interface UseButtonProps { + children: ReactNode; + onClick: () => void; +} + +const UseButton = ({ children, onClick }: UseButtonProps) => { + return ( + + ); +}; diff --git a/libs/deposits/src/lib/deposit-limits.tsx b/libs/deposits/src/lib/deposit-limits.tsx new file mode 100644 index 000000000..b355c011a --- /dev/null +++ b/libs/deposits/src/lib/deposit-limits.tsx @@ -0,0 +1,33 @@ +import { t } from '@vegaprotocol/react-helpers'; +import type BigNumber from 'bignumber.js'; + +interface DepositLimitsProps { + limits: { + min: BigNumber; + max: BigNumber; + }; +} + +export const DepositLimits = ({ limits }: DepositLimitsProps) => { + const minLimit = limits.min.toString(); + const maxLimit = limits.max.isEqualTo(Infinity) + ? t('No limit') + : limits.max.toString(); + return ( + <> +

{t('Temporary deposit limits')}

+ + + + + + + + + + + +
{t('Minimum')}{minLimit}
{t('Maximum')}{maxLimit}
+ + ); +}; diff --git a/libs/deposits/src/lib/deposit-manager.tsx b/libs/deposits/src/lib/deposit-manager.tsx new file mode 100644 index 000000000..ec6f2a96a --- /dev/null +++ b/libs/deposits/src/lib/deposit-manager.tsx @@ -0,0 +1,107 @@ +import { useEffect, useMemo, useState } from 'react'; +import { DepositForm } from './deposit-form'; +import { useGetBalanceOfERC20Token } from './use-get-balance-of-erc20-token'; +import { useSubmitDeposit } from './use-submit-deposit'; +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, +} from '@vegaprotocol/react-helpers'; + +export interface Asset { + __typename: 'Asset'; + id: string; + symbol: string; + name: string; + decimals: number; + source: { + __typename: 'ERC20'; + contractAddress: string; + }; +} + +interface DepositManagerProps { + requiredConfirmations: number; + bridgeAddress: string; + assets: Asset[]; + initialAssetId?: string; +} + +export const DepositManager = ({ + requiredConfirmations, + bridgeAddress, + assets, + initialAssetId, +}: DepositManagerProps) => { + const [assetId, setAssetId] = useState(initialAssetId); + + // Find the asset object from the select box + const asset = useMemo(() => { + const asset = assets?.find((a) => a.id === assetId); + return asset; + }, [assets, assetId]); + + const tokenContract = useTokenContract( + asset?.source.contractAddress, + process.env['NX_VEGA_ENV'] !== 'MAINNET' + ); + const bridgeContract = useBridgeContract(); + + // Get users balance of the erc20 token selected + const { balanceOf, refetch } = useGetBalanceOfERC20Token(tokenContract); + + // Get temporary deposit limits + const limits = useGetDepositLimits(bridgeContract, asset); + + // Get allowance (approved spending limit of brdige contract) for the selected asset + const allowance = useGetAllowance(tokenContract, bridgeAddress); + + // Set up approve transaction + const approve = useSubmitApproval(tokenContract, bridgeAddress); + + // Set up deposit transaction + const { confirmationEvent, ...deposit } = useSubmitDeposit( + bridgeContract, + requiredConfirmations + ); + + // Set up faucet transaction + const faucet = useSubmitFaucet(tokenContract); + + // Update balance after confirmation event has been received + useEffect(() => { + if (confirmationEvent !== null) { + refetch(); + } + }, [confirmationEvent, refetch]); + + return ( + <> + setAssetId(id)} + assets={sortBy(assets, 'name')} + submitApprove={approve.perform} + submitDeposit={deposit.perform} + requestFaucet={faucet.perform} + limits={limits} + allowance={allowance} + /> + + + + + ); +}; diff --git a/libs/deposits/src/lib/use-get-allowance.ts b/libs/deposits/src/lib/use-get-allowance.ts new file mode 100644 index 000000000..57d279922 --- /dev/null +++ b/libs/deposits/src/lib/use-get-allowance.ts @@ -0,0 +1,24 @@ +import type { ERC20Token } from '@vegaprotocol/smart-contracts-sdk'; +import { useWeb3React } from '@web3-react/core'; +import { useCallback } from 'react'; +import { useEthereumReadContract } from '@vegaprotocol/react-helpers'; + +export const useGetAllowance = ( + contract: ERC20Token | null, + bridgeAddress: string +) => { + const { account } = useWeb3React(); + + const getAllowance = useCallback(() => { + if (!contract || !account) { + return; + } + return contract.allowance(account, bridgeAddress); + }, [contract, account, bridgeAddress]); + + const { + state: { data }, + } = useEthereumReadContract(getAllowance); + + return data; +}; diff --git a/libs/deposits/src/lib/use-get-balance-of-erc20-token.ts b/libs/deposits/src/lib/use-get-balance-of-erc20-token.ts new file mode 100644 index 000000000..8a5d357aa --- /dev/null +++ b/libs/deposits/src/lib/use-get-balance-of-erc20-token.ts @@ -0,0 +1,20 @@ +import { useEthereumReadContract } from '@vegaprotocol/react-helpers'; +import type { ERC20Token } from '@vegaprotocol/smart-contracts-sdk'; +import { useWeb3React } from '@web3-react/core'; +import { useCallback } from 'react'; + +export const useGetBalanceOfERC20Token = (contract: ERC20Token | null) => { + const { account } = useWeb3React(); + + const getBalance = useCallback(() => { + if (!contract || !account) { + return; + } + + return contract.balanceOf(account); + }, [contract, account]); + + const { state, refetch } = useEthereumReadContract(getBalance); + + return { balanceOf: state.data, refetch }; +}; diff --git a/libs/deposits/src/lib/use-get-deposit-limits.ts b/libs/deposits/src/lib/use-get-deposit-limits.ts new file mode 100644 index 000000000..270f267d0 --- /dev/null +++ b/libs/deposits/src/lib/use-get-deposit-limits.ts @@ -0,0 +1,37 @@ +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'; + +interface Limits { + min: BigNumber; + max: BigNumber; +} + +export const useGetDepositLimits = ( + contract: VegaErc20Bridge | null, + asset?: Asset +): Limits | null => { + const getLimits = useCallback(async () => { + if (!contract || !asset) { + return; + } + + return Promise.all([ + contract.getDepositMinimum(asset.source.contractAddress, asset.decimals), + contract.getDepositMaximum(asset.source.contractAddress, asset.decimals), + ]); + }, [asset, contract]); + + const { + state: { data }, + } = useEthereumReadContract<[BigNumber, BigNumber] | undefined>(getLimits); + + if (!data) return null; + + return { + min: data[0], + max: data[1], + }; +}; diff --git a/libs/deposits/src/lib/use-submit-approval.ts b/libs/deposits/src/lib/use-submit-approval.ts new file mode 100644 index 000000000..5a19dfae5 --- /dev/null +++ b/libs/deposits/src/lib/use-submit-approval.ts @@ -0,0 +1,16 @@ +import type { ERC20Token } from '@vegaprotocol/smart-contracts-sdk'; +import { useEthereumTransaction } from '@vegaprotocol/react-helpers'; + +export const useSubmitApproval = ( + contract: ERC20Token | null, + bridgeAddress: string +) => { + const transaction = useEthereumTransaction(() => { + if (!contract) { + return null; + } + return contract.approve(bridgeAddress); + }); + + return transaction; +}; diff --git a/libs/deposits/src/lib/use-submit-deposit.ts b/libs/deposits/src/lib/use-submit-deposit.ts new file mode 100644 index 000000000..a13049c11 --- /dev/null +++ b/libs/deposits/src/lib/use-submit-deposit.ts @@ -0,0 +1,95 @@ +import { gql, useSubscription } from '@apollo/client'; +import type { + DepositEvent, + DepositEventVariables, + DepositEvent_busEvents_event_Deposit, +} from './__generated__/DepositEvent'; +import { DepositStatus } from '@vegaprotocol/types'; +import { useState } from 'react'; +import { useEthereumTransaction } from '@vegaprotocol/react-helpers'; +import type { VegaErc20Bridge } from '@vegaprotocol/smart-contracts-sdk'; + +const DEPOSIT_EVENT_SUB = gql` + subscription DepositEvent($partyId: ID!) { + busEvents(partyId: $partyId, batchSize: 0, types: [Deposit]) { + eventId + block + type + event { + ... on Deposit { + id + txHash + status + } + } + } + } +`; + +export const useSubmitDeposit = ( + contract: VegaErc20Bridge | null, + confirmations: number +) => { + const [confirmationEvent, setConfirmationEvent] = + useState(null); + // Store public key from contract arguments for use in the subscription, + // NOTE: it may be different from the users connected key + const [partyId, setPartyId] = useState(null); + + const transaction = useEthereumTransaction<{ + assetSource: string; + amount: string; + vegaPublicKey: string; + }>((args) => { + if (!contract) { + return null; + } + // New deposit started clear old confirmation event and start + // tracking deposits for the new public key + setConfirmationEvent(null); + setPartyId(args.vegaPublicKey); + + return contract.depositAsset( + args.assetSource, + args.amount, + args.vegaPublicKey + ); + }, confirmations); + + useSubscription(DEPOSIT_EVENT_SUB, { + variables: { partyId: partyId || '' }, + skip: !partyId, + onSubscriptionData: ({ subscriptionData }) => { + if (!subscriptionData.data?.busEvents?.length) { + return; + } + + const matchingDeposit = subscriptionData.data.busEvents.find((e) => { + if (e.event.__typename !== 'Deposit') { + return false; + } + + if ( + e.event.txHash === transaction.txHash && + // Note there is a bug in data node where the subscription is not emitted when the status + // changes from 'Open' to 'Finalized' as a result the deposit UI will hang in a pending state right now + // https://github.com/vegaprotocol/data-node/issues/460 + e.event.status === DepositStatus.Finalized + ) { + return true; + } + + return false; + }); + + if (matchingDeposit && matchingDeposit.event.__typename === 'Deposit') { + setConfirmationEvent(matchingDeposit.event); + } + }, + }); + + return { + ...transaction, + confirmationEvent, + }; +}; diff --git a/libs/deposits/src/lib/use-submit-faucet.ts b/libs/deposits/src/lib/use-submit-faucet.ts new file mode 100644 index 000000000..a151da783 --- /dev/null +++ b/libs/deposits/src/lib/use-submit-faucet.ts @@ -0,0 +1,13 @@ +import type { ERC20Token } from '@vegaprotocol/smart-contracts-sdk'; +import { useEthereumTransaction } from '@vegaprotocol/react-helpers'; + +export const useSubmitFaucet = (contract: ERC20Token | null) => { + const transaction = useEthereumTransaction(() => { + if (!contract) { + return null; + } + return contract.faucet(); + }); + + return transaction; +}; diff --git a/libs/deposits/src/setup-tests.ts b/libs/deposits/src/setup-tests.ts new file mode 100644 index 000000000..7b0828bfa --- /dev/null +++ b/libs/deposits/src/setup-tests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/libs/deposits/tsconfig.json b/libs/deposits/tsconfig.json new file mode 100644 index 000000000..4c089585e --- /dev/null +++ b/libs/deposits/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/deposits/tsconfig.lib.json b/libs/deposits/tsconfig.lib.json new file mode 100644 index 000000000..252904bb7 --- /dev/null +++ b/libs/deposits/tsconfig.lib.json @@ -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"] +} diff --git a/libs/deposits/tsconfig.spec.json b/libs/deposits/tsconfig.spec.json new file mode 100644 index 000000000..67f149c4c --- /dev/null +++ b/libs/deposits/tsconfig.spec.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/*.d.ts" + ] +} diff --git a/libs/market-list/src/lib/__generated__/Deposit.ts b/libs/market-list/src/lib/__generated__/Deposit.ts new file mode 100644 index 000000000..e2c9a2bf2 --- /dev/null +++ b/libs/market-list/src/lib/__generated__/Deposit.ts @@ -0,0 +1,53 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: DepositPage +// ==================================================== + +export interface DepositPage_assets_source_BuiltinAsset { + __typename: "BuiltinAsset"; +} + +export interface DepositPage_assets_source_ERC20 { + __typename: "ERC20"; + /** + * The address of the erc20 contract + */ + contractAddress: string; +} + +export type DepositPage_assets_source = DepositPage_assets_source_BuiltinAsset | DepositPage_assets_source_ERC20; + +export interface DepositPage_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: DepositPage_assets_source; +} + +export interface DepositPage { + /** + * The list of all assets in use in the vega network + */ + assets: DepositPage_assets[] | null; +} diff --git a/libs/market-list/src/lib/__generated__/DepositEvent.ts b/libs/market-list/src/lib/__generated__/DepositEvent.ts new file mode 100644 index 000000000..a07dc47c9 --- /dev/null +++ b/libs/market-list/src/lib/__generated__/DepositEvent.ts @@ -0,0 +1,63 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { BusEventType, DepositStatus } from "./globalTypes"; + +// ==================================================== +// GraphQL subscription operation: DepositEvent +// ==================================================== + +export interface DepositEvent_busEvents_event_TimeUpdate { + __typename: "TimeUpdate" | "MarketEvent" | "TransferResponses" | "PositionResolution" | "Order" | "Trade" | "Account" | "Party" | "MarginLevels" | "Proposal" | "Vote" | "MarketData" | "NodeSignature" | "LossSocialization" | "SettlePosition" | "Market" | "Asset" | "MarketTick" | "SettleDistressed" | "AuctionEvent" | "RiskFactor" | "Withdrawal" | "OracleSpec" | "LiquidityProvision"; +} + +export interface DepositEvent_busEvents_event_Deposit { + __typename: "Deposit"; + /** + * The Vega internal id of the deposit + */ + id: string; + /** + * Hash of the transaction on the foreign chain + */ + txHash: string | null; + /** + * The current status of the deposit + */ + status: DepositStatus; +} + +export type DepositEvent_busEvents_event = DepositEvent_busEvents_event_TimeUpdate | DepositEvent_busEvents_event_Deposit; + +export interface DepositEvent_busEvents { + __typename: "BusEvent"; + /** + * the id for this event + */ + eventId: string; + /** + * the block hash + */ + block: string; + /** + * the type of event we're dealing with + */ + type: BusEventType; + /** + * the payload - the wrapped event + */ + event: DepositEvent_busEvents_event; +} + +export interface DepositEvent { + /** + * Subscribe to event data from the event bus + */ + busEvents: DepositEvent_busEvents[] | null; +} + +export interface DepositEventVariables { + partyId: string; +} diff --git a/libs/market-list/src/lib/__generated__/DepositPage.ts b/libs/market-list/src/lib/__generated__/DepositPage.ts new file mode 100644 index 000000000..e2c9a2bf2 --- /dev/null +++ b/libs/market-list/src/lib/__generated__/DepositPage.ts @@ -0,0 +1,53 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: DepositPage +// ==================================================== + +export interface DepositPage_assets_source_BuiltinAsset { + __typename: "BuiltinAsset"; +} + +export interface DepositPage_assets_source_ERC20 { + __typename: "ERC20"; + /** + * The address of the erc20 contract + */ + contractAddress: string; +} + +export type DepositPage_assets_source = DepositPage_assets_source_BuiltinAsset | DepositPage_assets_source_ERC20; + +export interface DepositPage_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: DepositPage_assets_source; +} + +export interface DepositPage { + /** + * The list of all assets in use in the vega network + */ + assets: DepositPage_assets[] | null; +} diff --git a/libs/market-list/src/lib/__generated__/NetworkParamsQuery.ts b/libs/market-list/src/lib/__generated__/NetworkParamsQuery.ts new file mode 100644 index 000000000..f2debc714 --- /dev/null +++ b/libs/market-list/src/lib/__generated__/NetworkParamsQuery.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: NetworkParamsQuery +// ==================================================== + +export interface NetworkParamsQuery_networkParameters { + __typename: "NetworkParameter"; + /** + * The name of the network parameter + */ + key: string; + /** + * The value of the network parameter + */ + value: string; +} + +export interface NetworkParamsQuery { + /** + * return the full list of network parameters + */ + networkParameters: NetworkParamsQuery_networkParameters[] | null; +} diff --git a/libs/react-helpers/src/hooks/index.ts b/libs/react-helpers/src/hooks/index.ts index 0d0ae6419..6c690795c 100644 --- a/libs/react-helpers/src/hooks/index.ts +++ b/libs/react-helpers/src/hooks/index.ts @@ -1,3 +1,7 @@ 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'; diff --git a/libs/react-helpers/src/hooks/use-bridge-contract.ts b/libs/react-helpers/src/hooks/use-bridge-contract.ts new file mode 100644 index 000000000..6a2a489d2 --- /dev/null +++ b/libs/react-helpers/src/hooks/use-bridge-contract.ts @@ -0,0 +1,21 @@ +import type { Networks } from '@vegaprotocol/smart-contracts-sdk'; +import { VegaErc20Bridge } from '@vegaprotocol/smart-contracts-sdk'; +import { useWeb3React } from '@web3-react/core'; +import { useMemo } from 'react'; + +export const useBridgeContract = () => { + const { provider } = useWeb3React(); + + const contract = useMemo(() => { + if (!provider) { + return null; + } + return new VegaErc20Bridge( + process.env['NX_VEGA_ENV'] as Networks, + provider, + provider?.getSigner() + ); + }, [provider]); + + return contract; +}; diff --git a/libs/react-helpers/src/hooks/use-ethereum-read-contract.ts b/libs/react-helpers/src/hooks/use-ethereum-read-contract.ts new file mode 100644 index 000000000..982fd35ee --- /dev/null +++ b/libs/react-helpers/src/hooks/use-ethereum-read-contract.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useReducer, useRef } from 'react'; + +interface State { + data?: T; + error?: Error; + loading?: boolean; +} + +enum ActionType { + LOADING = 'LOADING', + ERROR = 'ERROR', + FETCHED = 'FETCHED', +} + +type Action = + | { type: ActionType.LOADING } + | { type: ActionType.FETCHED; payload: T } + | { type: ActionType.ERROR; error: Error }; + +export const useEthereumReadContract = ( + contractFunc: () => Promise | undefined +) => { + const cancelRequest = useRef(false); + + const initialState: State = { + error: undefined, + data: undefined, + loading: false, + }; + + const fetchReducer = (state: State, action: Action): State => { + switch (action.type) { + case ActionType.LOADING: + return { ...state, loading: true }; + case ActionType.FETCHED: + return { ...state, data: action.payload, loading: false }; + case ActionType.ERROR: + return { ...state, error: action.error, loading: false }; + } + }; + + const [state, dispatch] = useReducer(fetchReducer, initialState); + + const fetchCallback = useCallback(async () => { + dispatch({ type: ActionType.LOADING }); + try { + const result = contractFunc(); + if (!result) { + return; + } + const response = await result; + if (cancelRequest.current) return; + dispatch({ type: ActionType.FETCHED, payload: response }); + } catch (error) { + if (cancelRequest.current) return; + dispatch({ type: ActionType.ERROR, error: error as Error }); + } + }, [contractFunc]); + + useEffect(() => { + cancelRequest.current = false; + fetchCallback(); + + return () => { + cancelRequest.current = true; + }; + }, [fetchCallback]); + + return { state, refetch: fetchCallback }; +}; diff --git a/libs/react-helpers/src/hooks/use-ethereum-transaction.test.tsx b/libs/react-helpers/src/hooks/use-ethereum-transaction.test.tsx new file mode 100644 index 000000000..ad2e3a2a1 --- /dev/null +++ b/libs/react-helpers/src/hooks/use-ethereum-transaction.test.tsx @@ -0,0 +1,122 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { TxState } from './use-ethereum-transaction'; +import type { ReactNode } from 'react'; +import { useEthereumTransaction } from './use-ethereum-transaction'; +import type { ethers } from 'ethers'; +import { EthereumError } from '../lib/ethereum-error'; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +class MockContract { + static txHash = 'tx-hash'; + confirmations = 0; + depositAsset(args: { + assetSource: string; + amount: string; + vegaPublicKey: string; + }): Promise { + return Promise.resolve({ + hash: MockContract.txHash, + wait: () => { + this.confirmations++; + return new Promise((resolve) => { + setTimeout( + () => + resolve({ + confirmations: this.confirmations, + } as ethers.ContractReceipt), + 100 + ); + }); + }, + } as ethers.ContractTransaction); + } +} + +const mockContract = new MockContract(); +const requiredConfirmations = 3; + +function setup(perform: () => void) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook( + // @ts-ignore force MockContract + () => useEthereumTransaction(perform, requiredConfirmations), + { wrapper } + ); +} + +test('Ethereum transaction flow', async () => { + const { result } = setup(() => { + return mockContract.depositAsset({ + assetSource: 'asset-source', + amount: '100', + vegaPublicKey: 'vega-key', + }); + }); + + expect(result.current).toEqual({ + status: TxState.Default, + txHash: null, + error: null, + confirmations: 0, + perform: expect.any(Function), + }); + + act(() => { + result.current.perform(); + }); + + expect(result.current.status).toEqual(TxState.Requested); + expect(result.current.confirmations).toBe(0); + + await waitFor(() => { + expect(result.current.status).toEqual(TxState.Pending); + expect(result.current.txHash).toEqual(MockContract.txHash); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + expect(result.current.confirmations).toBe(1); + expect(result.current.status).toEqual(TxState.Pending); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + expect(result.current.confirmations).toBe(2); + expect(result.current.status).toEqual(TxState.Pending); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + expect(result.current.confirmations).toBe(3); + + // Now complete as required confirmations have been surpassed + expect(result.current.status).toEqual(TxState.Complete); +}); + +test('Error handling', async () => { + const { result } = setup(() => { + throw new EthereumError(errorMsg, 500); + }); + + const errorMsg = 'test-error'; + + act(() => { + 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); +}); diff --git a/libs/react-helpers/src/hooks/use-ethereum-transaction.ts b/libs/react-helpers/src/hooks/use-ethereum-transaction.ts new file mode 100644 index 000000000..7f08f088d --- /dev/null +++ b/libs/react-helpers/src/hooks/use-ethereum-transaction.ts @@ -0,0 +1,72 @@ +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 = ( + performTransaction: ( + args: TArgs + ) => Promise | null, + requiredConfirmations = 1 +) => { + const [confirmations, setConfirmations] = useState(0); + const [status, setStatus] = useState(TxState.Default); + const [txHash, setTxHash] = useState(null); + const [error, setError] = useState(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 }; +}; diff --git a/libs/react-helpers/src/hooks/use-token-contract.ts b/libs/react-helpers/src/hooks/use-token-contract.ts new file mode 100644 index 000000000..67cd8d7f9 --- /dev/null +++ b/libs/react-helpers/src/hooks/use-token-contract.ts @@ -0,0 +1,23 @@ +import { ERC20Token } from '@vegaprotocol/smart-contracts-sdk'; +import { useWeb3React } from '@web3-react/core'; +import { useMemo } from 'react'; + +export const useTokenContract = ( + contractAddress?: string, + faucetable = false +): ERC20Token | null => { + const { provider } = useWeb3React(); + const contract = useMemo(() => { + if (!provider || !contractAddress) { + return null; + } + return new ERC20Token( + contractAddress, + provider, + provider.getSigner(), + faucetable + ); + }, [provider, contractAddress, faucetable]); + + return contract; +}; diff --git a/libs/react-helpers/src/index.ts b/libs/react-helpers/src/index.ts index db48c17ad..b003197c1 100644 --- a/libs/react-helpers/src/index.ts +++ b/libs/react-helpers/src/index.ts @@ -4,5 +4,7 @@ export * from './lib/grid-cells'; export * from './lib/storage'; export * from './lib/generic-data-provider'; export * from './lib/i18n'; +export * from './lib/ethereum-error'; +export * from './lib/validate'; export * from './hooks'; diff --git a/libs/react-helpers/src/lib/ethereum-error.ts b/libs/react-helpers/src/lib/ethereum-error.ts new file mode 100644 index 000000000..7adca374f --- /dev/null +++ b/libs/react-helpers/src/lib/ethereum-error.ts @@ -0,0 +1,26 @@ +export class EthereumError extends Error { + code: number; + + constructor(message: string, code: number) { + super(message); + this.code = code; + this.name = 'EthereumError'; + } +} + +export const isEthereumError = (err: unknown): err is EthereumError => { + if (typeof err === 'object' && err !== null && 'code' in err) { + return true; + } + return false; +}; + +export const isExpectedEthereumError = (error: unknown) => { + const EXPECTED_ERRORS = [4001]; + + if (isEthereumError(error) && EXPECTED_ERRORS.indexOf(error.code) >= 0) { + return true; + } + + return false; +}; diff --git a/libs/react-helpers/src/lib/validate/index.ts b/libs/react-helpers/src/lib/validate/index.ts new file mode 100644 index 000000000..819b8b6f4 --- /dev/null +++ b/libs/react-helpers/src/lib/validate/index.ts @@ -0,0 +1,38 @@ +import BigNumber from 'bignumber.js'; +import { ethers } from 'ethers'; +import { t } from '../i18n'; + +export const required = (value: string) => { + if (value === null || value === undefined || value === '') { + return t('Required'); + } + return true; +}; + +export const ethereumAddress = (value: string) => { + if (!ethers.utils.isAddress(value)) { + return t('Invalid Ethereum address'); + } + return true; +}; + +export const vegaPublicKey = (value: string) => { + if (value.length !== 64 || !/^[A-Fa-f0-9]*$/i.test(value)) { + return t('Invalid Vega key'); + } + return true; +}; + +export const minSafe = (min: BigNumber) => (value: string) => { + if (new BigNumber(value).isLessThan(min)) { + return t('Amount is below permitted minimum'); + } + return true; +}; + +export const maxSafe = (max: BigNumber) => (value: string) => { + if (new BigNumber(value).isGreaterThan(max)) { + return t('Amount is above permitted maximum'); + } + return true; +}; diff --git a/libs/types/src/__generated__/globalTypes.ts b/libs/types/src/__generated__/globalTypes.ts index 0e2c72879..ca2c66dc6 100644 --- a/libs/types/src/__generated__/globalTypes.ts +++ b/libs/types/src/__generated__/globalTypes.ts @@ -67,6 +67,15 @@ export enum BusEventType { Withdrawal = "Withdrawal", } +/** + * The status of a deposit + */ +export enum DepositStatus { + Cancelled = "Cancelled", + Finalized = "Finalized", + Open = "Open", +} + /** * The current state of a market */ diff --git a/libs/ui-toolkit/jest.config.js b/libs/ui-toolkit/jest.config.js index 57204627e..fa8fb5999 100644 --- a/libs/ui-toolkit/jest.config.js +++ b/libs/ui-toolkit/jest.config.js @@ -6,5 +6,6 @@ module.exports = { }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/libs/ui-toolkit', + setupFiles: ['./src/setup-test-env.ts'], setupFilesAfterEnv: ['./src/setup-tests.ts'], }; diff --git a/libs/ui-toolkit/src/components/dialog/dialog.tsx b/libs/ui-toolkit/src/components/dialog/dialog.tsx index a6c81d936..b5e8cccc5 100644 --- a/libs/ui-toolkit/src/components/dialog/dialog.tsx +++ b/libs/ui-toolkit/src/components/dialog/dialog.tsx @@ -32,7 +32,10 @@ export function Dialog({ - + {title &&

{title}

} diff --git a/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.stories.tsx b/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.stories.tsx index 6579117f6..d2add16af 100644 --- a/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.stories.tsx +++ b/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.stories.tsx @@ -1,51 +1,24 @@ -import React from 'react'; import type { ComponentStory, ComponentMeta } from '@storybook/react'; import { EtherscanLink } from '.'; -import { EthereumChainIds, EthereumChainNames } from '../../utils/web3'; export default { title: 'EtherscanLink', component: EtherscanLink, - argTypes: { - chainId: { - options: Object.values(EthereumChainIds), - control: { - type: 'select', // Type 'select' is automatically inferred when 'options' is defined - labels: EthereumChainNames, - }, - }, - }, } as ComponentMeta; const Template: ComponentStory = (args) => ( ); -export const MainnetTx = Template.bind({}); -MainnetTx.args = { - chainId: EthereumChainIds.Mainnet, +export const Transaction = Template.bind({}); +Transaction.args = { tx: 'foo', text: 'View transaction on Etherscan', }; -export const RopstenTx = Template.bind({}); -RopstenTx.args = { - chainId: EthereumChainIds.Ropsten, - tx: 'foo', - text: 'View transaction on Etherscan', -}; - -export const MainnetAddress = Template.bind({}); -MainnetAddress.args = { - chainId: EthereumChainIds.Mainnet, - address: 'foo', - text: 'View transaction on Etherscan', -}; - -export const RopstenAddress = Template.bind({}); -RopstenAddress.args = { - chainId: EthereumChainIds.Ropsten, +export const Address = Template.bind({}); +Address.args = { address: 'foo', text: 'View transaction on Etherscan', }; diff --git a/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.test.tsx b/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.test.tsx index 156436135..feb050d71 100644 --- a/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.test.tsx +++ b/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.test.tsx @@ -1,64 +1,35 @@ import { render, screen } from '@testing-library/react'; import { EtherscanLink } from '.'; -import { EthereumChainIds } from '../../utils/web3'; test('It renders a link with the text', () => { - render( - - ); + render(); expect(screen.getByText('foo')).toBeInTheDocument(); }); test('It renders a link with the tx hash if no text is provided', () => { - render(); + render(); expect(screen.getByText('tx')).toBeInTheDocument(); }); test('It renders a link with the address if no text is provided', () => { - render( - - ); + render(); expect(screen.getByText('address')).toBeInTheDocument(); }); -test('It links to etherscan if network is mainnet', () => { - render( - - ); +test('It links to etherscan address', () => { + const hash = 'hash'; + render(); expect(screen.getByTestId('etherscan-link')).toHaveAttribute( 'href', - 'https://etherscan.io/address/address' + `${process.env['NX_ETHERSCAN_URL']}/address/${hash}` ); }); -test('It links to ropsten etherscan if network is ropsten', () => { - render( - - ); +test('It links to etherscan transaction', () => { + const hash = 'hash'; + render(); expect(screen.getByTestId('etherscan-link')).toHaveAttribute( 'href', - 'https://ropsten.etherscan.io/address/address' + `${process.env['NX_ETHERSCAN_URL']}/tx/${hash}` ); }); - -test("Doesn't render for address if chainid is null", () => { - render(); - expect(screen.queryByTestId('etherscan-link')).not.toBeInTheDocument(); -}); - -test("Doesn't render for tx if chainid is null", () => { - render(); - expect(screen.queryByTestId('etherscan-link')).not.toBeInTheDocument(); -}); - -test("Doesn't render for address if chainid is unknown", () => { - // @ts-ignore wrong chaindId passed - render(); - expect(screen.queryByTestId('etherscan-link')).not.toBeInTheDocument(); -}); - -test("Doesn't render for tx if chainid is unknown", () => { - // @ts-ignore wrong chaindId passed - render(); - expect(screen.queryByTestId('etherscan-link')).not.toBeInTheDocument(); -}); diff --git a/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.tsx b/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.tsx index bd81014de..0c3720dc7 100644 --- a/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.tsx +++ b/libs/ui-toolkit/src/components/etherscan-link/etherscan-link.tsx @@ -1,16 +1,11 @@ +import classNames from 'classnames'; +import type { AnchorHTMLAttributes } from 'react'; import React from 'react'; -import type { EthereumChainId } from '../../utils/web3'; -const etherscanUrls: Record = { - '0x1': 'https://etherscan.io', - '0x3': 'https://ropsten.etherscan.io', - '0x4': 'https://rinkeby.etherscan.io', - '0x5': 'https://goerli.etherscan.io', - '0x2a': 'https://kovan.etherscan.io', -}; +const ETHERSCAN_URL = process.env['NX_ETHERSCAN_URL'] as string; -interface BaseEtherscanLinkProps { - chainId: EthereumChainId | null; +interface BaseEtherscanLinkProps + extends AnchorHTMLAttributes { text?: string; } @@ -30,23 +25,22 @@ type EtherscanLinkProps = * Form an HTML link tag pointing to an appropriate Etherscan page */ export const EtherscanLink = ({ - chainId, text, + className, ...props }: EtherscanLinkProps) => { let hash: string; let txLink: string | null; - const createLink = React.useMemo( - () => etherscanLinkCreator(chainId), - [chainId] - ); + const anchorClasses = classNames('underline', className); if ('tx' in props) { hash = props.tx; - txLink = createLink ? createLink.tx(hash) : null; - } else { + txLink = `${ETHERSCAN_URL}/tx/${hash}`; + } else if ('address' in props) { hash = props.address; - txLink = createLink ? createLink.address(hash) : null; + txLink = `${ETHERSCAN_URL}/address/${hash}`; + } else { + throw new Error('Must provider either "tx" or "address" prop'); } const linkText = text ? text : hash; @@ -62,28 +56,11 @@ export const EtherscanLink = ({ href={txLink} target="_blank" rel="noreferrer" - className="etherscan-link" + className={anchorClasses} > {linkText} ); }; -function etherscanLinkCreator(chainId: EthereumChainId | null) { - if (!chainId) return null; - - const url = etherscanUrls[chainId]; - - return { - tx: (tx: string) => { - if (!url) return null; - return `${url}/tx/${tx}`; - }, - address: (address: string) => { - if (!url) return null; - return `${url}/address/${address}`; - }, - }; -} - EtherscanLink.displayName = 'EtherScanLink'; diff --git a/libs/ui-toolkit/src/components/icon/icon.tsx b/libs/ui-toolkit/src/components/icon/icon.tsx index 9d82398e1..d41f3bcf0 100644 --- a/libs/ui-toolkit/src/components/icon/icon.tsx +++ b/libs/ui-toolkit/src/components/icon/icon.tsx @@ -14,6 +14,8 @@ export const Icon = ({ size = 16, name, className }: IconProps) => { const effectiveClassName = classNames( 'inline-block', 'fill-current', + 'align-text-bottom', + 'fill-current', { 'w-20': size === 20, 'h-20': size === 20, diff --git a/libs/ui-toolkit/src/components/input-error/input-error.tsx b/libs/ui-toolkit/src/components/input-error/input-error.tsx index b456049df..6dbfb9568 100644 --- a/libs/ui-toolkit/src/components/input-error/input-error.tsx +++ b/libs/ui-toolkit/src/components/input-error/input-error.tsx @@ -39,6 +39,7 @@ export const InputError = ({ data-testid="input-error-text" className={effectiveClassName} {...props} + role="alert" > {children} diff --git a/libs/ui-toolkit/src/components/input/input.tsx b/libs/ui-toolkit/src/components/input/input.tsx index 9047ab05e..379bc75de 100644 --- a/libs/ui-toolkit/src/components/input/input.tsx +++ b/libs/ui-toolkit/src/components/input/input.tsx @@ -70,6 +70,7 @@ export const Input = forwardRef( if (appendIconName) { className += ' pr-28'; } + const input = ( { const items = size === 'small' ? 9 : 16; return ( -
+
{new Array(items).fill(null).map((_, i) => { return ( diff --git a/libs/ui-toolkit/src/components/transaction-dialog/dialog-rows.tsx b/libs/ui-toolkit/src/components/transaction-dialog/dialog-rows.tsx new file mode 100644 index 000000000..6dafd9578 --- /dev/null +++ b/libs/ui-toolkit/src/components/transaction-dialog/dialog-rows.tsx @@ -0,0 +1,94 @@ +import { EtherscanLink } from '../etherscan-link'; +import { t, TxState } from '@vegaprotocol/react-helpers'; + +const ACTIVE_CLASSES = 'text-black dark:text-white'; + +export const ConfirmRow = ({ status }: { status: TxState }) => { + if (status === TxState.Requested) { + return ( +

+ {t('Confirm transaction in wallet')} +

+ ); + } + + return

{t('Confirmed in wallet')}

; +}; + +interface TxRowProps { + status: TxState; + txHash: string | null; + confirmations: number; + requiredConfirmations: number; + highlightComplete?: boolean; +} + +export const TxRow = ({ + status, + txHash, + confirmations, + requiredConfirmations, + highlightComplete = true, +}: TxRowProps) => { + if (status === TxState.Pending) { + return ( +

+ + {t( + `Awaiting Ethereum transaction ${confirmations}/${requiredConfirmations} confirmations...` + )} + + +

+ ); + } + + if (status === TxState.Complete) { + return ( +

+ {t('Ethereum transaction complete')} + +

+ ); + } + + return

{t('Await Ethereum transaction')}

; +}; + +interface ConfirmationEventRowProps { + status: TxState; + confirmed: boolean; +} + +export const ConfirmationEventRow = ({ + status, + confirmed, +}: ConfirmationEventRowProps) => { + if (status !== TxState.Complete) { + return

{t('Vega confirmation')}

; + } + + if (!confirmed) { + return ( +

+ {t('Vega is confirming your transaction...')} +

+ ); + } + + return ( +

{t('Transaction confirmed')}

+ ); +}; diff --git a/libs/ui-toolkit/src/components/transaction-dialog/dialog-wrapper.tsx b/libs/ui-toolkit/src/components/transaction-dialog/dialog-wrapper.tsx new file mode 100644 index 000000000..34e92acaf --- /dev/null +++ b/libs/ui-toolkit/src/components/transaction-dialog/dialog-wrapper.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; + +interface DialogWrapperProps { + children: ReactNode; + icon: ReactNode; + title: string; +} + +export const DialogWrapper = ({ + children, + icon, + title, +}: DialogWrapperProps) => { + return ( +
+
{icon}
+
+

+ {title} +

+
{children}
+
+
+ ); +}; diff --git a/libs/ui-toolkit/src/components/transaction-dialog/index.ts b/libs/ui-toolkit/src/components/transaction-dialog/index.ts new file mode 100644 index 000000000..efaeaf442 --- /dev/null +++ b/libs/ui-toolkit/src/components/transaction-dialog/index.ts @@ -0,0 +1 @@ +export * from './transaction-dialog'; diff --git a/libs/ui-toolkit/src/components/transaction-dialog/transaction-dialog.spec.tsx b/libs/ui-toolkit/src/components/transaction-dialog/transaction-dialog.spec.tsx new file mode 100644 index 000000000..8de075a88 --- /dev/null +++ b/libs/ui-toolkit/src/components/transaction-dialog/transaction-dialog.spec.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { EthereumError, TxState } from '@vegaprotocol/react-helpers'; +import type { TransactionDialogProps } from './transaction-dialog'; +import { TransactionDialog } from './transaction-dialog'; + +jest.mock('@web3-react/core', () => ({ + useWeb3React: () => ({ + chainId: 3, + }), +})); + +let props: TransactionDialogProps; + +beforeEach(() => { + props = { + name: 'test', + status: TxState.Default, + txHash: null, + error: null, + confirmations: 1, + }; +}); + +const generateJsx = (moreProps?: Partial) => { + return ; +}; + +test('Opens when tx starts and closes if the user rejects the tx', () => { + const { container, rerender } = render(generateJsx()); + + // Dialog closed by default + expect(container).toBeEmptyDOMElement(); + + rerender(generateJsx({ status: TxState.Pending })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // User rejecting the tx closes the dialog + rerender( + generateJsx({ + status: TxState.Error, + error: new EthereumError('User rejected', 4001), + }) + ); + + expect(container).toBeEmptyDOMElement(); +}); + +test('Doesn\t repoen if user dismissed the dialog', () => { + const { container, rerender } = render( + generateJsx({ status: TxState.Pending }) + ); + + fireEvent.click(screen.getByTestId('dialog-close')); + + expect(container).toBeEmptyDOMElement(); + + rerender(generateJsx({ status: TxState.Complete })); + + // Should still be closed even though tx updated + expect(container).toBeEmptyDOMElement(); +}); + +test('Dialog states', () => { + const { rerender } = render(generateJsx({ status: TxState.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 })); + expect(screen.getByText(`${props.name} pending`)).toBeInTheDocument(); + expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument(); + expect( + screen.getByText('Awaiting Ethereum transaction 0/1 confirmations...') + ).toBeInTheDocument(); + expect(screen.getByTestId('etherscan-link')).toBeInTheDocument(); + + rerender(generateJsx({ status: TxState.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) })); + 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 }) + ); + expect(screen.getByText(`${props.name} pending`)).toBeInTheDocument(); + expect(screen.getByText('Confirmed in wallet')).toBeInTheDocument(); + expect(screen.getByText('Ethereum transaction complete')).toBeInTheDocument(); + expect( + screen.getByText('Vega is confirming your transaction...') + ).toBeInTheDocument(); + + // @ts-ignore enforce truthy on confirmation event + rerender(generateJsx({ confirmed: true, status: TxState.Complete })); + expect( + screen.queryByText('Vega is confirming your transaction...') + ).not.toBeInTheDocument(); + expect(screen.getByText('Transaction confirmed')).toBeInTheDocument(); +}); diff --git a/libs/ui-toolkit/src/components/transaction-dialog/transaction-dialog.tsx b/libs/ui-toolkit/src/components/transaction-dialog/transaction-dialog.tsx new file mode 100644 index 000000000..7aa899818 --- /dev/null +++ b/libs/ui-toolkit/src/components/transaction-dialog/transaction-dialog.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from 'react'; +import { + t, + TxState, + isExpectedEthereumError, +} from '@vegaprotocol/react-helpers'; +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; + error: Error | null; + confirmations: number; + txHash: string | null; + requiredConfirmations?: number; + // Undefined means this dialog isn't expecting an additional event for a complete state, a boolean + // value means it is but hasn't been received yet + confirmed?: boolean; +} + +export const TransactionDialog = ({ + name, + status, + error, + confirmations, + txHash, + requiredConfirmations = 1, + confirmed, +}: TransactionDialogProps) => { + const [dialogOpen, setDialogOpen] = useState(false); + const dialogDismissed = useRef(false); + + const renderContent = () => { + if (status === TxState.Error) { + return ( +

{error && error.message}

+ ); + } + + return ( + <> + + + {confirmed !== undefined && ( + + )} + + ); + }; + + const getWrapperProps = () => { + const propsMap = { + [TxState.Error]: { + title: t(`${name} failed`), + icon: , + intent: Intent.Danger, + }, + [TxState.Requested]: { + title: t('Confirm transaction'), + icon: , + intent: Intent.Prompt, + }, + [TxState.Pending]: { + title: t(`${name} pending`), + icon: , + intent: Intent.Progress, + }, + [TxState.Complete]: { + title: t(`${name} complete`), + icon: , + intent: Intent.Success, + }, + }; + + // Dialog not showing + if (status === TxState.Default) { + return { intent: undefined, title: '', icon: null }; + } + + // Confirmation event bool is required so + if (confirmed !== undefined) { + // Vega has confirmed Tx + if (confirmed === true) { + return propsMap[TxState.Complete]; + } + // Tx is complete but still awaiting for Vega to confirm + else if (status === TxState.Complete) { + return propsMap[TxState.Pending]; + } + } + + return propsMap[status]; + }; + + useEffect(() => { + // Close dialog if error is due to user rejecting the tx + if (status === TxState.Error && isExpectedEthereumError(error)) { + setDialogOpen(false); + return; + } + + if (status !== TxState.Default && !dialogDismissed.current) { + setDialogOpen(true); + return; + } + }, [status, error]); + + const { intent, ...wrapperProps } = getWrapperProps(); + + return ( + { + setDialogOpen(isOpen); + dialogDismissed.current = true; + }} + intent={intent} + > + {renderContent()} + + ); +}; diff --git a/libs/ui-toolkit/src/index.ts b/libs/ui-toolkit/src/index.ts index 80fdf8ff3..27225278a 100644 --- a/libs/ui-toolkit/src/index.ts +++ b/libs/ui-toolkit/src/index.ts @@ -1,12 +1,9 @@ -import * as EthereumUtils from './utils/web3'; - // Components export { AgGridLazy, AgGridDynamic } from './components/ag-grid'; export { AsyncRenderer } from './components/async-renderer'; export { Button, AnchorButton } from './components/button'; export { Callout } from './components/callout'; export { CopyWithTooltip } from './components/copy-with-tooltip'; -export { EthereumUtils }; export { EtherscanLink } from './components/etherscan-link'; export { FormGroup } from './components/form-group'; export { Icon } from './components/icon'; @@ -23,6 +20,7 @@ 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'; diff --git a/libs/ui-toolkit/src/setup-test-env.ts b/libs/ui-toolkit/src/setup-test-env.ts new file mode 100644 index 000000000..9fb0d0e50 --- /dev/null +++ b/libs/ui-toolkit/src/setup-test-env.ts @@ -0,0 +1 @@ +process.env['NX_ETHERSCAN_URL'] = 'https://etherscan.io'; diff --git a/libs/ui-toolkit/src/utils/intent.tsx b/libs/ui-toolkit/src/utils/intent.tsx index c5618ef0a..6eda6777b 100644 --- a/libs/ui-toolkit/src/utils/intent.tsx +++ b/libs/ui-toolkit/src/utils/intent.tsx @@ -1,5 +1,3 @@ -import classNames from 'classnames'; - export enum Intent { Danger = 'danger', Warning = 'warning', @@ -19,14 +17,15 @@ export enum TailwindIntents { } export const getIntentShadow = (intent?: Intent) => { - return classNames('shadow-callout', { + return { + 'shadow-callout': true, 'shadow-intent-danger': intent === Intent.Danger, 'shadow-intent-warning': intent === Intent.Warning, 'shadow-intent-prompt': intent === Intent.Prompt, 'shadow-black dark:shadow-white': intent === Intent.Progress, 'shadow-intent-success': intent === Intent.Success, 'shadow-intent-help': intent === Intent.Help, - }); + }; }; export const getVariantBackground = (variant?: TailwindIntents) => { diff --git a/libs/ui-toolkit/src/utils/web3.ts b/libs/ui-toolkit/src/utils/web3.ts deleted file mode 100644 index 8807b89de..000000000 --- a/libs/ui-toolkit/src/utils/web3.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type EthereumChainId = '0x1' | '0x3' | '0x4' | '0x5' | '0x2a'; -export type EthereumChainName = - | 'Mainnet' - | 'Ropsten' - | 'Rinkeby' - | 'Goerli' - | 'Kovan'; - -export const EthereumChainNames: Record = { - '0x1': 'Mainnet', - '0x3': 'Ropsten', - '0x4': 'Rinkeby', - '0x5': 'Goerli', - '0x2a': 'Kovan', -}; - -export const EthereumChainIds: Record = { - Mainnet: '0x1', - Ropsten: '0x3', - Rinkeby: '0x4', - Goerli: '0x5', - Kovan: '0x2a', -}; diff --git a/package.json b/package.json index 1bbc7a8ec..eda2dca70 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@sentry/tracing": "^6.19.2", "@types/lodash": "^4.14.180", "@types/uuid": "^8.3.4", + "@vegaprotocol/smart-contracts-sdk": "^1.6.0", "@vegaprotocol/vegawallet-service-api-client": "^0.4.6", "@walletconnect/ethereum-provider": "^1.7.5", "@web3-react/core": "8.0.20-beta.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index 4e12e4fa1..884d84f05 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,6 +17,7 @@ "paths": { "@vegaprotocol/cypress": ["libs/cypress/src/index.ts"], "@vegaprotocol/deal-ticket": ["libs/deal-ticket/src/index.ts"], + "@vegaprotocol/deposits": ["libs/deposits/src/index.ts"], "@vegaprotocol/market-list": ["libs/market-list/src/index.ts"], "@vegaprotocol/network-stats": ["libs/network-stats/src/index.ts"], "@vegaprotocol/order-list": ["libs/order-list/src/index.ts"], diff --git a/workspace.json b/workspace.json index 0316161af..947bc4abc 100644 --- a/workspace.json +++ b/workspace.json @@ -3,6 +3,7 @@ "projects": { "cypress": "libs/cypress", "deal-ticket": "libs/deal-ticket", + "deposits": "libs/deposits", "explorer": "apps/explorer", "explorer-e2e": "apps/explorer-e2e", "market-list": "libs/market-list", diff --git a/yarn.lock b/yarn.lock index 53043f53a..7369fee4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1584,6 +1584,13 @@ dependencies: "@ethersproject/logger" "^5.6.0" +"@ethersproject/bytes@5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.6.1.tgz#24f916e411f82a8a60412344bf4a813b917eefe7" + integrity sha512-NwQt7cKn5+ZE4uDn+X5RAXLp46E1chXoaMmrxAyA0rblpxz8t58lVkrHXoRIn0lz1joQElQ8410GqhTqMOwc6g== + dependencies: + "@ethersproject/logger" "^5.6.0" + "@ethersproject/constants@5.6.0", "@ethersproject/constants@^5.6.0": version "5.6.0" resolved "https://registry.yarnpkg.com/@ethersproject/constants/-/constants-5.6.0.tgz#55e3eb0918584d3acc0688e9958b0cedef297088" @@ -1678,6 +1685,13 @@ dependencies: "@ethersproject/logger" "^5.6.0" +"@ethersproject/networks@5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.6.1.tgz#7a21ed1f83e86121737b16841961ec99ccf5c9c7" + integrity sha512-b2rrupf3kCTcc3jr9xOWBuHylSFtbpJf79Ga7QR98ienU2UqGimPGEsYMgbI29KHJfA5Us89XwGVmxrlxmSrMg== + dependencies: + "@ethersproject/logger" "^5.6.0" + "@ethersproject/pbkdf2@5.6.0", "@ethersproject/pbkdf2@^5.6.0": version "5.6.0" resolved "https://registry.yarnpkg.com/@ethersproject/pbkdf2/-/pbkdf2-5.6.0.tgz#04fcc2d7c6bff88393f5b4237d906a192426685a" @@ -1718,6 +1732,31 @@ bech32 "1.1.4" ws "7.4.6" +"@ethersproject/providers@5.6.2": + version "5.6.2" + resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.6.2.tgz#b9807b1c8c6f59fa2ee4b3cf6519724d07a9f422" + integrity sha512-6/EaFW/hNWz+224FXwl8+HdMRzVHt8DpPmu5MZaIQqx/K/ELnC9eY236SMV7mleCM3NnEArFwcAAxH5kUUgaRg== + dependencies: + "@ethersproject/abstract-provider" "^5.6.0" + "@ethersproject/abstract-signer" "^5.6.0" + "@ethersproject/address" "^5.6.0" + "@ethersproject/basex" "^5.6.0" + "@ethersproject/bignumber" "^5.6.0" + "@ethersproject/bytes" "^5.6.0" + "@ethersproject/constants" "^5.6.0" + "@ethersproject/hash" "^5.6.0" + "@ethersproject/logger" "^5.6.0" + "@ethersproject/networks" "^5.6.0" + "@ethersproject/properties" "^5.6.0" + "@ethersproject/random" "^5.6.0" + "@ethersproject/rlp" "^5.6.0" + "@ethersproject/sha2" "^5.6.0" + "@ethersproject/strings" "^5.6.0" + "@ethersproject/transactions" "^5.6.0" + "@ethersproject/web" "^5.6.0" + bech32 "1.1.4" + ws "7.4.6" + "@ethersproject/providers@^5.6.0": version "5.6.1" resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.6.1.tgz#9a05f00ecbac59565bf6907c8d2af8ac33303b48" @@ -5847,6 +5886,15 @@ "@typescript-eslint/types" "5.14.0" eslint-visitor-keys "^3.0.0" +"@vegaprotocol/smart-contracts-sdk@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vegaprotocol/smart-contracts-sdk/-/smart-contracts-sdk-1.6.0.tgz#c689d5928148113dddbe39ed963c74817f432d5e" + integrity sha512-cNFDywrtrvR3V5WQL5BadMS8pckRaetXyxA5ttLZEghlpkMcz5BQ51GuAfK0ufank8vY3xYN0m1DalbfADQIGw== + dependencies: + deprecated-decorator "^0.1.6" + ethers "^5.5.2" + lodash "^4.17.21" + "@vegaprotocol/vegawallet-service-api-client@^0.4.6": version "0.4.6" resolved "https://registry.yarnpkg.com/@vegaprotocol/vegawallet-service-api-client/-/vegawallet-service-api-client-0.4.6.tgz#476d3dc5b91ed79f739b672e7260a4d88210f93a" @@ -9873,6 +9921,11 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= +deprecated-decorator@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/deprecated-decorator/-/deprecated-decorator-0.1.6.tgz#00966317b7a12fe92f3cc831f7583af329b86c37" + integrity sha1-AJZjF7ehL+kvPMgx91g68ym4bDc= + deps-sort@^2.0.0, deps-sort@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/deps-sort/-/deps-sort-2.0.1.tgz#9dfdc876d2bcec3386b6829ac52162cda9fa208d" @@ -10883,6 +10936,42 @@ etag@1.8.1, etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +ethers@^5.5.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.6.2.tgz#e75bac7f038c5e0fdde667dba62fc223924143a2" + integrity sha512-EzGCbns24/Yluu7+ToWnMca3SXJ1Jk1BvWB7CCmVNxyOeM4LLvw2OLuIHhlkhQk1dtOcj9UMsdkxUh8RiG1dxQ== + dependencies: + "@ethersproject/abi" "5.6.0" + "@ethersproject/abstract-provider" "5.6.0" + "@ethersproject/abstract-signer" "5.6.0" + "@ethersproject/address" "5.6.0" + "@ethersproject/base64" "5.6.0" + "@ethersproject/basex" "5.6.0" + "@ethersproject/bignumber" "5.6.0" + "@ethersproject/bytes" "5.6.1" + "@ethersproject/constants" "5.6.0" + "@ethersproject/contracts" "5.6.0" + "@ethersproject/hash" "5.6.0" + "@ethersproject/hdnode" "5.6.0" + "@ethersproject/json-wallets" "5.6.0" + "@ethersproject/keccak256" "5.6.0" + "@ethersproject/logger" "5.6.0" + "@ethersproject/networks" "5.6.1" + "@ethersproject/pbkdf2" "5.6.0" + "@ethersproject/properties" "5.6.0" + "@ethersproject/providers" "5.6.2" + "@ethersproject/random" "5.6.0" + "@ethersproject/rlp" "5.6.0" + "@ethersproject/sha2" "5.6.0" + "@ethersproject/signing-key" "5.6.0" + "@ethersproject/solidity" "5.6.0" + "@ethersproject/strings" "5.6.0" + "@ethersproject/transactions" "5.6.0" + "@ethersproject/units" "5.6.0" + "@ethersproject/wallet" "5.6.0" + "@ethersproject/web" "5.6.0" + "@ethersproject/wordlists" "5.6.0" + ethers@^5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.6.0.tgz#924eb965dc03963fad0a09ce687efdf49aca3b45"