diff --git a/libs/wallet/src/index.ts b/libs/wallet/src/index.ts index 5440dcd60..aa1221071 100644 --- a/libs/wallet/src/index.ts +++ b/libs/wallet/src/index.ts @@ -1,5 +1,6 @@ export * from './connectors'; export * from './context'; +export * from './use-vega-transaction'; export * from './use-vega-wallet'; export * from './use-vega-transaction-manager'; export * from './use-vega-transaction-store'; @@ -11,5 +12,6 @@ export * from './provider'; export * from './connect-dialog'; export * from './utils'; export * from './storage'; +export * from './types'; export * from './__generated__/TransactionResult'; export * from './__generated__/WithdrawalApproval'; diff --git a/libs/wallet/src/types.ts b/libs/wallet/src/types.ts new file mode 100644 index 000000000..6d756e71a --- /dev/null +++ b/libs/wallet/src/types.ts @@ -0,0 +1,15 @@ +export enum VegaTxStatus { + Default = 'Default', + Requested = 'Requested', + Pending = 'Pending', + Error = 'Error', + Complete = 'Complete', +} + +export interface VegaTxState { + status: VegaTxStatus; + error: Error | null; + txHash: string | null; + signature: string | null; + dialogOpen: boolean; +} diff --git a/libs/wallet/src/use-vega-transaction-manager.tsx b/libs/wallet/src/use-vega-transaction-manager.tsx index cc7afe3d0..7a7192c32 100644 --- a/libs/wallet/src/use-vega-transaction-manager.tsx +++ b/libs/wallet/src/use-vega-transaction-manager.tsx @@ -2,14 +2,12 @@ import { useVegaWallet } from './use-vega-wallet'; import { useEffect, useRef } from 'react'; import { WalletError } from './connectors'; import { ClientErrors } from './connectors'; -import { - useVegaTransactionStore, - VegaTxStatus, -} from './use-vega-transaction-store'; +import { useVegaTransactionStore } from './use-vega-transaction-store'; import { WalletClientError, WalletHttpError, } from '@vegaprotocol/wallet-client'; +import { VegaTxStatus } from './types'; const orderErrorResolve = (err: Error | unknown): Error => { if (err instanceof WalletClientError || err instanceof WalletError) { diff --git a/libs/wallet/src/use-vega-transaction-store.tsx b/libs/wallet/src/use-vega-transaction-store.tsx index 6f1034776..20c159415 100644 --- a/libs/wallet/src/use-vega-transaction-store.tsx +++ b/libs/wallet/src/use-vega-transaction-store.tsx @@ -21,22 +21,8 @@ import type { import type { WithdrawalApprovalQuery } from './__generated__/WithdrawalApproval'; import { subscribeWithSelector } from 'zustand/middleware'; - -export enum VegaTxStatus { - Default = 'Default', - Requested = 'Requested', - Pending = 'Pending', - Error = 'Error', - Complete = 'Complete', -} - -export interface VegaTxState { - status: VegaTxStatus; - error: Error | null; - txHash: string | null; - signature: string | null; - dialogOpen: boolean; -} +import type { VegaTxState } from './types'; +import { VegaTxStatus } from './types'; export interface VegaStoredTxState extends VegaTxState { id: number; @@ -48,6 +34,7 @@ export interface VegaStoredTxState extends VegaTxState { withdrawalApproval?: WithdrawalApprovalQuery['erc20WithdrawalApproval']; order?: OrderTxUpdateFieldsFragment; } + export interface VegaTransactionStore { transactions: (VegaStoredTxState | undefined)[]; create: (tx: Transaction, order?: OrderTxUpdateFieldsFragment) => number; diff --git a/libs/wallet/src/use-vega-transaction.spec.tsx b/libs/wallet/src/use-vega-transaction.spec.tsx new file mode 100644 index 000000000..0e85fbd13 --- /dev/null +++ b/libs/wallet/src/use-vega-transaction.spec.tsx @@ -0,0 +1,159 @@ +import { act, renderHook } from '@testing-library/react'; +import type { VegaWalletContextShape } from './context'; +import { VegaWalletContext } from './context'; +import type { ReactNode } from 'react'; +import { initialState, useVegaTransaction } from './use-vega-transaction'; +import type { Transaction } from './connectors'; +import { WalletError } from './connectors'; +import { VegaTxStatus } from './types'; + +const mockPubKey = '0x123'; + +const defaultWalletContext = { + pubKey: null, + pubKeys: [], + isReadOnly: false, + sendTx: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + selectPubKey: jest.fn(), + connector: null, +} as unknown as VegaWalletContextShape; + +function setup(context?: Partial) { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return renderHook(() => useVegaTransaction(), { wrapper }); +} + +describe('useVegaTransaction', () => { + it('has the correct default state', () => { + const { result } = setup(); + expect(result.current).toEqual({ + transaction: initialState, + send: expect.any(Function), + reset: expect.any(Function), + setComplete: expect.any(Function), + setTransaction: expect.any(Function), + Dialog: expect.any(Function), + }); + }); + + it('resets state if sendTx returns null (user rejects)', async () => { + const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(null)); + const { result } = setup({ sendTx: mockSendTx }); + await act(async () => { + result.current.send(mockPubKey, {} as Transaction); + }); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Default); + }); + + it('handles a single wallet error', () => { + const error = new WalletError('test error', 1, 'test data'); + const mockSendTx = jest.fn(() => { + throw error; + }); + const { result } = setup({ sendTx: mockSendTx }); + act(() => { + result.current.send(mockPubKey, {} as Transaction); + }); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Error); + expect(result.current.transaction.error).toHaveProperty( + 'message', + error.message + ); + expect(result.current.transaction.error).toHaveProperty('code', 1); + expect(result.current.transaction.error).toHaveProperty('data', error.data); + }); + + it('handles a single error', () => { + const error = new Error('test error'); + const mockSendTx = jest.fn(() => { + throw error; + }); + const { result } = setup({ sendTx: mockSendTx }); + act(() => { + result.current.send(mockPubKey, {} as Transaction); + }); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Error); + expect(result.current.transaction.error).toHaveProperty( + 'message', + error.message + ); + }); + + it('handles an unknown error', () => { + const unknownThrow = { foo: 'bar' }; + const mockSendTx = jest.fn(() => { + throw unknownThrow; + }); + const { result } = setup({ sendTx: mockSendTx }); + act(() => { + result.current.send(mockPubKey, {} as Transaction); + }); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Error); + expect(result.current.transaction.error).toHaveProperty( + 'title', + 'Something went wrong' + ); + expect(result.current.transaction.error).toHaveProperty('code', 105); + expect(result.current.transaction.error).toHaveProperty( + 'message', + 'Unknown error occurred' + ); + expect(result.current.transaction.error).toHaveProperty( + 'data', + 'Unknown error occurred' + ); + }); + + it('sets txHash and signature to state if successful', async () => { + const successObj = { + transactionHash: '0x123', + signature: 'signature', + }; + const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(successObj)); + const { result } = setup({ sendTx: mockSendTx }); + await act(async () => { + result.current.send(mockPubKey, {} as Transaction); + }); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Pending); + expect(result.current.transaction.txHash).toEqual( + successObj.transactionHash + ); + expect(result.current.transaction.signature).toEqual(successObj.signature); + }); + + it('setComplete sets VegaTxStatus', () => { + const { result } = setup(); + act(() => { + result.current.setComplete(); + }); + expect(result.current.transaction.status).toBe(VegaTxStatus.Complete); + }); + + it('reset resets transaction status', async () => { + const mockSendTx = jest.fn().mockReturnValue( + Promise.resolve({ + transactionHash: '0x123', + signature: 'signature', + }) + ); + + const { result } = setup({ sendTx: mockSendTx }); + + await act(async () => { + result.current.send(mockPubKey, {} as Transaction); + }); + expect(result.current.transaction.status).toBe(VegaTxStatus.Pending); + + act(() => { + result.current.reset(); + }); + + expect(result.current.transaction).toEqual(initialState); + }); +}); diff --git a/libs/wallet/src/use-vega-transaction.tsx b/libs/wallet/src/use-vega-transaction.tsx new file mode 100644 index 000000000..63deb226f --- /dev/null +++ b/libs/wallet/src/use-vega-transaction.tsx @@ -0,0 +1,131 @@ +import type { ReactNode } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { + WalletClientError, + WalletHttpError, +} from '@vegaprotocol/wallet-client'; +import { useVegaWallet } from './use-vega-wallet'; +import type { VegaTransactionContentMap } from './vega-transaction-dialog'; +import { VegaTransactionDialog } from './vega-transaction-dialog'; +import type { Intent } from '@vegaprotocol/ui-toolkit'; +import type { Transaction } from './connectors'; +import { WalletError } from './connectors'; +import { ClientErrors } from './connectors'; +import type { VegaTxState } from './types'; +import { VegaTxStatus } from './types'; + +export interface DialogProps { + intent?: Intent; + title?: string; + icon?: ReactNode; + content?: VegaTransactionContentMap; +} + +export const initialState = { + status: VegaTxStatus.Default, + error: null, + txHash: null, + signature: null, + dialogOpen: false, +}; + +export const orderErrorResolve = (err: Error | unknown): Error => { + if (err instanceof WalletClientError || err instanceof WalletError) { + return err; + } else if (err instanceof WalletHttpError) { + return ClientErrors.UNKNOWN; + } else if (err instanceof TypeError) { + return ClientErrors.NO_SERVICE; + } else if (err instanceof Error) { + return err; + } + return ClientErrors.UNKNOWN; +}; + +export const useVegaTransaction = () => { + const { sendTx, disconnect } = useVegaWallet(); + const [transaction, _setTransaction] = useState(initialState); + + const setTransaction = useCallback((update: Partial) => { + _setTransaction((curr) => ({ + ...curr, + ...update, + })); + }, []); + + const reset = useCallback(() => { + setTransaction(initialState); + }, [setTransaction]); + + const setComplete = useCallback(() => { + setTransaction({ status: VegaTxStatus.Complete }); + }, [setTransaction]); + + const send = useCallback( + async (pubKey: string, tx: Transaction) => { + try { + setTransaction({ + error: null, + txHash: null, + signature: null, + status: VegaTxStatus.Requested, + dialogOpen: true, + }); + + const res = await sendTx(pubKey, tx); + + if (res === null) { + // User rejected + reset(); + return null; + } + + if (res.signature && res.transactionHash) { + setTransaction({ + status: VegaTxStatus.Pending, + txHash: res.transactionHash, + signature: res.signature, + }); + + return res; + } + + return null; + } catch (err) { + const error = orderErrorResolve(err); + if ((error as WalletError).code === ClientErrors.NO_SERVICE.code) { + disconnect(); + } + setTransaction({ + error, + status: VegaTxStatus.Error, + }); + return null; + } + }, + [sendTx, setTransaction, reset, disconnect] + ); + + const Dialog = useMemo(() => { + return (props: DialogProps) => ( + { + if (!isOpen) reset(); + setTransaction({ dialogOpen: isOpen }); + }} + transaction={transaction} + /> + ); + }, [transaction, setTransaction, reset]); + + return { + send, + transaction, + reset, + setComplete, + setTransaction, + Dialog, + }; +}; diff --git a/libs/wallet/src/vega-transaction-dialog/index.ts b/libs/wallet/src/vega-transaction-dialog/index.ts new file mode 100644 index 000000000..96474a0d3 --- /dev/null +++ b/libs/wallet/src/vega-transaction-dialog/index.ts @@ -0,0 +1 @@ +export * from './vega-transaction-dialog'; diff --git a/libs/wallet/src/vega-transaction-dialog/vega-transaction-dialog.spec.tsx b/libs/wallet/src/vega-transaction-dialog/vega-transaction-dialog.spec.tsx new file mode 100644 index 000000000..8cfecfb79 --- /dev/null +++ b/libs/wallet/src/vega-transaction-dialog/vega-transaction-dialog.spec.tsx @@ -0,0 +1,133 @@ +import { render, screen } from '@testing-library/react'; +import { WalletError } from '../connectors'; +import { VegaTxStatus } from '../use-vega-transaction'; +import type { VegaTransactionDialogProps } from './vega-transaction-dialog'; +import { VegaTransactionDialog } from './vega-transaction-dialog'; + +jest.mock('@vegaprotocol/environment', () => ({ + useEnvironment: () => ({ + VEGA_EXPLORER_URL: 'https://test.explorer.vega.network', + VEGA_ENV: 'TESTNET', + }), + Networks: { + MAINNET: 'MAINNET', + TESTNET: 'TESTNET', + }, +})); + +describe('VegaTransactionDialog', () => { + let props: VegaTransactionDialogProps; + + beforeEach(() => { + props = { + isOpen: true, + onChange: () => false, + transaction: { + dialogOpen: true, + status: VegaTxStatus.Requested, + error: null, + txHash: null, + signature: null, + }, + }; + }); + + it('requested', () => { + render(); + expect(screen.getByTestId('dialog-title')).toHaveTextContent(/confirm/i); + expect(screen.getByTestId(VegaTxStatus.Requested)).toHaveTextContent( + /please open your wallet/i + ); + expect(screen.getByTestId('testnet-transaction-info')).toHaveTextContent( + /^\[This is TESTNET transaction only\]$/ + ); + }); + + it('pending', () => { + render( + + ); + expect(screen.getByTestId('dialog-title')).toHaveTextContent(/awaiting/i); + expect(screen.getByTestId(VegaTxStatus.Pending)).toHaveTextContent( + /please wait/i + ); + testBlockExplorerLink('tx-hash'); + }); + + it('error', () => { + render( + + ); + expect(screen.getByTestId('dialog-title')).toHaveTextContent(/failed/i); + expect(screen.getByTestId(VegaTxStatus.Error)).toHaveTextContent( + /rejected/i + ); + }); + + it('default complete', () => { + render( + + ); + expect(screen.getByTestId('dialog-title')).toHaveTextContent(/complete/i); + expect(screen.getByTestId(VegaTxStatus.Complete)).toHaveTextContent( + /confirmed/i + ); + testBlockExplorerLink('tx-hash'); + }); + + it.each(Object.keys(VegaTxStatus))( + 'renders custom content for %s', + (status) => { + const title = `${status} title`; + const text = `${status} content`; + const content = { + [status]:
{text}
, + }; + render( + + ); + expect(screen.getByTestId('dialog-title')).toHaveTextContent(title); + expect(screen.getByText(text)).toBeInTheDocument(); + } + ); + + function testBlockExplorerLink(txHash: string) { + expect(screen.getByTestId('tx-block-explorer')).toHaveTextContent( + 'View in block explorer' + ); + expect(screen.getByTestId('tx-block-explorer')).toHaveAttribute( + 'href', + `https://test.explorer.vega.network/txs/0x${txHash}` + ); + } +}); diff --git a/libs/wallet/src/vega-transaction-dialog/vega-transaction-dialog.tsx b/libs/wallet/src/vega-transaction-dialog/vega-transaction-dialog.tsx new file mode 100644 index 000000000..a53307598 --- /dev/null +++ b/libs/wallet/src/vega-transaction-dialog/vega-transaction-dialog.tsx @@ -0,0 +1,211 @@ +import type { ReactNode } from 'react'; +import { t } from '@vegaprotocol/i18n'; +import { Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit'; +import { WalletClientError } from '@vegaprotocol/wallet-client'; +import type { VegaTxState } from '../types'; +import { VegaTxStatus } from '../types'; +import { useVegaWallet } from '../use-vega-wallet'; + +export type VegaTransactionContentMap = { + [C in VegaTxStatus]?: JSX.Element; +}; +export interface VegaTransactionDialogProps { + isOpen: boolean; + transaction: VegaTxState; + onChange?: (isOpen: boolean) => void; + intent?: Intent; + title?: string; + icon?: ReactNode; + content?: VegaTransactionContentMap; +} + +export const VegaTransactionDialog = ({ + isOpen, + transaction, + onChange, + intent, + title, + icon, + content, +}: VegaTransactionDialogProps) => { + const computedIntent = intent ? intent : getIntent(transaction); + const computedTitle = title ? title : getTitle(transaction); + const computedIcon = icon ? icon : getIcon(transaction); + + return ( + + + + ); +}; +interface ContentProps { + transaction: VegaTxState; + content?: VegaTransactionContentMap; +} + +const Content = ({ transaction, content }: ContentProps) => { + if (!content) { + return ; + } + + if (transaction.status === VegaTxStatus.Default && content.Default) { + return content.Default; + } + + if (transaction.status === VegaTxStatus.Requested && content.Requested) { + return content.Requested; + } + + if (transaction.status === VegaTxStatus.Pending && content.Pending) { + return content.Pending; + } + + if (transaction.status === VegaTxStatus.Error && content.Error) { + return content.Error; + } + + if (transaction.status === VegaTxStatus.Complete && content.Complete) { + return content.Complete; + } + + return ; +}; + +interface VegaDialogProps { + transaction: VegaTxState; +} + +/** + * Default dialog content + */ +export const VegaDialog = ({ transaction }: VegaDialogProps) => { + const { links, network } = useVegaWallet(); + + let content = null; + if (transaction.status === VegaTxStatus.Requested) { + content = ( + <> +

+ {t( + 'Please open your wallet application and confirm or reject the transaction' + )} +

+ {network !== 'MAINNET' && ( +

+ {t('[This is %s transaction only]').replace('%s', network)} +

+ )} + + ); + } + + if (transaction.status === VegaTxStatus.Error) { + let messageText = ''; + if (transaction.error instanceof WalletClientError) { + messageText = `${transaction.error.title}: ${transaction.error.message}`; + } else if (transaction.error instanceof Error) { + messageText = transaction.error.message; + } + content =
{messageText}
; + } + + if (transaction.status === VegaTxStatus.Pending) { + content = ( +
+

+ {t('Please wait for your transaction to be confirmed')} -   + {transaction.txHash && ( + + {t('View in block explorer')} + + )} +

+
+ ); + } + + if (transaction.status === VegaTxStatus.Complete) { + content = ( +
+

+ {t('Your transaction has been confirmed')} -   + {transaction.txHash && ( + + {t('View in block explorer')} + + )} +

+
+ ); + } + + return
{content}
; +}; + +const getIntent = (transaction: VegaTxState) => { + switch (transaction.status) { + case VegaTxStatus.Requested: + return Intent.Warning; + case VegaTxStatus.Pending: + return Intent.None; + case VegaTxStatus.Error: + return Intent.Danger; + case VegaTxStatus.Complete: + return Intent.Success; + default: + return Intent.None; + } +}; + +const getTitle = (transaction: VegaTxState) => { + switch (transaction.status) { + case VegaTxStatus.Requested: + return t('Confirm transaction in wallet'); + case VegaTxStatus.Pending: + return t('Awaiting network confirmation'); + case VegaTxStatus.Error: + return t('Transaction failed'); + case VegaTxStatus.Complete: + return t('Transaction complete'); + default: + return ''; + } +}; + +const getIcon = (transaction: VegaTxState) => { + switch (transaction.status) { + case VegaTxStatus.Requested: + return ; + case VegaTxStatus.Pending: + return ( + + + + ); + case VegaTxStatus.Error: + return ; + case VegaTxStatus.Complete: + return ; + default: + return ''; + } +};