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
This commit is contained in:
Matthew Russell 2022-04-06 10:34:51 -07:00 committed by GitHub
parent ac2348ad7e
commit f244cd07d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 2576 additions and 208 deletions

View File

@ -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"
NX_ETHEREUM_PROVIDER_URL = "https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8"
NX_ETHERSCAN_URL = "https://ropsten.etherscan.io"

View File

@ -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"
NX_ETHEREUM_PROVIDER_URL = "https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8"
NX_ETHERSCAN_URL = "https://ropsten.etherscan.io"

View File

@ -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"
NX_ETHEREUM_PROVIDER_URL = "https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8"
NX_ETHERSCAN_URL = "https://etherscan.io"

View File

@ -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"

View File

@ -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"
NX_ETHEREUM_PROVIDER_URL = "https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8"
NX_ETHERSCAN_URL = "https://ropsten.etherscan.io"

View File

@ -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"
NX_ETHEREUM_PROVIDER_URL = "https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8"
NX_ETHERSCAN_URL = "https://ropsten.etherscan.io"

View File

@ -0,0 +1 @@
export { PageQueryContainer } from './page-query-container';

View File

@ -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;
}

View File

@ -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<NetworkParametersQuery> = {
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(
<MockedProvider mocks={[mock]}>
<Web3Container>
{({ ethereumConfig }) => (
<div>
<div>Child</div>
<div>{ethereumConfig.collateral_bridge_contract.address}</div>
</div>
)}
</Web3Container>
</MockedProvider>
);
}
test('Prompt to connect opens dialog', async () => {
mockHookValue = defaultHookValue;
await act(async () => {
render(
<Web3Container>
<div>Child</div>
</Web3Container>
);
});
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(
<Web3Container>
<div>Child</div>
</Web3Container>
);
});
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(
<Web3Container>
<div>Child</div>
</Web3Container>
);
});
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<NetworkParametersQuery> = {
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<NetworkParametersQuery> = {
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();
});

View File

@ -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 (
<Web3Provider connectors={Connectors}>
<Web3Content setDialogOpen={setDialogOpen}>{children}</Web3Content>
<Web3ConnectDialog
connectors={Connectors}
dialogOpen={dialogOpen}
setDialogOpen={setDialogOpen}
desiredChainId={Number(process.env['NX_ETHEREUM_CHAIN_ID'] || 3)}
/>
</Web3Provider>
<PageQueryContainer<NetworkParamsQuery> query={NETWORK_PARAMS_QUERY}>
{(data) => {
const ethereumConfigParam = data.networkParameters?.find(
(np) => np.key === 'blockchains.ethereumConfig'
);
if (!ethereumConfigParam) {
return (
<Splash>
<p>{t('No ethereum config found')}</p>
</Splash>
);
}
let ethereumConfig: EthereumConfig;
try {
ethereumConfig = JSON.parse(ethereumConfigParam.value);
} catch {
return (
<Splash>
<p>{t('Could not parse config')}</p>
</Splash>
);
}
return (
<Web3Provider connectors={Connectors}>
<Web3Content
appChainId={Number(ethereumConfig.chain_id)}
setDialogOpen={setDialogOpen}
>
{children({ ethereumConfig })}
</Web3Content>
<Web3ConnectDialog
connectors={Connectors}
dialogOpen={dialogOpen}
setDialogOpen={setDialogOpen}
desiredChainId={Number(ethereumConfig.chain_id)}
/>
</Web3Provider>
);
}}
</PageQueryContainer>
);
};
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]);

View File

@ -6,17 +6,21 @@ export const metamask = initializeConnector<MetaMask>(
(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<WalletConnect>(
(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 = {

View File

@ -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;
}

View File

@ -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 (
<PageQueryContainer<DepositPage> query={DEPOSIT_PAGE_QUERY}>
{(data) => {
if (!data.assets?.length) {
return (
<Splash>
<p>{t('No assets on this network')}</p>
</Splash>
);
}
return (
<DepositManager
bridgeAddress={ethereumConfig.collateral_bridge_contract.address}
requiredConfirmations={ethereumConfig.confirmations}
assets={data.assets.filter(isERC20Asset)}
initialAssetId={assetId}
/>
);
}}
</PageQueryContainer>
);
};
const isERC20Asset = (asset: DepositPage_assets): asset is Asset => {
if (asset.source.__typename === 'ERC20') {
return true;
}
return false;
};

View File

@ -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 (
<Web3Container>
<div>
<h1>Deposit</h1>
<Info />
</div>
{({ ethereumConfig }) => (
<div className="max-w-[420px] p-24 mx-auto">
<h1 className="text-h3 mb-12">Deposit</h1>
<DepositContainer ethereumConfig={ethereumConfig} assetId={assetId} />
</div>
)}
</Web3Container>
);
};
const Info = () => {
const { isActive, chainId, account } = useWeb3React();
if (!isActive) {
return <div>Not active</div>;
}
return (
<div>
<p>{chainId}</p>
<p>{account ? account : 'No account'}</p>
</div>
);
};
export default Deposit;

View File

@ -10,6 +10,9 @@ const Portfolio = () => {
{keypair && <p>{t(`Keypair: ${keypair.name} ${keypair.pub}`)}</p>}
<div className="flex gap-4">
<AnchorButton href="/portfolio/deposit">{t('Deposit')}</AnchorButton>
<AnchorButton href="/portfolio/deposit?assetId=8b52d4a3a4b0ffe733cddbc2b67be273816cfeb6ca4c8b339bac03ffba08e4e4">
{t('Deposit tEURO')}
</AnchorButton>
</div>
</div>
);

12
libs/deposits/.babelrc Normal file
View File

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

View File

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

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

@ -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).

View File

@ -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'],
};

View File

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

View File

@ -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
}
}
}
}

View File

@ -0,0 +1 @@
export const FAUCETABLE = process.env['NX_VEGA_ENV'] !== 'MAINNET';

View File

@ -0,0 +1 @@
export * from './lib/deposit-manager';

View File

@ -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;
}

View File

@ -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(<DepositForm {...props} />);
// 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(
<DepositForm
{...props}
limits={{ min: new BigNumber(0), max: new BigNumber(100) }}
/>
);
const amountMoreThanAllowance = '31';
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: amountMoreThanAllowance },
});
expect(await screen.findByText('Amount is above approved amount'));
// Min amount validation
rerender(<DepositForm {...props} selectedAsset={asset} />); // 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(
<DepositForm
{...props}
limits={{ max: new BigNumber(20), min: new BigNumber(10) }}
/>
);
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(
<DepositForm
{...props}
allowance={new BigNumber(0)}
selectedAsset={asset}
/>
);
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(
<DepositForm
{...props}
allowance={new BigNumber(100)}
available={new BigNumber(50)}
limits={limits}
selectedAsset={asset}
/>
);
// 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}`,
});
});

View File

@ -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<void>;
submitDeposit: (args: {
assetSource: string;
amount: string;
vegaPublicKey: string;
}) => Promise<void>;
requestFaucet: () => Promise<void>;
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<FormFields>({
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 (
<form onSubmit={handleSubmit(onDeposit)} noValidate={true}>
<FormGroup
label={t('From (Ethereum address)')}
labelFor="ethereum-address"
>
<Input
{...register('from', { validate: { required, ethereumAddress } })}
id="ethereum-address"
/>
{errors.from?.message && (
<InputError intent="danger" className="mt-4">
{errors.from.message}
</InputError>
)}
</FormGroup>
<FormGroup label={t('Asset')} labelFor="asset" className="relative">
<Select {...register('asset', { validate: { required } })} id="asset">
<option value="">{t('Please select')}</option>
{assets.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</Select>
{errors.asset?.message && (
<InputError intent="danger" className="mt-4">
{errors.asset.message}
</InputError>
)}
{FAUCETABLE && selectedAsset && (
<UseButton onClick={requestFaucet}>
{t(`Get ${selectedAsset.symbol}`)}
</UseButton>
)}
</FormGroup>
<FormGroup
label={t('To (Vega key)')}
labelFor="vega-key"
className="relative"
>
<Input
{...register('to', { validate: { required, vegaPublicKey } })}
id="vega-key"
/>
{errors.to?.message && (
<InputError intent="danger" className="mt-4">
{errors.to.message}
</InputError>
)}
{keypair?.pub && (
<UseButton
onClick={() => {
setValue('to', keypair.pub);
clearErrors('to');
}}
>
{t('Use connected')}
</UseButton>
)}
</FormGroup>
{selectedAsset && limits && (
<FormGroup>
<DepositLimits limits={limits} />
</FormGroup>
)}
<FormGroup label={t('Amount')} labelFor="amount" className="relative">
<Input
type="number"
autoComplete="off"
id="amount"
{...register('amount', {
required: t('Required'),
validate: {
minSafe: (value) => 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 && (
<InputError intent="danger" className="mt-4">
{errors.amount.message}
</InputError>
)}
{account && selectedAsset && available && (
<UseButton
onClick={() => {
setValue('amount', max.amount.toFixed(selectedAsset.decimals));
clearErrors('amount');
}}
>
{t('Use maximum')}
</UseButton>
)}
</FormGroup>
<FormButton
selectedAsset={selectedAsset}
amount={new BigNumber(amount || 0)}
allowance={allowance}
onApproveClick={submitApprove}
/>
</form>
);
};
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 = (
<Button type="submit" className="w-full">
{t('Deposit')}
</Button>
);
} else if (approved) {
message = (
<>
<Icon name="tick" /> <span>{t('Approved')}</span>
</>
);
button = (
<Button type="submit" className="w-full">
{t('Deposit')}
</Button>
);
} else {
message = t(`Deposits of ${selectedAsset.symbol} not approved`);
button = (
<Button onClick={onApproveClick} className="w-full">
{t(`Approve ${selectedAsset.symbol}`)}
</Button>
);
}
return (
<div className="flex flex-col gap-4">
{message && <p className="text-ui text-center mb-4">{message}</p>}
{button}
</div>
);
};
interface UseButtonProps {
children: ReactNode;
onClick: () => void;
}
const UseButton = ({ children, onClick }: UseButtonProps) => {
return (
<button
type="button"
className="ml-auto text-ui absolute top-0 right-0 underline"
onClick={onClick}
>
{children}
</button>
);
};

View File

@ -0,0 +1,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 (
<>
<p className="text-ui font-bold">{t('Temporary deposit limits')}</p>
<table className="w-full text-ui">
<tbody>
<tr>
<th className="text-left font-normal">{t('Minimum')}</th>
<td className="text-right">{minLimit}</td>
</tr>
<tr>
<th className="text-left font-normal">{t('Maximum')}</th>
<td className="text-right">{maxLimit}</td>
</tr>
</tbody>
</table>
</>
);
};

View File

@ -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<string | undefined>(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 (
<>
<DepositForm
available={balanceOf}
selectedAsset={asset}
onSelectAsset={(id) => setAssetId(id)}
assets={sortBy(assets, 'name')}
submitApprove={approve.perform}
submitDeposit={deposit.perform}
requestFaucet={faucet.perform}
limits={limits}
allowance={allowance}
/>
<TransactionDialog {...approve} name="approve" />
<TransactionDialog {...faucet} name="faucet" />
<TransactionDialog
{...deposit}
name="deposit"
confirmed={Boolean(confirmationEvent)}
// Must wait for additional confirmations for Vega to pick up the Ethereum transaction
requiredConfirmations={requiredConfirmations}
/>
</>
);
};

View File

@ -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;
};

View File

@ -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 };
};

View File

@ -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],
};
};

View File

@ -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;
};

View File

@ -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<DepositEvent_busEvents_event_Deposit | null>(null);
// Store public key from contract arguments for use in the subscription,
// NOTE: it may be different from the users connected key
const [partyId, setPartyId] = useState<string | null>(null);
const transaction = 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<DepositEvent, DepositEventVariables>(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,
};
};

View File

@ -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;
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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"
]
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
};

View File

@ -0,0 +1,70 @@
import { useCallback, useEffect, useReducer, useRef } from 'react';
interface State<T> {
data?: T;
error?: Error;
loading?: boolean;
}
enum ActionType {
LOADING = 'LOADING',
ERROR = 'ERROR',
FETCHED = 'FETCHED',
}
type Action<T> =
| { type: ActionType.LOADING }
| { type: ActionType.FETCHED; payload: T }
| { type: ActionType.ERROR; error: Error };
export const useEthereumReadContract = <T>(
contractFunc: () => Promise<T> | undefined
) => {
const cancelRequest = useRef(false);
const initialState: State<T> = {
error: undefined,
data: undefined,
loading: false,
};
const fetchReducer = (state: State<T>, action: Action<T>): State<T> => {
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 };
};

View File

@ -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<ethers.ContractTransaction> {
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 }) => (
<MockedProvider>{children}</MockedProvider>
);
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);
});

View File

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

View File

@ -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;
};

View File

@ -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';

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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
*/

View File

@ -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'],
};

View File

@ -32,7 +32,10 @@ export function Dialog({
<DialogPrimitives.Portal>
<DialogPrimitives.Overlay className="fixed inset-0 bg-black/50 dark:bg-white/15" />
<DialogPrimitives.Content className={contentClasses}>
<DialogPrimitives.Close className="p-12 absolute top-0 right-0">
<DialogPrimitives.Close
className="p-12 absolute top-0 right-0"
data-testid="dialog-close"
>
<Icon name="cross" />
</DialogPrimitives.Close>
{title && <h1 className="text-h5 mb-12">{title}</h1>}

View File

@ -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<typeof EtherscanLink>;
const Template: ComponentStory<typeof EtherscanLink> = (args) => (
<EtherscanLink {...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',
};

View File

@ -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(
<EtherscanLink text="foo" chainId={EthereumChainIds.Mainnet} tx="tx" />
);
render(<EtherscanLink text="foo" tx="tx" />);
expect(screen.getByText('foo')).toBeInTheDocument();
});
test('It renders a link with the tx hash if no text is provided', () => {
render(<EtherscanLink chainId={EthereumChainIds.Mainnet} tx="tx" />);
render(<EtherscanLink tx="tx" />);
expect(screen.getByText('tx')).toBeInTheDocument();
});
test('It renders a link with the address if no text is provided', () => {
render(
<EtherscanLink chainId={EthereumChainIds.Mainnet} address="address" />
);
render(<EtherscanLink address="address" />);
expect(screen.getByText('address')).toBeInTheDocument();
});
test('It links to etherscan if network is mainnet', () => {
render(
<EtherscanLink chainId={EthereumChainIds.Mainnet} address="address" />
);
test('It links to etherscan address', () => {
const hash = 'hash';
render(<EtherscanLink address={hash} />);
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(
<EtherscanLink chainId={EthereumChainIds.Ropsten} address="address" />
);
test('It links to etherscan transaction', () => {
const hash = 'hash';
render(<EtherscanLink tx={hash} />);
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(<EtherscanLink chainId={null} address="address" />);
expect(screen.queryByTestId('etherscan-link')).not.toBeInTheDocument();
});
test("Doesn't render for tx if chainid is null", () => {
render(<EtherscanLink chainId={null} tx="tx" />);
expect(screen.queryByTestId('etherscan-link')).not.toBeInTheDocument();
});
test("Doesn't render for address if chainid is unknown", () => {
// @ts-ignore wrong chaindId passed
render(<EtherscanLink chainId={'foo'} address="address" />);
expect(screen.queryByTestId('etherscan-link')).not.toBeInTheDocument();
});
test("Doesn't render for tx if chainid is unknown", () => {
// @ts-ignore wrong chaindId passed
render(<EtherscanLink chainId={'foo'} tx="tx" />);
expect(screen.queryByTestId('etherscan-link')).not.toBeInTheDocument();
});

View File

@ -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<EthereumChainId, string> = {
'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<HTMLAnchorElement> {
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}
</a>
);
};
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';

View File

@ -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,

View File

@ -39,6 +39,7 @@ export const InputError = ({
data-testid="input-error-text"
className={effectiveClassName}
{...props}
role="alert"
>
<Icon name="warning-sign" className={iconClassName} />
{children}

View File

@ -70,6 +70,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
if (appendIconName) {
className += ' pr-28';
}
const input = (
<input
{...props}

View File

@ -20,7 +20,7 @@ export const Loader = ({ size = 'large' }: LoaderProps) => {
const items = size === 'small' ? 9 : 16;
return (
<div className="flex flex-col items-center" data-testid="splash-loader">
<div className="flex flex-col items-center" data-testid="loader">
<div className={`${wrapperClasses} flex flex-wrap`}>
{new Array(items).fill(null).map((_, i) => {
return (

View File

@ -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 (
<p className="text-black dark:text-white">
{t('Confirm transaction in wallet')}
</p>
);
}
return <p>{t('Confirmed in wallet')}</p>;
};
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 (
<p className={`flex justify-between ${ACTIVE_CLASSES}`}>
<span>
{t(
`Awaiting Ethereum transaction ${confirmations}/${requiredConfirmations} confirmations...`
)}
</span>
<EtherscanLink
tx={txHash || ''}
className="text-vega-pink dark:text-vega-yellow"
text={t('View on Etherscan')}
/>
</p>
);
}
if (status === TxState.Complete) {
return (
<p
className={`flex justify-between ${
highlightComplete ? ACTIVE_CLASSES : ''
}`}
>
<span>{t('Ethereum transaction complete')}</span>
<EtherscanLink
tx={txHash || ''}
className="text-vega-pink dark:text-vega-yellow"
text={t('View on Etherscan')}
/>
</p>
);
}
return <p>{t('Await Ethereum transaction')}</p>;
};
interface ConfirmationEventRowProps {
status: TxState;
confirmed: boolean;
}
export const ConfirmationEventRow = ({
status,
confirmed,
}: ConfirmationEventRowProps) => {
if (status !== TxState.Complete) {
return <p>{t('Vega confirmation')}</p>;
}
if (!confirmed) {
return (
<p className="text-black dark:text-white">
{t('Vega is confirming your transaction...')}
</p>
);
}
return (
<p className="text-black dark:text-white">{t('Transaction confirmed')}</p>
);
};

View File

@ -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 (
<div className="flex gap-12 max-w-full text-ui">
<div className="pt-8 fill-current">{icon}</div>
<div className="flex-1">
<h1 className="text-h4 text-black dark:text-white capitalize mb-12">
{title}
</h1>
<div className="text-black-40 dark:text-white-40">{children}</div>
</div>
</div>
);
};

View File

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

View File

@ -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<TransactionDialogProps>) => {
return <TransactionDialog {...props} {...moreProps} />;
};
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();
});

View File

@ -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 (
<p className="text-black dark:text-white">{error && error.message}</p>
);
}
return (
<>
<ConfirmRow status={status} />
<TxRow
status={status}
txHash={txHash}
confirmations={confirmations}
requiredConfirmations={requiredConfirmations}
highlightComplete={false}
/>
{confirmed !== undefined && (
<ConfirmationEventRow status={status} confirmed={confirmed} />
)}
</>
);
};
const getWrapperProps = () => {
const propsMap = {
[TxState.Error]: {
title: t(`${name} failed`),
icon: <Icon name="warning-sign" size={20} />,
intent: Intent.Danger,
},
[TxState.Requested]: {
title: t('Confirm transaction'),
icon: <Icon name="hand-up" size={20} />,
intent: Intent.Prompt,
},
[TxState.Pending]: {
title: t(`${name} pending`),
icon: <Loader />,
intent: Intent.Progress,
},
[TxState.Complete]: {
title: t(`${name} complete`),
icon: <Icon name="tick" />,
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 (
<Dialog
open={dialogOpen}
onChange={(isOpen) => {
setDialogOpen(isOpen);
dialogDismissed.current = true;
}}
intent={intent}
>
<DialogWrapper {...wrapperProps}>{renderContent()}</DialogWrapper>
</Dialog>
);
};

View File

@ -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';

View File

@ -0,0 +1 @@
process.env['NX_ETHERSCAN_URL'] = 'https://etherscan.io';

View File

@ -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) => {

View File

@ -1,23 +0,0 @@
export type EthereumChainId = '0x1' | '0x3' | '0x4' | '0x5' | '0x2a';
export type EthereumChainName =
| 'Mainnet'
| 'Ropsten'
| 'Rinkeby'
| 'Goerli'
| 'Kovan';
export const EthereumChainNames: Record<EthereumChainId, EthereumChainName> = {
'0x1': 'Mainnet',
'0x3': 'Ropsten',
'0x4': 'Rinkeby',
'0x5': 'Goerli',
'0x2a': 'Kovan',
};
export const EthereumChainIds: Record<EthereumChainName, EthereumChainId> = {
Mainnet: '0x1',
Ropsten: '0x3',
Rinkeby: '0x4',
Goerli: '0x5',
Kovan: '0x2a',
};

View File

@ -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",

View File

@ -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"],

View File

@ -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",

View File

@ -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"