feat: deposit and deal ticket transaction stores and toasts (#2495)

This commit is contained in:
Art 2023-01-19 13:10:52 +01:00 committed by GitHub
parent 4ce2924380
commit 5ccef2de5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1832 additions and 1140 deletions

View File

@ -1,5 +1,4 @@
import { useParams } from 'react-router-dom';
import { DealTicketManager } from '@vegaprotocol/deal-ticket';
import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
import { t, useDataProvider } from '@vegaprotocol/react-helpers';
import { useVegaWallet } from '@vegaprotocol/wallet';
@ -50,10 +49,10 @@ export const DealTicketContainer = () => {
);
const container = (
<DealTicketManager market={data}>
<>
{loading ? loader : balance}
<DealTicketSteps market={data} />
</DealTicketManager>
</>
);
return (

View File

@ -1,8 +1,8 @@
import React from 'react';
import { usePrevious } from './use-previous';
import type { BigNumber } from '../lib/bignumber';
import { theme } from '@vegaprotocol/tailwindcss-config';
import colors from 'tailwindcss/colors';
import { usePrevious } from '@vegaprotocol/react-helpers';
const customColors = theme.colors;
const FLASH_DURATION = 1200; // Duration of flash animation in milliseconds

View File

@ -4,7 +4,7 @@ NX_HOSTED_WALLET_URL=https://wallet.testnet.vega.xyz
NX_VEGA_CONFIG_URL=''
NX_VEGA_DOCS_URL=https://docs.vega.xyz/testnet
NX_VEGA_ENV=CUSTOM
NX_VEGA_EXPLORER_URL=https://stagnet3.explorer.vega.xyz
NX_VEGA_EXPLORER_URL=https://explorer.fairground.wtf
NX_VEGA_NETWORKS={\"TESTNET\":\"https://console.fairground.wtf\",\"STAGNET1\":\"https://stagnet1.console.vega.xyz\",\"STAGNET3\":\"https://stagnet3.console.vega.xyz\"}
NX_VEGA_TOKEN_URL=https://token.fairground.wtf
NX_VEGA_URL=http://localhost:3028/query
@ -13,7 +13,7 @@ NX_VEGA_WALLET_URL=http://localhost:1789
# Expose some env vars to cypress environment for market setup
CYPRESS_ETH_WALLET_MNEMONIC=ozone access unlock valid olympic save include omit supply green clown session
CYPRESS_ETHEREUM_PROVIDER_URL=http://localhost:8545
CYPRESS_EXPLORER_URL=https://stagnet3.explorer.vega.xyz
CYPRESS_EXPLORER_URL=https://explorer.fairground.wtf
CYPRESS_FAUCET_URL=http://localhost:1790/api/v1/mint
CYPRESS_TRUNCATED_VEGA_PUBLIC_KEY=02ecea…342f65
CYPRESS_TRUNCATED_VEGA_PUBLIC_KEY2=7f9cf0…c25535

View File

@ -4,7 +4,7 @@ NX_HOSTED_WALLET_URL=https://wallet.testnet.vega.xyz
NX_VEGA_CONFIG_URL=''
NX_VEGA_DOCS_URL=https://docs.vega.xyz/testnet
NX_VEGA_ENV=CUSTOM
NX_VEGA_EXPLORER_URL=https://stagnet3.explorer.vega.xyz
NX_VEGA_EXPLORER_URL=https://explorer.fairground.wtf
NX_VEGA_NETWORKS={\"TESTNET\":\"https://console.fairground.wtf\",\"STAGNET1\":\"https://stagnet1.console.vega.xyz\",\"STAGNET3\":\"https://stagnet3.console.vega.xyz\"}
NX_VEGA_TOKEN_URL=https://token.fairground.wtf
NX_VEGA_URL=http://localhost:3028/query
@ -13,7 +13,7 @@ NX_VEGA_WALLET_URL=http://localhost:1789
# Expose some env vars to cypress environment for market setup
CYPRESS_ETH_WALLET_MNEMONIC=ozone access unlock valid olympic save include omit supply green clown session
CYPRESS_ETHEREUM_PROVIDER_URL=http://localhost:8545
CYPRESS_EXPLORER_URL=https://stagnet3.explorer.vega.xyz
CYPRESS_EXPLORER_URL=https://explorer.fairground.wtf
CYPRESS_FAUCET_URL=http://localhost:1790/api/v1/mint
CYPRESS_TRUNCATED_VEGA_PUBLIC_KEY=02ecea…342f65
CYPRESS_TRUNCATED_VEGA_PUBLIC_KEY2=7f9cf0…c25535

View File

@ -88,7 +88,7 @@ describe('orders list', { tags: '@smoke' }, () => {
cy.get(`[row-id="${partiallyFilledId}"]`).within(() => {
cy.get(`[col-id='${orderStatus}']`).should(
'have.text',
'PartiallyFilled'
'Partially Filled'
);
cy.get(`[col-id='${orderRemaining}']`).should('have.text', '7/10');
cy.getByTestId(cancelOrderBtn).should('not.exist');
@ -190,7 +190,7 @@ describe('subscribe orders', { tags: '@smoke' }, () => {
});
cy.getByTestId(`order-status-${orderId}`).should(
'have.text',
'PartiallyFilled'
'Partially Filled'
);
cy.getByTestId(`order-status-${orderId}`)
.parentsUntil(`.ag-row`)

View File

@ -21,6 +21,7 @@ export const testOrderSubmission = (
orderSubmission: expectedOrder,
};
vegaWalletTransaction(transaction);
verifyToast();
};
export const testOrderAmendment = (
@ -36,6 +37,7 @@ export const testOrderAmendment = (
orderAmendment: expectedOrder,
};
vegaWalletTransaction(transaction);
verifyToast();
};
export const testOrderCancellation = (
@ -51,11 +53,10 @@ export const testOrderCancellation = (
orderCancellation: expectedOrder,
};
vegaWalletTransaction(transaction);
verifyToast();
};
const vegaWalletTransaction = (transaction: Transaction) => {
const dialogTitle = 'dialog-title';
const orderTransactionHash = 'tx-block-explorer';
cy.wait('@VegaWalletTransaction')
.its('request.body.params')
.should('deep.equal', {
@ -65,12 +66,13 @@ const vegaWalletTransaction = (transaction: Transaction) => {
sendingMode: 'TYPE_SYNC',
transaction,
});
cy.getByTestId(dialogTitle).should(
'have.text',
'Awaiting network confirmation'
);
cy.getByTestId(orderTransactionHash)
.invoke('attr', 'href')
.should('include', `${Cypress.env('EXPLORER_URL')}/txs/0xtest-tx-hash`);
cy.getByTestId('dialog-close').click();
};
const verifyToast = () => {
cy.getByTestId('toast').should('contain.text', 'Awaiting confirmation');
cy.getByTestId('toast')
.find('a')
.invoke('attr', 'href')
.should('include', `${Cypress.env('EXPLORER_URL')}/txs/test-tx-hash`);
cy.getByTestId('toast-close').click();
};

View File

@ -0,0 +1,194 @@
import { useAssetsDataProvider } from '@vegaprotocol/assets';
import { ETHERSCAN_TX, useEtherscanLink } from '@vegaprotocol/environment';
import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers';
import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit';
import { ExternalLink, Intent, ProgressBar } from '@vegaprotocol/ui-toolkit';
import { useCallback, useMemo } from 'react';
import compact from 'lodash/compact';
import type { EthStoredTxState } from '@vegaprotocol/web3';
import {
EthTxStatus,
isEthereumError,
TransactionContent,
useEthTransactionStore,
} from '@vegaprotocol/web3';
const intentMap: { [s in EthTxStatus]: Intent } = {
Default: Intent.Primary,
Requested: Intent.Warning,
Pending: Intent.Warning,
Error: Intent.Danger,
Complete: Intent.Success,
Confirmed: Intent.Success,
};
const EthTransactionDetails = ({ tx }: { tx: EthStoredTxState }) => {
const { data } = useAssetsDataProvider();
if (!data) return null;
const ETH_WITHDRAW =
tx.methodName === 'withdraw_asset' && tx.args.length > 2 && tx.assetId;
const ETH_DEPOSIT =
tx.methodName === 'deposit_asset' && tx.args.length > 2 && tx.assetId;
let label = '';
if (ETH_WITHDRAW) label = t('Withdraw');
if (ETH_DEPOSIT) label = t('Deposit');
if (ETH_WITHDRAW || ETH_DEPOSIT) {
const asset = data.find((a) => a.id === tx.assetId);
if (asset) {
const num = formatNumber(
toBigNum(tx.args[1], asset.decimals),
asset.decimals
);
const details = (
<div className="mt-[5px]">
<span className="font-mono text-xs p-1 bg-gray-100 rounded">
{label} {num} {asset.symbol}
</span>
</div>
);
return (
<>
{details}
{tx.requiresConfirmation &&
[EthTxStatus.Pending].includes(tx.status) && (
<div className="mt-[10px]">
<span className="font-mono text-xs">
{t('Awaiting confirmations')}{' '}
{`(${tx.confirmations}/${tx.requiredConfirmations})`}
</span>
<ProgressBar
value={(tx.confirmations / tx.requiredConfirmations) * 100}
intent={Intent.Warning}
/>
</div>
)}
</>
);
}
}
return null;
};
type EthTxToastContentProps = {
tx: EthStoredTxState;
};
const EthTxRequestedToastContent = ({ tx }: EthTxToastContentProps) => {
return (
<div>
<h3 className="font-bold">{t('Action required')}</h3>
<p>
{t(
'Please go to your wallet application and approve or reject the transaction.'
)}
</p>
<EthTransactionDetails tx={tx} />
</div>
);
};
const EthTxPendingToastContent = ({ tx }: EthTxToastContentProps) => {
const etherscanLink = useEtherscanLink();
return (
<div>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3>
<p>{t('Please wait for your transaction to be confirmed')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={etherscanLink(ETHERSCAN_TX.replace(':hash', tx.txHash))}
rel="noreferrer"
>
{t('View on Etherscan')}
</ExternalLink>
</p>
)}
<EthTransactionDetails tx={tx} />
</div>
);
};
const EthTxErrorToastContent = ({ tx }: EthTxToastContentProps) => {
let errorMessage = '';
if (isEthereumError(tx.error)) {
errorMessage = tx.error.reason;
} else if (tx.error instanceof Error) {
errorMessage = tx.error.message;
}
return (
<div>
<h3 className="font-bold">{t('Error occurred')}</h3>
<p>{errorMessage}</p>
<EthTransactionDetails tx={tx} />
</div>
);
};
const EthTxConfirmedToastContent = ({ tx }: EthTxToastContentProps) => {
const etherscanLink = useEtherscanLink();
return (
<div>
<h3 className="font-bold">{t('Transaction completed')}</h3>
<p>{t('Your transaction has been completed')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={etherscanLink(ETHERSCAN_TX.replace(':hash', tx.txHash))}
rel="noreferrer"
>
{t('View on Etherscan')}
</ExternalLink>
</p>
)}
<EthTransactionDetails tx={tx} />
</div>
);
};
export const useEthereumTransactionToasts = () => {
const ethTransactions = useEthTransactionStore((state) =>
state.transactions.filter((transaction) => transaction?.dialogOpen)
);
const dismissEthTransaction = useEthTransactionStore(
(state) => state.dismiss
);
const fromEthTransaction = useCallback(
(tx: EthStoredTxState): Toast => {
let content: ToastContent = <TransactionContent {...tx} />;
if (tx.status === EthTxStatus.Requested) {
content = <EthTxRequestedToastContent tx={tx} />;
}
if (tx.status === EthTxStatus.Pending) {
content = <EthTxPendingToastContent tx={tx} />;
}
if (
tx.status === EthTxStatus.Confirmed ||
tx.status === EthTxStatus.Complete
) {
content = <EthTxConfirmedToastContent tx={tx} />;
}
if (tx.status === EthTxStatus.Error) {
content = <EthTxErrorToastContent tx={tx} />;
}
return {
id: `eth-${tx.id}`,
intent: intentMap[tx.status],
onClose: () => dismissEthTransaction(tx.id),
loader: tx.status === EthTxStatus.Pending,
content,
};
},
[dismissEthTransaction]
);
return useMemo(() => {
return [...compact(ethTransactions).map(fromEthTransaction)];
}, [ethTransactions, fromEthTransaction]);
};

View File

@ -0,0 +1,78 @@
import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers';
import type { Toast } from '@vegaprotocol/ui-toolkit';
import { Intent } from '@vegaprotocol/ui-toolkit';
import { ApprovalStatus, VerificationStatus } from '@vegaprotocol/withdraws';
import { useCallback, useMemo } from 'react';
import compact from 'lodash/compact';
import type { EthWithdrawalApprovalState } from '@vegaprotocol/web3';
import { useEthWithdrawApprovalsStore } from '@vegaprotocol/web3';
const intentMap: { [s in ApprovalStatus]: Intent } = {
Pending: Intent.Warning,
Error: Intent.Danger,
Idle: Intent.None,
Delayed: Intent.Warning,
Ready: Intent.Success,
};
const EthWithdrawalApprovalToastContent = ({
tx,
}: {
tx: EthWithdrawalApprovalState;
}) => {
let title = '';
if (tx.status === ApprovalStatus.Error) {
title = t('Error occurred');
}
if (tx.status === ApprovalStatus.Pending) {
title = t('Pending approval');
}
if (tx.status === ApprovalStatus.Delayed) {
title = t('Delayed');
}
const num = formatNumber(
toBigNum(tx.withdrawal.amount, tx.withdrawal.asset.decimals),
tx.withdrawal.asset.decimals
);
const details = (
<div className="mt-[5px]">
<span className="font-mono text-xs p-1 bg-gray-100 rounded">
{t('Withdraw')} {num} {tx.withdrawal.asset.symbol}
</span>
</div>
);
return (
<div>
{title.length > 0 && <h3 className="font-bold">{title}</h3>}
<VerificationStatus state={tx} />
{details}
</div>
);
};
export const useEthereumWithdrawApprovalsToasts = () => {
const { withdrawApprovals, dismissWithdrawApproval } =
useEthWithdrawApprovalsStore((state) => ({
withdrawApprovals: state.transactions.filter(
(transaction) => transaction?.dialogOpen
),
dismissWithdrawApproval: state.dismiss,
}));
const fromWithdrawalApproval = useCallback(
(tx: EthWithdrawalApprovalState): Toast => ({
id: `withdrawal-${tx.id}`,
intent: intentMap[tx.status],
onClose: () => dismissWithdrawApproval(tx.id),
loader: tx.status === ApprovalStatus.Pending,
content: <EthWithdrawalApprovalToastContent tx={tx} />,
}),
[dismissWithdrawApproval]
);
const toasts = useMemo(() => {
return [...compact(withdrawApprovals).map(fromWithdrawalApproval)];
}, [fromWithdrawalApproval, withdrawApprovals]);
return toasts;
};

View File

@ -0,0 +1,312 @@
import { render } from '@testing-library/react';
import {
OrderStatus,
OrderTimeInForce,
OrderType,
Side,
} from '@vegaprotocol/types';
import type { VegaStoredTxState } from '@vegaprotocol/wallet';
import { VegaTxStatus } from '@vegaprotocol/wallet';
import { VegaTransactionDetails } from './use-vega-transaction-toasts';
jest.mock('@vegaprotocol/assets', () => {
const A1 = {
decimals: 2,
id: 'asset-1',
name: 'A1',
quantum: '10',
symbol: '$A',
};
return {
...jest.requireActual('@vegaprotocol/assets'),
useAssetsDataProvider: jest.fn(() => ({ data: [A1] })),
};
});
jest.mock('@vegaprotocol/market-list', () => {
const M1 = {
id: 'market-1',
decimalPlaces: 2,
positionDecimalPlaces: 2,
tradableInstrument: {
instrument: {
id: 'M1',
name: 'M1',
code: 'M1',
product: {
quoteName: '',
settlementAsset: {
id: 'asset-1',
symbol: '$A',
decimals: 2,
},
},
},
},
};
return {
...jest.requireActual('@vegaprotocol/market-list'),
useMarketList: jest.fn(() => ({ data: [M1] })),
};
});
jest.mock('@vegaprotocol/orders', () => {
return {
...jest.requireActual('@vegaprotocol/orders'),
useOrderByIdQuery: jest.fn(({ variables: { orderId } }) => {
if (orderId === '0') {
return {
data: {
orderByID: {
id: '0',
side: 'SIDE_BUY',
size: '10',
timeInForce: 'TIME_IN_FORCE_FOK',
type: 'TYPE_MARKET',
price: '1234',
createdAt: new Date(),
status: 'STATUS_ACTIVE',
market: { id: 'market-1' },
},
},
};
} else {
return { data: undefined };
}
}),
};
});
const unsupportedTransaction: VegaStoredTxState = {
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
body: { delegateSubmission: { nodeId: '1', amount: '0' } },
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
dialogOpen: false,
};
const withdraw: VegaStoredTxState = {
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
body: {
withdrawSubmission: {
amount: '1234',
asset: 'asset-1',
ext: { erc20: { receiverAddress: '0x0' } },
},
},
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
dialogOpen: false,
};
const submitOrder: VegaStoredTxState = {
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
body: {
orderSubmission: {
marketId: 'market-1',
side: Side.SIDE_BUY,
size: '10',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
type: OrderType.TYPE_MARKET,
price: '1234',
},
},
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
dialogOpen: false,
order: {
id: '0',
side: Side.SIDE_BUY,
size: '10',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
type: OrderType.TYPE_MARKET,
price: '1234',
createdAt: new Date(),
market: {
id: 'market-1',
decimalPlaces: 2,
positionDecimalPlaces: 2,
tradableInstrument: {
instrument: {
code: 'M1',
name: 'M1',
product: {
settlementAsset: {
decimals: 2,
symbol: '$A',
},
},
},
},
},
status: OrderStatus.STATUS_ACTIVE,
},
};
const editOrder: VegaStoredTxState = {
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
body: {
orderAmendment: {
marketId: 'market-1',
orderId: '0',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
price: '1000',
sizeDelta: 1,
},
},
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
dialogOpen: false,
order: {
id: '0',
side: Side.SIDE_BUY,
size: '10',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
type: OrderType.TYPE_MARKET,
price: '1234',
createdAt: new Date(),
market: {
id: 'market-1',
decimalPlaces: 2,
positionDecimalPlaces: 2,
tradableInstrument: {
instrument: {
code: 'M1',
name: 'M1',
product: {
settlementAsset: {
decimals: 2,
symbol: '$A',
},
},
},
},
},
status: OrderStatus.STATUS_ACTIVE,
},
};
const cancelOrder: VegaStoredTxState = {
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
body: {
orderCancellation: {
marketId: 'market-1',
orderId: '0',
},
},
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
dialogOpen: false,
};
const cancelAll: VegaStoredTxState = {
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
body: {
orderCancellation: {
marketId: undefined,
orderId: undefined,
},
},
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
dialogOpen: false,
};
const closePosition: VegaStoredTxState = {
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
body: {
batchMarketInstructions: {
cancellations: [{ marketId: 'market-1', orderId: '' }],
submissions: [
{
marketId: 'market-1',
side: Side.SIDE_BUY,
size: '10',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
type: OrderType.TYPE_MARKET,
price: '1234',
},
],
},
},
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
dialogOpen: false,
};
const batch: VegaStoredTxState = {
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
body: {
batchMarketInstructions: {
submissions: [
{
marketId: 'market-1',
side: Side.SIDE_BUY,
size: '10',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
type: OrderType.TYPE_MARKET,
price: '1234',
},
],
},
},
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
dialogOpen: false,
};
describe('VegaTransactionDetails', () => {
it('does not display details if tx type cannot be determined', () => {
const { queryByTestId } = render(
<VegaTransactionDetails tx={unsupportedTransaction} />
);
expect(queryByTestId('vega-tx-details')).toBeNull();
});
it.each([
{ tx: withdraw, details: 'Withdraw 12.34 $A' },
{ tx: submitOrder, details: 'Submit order - activeM1+0.10 @ 12.34 $A' },
{
tx: editOrder,
details: 'Edit order - activeM1+0.10 @ 12.34 $A+0.11 @ 10.00 $A',
},
{ tx: cancelOrder, details: 'Cancel orderM1+0.10 @ 12.34 $A' },
{ tx: cancelAll, details: 'Cancel all orders' },
{ tx: closePosition, details: 'Close position for M1' },
{ tx: batch, details: 'Batch market instruction' },
])('display details for transaction', ({ tx, details }) => {
const { queryByTestId } = render(<VegaTransactionDetails tx={tx} />);
expect(queryByTestId('vega-tx-details')?.textContent).toEqual(details);
});
});

View File

@ -0,0 +1,567 @@
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import compact from 'lodash/compact';
import type {
BatchMarketInstructionSubmissionBody,
OrderAmendment,
OrderBusEventFieldsFragment,
OrderCancellationBody,
OrderSubmission,
VegaStoredTxState,
WithdrawalBusEventFieldsFragment,
} from '@vegaprotocol/wallet';
import { isBatchMarketInstructionsTransaction } from '@vegaprotocol/wallet';
import {
ClientErrors,
useReconnectVegaWallet,
WalletError,
} from '@vegaprotocol/wallet';
import {
isOrderAmendmentTransaction,
isOrderCancellationTransaction,
isOrderSubmissionTransaction,
isWithdrawTransaction,
useVegaTransactionStore,
VegaTxStatus,
} from '@vegaprotocol/wallet';
import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit';
import { Button, ExternalLink, Intent } from '@vegaprotocol/ui-toolkit';
import {
addDecimalsFormatNumber,
formatNumber,
Size,
t,
toBigNum,
} from '@vegaprotocol/react-helpers';
import { useAssetsDataProvider } from '@vegaprotocol/assets';
import { useEthWithdrawApprovalsStore } from '@vegaprotocol/web3';
import { DApp, EXPLORER_TX, useLinks } from '@vegaprotocol/environment';
import { getRejectionReason, useOrderByIdQuery } from '@vegaprotocol/orders';
import { useMarketList } from '@vegaprotocol/market-list';
import first from 'lodash/first';
import type { Side } from '@vegaprotocol/types';
import { OrderStatusMapping } from '@vegaprotocol/types';
const intentMap: { [s in VegaTxStatus]: Intent } = {
Default: Intent.Primary,
Requested: Intent.Warning,
Pending: Intent.Warning,
Error: Intent.Danger,
Complete: Intent.Success,
};
const isClosePositionTransaction = (tx: VegaStoredTxState) => {
if (isBatchMarketInstructionsTransaction(tx.body)) {
const amendments =
tx.body.batchMarketInstructions.amendments &&
tx.body.batchMarketInstructions.amendments?.length > 0;
const cancellation =
tx.body.batchMarketInstructions.cancellations?.length === 1 &&
tx.body.batchMarketInstructions.cancellations[0].orderId === '' &&
tx.body.batchMarketInstructions.cancellations[0];
const submission =
cancellation &&
tx.body.batchMarketInstructions.submissions?.length === 1 &&
tx.body.batchMarketInstructions.submissions[0].marketId ===
cancellation.marketId;
return !amendments && cancellation && submission;
}
return false;
};
const isTransactionTypeSupported = (tx: VegaStoredTxState) => {
const withdraw = isWithdrawTransaction(tx.body);
const submitOrder = isOrderSubmissionTransaction(tx.body);
const cancelOrder = isOrderCancellationTransaction(tx.body);
const editOrder = isOrderAmendmentTransaction(tx.body);
const batchMarketInstructions = isBatchMarketInstructionsTransaction(tx.body);
return (
withdraw ||
submitOrder ||
cancelOrder ||
editOrder ||
batchMarketInstructions
);
};
const Details = ({
children,
title = '',
}: {
children: ReactNode;
title?: string;
}) => (
<div className="pt-[5px]" data-testid="vega-tx-details" title={title}>
<div className="font-mono text-xs p-2 bg-neutral-100 rounded">
{children}
</div>
</div>
);
type SizeAtPriceProps = {
side: Side;
size: string;
price: string | undefined;
meta: { positionDecimalPlaces: number; decimalPlaces: number; asset: string };
};
const SizeAtPrice = ({ side, size, price, meta }: SizeAtPriceProps) => {
return (
<>
<Size
side={side}
value={size}
positionDecimalPlaces={meta.positionDecimalPlaces}
forceTheme="light"
/>{' '}
{price && price !== '0' && meta.decimalPlaces
? `@ ${addDecimalsFormatNumber(price, meta.decimalPlaces)} ${
meta.asset
}`
: `@ ~ ${meta.asset}`}
</>
);
};
const SubmitOrderDetails = ({
data,
order,
}: {
data: OrderSubmission;
order?: OrderBusEventFieldsFragment;
}) => {
const { data: markets } = useMarketList();
const market = order
? order.market
: markets?.find((m) => m.id === data.marketId);
if (!market) return null;
const price = order ? order.price : data.price;
const size = order ? order.size : data.size;
const side = order ? order.side : data.side;
return (
<Details>
<h4 className="font-bold">
{order
? t(
`Submit order - ${OrderStatusMapping[order.status].toLowerCase()}`
)
: t('Submit order')}
</h4>
<p>{market?.tradableInstrument.instrument.code}</p>
<p>
<SizeAtPrice
meta={{
positionDecimalPlaces: market.positionDecimalPlaces,
decimalPlaces: market.decimalPlaces,
asset:
market.tradableInstrument.instrument.product.settlementAsset
.symbol,
}}
side={side}
size={size}
price={price}
/>
</p>
{order && order.rejectionReason && (
<p className="italic">{getRejectionReason(order)}</p>
)}
</Details>
);
};
const EditOrderDetails = ({
data,
order,
}: {
data: OrderAmendment;
order?: OrderBusEventFieldsFragment;
}) => {
const { data: orderById } = useOrderByIdQuery({
variables: { orderId: data.orderId },
});
const { data: markets } = useMarketList();
const originalOrder = orderById?.orderByID;
if (!originalOrder) return null;
const market = markets?.find((m) => m.id === originalOrder.market.id);
if (!market) return null;
const original = (
<SizeAtPrice
side={originalOrder.side}
size={originalOrder.size}
price={originalOrder.price}
meta={{
positionDecimalPlaces: market.positionDecimalPlaces,
decimalPlaces: market.decimalPlaces,
asset:
market.tradableInstrument.instrument.product.settlementAsset.symbol,
}}
/>
);
const edited = (
<SizeAtPrice
side={originalOrder.side}
size={String(Number(originalOrder.size) + (data.sizeDelta || 0))}
price={data.price}
meta={{
positionDecimalPlaces: market.positionDecimalPlaces,
decimalPlaces: market.decimalPlaces,
asset:
market.tradableInstrument.instrument.product.settlementAsset.symbol,
}}
/>
);
return (
<Details title={data.orderId}>
<h4 className="font-bold">
{order
? t(`Edit order - ${OrderStatusMapping[order.status].toLowerCase()}`)
: t('Edit order')}
</h4>
<p>{market?.tradableInstrument.instrument.code}</p>
<p>
<s>{original}</s>
</p>
<p>{edited}</p>
{order && order.rejectionReason && (
<p className="italic">{getRejectionReason(order)}</p>
)}
</Details>
);
};
const CancelOrderDetails = ({
orderId,
order,
}: {
orderId: string;
order?: OrderBusEventFieldsFragment;
}) => {
const { data: orderById } = useOrderByIdQuery({
variables: { orderId },
});
const { data: markets } = useMarketList();
const originalOrder = orderById?.orderByID;
if (!originalOrder) return null;
const market = markets?.find((m) => m.id === originalOrder.market.id);
if (!market) return null;
const original = (
<SizeAtPrice
side={originalOrder.side}
size={originalOrder.size}
price={originalOrder.price}
meta={{
positionDecimalPlaces: market.positionDecimalPlaces,
decimalPlaces: market.decimalPlaces,
asset:
market.tradableInstrument.instrument.product.settlementAsset.symbol,
}}
/>
);
return (
<Details title={orderId}>
<h4 className="font-bold">
{order
? t(
`Cancel order - ${OrderStatusMapping[order.status].toLowerCase()}`
)
: t('Cancel order')}
</h4>
<p>{market?.tradableInstrument.instrument.code}</p>
<p>
<s>{original}</s>
</p>
{order && order.rejectionReason && (
<p className="italic">{getRejectionReason(order)}</p>
)}
</Details>
);
};
export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
const { data: assets } = useAssetsDataProvider();
const { data: markets } = useMarketList();
if (isWithdrawTransaction(tx.body)) {
const transactionDetails = tx.body;
const asset = assets?.find(
(a) => a.id === transactionDetails.withdrawSubmission.asset
);
if (asset) {
const num = formatNumber(
toBigNum(transactionDetails.withdrawSubmission.amount, asset.decimals),
asset.decimals
);
return (
<Details>
{t('Withdraw')} {num} {asset.symbol}
</Details>
);
}
}
if (isOrderSubmissionTransaction(tx.body)) {
return (
<SubmitOrderDetails data={tx.body.orderSubmission} order={tx.order} />
);
}
if (isOrderCancellationTransaction(tx.body)) {
// CANCEL ALL (from Portfolio)
if (
tx.body.orderCancellation.marketId === undefined &&
tx.body.orderCancellation.orderId === undefined
) {
return <Details>{t('Cancel all orders')}</Details>;
}
// CANCEL
if (
tx.body.orderCancellation.orderId &&
tx.body.orderCancellation.marketId
) {
return (
<CancelOrderDetails
orderId={String(tx.body.orderCancellation.orderId)}
order={tx.order}
/>
);
}
// CANCEL ALL (from Trading)
if (tx.body.orderCancellation.marketId) {
const marketName = markets?.find(
(m) =>
m.id === (tx.body as OrderCancellationBody).orderCancellation.marketId
)?.tradableInstrument.instrument.code;
return (
<Details>
{marketName
? `${t('Cancel all orders for')} ${marketName}`
: t('Cancel all orders')}
</Details>
);
}
}
if (isOrderAmendmentTransaction(tx.body)) {
return (
<EditOrderDetails
data={tx.body.orderAmendment}
order={tx.order}
></EditOrderDetails>
);
}
if (isClosePositionTransaction(tx)) {
const transaction = tx.body as BatchMarketInstructionSubmissionBody;
const marketId = first(
transaction.batchMarketInstructions.cancellations
)?.marketId;
const market = marketId && markets?.find((m) => m.id === marketId);
if (market) {
return (
<Details>
{t('Close position for')} {market.tradableInstrument.instrument.code}
</Details>
);
}
}
if (isBatchMarketInstructionsTransaction(tx.body)) {
return <Details>{t('Batch market instruction')}</Details>;
}
return null;
};
type VegaTxToastContentProps = { tx: VegaStoredTxState };
const VegaTxRequestedToastContent = ({ tx }: VegaTxToastContentProps) => (
<div>
<h3 className="font-bold">{t('Action required')}</h3>
<p>
{t(
'Please go to your Vega wallet application and approve or reject the transaction.'
)}
</p>
<VegaTransactionDetails tx={tx} />
</div>
);
const VegaTxPendingToastContentProps = ({ tx }: VegaTxToastContentProps) => {
const explorerLink = useLinks(DApp.Explorer);
return (
<div>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3>
<p>{t('Please wait for your transaction to be confirmed')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(EXPLORER_TX.replace(':hash', tx.txHash))}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
</div>
);
};
const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
const { createEthWithdrawalApproval } = useEthWithdrawApprovalsStore(
(state) => ({
createEthWithdrawalApproval: state.create,
})
);
const explorerLink = useLinks(DApp.Explorer);
if (isWithdrawTransaction(tx.body)) {
const completeWithdrawalButton = tx.withdrawal && (
<div className="mt-[10px]">
<Button
size="xs"
onClick={() => {
createEthWithdrawalApproval(
tx.withdrawal as WithdrawalBusEventFieldsFragment,
tx.withdrawalApproval
);
}}
>
{t('Complete withdrawal')}
</Button>
</div>
);
return (
<div>
<h3 className="font-bold">{t('Funds unlocked')}</h3>
<p>{t('Your funds have been unlocked for withdrawal')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(EXPLORER_TX.replace(':hash', tx.txHash))}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
{completeWithdrawalButton}
</div>
);
}
return (
<div>
<h3 className="font-bold">{t('Confirmed')}</h3>
<p>{t('Your transaction has been confirmed ')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(EXPLORER_TX.replace(':hash', tx.txHash))}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
</div>
);
};
const VegaTxErrorToastContent = ({ tx }: VegaTxToastContentProps) => {
let label = t('Error occurred');
let errorMessage = `${tx.error?.message} ${
tx.error instanceof WalletError && tx.error?.data
? `: ${tx.error?.data}`
: ''
}`;
const reconnectVegaWallet = useReconnectVegaWallet();
const orderRejection = tx.order && getRejectionReason(tx.order);
const walletNoConnectionCodes = [
ClientErrors.NO_SERVICE.code,
ClientErrors.NO_CLIENT.code,
];
const walletError =
tx.error instanceof WalletError &&
walletNoConnectionCodes.includes(tx.error.code);
if (orderRejection) {
label = t('Order rejected');
errorMessage = orderRejection;
}
if (walletError) {
label = t('Wallet disconnected');
errorMessage = t('The connection to your Vega Wallet has been lost.');
}
return (
<div>
<h3 className="font-bold">{label}</h3>
<p>{errorMessage}</p>
{walletError && (
<Button size="xs" onClick={reconnectVegaWallet}>
{t('Connect vega wallet')}
</Button>
)}
<VegaTransactionDetails tx={tx} />
</div>
);
};
export const useVegaTransactionToasts = () => {
const vegaTransactions = useVegaTransactionStore((state) =>
state.transactions.filter((transaction) => transaction?.dialogOpen)
);
const dismissVegaTransaction = useVegaTransactionStore(
(state) => state.dismiss
);
const fromVegaTransaction = useCallback(
(tx: VegaStoredTxState): Toast => {
let content: ToastContent;
if (tx.status === VegaTxStatus.Requested) {
content = <VegaTxRequestedToastContent tx={tx} />;
}
if (tx.status === VegaTxStatus.Pending) {
content = <VegaTxPendingToastContentProps tx={tx} />;
}
if (tx.status === VegaTxStatus.Complete) {
content = <VegaTxCompleteToastsContent tx={tx} />;
}
if (tx.status === VegaTxStatus.Error) {
content = <VegaTxErrorToastContent tx={tx} />;
}
return {
id: `vega-${tx.id}`,
intent: intentMap[tx.status],
onClose: () => dismissVegaTransaction(tx.id),
loader: tx.status === VegaTxStatus.Pending,
content,
};
},
[dismissVegaTransaction]
);
const toasts = useMemo(() => {
return [
...compact(vegaTransactions)
.filter((tx) => isTransactionTypeSupported(tx))
.map(fromVegaTransaction),
];
}, [fromVegaTransaction, vegaTransactions]);
return toasts;
};

View File

@ -1,473 +1,32 @@
import {
Button,
ExternalLink,
Intent,
ProgressBar,
ToastsContainer,
} from '@vegaprotocol/ui-toolkit';
import { useCallback, useMemo } from 'react';
import {
useEthTransactionStore,
useEthWithdrawApprovalsStore,
TransactionContent,
EthTxStatus,
isEthereumError,
ApprovalStatus,
} from '@vegaprotocol/web3';
import {
isWithdrawTransaction,
useVegaTransactionStore,
VegaTxStatus,
WalletError,
} from '@vegaprotocol/wallet';
import { VegaTransaction } from '../components/vega-transaction';
import { VerificationStatus } from '@vegaprotocol/withdraws';
import compact from 'lodash/compact';
import { ToastsContainer } from '@vegaprotocol/ui-toolkit';
import { useMemo } from 'react';
import sortBy from 'lodash/sortBy';
import type {
EthStoredTxState,
EthWithdrawalApprovalState,
} from '@vegaprotocol/web3';
import type { Toast } from '@vegaprotocol/ui-toolkit';
import type {
VegaStoredTxState,
WithdrawSubmissionBody,
WithdrawalBusEventFieldsFragment,
} from '@vegaprotocol/wallet';
import type { Asset } from '@vegaprotocol/assets';
import { useAssetsDataProvider } from '@vegaprotocol/assets';
import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers';
import {
DApp,
ETHERSCAN_TX,
EXPLORER_TX,
useEtherscanLink,
useLinks,
} from '@vegaprotocol/environment';
import { prepend0x } from '@vegaprotocol/smart-contracts';
import { useUpdateNetworkParametersToasts } from '@vegaprotocol/governance';
const intentMap = {
Default: Intent.Primary,
Requested: Intent.Warning,
Pending: Intent.Warning,
Error: Intent.Danger,
Complete: Intent.Success,
Confirmed: Intent.Success,
Idle: Intent.None,
Delayed: Intent.Warning,
Ready: Intent.Success,
};
const TransactionDetails = ({
label,
amount,
asset,
}: {
label: string;
amount: string;
asset: Pick<Asset, 'symbol' | 'decimals'>;
}) => {
const num = formatNumber(toBigNum(amount, asset.decimals), asset.decimals);
return (
<div className="mt-[5px]">
<span className="font-mono text-xs p-1 bg-gray-100 rounded">
{label} {num} {asset.symbol}
</span>
</div>
);
};
const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
const { data } = useAssetsDataProvider();
if (!data) return null;
const VEGA_WITHDRAW = isWithdrawTransaction(tx.body);
if (VEGA_WITHDRAW) {
const transactionDetails = tx.body as WithdrawSubmissionBody;
const asset = data?.find(
(a) => a.id === transactionDetails.withdrawSubmission.asset
);
if (asset) {
return (
<TransactionDetails
label={t('Withdraw')}
amount={transactionDetails.withdrawSubmission.amount}
asset={asset}
/>
);
}
}
return null;
};
const EthTransactionDetails = ({ tx }: { tx: EthStoredTxState }) => {
const { data } = useAssetsDataProvider();
if (!data) return null;
const ETH_WITHDRAW =
tx.methodName === 'withdraw_asset' && tx.args.length > 2 && tx.asset;
if (ETH_WITHDRAW) {
const asset = data.find((a) => a.id === tx.asset);
if (asset) {
return (
<>
<TransactionDetails
label={t('Withdraw')}
amount={tx.args[1]}
asset={asset}
/>
{tx.requiresConfirmation && (
<div className="mt-[10px]">
<span className="font-mono text-xs">
{t('Awaiting confirmations')}{' '}
{`(${tx.confirmations}/${tx.requiredConfirmations})`}
</span>
<ProgressBar
value={(tx.confirmations / tx.requiredConfirmations) * 100}
/>
</div>
)}
</>
);
}
}
return null;
};
import { useVegaTransactionToasts } from '../lib/hooks/use-vega-transaction-toasts';
import { useEthereumTransactionToasts } from '../lib/hooks/use-ethereum-transaction-toasts';
import { useEthereumWithdrawApprovalsToasts } from '../lib/hooks/use-ethereum-withdraw-approval-toasts';
export const ToastsManager = () => {
const updateNetworkParametersToasts = useUpdateNetworkParametersToasts();
const vegaTransactions = useVegaTransactionStore((state) =>
state.transactions.filter((transaction) => transaction?.dialogOpen)
);
const dismissVegaTransaction = useVegaTransactionStore(
(state) => state.dismiss
);
const ethTransactions = useEthTransactionStore((state) =>
state.transactions.filter((transaction) => transaction?.dialogOpen)
);
const dismissEthTransaction = useEthTransactionStore(
(state) => state.dismiss
);
const { withdrawApprovals, createEthWithdrawalApproval } =
useEthWithdrawApprovalsStore((state) => ({
withdrawApprovals: state.transactions.filter(
(transaction) => transaction?.dialogOpen
),
createEthWithdrawalApproval: state.create,
}));
const dismissWithdrawApproval = useEthWithdrawApprovalsStore(
(state) => state.dismiss
);
const explorerLink = useLinks(DApp.Explorer);
const etherscanLink = useEtherscanLink();
const fromVegaTransaction = useCallback(
(tx: VegaStoredTxState): Toast => {
let toast: Partial<Toast> = {};
const defaultValues = {
id: `vega-${tx.id}`,
intent: intentMap[tx.status],
render: () => {
return <VegaTransaction transaction={tx} />;
},
onClose: () => dismissVegaTransaction(tx.id),
};
if (tx.status === VegaTxStatus.Requested) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Action required')}</h3>
<p>
{t(
'Please go to your Vega wallet application and approve or reject the transaction.'
)}
</p>
<VegaTransactionDetails tx={tx} />
</div>
);
},
};
}
if (tx.status === VegaTxStatus.Pending) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3>
<p>{t('Please wait for your transaction to be confirmed')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(
EXPLORER_TX.replace(':hash', prepend0x(tx.txHash))
)}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
</div>
);
},
loader: true,
};
}
if (tx.status === VegaTxStatus.Complete) {
toast = {
render: () => {
if (isWithdrawTransaction(tx.body)) {
const completeWithdrawalButton = tx.withdrawal && (
<div className="mt-[10px]">
<Button
size="xs"
onClick={() => {
createEthWithdrawalApproval(
tx.withdrawal as WithdrawalBusEventFieldsFragment,
tx.withdrawalApproval
);
}}
>
{t('Complete withdrawal')}
</Button>
</div>
);
return (
<div>
<h3 className="font-bold">{t('Funds unlocked')}</h3>
<p>{t('Your funds have been unlocked for withdrawal')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(
EXPLORER_TX.replace(':hash', prepend0x(tx.txHash))
)}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
{completeWithdrawalButton}
</div>
);
}
return (
<div>
<h3 className="font-bold">{t('Confirmed')}</h3>
<p>{t('Your transaction has been confirmed ')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(
EXPLORER_TX.replace(':hash', prepend0x(tx.txHash))
)}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
</div>
);
},
};
}
if (tx.status === VegaTxStatus.Error) {
toast = {
render: () => {
const error = `${tx.error?.message} ${
tx.error instanceof WalletError
? tx.error?.data
? `: ${tx.error?.data}`
: ''
: ''
}`;
return (
<div>
<h3 className="font-bold">{t('Error occurred')}</h3>
<p>{error}</p>
<VegaTransactionDetails tx={tx} />
</div>
);
},
};
}
return {
...defaultValues,
...toast,
};
},
[createEthWithdrawalApproval, dismissVegaTransaction, explorerLink]
);
const fromEthTransaction = useCallback(
(tx: EthStoredTxState): Toast => {
let toast: Partial<Toast> = {};
const defaultValues = {
id: `eth-${tx.id}`,
intent: intentMap[tx.status],
render: () => {
return <TransactionContent {...tx} />;
},
onClose: () => dismissEthTransaction(tx.id),
};
if (tx.status === EthTxStatus.Requested) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Action required')}</h3>
<p>
{t(
'Please go to your wallet application and approve or reject the transaction.'
)}
</p>
<EthTransactionDetails tx={tx} />
</div>
);
},
};
}
if (tx.status === EthTxStatus.Pending) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3>
<p>{t('Please wait for your transaction to be confirmed')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={etherscanLink(
ETHERSCAN_TX.replace(':hash', tx.txHash)
)}
rel="noreferrer"
>
{t('View on Etherscan')}
</ExternalLink>
</p>
)}
<EthTransactionDetails tx={tx} />
</div>
);
},
loader: true,
};
}
if (tx.status === EthTxStatus.Confirmed) {
toast = {
render: () => {
return (
<div>
<h3 className="font-bold">{t('Transaction completed')}</h3>
<p>{t('Your transaction has been completed')}</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={etherscanLink(
ETHERSCAN_TX.replace(':hash', tx.txHash)
)}
rel="noreferrer"
>
{t('View on Etherscan')}
</ExternalLink>
</p>
)}
<EthTransactionDetails tx={tx} />
</div>
);
},
};
}
if (tx.status === EthTxStatus.Error) {
toast = {
render: () => {
let errorMessage = '';
if (isEthereumError(tx.error)) {
errorMessage = tx.error.reason;
} else if (tx.error instanceof Error) {
errorMessage = tx.error.message;
}
return (
<div>
<h3 className="font-bold">{t('Error occurred')}</h3>
<p>{errorMessage}</p>
<EthTransactionDetails tx={tx} />
</div>
);
},
};
}
return {
...defaultValues,
...toast,
};
},
[dismissEthTransaction, etherscanLink]
);
const fromWithdrawalApproval = useCallback(
(tx: EthWithdrawalApprovalState): Toast => ({
id: `withdrawal-${tx.id}`,
intent: intentMap[tx.status],
render: () => {
let title = '';
if (tx.status === ApprovalStatus.Error) {
title = t('Error occurred');
}
if (tx.status === ApprovalStatus.Pending) {
title = t('Pending approval');
}
if (tx.status === ApprovalStatus.Delayed) {
title = t('Delayed');
}
return (
<div>
{title.length > 0 && <h3 className="font-bold">{title}</h3>}
<VerificationStatus state={tx} />
<TransactionDetails
label={t('Withdraw')}
amount={tx.withdrawal.amount}
asset={tx.withdrawal.asset}
/>
</div>
);
},
onClose: () => dismissWithdrawApproval(tx.id),
loader: tx.status === ApprovalStatus.Pending,
}),
[dismissWithdrawApproval]
);
const vegaTransactionToasts = useVegaTransactionToasts();
const ethTransactionToasts = useEthereumTransactionToasts();
const withdrawApprovalToasts = useEthereumWithdrawApprovalsToasts();
const toasts = useMemo(() => {
return sortBy(
[
...compact(vegaTransactions).map(fromVegaTransaction),
...compact(ethTransactions).map(fromEthTransaction),
...compact(withdrawApprovals).map(fromWithdrawalApproval),
...vegaTransactionToasts,
...ethTransactionToasts,
...withdrawApprovalToasts,
...updateNetworkParametersToasts,
],
['createdBy']
);
}, [
vegaTransactions,
fromVegaTransaction,
ethTransactions,
fromEthTransaction,
withdrawApprovals,
fromWithdrawalApproval,
vegaTransactionToasts,
ethTransactionToasts,
withdrawApprovalToasts,
updateNetworkParametersToasts,
]);

View File

@ -5,7 +5,7 @@ import { accountsDataProvider } from './accounts-data-provider';
import type { Account } from './accounts-data-provider';
import { getSettlementAccount } from './get-settlement-account';
export const useAccountBalance = (assetId: string) => {
export const useAccountBalance = (assetId?: string) => {
const { pubKey } = useVegaWallet();
const [accountBalance, setAccountBalance] = useState<string>('');
const [accountDecimals, setAccountDecimals] = useState<number | null>(null);
@ -14,7 +14,9 @@ export const useAccountBalance = (assetId: string) => {
}, [pubKey]);
const update = useCallback(
({ data }: { data: Account[] | null }) => {
const account = getSettlementAccount({ accounts: data, assetId });
const account = assetId
? getSettlementAccount({ accounts: data, assetId })
: undefined;
if (accountBalance !== account?.balance) {
setAccountBalance(account?.balance || '');
}

View File

@ -4,31 +4,25 @@ import { Button } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
interface Props {
transactionStatus: 'default' | 'pending';
disabled: boolean;
variant: ButtonVariant;
}
export const DealTicketButton = ({
transactionStatus,
disabled,
variant,
}: Props) => {
export const DealTicketButton = ({ disabled, variant }: Props) => {
const { pubKey } = useVegaWallet();
const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({
openVegaWalletDialog: store.openVegaWalletDialog,
}));
const isPending = transactionStatus === 'pending';
return pubKey ? (
<div className="mb-4">
<Button
variant={variant}
fill
type="submit"
disabled={disabled || isPending}
disabled={disabled}
data-testid="place-order"
>
{isPending ? t('Pending...') : t('Place order')}
{t('Place order')}
</Button>
</div>
) : (

View File

@ -5,8 +5,9 @@ import type {
MarketDataUpdateFieldsFragment,
MarketDealTicket,
} from '@vegaprotocol/market-list';
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import { marketDealTicketProvider } from '@vegaprotocol/market-list';
import { DealTicketManager } from './deal-ticket-manager';
import { DealTicket } from './deal-ticket';
export interface DealTicketContainerProps {
marketId: string;
@ -27,6 +28,7 @@ export const DealTicketContainer = ({ marketId }: DealTicketContainerProps) => {
variables,
skip: !marketId,
});
const create = useVegaTransactionStore((state) => state.create);
return (
<AsyncRenderer<MarketDealTicket>
@ -35,7 +37,10 @@ export const DealTicketContainer = ({ marketId }: DealTicketContainerProps) => {
error={error}
>
{data ? (
<DealTicketManager market={data} />
<DealTicket
market={data}
submit={(orderSubmission) => create({ orderSubmission })}
/>
) : (
<Splash>
<p>{t('Could not load market')}</p>

View File

@ -1,171 +0,0 @@
import { useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
import type { VegaTxState } from '@vegaprotocol/wallet';
import {
VegaTxStatus,
WalletError,
useVegaWallet,
useVegaWalletDialogStore,
ClientErrors,
} from '@vegaprotocol/wallet';
import { DealTicket } from './deal-ticket';
import type { MarketDealTicket } from '@vegaprotocol/market-list';
import { useOrderSubmit, OrderFeedback } from '@vegaprotocol/orders';
import * as Schema from '@vegaprotocol/types';
import { Button, Icon, Intent } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
export interface DealTicketManagerProps {
market: MarketDealTicket;
children?: ReactNode | ReactNode[];
}
interface ErrorContentProps {
transaction: VegaTxState;
reset: () => void;
}
const ErrorContent = ({ transaction, reset }: ErrorContentProps) => {
const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({
openVegaWalletDialog: store.openVegaWalletDialog,
}));
const { disconnect } = useVegaWallet();
const reconnect = useCallback(async () => {
reset();
await disconnect();
openVegaWalletDialog();
}, [reset, disconnect, openVegaWalletDialog]);
return useMemo(() => {
const { error } = transaction;
if (error) {
if (
error instanceof WalletError &&
error.code === ClientErrors.NO_SERVICE.code
) {
return (
<ul data-testid="connectors-list" className="mb-6">
<li className="mb-2 last:mb-0" data-testid={transaction.status}>
{t('The connection to your Vega Wallet has been lost.')}
</li>
<li className="mb-0 pt-2">
<Button onClick={reconnect}>{t('Connect vega wallet')}</Button>
</li>
</ul>
);
}
return (
<p data-testid={transaction.status}>
{error.message}{' '}
{error instanceof WalletError ? `: ${error.data}` : null}
</p>
);
}
return null;
}, [transaction, reconnect]);
};
export const DealTicketManager = ({
market,
children,
}: DealTicketManagerProps) => {
const { submit, transaction, finalizedOrder, Dialog, reset } =
useOrderSubmit();
return (
<>
{children || (
<DealTicket
market={market}
submit={(order) => submit(order)}
transactionStatus={
transaction.status === VegaTxStatus.Requested ||
transaction.status === VegaTxStatus.Pending
? 'pending'
: 'default'
}
/>
)}
<Dialog
title={getOrderDialogTitle(finalizedOrder?.status)}
intent={getOrderDialogIntent(finalizedOrder?.status)}
icon={getOrderDialogIcon(finalizedOrder?.status)}
content={{
Complete: (
<OrderFeedback transaction={transaction} order={finalizedOrder} />
),
Error: <ErrorContent transaction={transaction} reset={reset} />,
}}
/>
</>
);
};
export const getOrderDialogTitle = (
status?: Schema.OrderStatus
): string | undefined => {
if (!status) {
return;
}
switch (status) {
case Schema.OrderStatus.STATUS_ACTIVE:
return t('Order submitted');
case Schema.OrderStatus.STATUS_FILLED:
return t('Order filled');
case Schema.OrderStatus.STATUS_PARTIALLY_FILLED:
return t('Order partially filled');
case Schema.OrderStatus.STATUS_PARKED:
return t('Order parked');
case Schema.OrderStatus.STATUS_STOPPED:
return t('Order stopped');
case Schema.OrderStatus.STATUS_CANCELLED:
return t('Order cancelled');
case Schema.OrderStatus.STATUS_EXPIRED:
return t('Order expired');
case Schema.OrderStatus.STATUS_REJECTED:
return t('Order rejected');
default:
return t('Submission failed');
}
};
export const getOrderDialogIntent = (
status?: Schema.OrderStatus
): Intent | undefined => {
if (!status) {
return;
}
switch (status) {
case Schema.OrderStatus.STATUS_PARKED:
case Schema.OrderStatus.STATUS_EXPIRED:
case Schema.OrderStatus.STATUS_PARTIALLY_FILLED:
return Intent.Warning;
case Schema.OrderStatus.STATUS_REJECTED:
case Schema.OrderStatus.STATUS_STOPPED:
case Schema.OrderStatus.STATUS_CANCELLED:
return Intent.Danger;
case Schema.OrderStatus.STATUS_FILLED:
case Schema.OrderStatus.STATUS_ACTIVE:
return Intent.Success;
default:
return;
}
};
export const getOrderDialogIcon = (
status?: Schema.OrderStatus
): ReactNode | undefined => {
if (!status) {
return;
}
switch (status) {
case Schema.OrderStatus.STATUS_PARKED:
case Schema.OrderStatus.STATUS_EXPIRED:
return <Icon name="warning-sign" />;
case Schema.OrderStatus.STATUS_REJECTED:
case Schema.OrderStatus.STATUS_STOPPED:
case Schema.OrderStatus.STATUS_CANCELLED:
return <Icon name="error" />;
default:
return;
}
};

View File

@ -26,7 +26,6 @@ jest.mock('../../hooks/use-has-no-balance', () => {
const market = generateMarket();
const submit = jest.fn();
const transactionStatus = 'default';
const mockChainId = 'chain-id';
@ -46,12 +45,7 @@ function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
return (
<MockedProvider mocks={[chainIdMock]}>
<VegaWalletContext.Provider value={{ pubKey: mockChainId } as any}>
<DealTicket
defaultOrder={order}
market={market}
submit={submit}
transactionStatus={transactionStatus}
/>
<DealTicket market={market} submit={submit} />
</VegaWalletContext.Provider>
</MockedProvider>
);

View File

@ -1,4 +1,4 @@
import { removeDecimal, t } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers';
import * as Schema from '@vegaprotocol/types';
import { memo, useCallback, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
@ -10,6 +10,7 @@ import { SideSelector } from './side-selector';
import { TimeInForceSelector } from './time-in-force-selector';
import { TypeSelector } from './type-selector';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { normalizeOrderSubmission } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { InputError } from '@vegaprotocol/ui-toolkit';
import { useOrderMarginValidation } from '../../hooks/use-order-margin-validation';
@ -32,8 +33,6 @@ export type TransactionStatus = 'default' | 'pending';
export interface DealTicketProps {
market: MarketDealTicket;
submit: (order: OrderSubmissionBody['orderSubmission']) => void;
transactionStatus: TransactionStatus;
defaultOrder?: OrderSubmissionBody['orderSubmission'];
}
export type DealTicketFormFields = OrderSubmissionBody['orderSubmission'] & {
@ -42,11 +41,7 @@ export type DealTicketFormFields = OrderSubmissionBody['orderSubmission'] & {
summary: string;
};
export const DealTicket = ({
market,
submit,
transactionStatus,
}: DealTicketProps) => {
export const DealTicket = ({ market, submit }: DealTicketProps) => {
const { pubKey } = useVegaWallet();
const [persistedOrder, setPersistedOrder] = usePersistedOrder(market);
const {
@ -123,15 +118,13 @@ export const DealTicket = ({
return;
}
submit({
...order,
price: order.price && removeDecimal(order.price, market.decimalPlaces),
size: removeDecimal(order.size, market.positionDecimalPlaces),
expiresAt:
order.timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
? order.expiresAt
: undefined,
});
submit(
normalizeOrderSubmission(
order,
market.decimalPlaces,
market.positionDecimalPlaces
)
);
},
[
submit,
@ -209,7 +202,6 @@ export const DealTicket = ({
)}
<DealTicketButton
disabled={Object.keys(errors).length >= 1}
transactionStatus={transactionStatus}
variant={order.side === Schema.Side.SIDE_BUY ? 'ternary' : 'secondary'}
/>
<SummaryMessage

View File

@ -1,7 +1,6 @@
export * from './deal-ticket-amount';
export * from './deal-ticket-container';
export * from './deal-ticket-limit-amount';
export * from './deal-ticket-manager';
export * from './deal-ticket-market-amount';
export * from './deal-ticket';
export * from './expiry-selector';

View File

@ -5,6 +5,7 @@ import { Dialog } from '@vegaprotocol/ui-toolkit';
import { useCallback, useState } from 'react';
import { DepositContainer } from './deposit-container';
import { useWeb3ConnectStore } from '@vegaprotocol/web3';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
interface State {
isOpen: boolean;
@ -41,6 +42,9 @@ const DEFAULT_STYLE: DepositDialogStyleProps = {
export const DepositDialog = () => {
const { assetId, isOpen, open, close } = useDepositDialog();
const assetDetailsDialogOpen = useAssetDetailsDialogStore(
(state) => state.isOpen
);
const connectWalletDialogIsOpen = useWeb3ConnectStore(
(state) => state.isOpen
);
@ -55,7 +59,7 @@ export const DepositDialog = () => {
);
return (
<Dialog
open={isOpen && !connectWalletDialogIsOpen}
open={isOpen && !(connectWalletDialogIsOpen || assetDetailsDialogOpen)}
onChange={(isOpen) => (isOpen ? open() : close())}
{...dialogStyleProps}
>

View File

@ -322,7 +322,6 @@ const FormButton = ({
</Button>
);
} else if (chainId !== desiredChainId) {
console.log(chainId, desiredChainId);
const chainName = getChainName(desiredChainId);
message = t(`This app only works on ${chainName}.`);
button = (

View File

@ -1,16 +1,23 @@
import { DepositForm } from './deposit-form';
import { useSubmitDeposit } from './use-submit-deposit';
import type { DepositFormProps } from './deposit-form';
import { removeDecimal } from '@vegaprotocol/react-helpers';
import { prepend0x } from '@vegaprotocol/smart-contracts';
import sortBy from 'lodash/sortBy';
import { useSubmitApproval } from './use-submit-approval';
import { useSubmitFaucet } from './use-submit-faucet';
import { useDepositStore } from './deposit-store';
import { useCallback, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useDepositBalances } from './use-deposit-balances';
import { useDepositDialog } from './deposit-dialog';
import type { Asset } from '@vegaprotocol/assets';
import type { DepositDialogStylePropsSetter } from './deposit-dialog';
import pick from 'lodash/pick';
import type { EthTransaction } from '@vegaprotocol/web3';
import { EthTxStatus } from '@vegaprotocol/web3';
import {
EthTxStatus,
useEthTransactionStore,
useBridgeContract,
useEthereumConfig,
} from '@vegaprotocol/web3';
import { t } from '@vegaprotocol/react-helpers';
interface DepositManagerProps {
@ -20,53 +27,36 @@ interface DepositManagerProps {
setDialogStyleProps?: DepositDialogStylePropsSetter;
}
const useDepositAsset = (assets: Asset[], assetId?: string) => {
const { asset, balance, allowance, deposited, max, update } =
useDepositStore();
const handleSelectAsset = useCallback(
(id: string) => {
const asset = assets.find((a) => a.id === id);
update({ asset });
},
[assets, update]
);
useEffect(() => {
handleSelectAsset(assetId || '');
}, [assetId, handleSelectAsset]);
return { asset, balance, allowance, deposited, max, handleSelectAsset };
};
const getProps = (txContent?: EthTransaction['TxContent']) =>
txContent ? pick(txContent, ['title', 'icon', 'intent']) : undefined;
export const DepositManager = ({
assetId,
assetId: initialAssetId,
assets,
isFaucetable,
setDialogStyleProps,
}: DepositManagerProps) => {
const { asset, balance, allowance, deposited, max, handleSelectAsset } =
useDepositAsset(assets, assetId);
const createEthTransaction = useEthTransactionStore((state) => state.create);
const { config } = useEthereumConfig();
const [assetId, setAssetId] = useState(initialAssetId);
const asset = assets.find((a) => a.id === assetId);
const bridgeContract = useBridgeContract();
const closeDepositDialog = useDepositDialog((state) => state.close);
useDepositBalances(isFaucetable);
const { balance, allowance, deposited, max, refresh } = useDepositBalances(
asset,
isFaucetable
);
// Set up approve transaction
const approve = useSubmitApproval();
// Set up deposit transaction
const deposit = useSubmitDeposit();
const approve = useSubmitApproval(asset);
// Set up faucet transaction
const faucet = useSubmitFaucet();
const faucet = useSubmitFaucet(asset);
const transactionInProgress = [
approve.TxContent,
deposit.TxContent,
faucet.TxContent,
].filter((t) => t.status !== EthTxStatus.Default)[0];
const transactionInProgress = [approve.TxContent, faucet.TxContent].filter(
(t) => t.status !== EthTxStatus.Default
)[0];
useEffect(() => {
setDialogStyleProps?.(getProps(transactionInProgress));
@ -74,17 +64,44 @@ export const DepositManager = ({
const returnLabel = t('Return to deposit');
const submitDeposit = (
args: Parameters<DepositFormProps['submitDeposit']>['0']
) => {
if (!asset) {
return;
}
createEthTransaction(
bridgeContract,
'deposit_asset',
[
args.assetSource,
removeDecimal(args.amount, asset.decimals),
prepend0x(args.vegaPublicKey),
],
asset.id,
config?.confirmations ?? 1,
true
);
closeDepositDialog();
};
return (
<>
{!transactionInProgress && (
<DepositForm
balance={balance}
selectedAsset={asset}
onSelectAsset={handleSelectAsset}
onSelectAsset={setAssetId}
assets={sortBy(assets, 'name')}
submitApprove={() => approve.perform()}
submitDeposit={(args) => deposit.perform(args)}
requestFaucet={() => faucet.perform()}
submitApprove={async () => {
await approve.perform();
refresh();
}}
submitDeposit={submitDeposit}
requestFaucet={async () => {
await faucet.perform();
refresh();
}}
deposited={deposited}
max={max}
allowance={allowance}
@ -94,7 +111,6 @@ export const DepositManager = ({
<approve.TxContent.Content returnLabel={returnLabel} />
<faucet.TxContent.Content returnLabel={returnLabel} />
<deposit.TxContent.Content returnLabel={returnLabel} />
</>
);
};

View File

@ -1,23 +0,0 @@
import type { Asset } from '@vegaprotocol/assets';
import BigNumber from 'bignumber.js';
import create from 'zustand';
interface DepositStore {
balance: BigNumber;
allowance: BigNumber;
asset: Asset | undefined;
deposited: BigNumber;
max: BigNumber;
update: (state: Partial<DepositStore>) => void;
}
export const useDepositStore = create<DepositStore>((set) => ({
balance: new BigNumber(0),
allowance: new BigNumber(0),
deposited: new BigNumber(0),
max: new BigNumber(0),
asset: undefined,
update: (updatedState) => {
set(updatedState);
},
}));

View File

@ -3,7 +3,6 @@ export * from './deposit-container';
export * from './deposit-form';
export * from './deposit-limits';
export * from './deposit-manager';
export * from './deposit-store';
export * from './deposits-table';
export * from './use-deposit-balances';
export * from './use-deposits';
@ -12,6 +11,5 @@ export * from './use-get-balance-of-erc20-token';
export * from './use-get-deposit-maximum';
export * from './use-get-deposited-amount';
export * from './use-submit-approval';
export * from './use-submit-deposit';
export * from './use-submit-faucet';
export * from './deposit-dialog';

View File

@ -1,19 +1,40 @@
import { useBridgeContract, useTokenContract } from '@vegaprotocol/web3';
import { useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import BigNumber from 'bignumber.js';
import * as Sentry from '@sentry/react';
import { useDepositStore } from './deposit-store';
import { useGetAllowance } from './use-get-allowance';
import { useGetBalanceOfERC20Token } from './use-get-balance-of-erc20-token';
import { useGetDepositMaximum } from './use-get-deposit-maximum';
import { useGetDepositedAmount } from './use-get-deposited-amount';
import { isAssetTypeERC20 } from '@vegaprotocol/react-helpers';
import { isAssetTypeERC20, usePrevious } from '@vegaprotocol/react-helpers';
import { useAccountBalance } from '@vegaprotocol/accounts';
import type { Asset } from '@vegaprotocol/assets';
type DepositBalances = {
balance: BigNumber;
allowance: BigNumber;
deposited: BigNumber;
max: BigNumber;
refresh: () => void;
};
type DepositBalancesState = Omit<DepositBalances, 'refresh'>;
const initialState: DepositBalancesState = {
balance: new BigNumber(0),
allowance: new BigNumber(0),
deposited: new BigNumber(0),
max: new BigNumber(0),
};
/**
* Hook which fetches all the balances required for depositing
* whenever the asset changes in the form
*/
export const useDepositBalances = (isFaucetable: boolean) => {
const { asset, update } = useDepositStore();
export const useDepositBalances = (
asset: Asset | undefined,
isFaucetable: boolean
): DepositBalances => {
const tokenContract = useTokenContract(
isAssetTypeERC20(asset) ? asset.source.contractAddress : undefined,
isFaucetable
@ -23,9 +44,20 @@ export const useDepositBalances = (isFaucetable: boolean) => {
const getBalance = useGetBalanceOfERC20Token(tokenContract, asset);
const getDepositMaximum = useGetDepositMaximum(bridgeContract, asset);
const getDepositedAmount = useGetDepositedAmount(asset);
const prevAsset = usePrevious(asset);
const [state, setState] = useState<DepositBalancesState>(initialState);
useEffect(() => {
const getBalances = async () => {
if (asset?.id !== prevAsset?.id) {
// reset values to initial state when asset changes
setState(initialState);
}
}, [asset?.id, prevAsset?.id]);
const { accountBalance } = useAccountBalance(asset?.id);
const getBalances = useCallback(async () => {
if (!asset) return;
try {
const [max, deposited, balance, allowance] = await Promise.all([
getDepositMaximum(),
@ -34,26 +66,20 @@ export const useDepositBalances = (isFaucetable: boolean) => {
getAllowance(),
]);
update({
max,
deposited,
balance,
allowance,
setState({
max: max ?? initialState.max,
deposited: deposited ?? initialState.deposited,
balance: balance ?? initialState.balance,
allowance: allowance ?? initialState.allowance,
});
} catch (err) {
Sentry.captureException(err);
}
};
}, [asset, getAllowance, getBalance, getDepositMaximum, getDepositedAmount]);
if (asset) {
useEffect(() => {
getBalances();
}
}, [
asset,
update,
getDepositMaximum,
getDepositedAmount,
getAllowance,
getBalance,
]);
}, [asset, getBalances, accountBalance]);
return { ...state, refresh: getBalances };
};

View File

@ -6,17 +6,14 @@ import {
useEthereumTransaction,
useTokenContract,
} from '@vegaprotocol/web3';
import { useDepositStore } from './deposit-store';
import { useGetAllowance } from './use-get-allowance';
import type { Asset } from '@vegaprotocol/assets';
export const useSubmitApproval = () => {
export const useSubmitApproval = (asset?: Asset) => {
const { config } = useEthereumConfig();
const { asset, update } = useDepositStore();
const contract = useTokenContract(
isAssetTypeERC20(asset) ? asset.source.contractAddress : undefined,
true
);
const getAllowance = useGetAllowance(contract, asset);
const transaction = useEthereumTransaction<Token, 'approve'>(
contract,
'approve'
@ -31,8 +28,6 @@ export const useSubmitApproval = () => {
config.collateral_bridge_contract.address,
amount
);
const allowance = await getAllowance();
update({ allowance });
} catch (err) {
Sentry.captureException(err);
}

View File

@ -1,103 +0,0 @@
import { useSubscription } from '@apollo/client';
import * as Sentry from '@sentry/react';
import type {
DepositEventSubscription,
DepositEventSubscriptionVariables,
} from './__generated__/Deposit';
import { DepositEventDocument } from './__generated__/Deposit';
import * as Schema from '@vegaprotocol/types';
import { useState } from 'react';
import {
isAssetTypeERC20,
remove0x,
removeDecimal,
} from '@vegaprotocol/react-helpers';
import {
useBridgeContract,
useEthereumConfig,
useEthereumTransaction,
useTokenContract,
} from '@vegaprotocol/web3';
import type { CollateralBridge } from '@vegaprotocol/smart-contracts';
import { prepend0x } from '@vegaprotocol/smart-contracts';
import { useDepositStore } from './deposit-store';
import { useGetBalanceOfERC20Token } from './use-get-balance-of-erc20-token';
export const useSubmitDeposit = () => {
const { asset, update } = useDepositStore();
const { config } = useEthereumConfig();
const bridgeContract = useBridgeContract();
const tokenContract = useTokenContract(
isAssetTypeERC20(asset) ? asset.source.contractAddress : undefined,
true
);
// 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 getBalance = useGetBalanceOfERC20Token(tokenContract, asset);
const transaction = useEthereumTransaction<CollateralBridge, 'deposit_asset'>(
bridgeContract,
'deposit_asset',
config?.confirmations,
true
);
useSubscription<DepositEventSubscription, DepositEventSubscriptionVariables>(
DepositEventDocument,
{
variables: { partyId: partyId ? remove0x(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.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 === Schema.DepositStatus.STATUS_FINALIZED
) {
return true;
}
return false;
});
if (matchingDeposit && matchingDeposit.event.__typename === 'Deposit') {
transaction.setConfirmed();
}
},
}
);
return {
...transaction,
perform: async (args: {
assetSource: string;
amount: string;
vegaPublicKey: string;
}) => {
if (!asset) return;
try {
setPartyId(args.vegaPublicKey);
const publicKey = prepend0x(args.vegaPublicKey);
const amount = removeDecimal(args.amount, asset.decimals);
await transaction.perform(args.assetSource, amount, publicKey);
const balance = await getBalance();
update({ balance });
} catch (err) {
Sentry.captureException(err);
}
},
};
};

View File

@ -1,17 +1,14 @@
import type { TokenFaucetable } from '@vegaprotocol/smart-contracts';
import * as Sentry from '@sentry/react';
import { useEthereumTransaction, useTokenContract } from '@vegaprotocol/web3';
import { useDepositStore } from './deposit-store';
import { useGetBalanceOfERC20Token } from './use-get-balance-of-erc20-token';
import { isAssetTypeERC20 } from '@vegaprotocol/react-helpers';
import type { Asset } from '@vegaprotocol/assets';
export const useSubmitFaucet = () => {
const { asset, update } = useDepositStore();
export const useSubmitFaucet = (asset?: Asset) => {
const contract = useTokenContract(
isAssetTypeERC20(asset) ? asset.source.contractAddress : undefined,
true
);
const getBalance = useGetBalanceOfERC20Token(contract, asset);
const transaction = useEthereumTransaction<TokenFaucetable, 'faucet'>(
contract,
'faucet'
@ -21,8 +18,6 @@ export const useSubmitFaucet = () => {
perform: async () => {
try {
await transaction.perform();
const balance = await getBalance();
update({ balance });
} catch (err) {
Sentry.captureException(err);
}

View File

@ -65,9 +65,7 @@ export const useUpdateNetworkParametersToasts = (): Toast[] => {
return {
id: `update-network-param-proposal-${proposal.id}`,
intent: Intent.Warning,
render: () => (
<UpdateNetworkParameterToastContent proposal={proposal} />
),
content: <UpdateNetworkParameterToastContent proposal={proposal} />,
onClose: () => remove(id),
closeAfter: CLOSE_AFTER,
};

View File

@ -22,6 +22,12 @@ fragment OrderFields on Order {
}
}
query OrderById($orderId: ID!) {
orderByID(id: $orderId) {
...OrderFields
}
}
query Orders(
$partyId: ID!
$pagination: Pagination

View File

@ -5,6 +5,13 @@ import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type OrderFieldsFragment = { __typename?: 'Order', id: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, market: { __typename?: 'Market', id: string }, liquidityProvision?: { __typename: 'LiquidityProvision' } | null, peggedOrder?: { __typename: 'PeggedOrder' } | null };
export type OrderByIdQueryVariables = Types.Exact<{
orderId: Types.Scalars['ID'];
}>;
export type OrderByIdQuery = { __typename?: 'Query', orderByID: { __typename?: 'Order', id: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, market: { __typename?: 'Market', id: string }, liquidityProvision?: { __typename: 'LiquidityProvision' } | null, peggedOrder?: { __typename: 'PeggedOrder' } | null } };
export type OrdersQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
pagination?: Types.InputMaybe<Types.Pagination>;
@ -72,6 +79,41 @@ export const OrderUpdateFieldsFragmentDoc = gql`
}
}
`;
export const OrderByIdDocument = gql`
query OrderById($orderId: ID!) {
orderByID(id: $orderId) {
...OrderFields
}
}
${OrderFieldsFragmentDoc}`;
/**
* __useOrderByIdQuery__
*
* To run a query within a React component, call `useOrderByIdQuery` and pass it any options that fit your needs.
* When your component renders, `useOrderByIdQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useOrderByIdQuery({
* variables: {
* orderId: // value for 'orderId'
* },
* });
*/
export function useOrderByIdQuery(baseOptions: Apollo.QueryHookOptions<OrderByIdQuery, OrderByIdQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<OrderByIdQuery, OrderByIdQueryVariables>(OrderByIdDocument, options);
}
export function useOrderByIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<OrderByIdQuery, OrderByIdQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<OrderByIdQuery, OrderByIdQueryVariables>(OrderByIdDocument, options);
}
export type OrderByIdQueryHookResult = ReturnType<typeof useOrderByIdQuery>;
export type OrderByIdLazyQueryHookResult = ReturnType<typeof useOrderByIdLazyQuery>;
export type OrderByIdQueryResult = Apollo.QueryResult<OrderByIdQuery, OrderByIdQueryVariables>;
export const OrdersDocument = gql`
query Orders($partyId: ID!, $pagination: Pagination, $dateRange: DateRange, $filter: OrderFilter, $marketId: ID) {
party(id: $partyId) {

View File

@ -76,7 +76,9 @@ export const OrderFeedback = ({ transaction, order }: OrderFeedbackProps) => {
);
};
const getRejectionReason = (order: OrderEventFieldsFragment): string | null => {
export const getRejectionReason = (
order: OrderEventFieldsFragment
): string | null => {
switch (order.status) {
case Schema.OrderStatus.STATUS_STOPPED:
return t(

View File

@ -17,11 +17,11 @@ import type { Filter, Sort } from './use-order-list-data';
import { useEnvironment } from '@vegaprotocol/environment';
import { Link } from '@vegaprotocol/ui-toolkit';
import type { TransactionResult } from '@vegaprotocol/wallet';
import type { VegaTxState } from '@vegaprotocol/wallet';
import { useOrderCancel } from '../../order-hooks/use-order-cancel';
import { useOrderEdit } from '../../order-hooks/use-order-edit';
import { OrderFeedback } from '../order-feedback';
import {
normalizeOrderAmendment,
useVegaTransactionStore,
} from '@vegaprotocol/wallet';
import type { VegaTxState, TransactionResult } from '@vegaprotocol/wallet';
import { OrderEditDialog } from '../order-list/order-edit-dialog';
import type { OrderEventFieldsFragment } from '../../order-hooks';
import * as Schema from '@vegaprotocol/types';
@ -76,8 +76,7 @@ export const OrderListManager = ({
const [sort, setSort] = useState<Sort[] | undefined>();
const [filter, setFilter] = useState<Filter | undefined>();
const [editOrder, setEditOrder] = useState<Order | null>(null);
const orderCancel = useOrderCancel();
const orderEdit = useOrderEdit(editOrder);
const create = useVegaTransactionStore((state) => state.create);
const hasActiveOrder = useHasActiveOrder(marketId);
const { data, error, loading, addNewRows, getRows } = useOrderListData({
@ -136,9 +135,11 @@ export const OrderListManager = ({
onSortChanged={onSortChange}
cancel={(order: Order) => {
if (!order.market) return;
orderCancel.cancel({
create({
orderCancellation: {
orderId: order.id,
marketId: order.market.id,
},
});
}}
setEditOrder={setEditOrder}
@ -157,7 +158,13 @@ export const OrderListManager = ({
<div className="w-full dark:bg-black bg-white absolute bottom-0 h-auto flex justify-end px-[11px] py-2">
<Button
size="sm"
onClick={() => orderCancel.cancel({ marketId })}
onClick={() => {
create({
orderCancellation: {
marketId,
},
});
}}
data-testid="cancelAll"
>
{t('Cancel all')}
@ -165,31 +172,7 @@ export const OrderListManager = ({
</div>
)}
</div>
<orderCancel.Dialog
title={getCancelDialogTitle(orderCancel)}
intent={getCancelDialogIntent(orderCancel)}
content={{
Complete: orderCancel.cancelledOrder ? (
<OrderFeedback
transaction={orderCancel.transaction}
order={orderCancel.cancelledOrder}
/>
) : (
<TransactionComplete {...orderCancel} />
),
}}
/>
<orderEdit.Dialog
title={getEditDialogTitle(orderEdit.updatedOrder?.status)}
content={{
Complete: (
<OrderFeedback
transaction={orderEdit.transaction}
order={orderEdit.updatedOrder}
/>
),
}}
/>
{editOrder && (
<OrderEditDialog
isOpen={Boolean(editOrder)}
@ -198,8 +181,17 @@ export const OrderListManager = ({
}}
order={editOrder}
onSubmit={(fields) => {
if (!editOrder.market) {
return;
}
const orderAmendment = normalizeOrderAmendment(
editOrder,
editOrder.market,
fields.limitPrice,
fields.size
);
create({ orderAmendment });
setEditOrder(null);
orderEdit.edit({ price: fields.limitPrice, size: fields.size });
}}
/>
)}

View File

@ -9,7 +9,6 @@ import type { OrderEventSubscription } from './';
import { OrderEventDocument } from './';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { toNanoSeconds } from '@vegaprotocol/react-helpers';
const defaultMarket = {
__typename: 'Market',
@ -180,7 +179,7 @@ describe('useOrderSubmit', () => {
side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT,
price: '123456789',
expiresAt: toNanoSeconds(order.expiresAt),
expiresAt: new Date('2022-01-01').toISOString(),
},
});
});
@ -217,7 +216,7 @@ describe('useOrderSubmit', () => {
side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
price: '123456789',
expiresAt: undefined,
expiresAt: new Date('2022-01-01').toISOString(),
},
});
});

View File

@ -1,7 +1,6 @@
import { useCallback, useState } from 'react';
import type { ReactNode } from 'react';
import type { OrderEventFieldsFragment } from './__generated__/OrderEvent';
import { toNanoSeconds } from '@vegaprotocol/react-helpers';
import {
useVegaWallet,
useVegaTransaction,
@ -108,28 +107,15 @@ export const useOrderSubmit = () => {
}, [resetTransaction]);
const submit = useCallback(
async (order: OrderSubmissionBody['orderSubmission']) => {
if (!pubKey || !order.side) {
async (orderSubmission: OrderSubmissionBody['orderSubmission']) => {
if (!pubKey || !orderSubmission.side) {
return;
}
setFinalizedOrder(null);
try {
const res = await send(pubKey, {
orderSubmission: {
...order,
price:
order.type === Schema.OrderType.TYPE_LIMIT && order.price
? order.price
: undefined,
expiresAt:
order.expiresAt &&
order.timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
? toNanoSeconds(order.expiresAt) // Wallet expects timestamp in nanoseconds
: undefined,
},
});
const res = await send(pubKey, { orderSubmission });
if (res) {
const orderId = determineId(res.signature);

View File

@ -1,10 +1,9 @@
import { useRef } from 'react';
import { AsyncRenderer, Icon, Intent } from '@vegaprotocol/ui-toolkit';
import { useClosePosition, usePositionsData, PositionsTable } from '../';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { usePositionsData, PositionsTable } from '../';
import type { AgGridReact } from 'ag-grid-react';
import { Requested } from './close-position-dialog/requested';
import { Complete } from './close-position-dialog/complete';
import type { TransactionResult } from '@vegaprotocol/wallet';
import * as Schema from '@vegaprotocol/types';
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import { t } from '@vegaprotocol/react-helpers';
interface PositionsManagerProps {
@ -14,14 +13,7 @@ interface PositionsManagerProps {
export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null);
const { data, error, loading, getRows } = usePositionsData(partyId, gridRef);
const {
submit,
closingOrder,
closingOrderResult,
transaction,
transactionResult,
Dialog,
} = useClosePosition();
const create = useVegaTransactionStore((store) => store.create);
return (
<div className="h-full relative">
@ -29,7 +21,30 @@ export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
rowModelType="infinite"
ref={gridRef}
datasource={{ getRows }}
onClose={(position) => submit(position)}
onClose={({ marketId, openVolume }) =>
create({
batchMarketInstructions: {
cancellations: [
{
marketId,
orderId: '', // omit order id to cancel all active orders
},
],
submissions: [
{
marketId: marketId,
type: Schema.OrderType.TYPE_MARKET as const,
timeInForce: Schema.OrderTimeInForce
.TIME_IN_FORCE_FOK as const,
side: openVolume.startsWith('-')
? Schema.Side.SIDE_BUY
: Schema.Side.SIDE_SELL,
size: openVolume.replace('-', ''),
},
],
},
})
}
noRowsOverlayComponent={() => null}
/>
<div className="pointer-events-none absolute inset-0">
@ -41,63 +56,6 @@ export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
noDataCondition={(data) => !(data && data.length)}
/>
</div>
<Dialog
intent={getDialogIntent(transactionResult)}
icon={getDialogIcon(transactionResult)}
title={getDialogTitle(transactionResult)}
content={{
Requested: <Requested partyId={partyId} order={closingOrder} />,
Complete: (
<Complete
partyId={partyId}
closingOrder={closingOrder}
closingOrderResult={closingOrderResult}
transaction={transaction}
transactionResult={transactionResult}
/>
),
}}
/>
</div>
);
};
const getDialogIntent = (transactionResult?: TransactionResult) => {
if (!transactionResult) {
return;
}
if (
transactionResult &&
'error' in transactionResult &&
transactionResult.error
) {
return Intent.Danger;
}
return Intent.Success;
};
const getDialogIcon = (transactionResult?: TransactionResult) => {
if (!transactionResult) {
return;
}
if (transactionResult.status) {
return <Icon name="tick" />;
}
return <Icon name="error" />;
};
const getDialogTitle = (transactionResult?: TransactionResult) => {
if (!transactionResult) {
return;
}
if (transactionResult.status) {
return t('Position closed');
}
return t('Position not closed');
};

View File

@ -11,3 +11,4 @@ export * from './use-screen-dimensions';
export * from './use-theme-switcher';
export * from './use-storybook-theme-observer';
export * from './use-yesterday';
export * from './use-previous';

View File

@ -0,0 +1,22 @@
import { renderHook } from '@testing-library/react';
import { usePrevious } from './use-previous';
describe('usePrevious', () => {
it('returns previous value', () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) => usePrevious(value),
{
initialProps: {
value: 'ABC',
},
}
);
expect(result.current).toEqual('ABC');
rerender({ value: 'DEF' });
expect(result.current).toEqual('ABC');
rerender({ value: 'GHI' });
expect(result.current).toEqual('DEF');
rerender({ value: 'JKL' });
expect(result.current).toEqual('GHI');
});
});

View File

@ -1,9 +1,9 @@
import React from 'react';
import { useEffect, useRef } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const ref = React.useRef<T | undefined>(value);
const ref = useRef<T | undefined>(value);
React.useEffect(() => {
useEffect(() => {
ref.current = value;
}, [value]);

View File

@ -7,17 +7,31 @@ export const Size = ({
value,
side,
positionDecimalPlaces = 0,
forceTheme,
}: {
value: string;
side: Schema.Side;
side?: Schema.Side;
positionDecimalPlaces?: number;
forceTheme?: 'dark' | 'light';
}) => {
return (
<span
data-testid="size"
className={classNames('text-right', {
'text-vega-green dark:text-vega-green': side === Schema.Side.SIDE_BUY,
'text-vega-pink dark:text-vega-pink': side === Schema.Side.SIDE_SELL,
// BUY
'text-vega-green-dark dark:text-vega-green':
side === Schema.Side.SIDE_BUY && !forceTheme,
'text-vega-green-dark':
side === Schema.Side.SIDE_BUY && forceTheme === 'light',
'text-vega-green':
side === Schema.Side.SIDE_BUY && forceTheme === 'dark',
// SELL
'text-vega-pink-dark dark:text-vega-pink':
side === Schema.Side.SIDE_SELL && !forceTheme,
'text-vega-pink-dark':
side === Schema.Side.SIDE_SELL && forceTheme === 'light',
'text-vega-pink':
side === Schema.Side.SIDE_SELL && forceTheme === 'dark',
})}
>
{side === Schema.Side.SIDE_BUY

View File

@ -215,7 +215,7 @@ export const OrderStatusMapping: {
STATUS_EXPIRED: 'Expired',
STATUS_FILLED: 'Filled',
STATUS_PARKED: 'Parked',
STATUS_PARTIALLY_FILLED: 'PartiallyFilled',
STATUS_PARTIALLY_FILLED: 'Partially Filled',
STATUS_REJECTED: 'Rejected',
STATUS_STOPPED: 'Stopped',
};

View File

@ -9,18 +9,14 @@ export default {
} as ComponentMeta<typeof Toast>;
const Template: ComponentStory<typeof Toast> = (args) => {
return (
<Toast
{...args}
render={() => (
const toastContent = (
<>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
<p>Eaque exercitationem saepe cupiditate sunt impedit.</p>
<p>I really like 🥪🥪🥪!</p>
</>
)}
/>
);
return <Toast {...args} content={toastContent} />;
};
export const Default = Template.bind({});

View File

@ -11,15 +11,14 @@ import { Intent } from '../../utils/intent';
import { Icon } from '../icon';
import { Loader } from '../loader';
type ToastContentProps = { id: string };
type ToastContent = (props: ToastContentProps) => JSX.Element;
export type ToastContent = JSX.Element | undefined;
type ToastState = 'initial' | 'showing' | 'expired';
export type Toast = {
id: string;
intent: Intent;
render: ToastContent;
content: ToastContent;
closeAfter?: number;
onClose?: () => void;
signal?: 'close';
@ -52,7 +51,7 @@ export const CLOSE_DELAY = 750;
export const Toast = ({
id,
intent,
render,
content,
closeAfter,
signal,
state = 'initial',
@ -137,7 +136,7 @@ export const Toast = ({
className="flex-1 p-2 pr-6 text-sm overflow-auto"
data-testid="toast-content"
>
{render({ id })}
{content}
</div>
</div>
</div>

View File

@ -23,17 +23,17 @@ describe('ToastsContainer', () => {
add({
id: 'toast-a',
intent: Intent.None,
render: () => <p>A</p>,
content: <p>A</p>,
});
add({
id: 'toast-b',
intent: Intent.None,
render: () => <p>B</p>,
content: <p>B</p>,
});
add({
id: 'toast-c',
intent: Intent.None,
render: () => <p>C</p>,
content: <p>C</p>,
});
});
const { baseElement } = render(
@ -54,17 +54,17 @@ describe('ToastsContainer', () => {
add({
id: 'toast-a',
intent: Intent.None,
render: () => <p>A</p>,
content: <p>A</p>,
});
add({
id: 'toast-b',
intent: Intent.None,
render: () => <p>B</p>,
content: <p>B</p>,
});
add({
id: 'toast-c',
intent: Intent.None,
render: () => <p>C</p>,
content: <p>C</p>,
});
});
const { baseElement } = render(
@ -90,13 +90,13 @@ describe('ToastsContainer', () => {
add({
id: 'toast-a',
intent: Intent.None,
render: () => <p>A</p>,
content: <p>A</p>,
onClose: () => remove('toast-a'),
});
add({
id: 'toast-b',
intent: Intent.None,
render: () => <p>B</p>,
content: <p>B</p>,
onClose: () => remove('toast-b'),
});
});

View File

@ -69,7 +69,7 @@ const randomToast = (): Toast => {
Intent.Danger,
Intent.Success,
]) as Intent,
render: () => <p>{content}</p>,
content: <p>{content}</p>,
closeAfter: sample([undefined, random(1000, 5000)]),
};
};
@ -114,20 +114,20 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
];
add({
...t,
render: ({ id }) => (
content: (
<>
<h1>{words[0]}</h1>
<div>
<button
className="underline text-gray-600 mr-2"
onClick={() => setTimeout(() => close(id), 500)}
onClick={() => setTimeout(() => close(t.id), 500)}
>
{words[1]}
</button>
<button
className="underline text-gray-600"
onClick={() =>
update(id, {
update(t.id, {
intent: sample([
Intent.Danger,
Intent.Warning,
@ -164,7 +164,7 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
};
add({
...t,
render: () => <ToastContent />,
content: <ToastContent />,
onClose: () => remove(t.id),
});
};

View File

@ -13,7 +13,7 @@ export const ToastsContainer = ({
return (
<ul
className={classNames(
'absolute top-0 right-0 pt-2 pr-2 max-w-full z-20 max-h-full overflow-auto',
'absolute top-0 right-0 pt-2 pr-2 max-w-full z-20 max-h-full overflow-x-hidden overflow-y-auto',
{
'flex flex-col-reverse': order === 'desc',
}

View File

@ -71,6 +71,15 @@ fragment OrderBusEventFields on Order {
tradableInstrument {
instrument {
name
code
product {
... on Future {
settlementAsset {
symbol
decimals
}
}
}
}
}
}

View File

@ -21,14 +21,14 @@ export type WithdrawalBusEventSubscriptionVariables = Types.Exact<{
export type WithdrawalBusEventSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', event: { __typename?: 'AccountEvent' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit' } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order' } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal' } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult' } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal', id: string, status: Types.WithdrawalStatus, amount: string, createdTimestamp: any, withdrawnTimestamp?: any | null, txHash?: string | null, pendingOnForeignChain: boolean, asset: { __typename?: 'Asset', id: string, name: string, symbol: string, decimals: number, status: Types.AssetStatus, source: { __typename?: 'BuiltinAsset' } | { __typename?: 'ERC20', contractAddress: string } }, details?: { __typename?: 'Erc20WithdrawalDetails', receiverAddress: string } | null } }> | null };
export type OrderBusEventFieldsFragment = { __typename?: 'Order', type?: Types.OrderType | null, id: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, createdAt: any, size: string, price: string, timeInForce: Types.OrderTimeInForce, expiresAt?: any | null, side: Types.Side, market: { __typename?: 'Market', id: string, decimalPlaces: number, positionDecimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string } } } };
export type OrderBusEventFieldsFragment = { __typename?: 'Order', type?: Types.OrderType | null, id: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, createdAt: any, size: string, price: string, timeInForce: Types.OrderTimeInForce, expiresAt?: any | null, side: Types.Side, market: { __typename?: 'Market', id: string, decimalPlaces: number, positionDecimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string, code: string, product: { __typename?: 'Future', settlementAsset: { __typename?: 'Asset', symbol: string, decimals: number } } } } } };
export type OrderBusEventsSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type OrderBusEventsSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', type: Types.BusEventType, event: { __typename?: 'AccountEvent' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit' } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order', type?: Types.OrderType | null, id: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, createdAt: any, size: string, price: string, timeInForce: Types.OrderTimeInForce, expiresAt?: any | null, side: Types.Side, market: { __typename?: 'Market', id: string, decimalPlaces: number, positionDecimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string } } } } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal' } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult' } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal' } }> | null };
export type OrderBusEventsSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', type: Types.BusEventType, event: { __typename?: 'AccountEvent' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit' } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order', type?: Types.OrderType | null, id: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, createdAt: any, size: string, price: string, timeInForce: Types.OrderTimeInForce, expiresAt?: any | null, side: Types.Side, market: { __typename?: 'Market', id: string, decimalPlaces: number, positionDecimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string, code: string, product: { __typename?: 'Future', settlementAsset: { __typename?: 'Asset', symbol: string, decimals: number } } } } } } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal' } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult' } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal' } }> | null };
export type DepositBusEventFieldsFragment = { __typename?: 'Deposit', id: string, status: Types.DepositStatus, amount: string, createdTimestamp: any, creditedTimestamp?: any | null, txHash?: string | null, asset: { __typename?: 'Asset', id: string, symbol: string, decimals: number } };
@ -94,6 +94,15 @@ export const OrderBusEventFieldsFragmentDoc = gql`
tradableInstrument {
instrument {
name
code
product {
... on Future {
settlementAsset {
symbol
decimals
}
}
}
}
}
}

View File

@ -1,5 +1,5 @@
import { t } from '@vegaprotocol/react-helpers';
import { WalletClient } from '@vegaprotocol/wallet-client';
import { WalletClient, WalletClientError } from '@vegaprotocol/wallet-client';
import { clearConfig, getConfig, setConfig } from '../storage';
import type { Transaction, VegaConnector } from './vega-connector';
import { WalletError } from './vega-connector';
@ -21,16 +21,14 @@ export const ClientErrors = {
105,
t('Unknown error occurred')
),
NO_CLIENT: new WalletError(t('No client found.'), 106),
REQUEST_REJECTED: new WalletError(
t('Request rejected'),
107,
t('The request has been rejected by the user')
),
} as const;
class NoClientError extends Error {
constructor() {
super(
t('No client found. The connector needs to be initialized with a url.')
);
}
}
export class JsonRpcConnector implements VegaConnector {
version = VERSION;
private _url: string | null = null;
@ -62,7 +60,7 @@ export class JsonRpcConnector implements VegaConnector {
async getChainId() {
if (!this.client) {
throw new NoClientError();
throw ClientErrors.NO_CLIENT;
}
try {
const { result } = await this.client.GetChainId();
@ -74,7 +72,7 @@ export class JsonRpcConnector implements VegaConnector {
async connectWallet() {
if (!this.client) {
throw new NoClientError();
throw ClientErrors.NO_CLIENT;
}
try {
@ -86,7 +84,11 @@ export class JsonRpcConnector implements VegaConnector {
});
return result;
} catch (err) {
throw ClientErrors.INVALID_RESPONSE;
const clientErr =
err instanceof WalletClientError && err.code === 3001
? ClientErrors.REQUEST_REJECTED
: ClientErrors.INVALID_RESPONSE;
throw clientErr;
}
}
@ -94,7 +96,7 @@ export class JsonRpcConnector implements VegaConnector {
// which retrieves the session token
async connect() {
if (!this.client) {
throw new NoClientError();
throw ClientErrors.NO_CLIENT;
}
try {
@ -107,7 +109,7 @@ export class JsonRpcConnector implements VegaConnector {
async disconnect() {
if (!this.client) {
throw new NoClientError();
throw ClientErrors.NO_CLIENT;
}
await this.client.DisconnectWallet();
@ -116,10 +118,9 @@ export class JsonRpcConnector implements VegaConnector {
async sendTx(pubKey: string, transaction: Transaction) {
if (!this.client) {
throw new NoClientError();
throw ClientErrors.NO_CLIENT;
}
try {
const { result } = await this.client.SendTransaction({
publicKey: pubKey,
sendingMode: 'TYPE_SYNC',
@ -132,9 +133,6 @@ export class JsonRpcConnector implements VegaConnector {
receivedAt: result.receivedAt,
signature: result.transaction.signature.value,
};
} catch (err) {
throw ClientErrors.INVALID_RESPONSE;
}
}
async checkCompat() {

View File

@ -318,6 +318,11 @@ export const isOrderAmendmentTransaction = (
transaction: Transaction
): transaction is OrderAmendmentBody => 'orderAmendment' in transaction;
export const isBatchMarketInstructionsTransaction = (
transaction: Transaction
): transaction is BatchMarketInstructionSubmissionBody =>
'batchMarketInstructions' in transaction;
export interface TransactionResponse {
transactionHash: string;
signature: string; // still to be added by core

View File

@ -4,6 +4,7 @@ import { ClientErrors } from './connectors';
import { WalletError } from './connectors';
import { VegaTxStatus } from './use-vega-transaction';
import { useVegaTransactionStore } from './use-vega-transaction-store';
import { WalletClientError } from '@vegaprotocol/wallet-client';
export const useVegaTransactionManager = () => {
const { sendTx, pubKey } = useVegaWallet();
@ -39,7 +40,10 @@ export const useVegaTransactionManager = () => {
})
.catch((err) => {
update(transaction.id, {
error: err instanceof WalletError ? err : ClientErrors.UNKNOWN,
error:
err instanceof WalletError || err instanceof WalletClientError
? err
: ClientErrors.UNKNOWN,
status: VegaTxStatus.Error,
});
});

View File

@ -5,6 +5,7 @@ import {
isOrderSubmissionTransaction,
isOrderCancellationTransaction,
isOrderAmendmentTransaction,
isBatchMarketInstructionsTransaction,
} from './connectors';
import { determineId } from './utils';
@ -126,17 +127,42 @@ export const useVegaTransactionStore = create<VegaTransactionStore>(
updateOrder: (order: OrderBusEventFieldsFragment) => {
set(
produce((state: VegaTransactionStore) => {
const transaction = state.transactions.find(
(transaction) =>
transaction &&
transaction.status === VegaTxStatus.Pending &&
const transaction = state.transactions.find((transaction) => {
if (!transaction || transaction.status !== VegaTxStatus.Pending) {
return false;
}
if (
isOrderSubmissionTransaction(transaction?.body) &&
transaction.signature &&
(isOrderSubmissionTransaction(transaction?.body) ||
isOrderCancellationTransaction(transaction?.body) ||
isOrderAmendmentTransaction(transaction?.body)) &&
order.id === determineId(transaction.signature)
);
) {
return true;
}
if (
isOrderCancellationTransaction(transaction?.body) &&
order.id === transaction.body.orderCancellation.orderId
) {
return true;
}
if (
isOrderAmendmentTransaction(transaction?.body) &&
order.id === transaction.body.orderAmendment.orderId
) {
return true;
}
if (
isBatchMarketInstructionsTransaction(transaction?.body) &&
transaction.signature &&
order.id === determineId(transaction.signature)
) {
return true;
}
return false;
});
if (transaction) {
// TODO: handle multiple orders from batch market instructions
// Note: If multiple orders are submitted the first order ID is determined by hashing the signature of the transaction
// (see determineId function). For each subsequent order's ID, a hash of the previous orders ID is used
transaction.order = order;
transaction.status = VegaTxStatus.Complete;
transaction.dialogOpen = true;
@ -158,6 +184,14 @@ export const useVegaTransactionStore = create<VegaTransactionStore>(
);
if (transaction) {
transaction.transactionResult = transactionResult;
if (
isOrderCancellationTransaction(transaction.body) &&
!transaction.body.orderCancellation.orderId &&
!transactionResult.error &&
transactionResult.status
) {
transaction.status = VegaTxStatus.Complete;
}
transaction.dialogOpen = true;
transaction.updatedAt = new Date();
}

View File

@ -87,6 +87,15 @@ const orderBusEvent: OrderBusEventFieldsFragment = {
__typename: 'TradableInstrument',
instrument: {
name: 'UNIDAI Monthly (30 Jun 2022)',
code: 'UNIDAI',
product: {
__typename: 'Future',
settlementAsset: {
__typename: 'Asset',
decimals: 8,
symbol: 'AAA',
},
},
__typename: 'Instrument',
},
},

View File

@ -7,6 +7,7 @@ import type { Intent } from '@vegaprotocol/ui-toolkit';
import type { Transaction } from './connectors';
import { ClientErrors } from './connectors';
import { WalletError } from './connectors';
import type { WalletClientError } from '@vegaprotocol/wallet-client';
export interface DialogProps {
intent?: Intent;
@ -25,7 +26,7 @@ export enum VegaTxStatus {
export interface VegaTxState {
status: VegaTxStatus;
error: WalletError | Error | null;
error: WalletError | WalletClientError | Error | null;
txHash: string | null;
signature: string | null;
dialogOpen: boolean;

View File

@ -1,5 +1,5 @@
import { useContext } from 'react';
import { VegaWalletContext } from '.';
import { useCallback, useContext } from 'react';
import { useVegaWalletDialogStore, VegaWalletContext } from '.';
export function useVegaWallet() {
const context = useContext(VegaWalletContext);
@ -8,3 +8,16 @@ export function useVegaWallet() {
}
return context;
}
export function useReconnectVegaWallet() {
const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({
openVegaWalletDialog: store.openVegaWalletDialog,
}));
const { disconnect } = useVegaWallet();
const reconnect = useCallback(async () => {
await disconnect();
openVegaWalletDialog();
}, [disconnect, openVegaWalletDialog]);
return reconnect;
}

View File

@ -1,10 +1,127 @@
import { determineId } from './utils';
it('produces a known result for an ID', () => {
import {
determineId,
normalizeOrderAmendment,
normalizeOrderSubmission,
} from './utils';
import type { OrderSubmissionBody } from './connectors/vega-connector';
import * as Schema from '@vegaprotocol/types';
describe('determineId', () => {
it('produces a known result for an ID', () => {
const res = determineId(
'cfe592d169f87d0671dd447751036d0dddc165b9c4b65e5a5060e2bbadd1aa726d4cbe9d3c3b327bcb0bff4f83999592619a2493f9bbd251fae99ce7ce766909'
);
expect(res).toStrictEqual(
'2fca514cebf9f465ae31ecb4c5721e3a6f5f260425ded887ca50ba15b81a5d50'
);
});
});
describe('normalizeOrderSubmission', () => {
it('sets and formats price only for limit orders', () => {
expect(
normalizeOrderSubmission(
{ price: '100' } as unknown as OrderSubmissionBody['orderSubmission'],
2,
1
).price
).toBeUndefined();
expect(
normalizeOrderSubmission(
{
price: '100',
type: Schema.OrderType.TYPE_LIMIT,
} as unknown as OrderSubmissionBody['orderSubmission'],
2,
1
).price
).toEqual('10000');
});
it('sets and formats expiresAt only for time in force orders', () => {
expect(
normalizeOrderSubmission(
{
expiresAt: '2022-01-01T00:00:00.000Z',
} as OrderSubmissionBody['orderSubmission'],
2,
1
).expiresAt
).toBeUndefined();
expect(
normalizeOrderSubmission(
{
expiresAt: '2022-01-01T00:00:00.000Z',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT,
} as OrderSubmissionBody['orderSubmission'],
2,
1
).expiresAt
).toEqual('1640995200000000000');
});
it('formats size', () => {
expect(
normalizeOrderSubmission(
{
size: '100',
} as OrderSubmissionBody['orderSubmission'],
2,
1
).size
).toEqual('1000');
});
});
describe('normalizeOrderAmendment', () => {
type Order = Parameters<typeof normalizeOrderAmendment>[0];
type Market = Parameters<typeof normalizeOrderAmendment>[1];
const order: Order = {
id: '123',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT,
size: '100',
expiresAt: '2022-01-01T00:00:00.000Z',
};
const market: Market = {
id: '456',
decimalPlaces: 1,
positionDecimalPlaces: 1,
};
it('sets and formats order id, market id, expires and timeInForce as given', () => {
const orderAmendment = normalizeOrderAmendment(order, market, '1', '1');
expect(orderAmendment.orderId).toEqual('123');
expect(orderAmendment.marketId).toEqual('456');
expect(orderAmendment.expiresAt).toEqual('1640995200000000000');
expect(orderAmendment.timeInForce).toEqual(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
});
it.each([
['1.1', 1, '11'],
['1.1', 2, '110'],
['0.001', 8, '100000'],
])('sets and formats price', (price, decimalPlaces, output) => {
const orderAmendment = normalizeOrderAmendment(
order,
{ ...market, decimalPlaces },
price,
'1'
);
expect(orderAmendment.price).toEqual(output);
});
it.each([
['9', 1, -10],
['90', 2, 8900],
['0.001', 8, 99900],
])('sets and formats size delta', (size, positionDecimalPlaces, output) => {
const orderAmendment = normalizeOrderAmendment(
order,
{ ...market, positionDecimalPlaces },
'1',
size
);
expect(orderAmendment.sizeDelta).toEqual(output);
});
});

View File

@ -1,6 +1,14 @@
import { removeDecimal, toNanoSeconds } from '@vegaprotocol/react-helpers';
import type { Market, Order } from '@vegaprotocol/types';
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import BigNumber from 'bignumber.js';
import { ethers } from 'ethers';
import { sha3_256 } from 'js-sha3';
import type { Transaction } from './connectors';
import type {
OrderAmendmentBody,
OrderSubmissionBody,
Transaction,
} from './connectors';
/**
* Creates an ID in the same way that core does on the backend. This way we
@ -18,3 +26,40 @@ export const encodeTransaction = (tx: Transaction): string => {
ethers.utils.toUtf8Bytes(JSON.stringify(tx))
);
};
export const normalizeOrderSubmission = (
order: OrderSubmissionBody['orderSubmission'],
decimalPlaces: number,
positionDecimalPlaces: number
): OrderSubmissionBody['orderSubmission'] => ({
...order,
price:
order.type === OrderType.TYPE_LIMIT && order.price
? removeDecimal(order.price, decimalPlaces)
: undefined,
size: removeDecimal(order.size, positionDecimalPlaces),
expiresAt:
order.expiresAt && order.timeInForce === OrderTimeInForce.TIME_IN_FORCE_GTT
? toNanoSeconds(order.expiresAt)
: undefined,
});
export const normalizeOrderAmendment = (
order: Pick<Order, 'id' | 'timeInForce' | 'size' | 'expiresAt'>,
market: Pick<Market, 'id' | 'decimalPlaces' | 'positionDecimalPlaces'>,
price: string,
size: string
): OrderAmendmentBody['orderAmendment'] => ({
orderId: order.id,
marketId: market.id,
price: removeDecimal(price, market.decimalPlaces),
timeInForce: order.timeInForce,
sizeDelta: size
? new BigNumber(removeDecimal(size, market.positionDecimalPlaces))
.minus(order.size)
.toNumber()
: 0,
expiresAt: order.expiresAt
? toNanoSeconds(order.expiresAt) // Wallet expects timestamp in nanoseconds
: undefined,
});

View File

@ -21,19 +21,19 @@ export interface EthStoredTxState extends EthTxState {
id: number;
createdAt: Date;
updatedAt: Date;
contract: Contract;
contract: Contract | null;
methodName: ContractMethod;
args: string[];
requiredConfirmations: number;
requiresConfirmation: boolean;
asset?: string;
assetId?: string;
deposit?: DepositBusEventFieldsFragment;
}
export interface EthTransactionStore {
transactions: (EthStoredTxState | undefined)[];
create: (
contract: Contract,
contract: Contract | null,
methodName: ContractMethod,
args: string[],
assetId?: string,
@ -58,10 +58,10 @@ export const useEthTransactionStore = create<EthTransactionStore>(
(set, get) => ({
transactions: [] as EthStoredTxState[],
create: (
contract: Contract,
contract: Contract | null,
methodName: ContractMethod,
args: string[] = [],
asset,
assetId?: string,
requiredConfirmations = 1,
requiresConfirmation = false
) => {
@ -82,7 +82,7 @@ export const useEthTransactionStore = create<EthTransactionStore>(
dialogOpen: true,
requiredConfirmations,
requiresConfirmation,
asset: asset,
assetId,
};
set({ transactions: transactions.concat(transaction) });
return transaction.id;

View File

@ -36,7 +36,7 @@
"@sentry/nextjs": "^6.19.3",
"@sentry/react": "^6.19.2",
"@sentry/tracing": "^6.19.2",
"@vegaprotocol/wallet-client": "0.1.4",
"@vegaprotocol/wallet-client": "0.1.5",
"@walletconnect/ethereum-provider": "^1.7.5",
"@web3-react/core": "8.0.20-beta.0",
"@web3-react/metamask": "8.0.16-beta.0",

View File

@ -7559,10 +7559,10 @@
"@typescript-eslint/types" "5.40.0"
eslint-visitor-keys "^3.3.0"
"@vegaprotocol/wallet-client@0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@vegaprotocol/wallet-client/-/wallet-client-0.1.4.tgz#202fa1a84dbef57199810383f2887a7ee0afd64c"
integrity sha512-uGEbusoi3lwyl7Nn9ovBg9YHrfcH/Rl33KUcLfdMeTX8FZO+n7BVm3ejd00e5RsM/PJlqJ1oW4qkiq7kervxng==
"@vegaprotocol/wallet-client@0.1.5":
version "0.1.5"
resolved "https://registry.yarnpkg.com/@vegaprotocol/wallet-client/-/wallet-client-0.1.5.tgz#9d72a7fc9ceb9767f5119c9b7eebe29a2c30f682"
integrity sha512-7FmIBFxissr3h2QsEjvD+HXEXJ3u/oaVUg055IgZ08dmy1+4Nx22BOffFyidLBmaH1xJYjyiqxHnhLGnN5BfwA==
dependencies:
express "4.18.2"
nanoid "3.3.4"