From 6a55319e04ff1ebf4d79ff8d50257ebb8231744c Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Wed, 12 Jul 2023 11:34:42 +0100 Subject: [PATCH] feat(trading,governance,wallet): browser wallet integration (#4121) --- .../src/integration/view/wallet-vega.cy.ts | 4 +- .../connect-to-vega/connect-to-vega.tsx | 9 - .../vega-wallet-container.tsx | 10 - .../vega-wallet-dialogs.tsx | 6 - .../components/vega-wallet/vega-wallet.tsx | 5 - .../contexts/app-state/app-state-context.ts | 13 - .../contexts/app-state/app-state-provider.tsx | 14 - apps/governance/src/lib/vega-connectors.ts | 3 + .../components/vote-details/vote-buttons.tsx | 11 +- .../hooks/use-vote-information.spec.ts | 1 - .../routes/rewards/connect-to-see-rewards.tsx | 9 - .../src/integration/wallet-vega.cy.ts | 6 +- .../vega-wallet-connect-button.tsx | 3 + apps/trading/lib/vega-connectors.ts | 3 + .../src/components/dialog/dialog.tsx | 4 +- .../dropdown-menu/dropdown-menu.tsx | 4 +- .../svg-icons/icon-chevron-left.tsx | 7 + .../icon/vega-icons/vega-icon-record.ts | 3 + .../connect-dialog-elements.tsx | 35 +- .../connect-dialog/connect-dialog.spec.tsx | 149 +++++++- .../src/connect-dialog/connect-dialog.tsx | 337 ++++++++---------- .../injected-connector-form.tsx | 153 ++++++++ .../src/connectors/injected-connector.ts | 82 ++++- libs/wallet/src/connectors/vega-connector.ts | 2 +- libs/wallet/src/provider.tsx | 1 - libs/wallet/src/storage.ts | 2 +- libs/wallet/src/test-helpers.ts | 39 ++ .../src/use-injected-connector.spec.tsx | 104 ++++++ libs/wallet/src/use-injected-connector.ts | 61 ++++ libs/wallet/src/use-json-rpc-connect.ts | 1 - 30 files changed, 773 insertions(+), 308 deletions(-) create mode 100644 libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-chevron-left.tsx create mode 100644 libs/wallet/src/connect-dialog/injected-connector-form.tsx create mode 100644 libs/wallet/src/test-helpers.ts create mode 100644 libs/wallet/src/use-injected-connector.spec.tsx create mode 100644 libs/wallet/src/use-injected-connector.ts diff --git a/apps/governance-e2e/src/integration/view/wallet-vega.cy.ts b/apps/governance-e2e/src/integration/view/wallet-vega.cy.ts index cc513e637..af6f3f9b0 100644 --- a/apps/governance-e2e/src/integration/view/wallet-vega.cy.ts +++ b/apps/governance-e2e/src/integration/view/wallet-vega.cy.ts @@ -78,7 +78,7 @@ context( cy.getByTestId('connector-jsonRpc') .should('be.visible') .and('have.text', 'Connect Vega wallet'); - cy.getByTestId('connector-hosted') + cy.getByTestId('connector-rest') .should('be.visible') .and('have.text', 'Hosted Fairground wallet'); }); @@ -94,7 +94,7 @@ context( describe('when rest connector form opened', function () { before('click hosted wallet app button', function () { cy.getByTestId(connectorsList).within(() => { - cy.getByTestId('connector-hosted').click(); + cy.getByTestId('connector-rest').click(); }); }); diff --git a/apps/governance/src/components/connect-to-vega/connect-to-vega.tsx b/apps/governance/src/components/connect-to-vega/connect-to-vega.tsx index c348f3371..9871eec34 100644 --- a/apps/governance/src/components/connect-to-vega/connect-to-vega.tsx +++ b/apps/governance/src/components/connect-to-vega/connect-to-vega.tsx @@ -2,13 +2,8 @@ import { Button } from '@vegaprotocol/ui-toolkit'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useVegaWalletDialogStore } from '@vegaprotocol/wallet'; -import { - AppStateActionType, - useAppState, -} from '../../contexts/app-state/app-state-context'; export const ConnectToVega = () => { - const { appDispatch } = useAppState(); const { t } = useTranslation(); const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({ openVegaWalletDialog: store.openVegaWalletDialog, @@ -16,10 +11,6 @@ export const ConnectToVega = () => { return ( - {t('Connect')} -
- -
- - {type === 'hosted' ? ( - -

- {t('For demo purposes get a ')} - - {t('hosted wallet')} - - {t(', or for the real experience create a wallet in the ')} - - {t('Vega wallet app')} - -

-
- ) : ( - - )} + + {t('Connect')} +
+ +
); } if (connector instanceof JsonRpcConnector) { return ( - - - + ); } if (connector instanceof ViewConnector) { return ( - <> - - - - - + ); } @@ -366,12 +333,12 @@ const ConnectionOption = ({ onClick={onClick} size="lg" fill={true} - variant={['hosted', 'view'].includes(type) ? 'default' : 'primary'} + variant={['rest', 'view'].includes(type) ? 'default' : 'primary'} data-testid={`connector-${type}`} > - + {text} - + ); @@ -387,9 +354,7 @@ const CustomUrlInput = ({ const [urlInputExpanded, setUrlInputExpanded] = useState(false); return urlInputExpanded ? ( <> -

- {t('Custom wallet location')} -

+

{t('Custom wallet location')}

-

- {t('Choose wallet app to connect')} -

+

{t('Choose wallet app to connect')}

) : ( -

+

{t( 'Choose wallet app to connect, or to change port or server URL enter a ' )} diff --git a/libs/wallet/src/connect-dialog/injected-connector-form.tsx b/libs/wallet/src/connect-dialog/injected-connector-form.tsx new file mode 100644 index 000000000..f1c51203d --- /dev/null +++ b/libs/wallet/src/connect-dialog/injected-connector-form.tsx @@ -0,0 +1,153 @@ +import { t } from '@vegaprotocol/i18n'; +import { Status } from '../use-injected-connector'; +import { ConnectDialogTitle } from './connect-dialog-elements'; +import type { ReactNode } from 'react'; +import { + Button, + ButtonLink, + Diamond, + Loader, + Tick, +} from '@vegaprotocol/ui-toolkit'; +import { setAcknowledged } from '../storage'; +import { useVegaWallet } from '../use-vega-wallet'; + +export const InjectedConnectorForm = ({ + status, + onConnect, + riskMessage, + appChainId, + reset, + error, +}: { + // connector: JsonRpcConnector; + appChainId: string; + status: Status; + error: Error | null; + onConnect: () => void; + reset: () => void; + riskMessage?: React.ReactNode; +}) => { + const { disconnect } = useVegaWallet(); + + if (status === Status.Idle) { + return null; + } + + if (status === Status.Error) { + return ; + } + + if (status === Status.GettingChainId) { + return ( + <> + {t('Verifying chain')} +

+ +
+ + ); + } + + if (status === Status.Connected) { + return ( + <> + {t('Successfully connected')} +
+ +
+ + ); + } + + if (status === Status.Connecting) { + return ( + <> + {t('Connecting...')} +
+ +
+

+ {t( + "Approve the connection from your Vega wallet app. If you have multiple wallets you'll need to choose which to connect with." + )} +

+ + ); + } + + if (status === Status.AcknowledgeNeeded) { + const setConnection = () => { + setAcknowledged(); + onConnect(); + }; + const handleDisagree = () => { + disconnect(); + onConnect(); // this is dialog closing + }; + return ( + <> + {t('Understand the risk')} + {riskMessage} +
+
+ +
+
+ +
+
+ + ); + } + return null; +}; + +const Center = ({ children }: { children: ReactNode }) => { + return ( +
{children}
+ ); +}; + +const Error = ({ + error, + appChainId, + onTryAgain, +}: { + error: Error | null; + appChainId: string; + onTryAgain: () => void; +}) => { + let title = t('Something went wrong'); + let text: ReactNode | undefined = t('An unknown error occurred'); + const tryAgain: ReactNode | null = ( +

+ {t('Try again')} +

+ ); + + if (error) { + if (error.message === 'Invalid chain') { + title = t('Wrong network'); + text = t( + 'To complete your wallet connection, set your wallet network in your app to "%s".', + appChainId + ); + } else if (error.message === 'window.vega not found') { + title = t('No wallet detected'); + text = t('Vega browser extension not installed'); + } + } + + return ( + <> + {title} +

{text}

+ {tryAgain} + + ); +}; diff --git a/libs/wallet/src/connectors/injected-connector.ts b/libs/wallet/src/connectors/injected-connector.ts index 1f9a9398c..f0d8754f0 100644 --- a/libs/wallet/src/connectors/injected-connector.ts +++ b/libs/wallet/src/connectors/injected-connector.ts @@ -1,21 +1,83 @@ -import type { VegaConnector } from './vega-connector'; +import { clearConfig, setConfig } from '../storage'; +import type { Transaction, VegaConnector } from './vega-connector'; + +declare global { + interface Vega { + getChainId: () => Promise<{ chainID: string }>; + connectWallet: () => Promise; + disconnectWallet: () => Promise; + listKeys: () => Promise<{ + keys: Array<{ name: string; publicKey: string }>; + }>; + sendTransaction: (params: { + publicKey: string; + transaction: Transaction; + sendingMode: 'TYPE_SYNC'; + }) => Promise<{ + receivedAt: string; + sentAt: string; + transaction: { + from: { + pubKey: string; + }; + inputData: string; + pow: { + tid: string; + nonce: string; + }; + signature: { + algo: string; + value: string; + version: number; + }; + version: number; + }; + transactionHash: string; + }>; + } + + interface Window { + vega: Vega; + } +} -/** - * Dummy injected connector that we may use when browser wallet is implemented - */ export class InjectedConnector implements VegaConnector { description = 'Connects using the Vega wallet browser extension'; + async getChainId() { + return window.vega.getChainId(); + } + + connectWallet() { + return window.vega.connectWallet(); + } + async connect() { - return [{ publicKey: '0x123', name: 'text key' }]; + const res = await window.vega.listKeys(); + setConfig({ + connector: 'injected', + token: null, // no token required for injected + url: null, // no url for injected + }); + return res.keys; } - async disconnect() { - return; + disconnect() { + clearConfig(); + return window.vega.disconnectWallet(); } - // @ts-ignore injected connector is not implemented - sendTx() { - throw new Error('Not implemented'); + async sendTx(pubKey: string, transaction: Transaction) { + const result = await window.vega.sendTransaction({ + publicKey: pubKey, + transaction, + sendingMode: 'TYPE_SYNC' as const, + }); + return { + transactionHash: result.transactionHash, + receivedAt: result.receivedAt, + sentAt: result.sentAt, + signature: result.transaction.signature.value, + }; } } diff --git a/libs/wallet/src/connectors/vega-connector.ts b/libs/wallet/src/connectors/vega-connector.ts index 27327a916..87e34babb 100644 --- a/libs/wallet/src/connectors/vega-connector.ts +++ b/libs/wallet/src/connectors/vega-connector.ts @@ -411,7 +411,7 @@ export interface PubKey { } export interface VegaConnector { - url: string | null; + url?: string | null; /** Connect to wallet and return keys */ connect(): Promise; diff --git a/libs/wallet/src/provider.tsx b/libs/wallet/src/provider.tsx index 2ab6f7da2..018cbe8b4 100644 --- a/libs/wallet/src/provider.tsx +++ b/libs/wallet/src/provider.tsx @@ -106,7 +106,6 @@ export const VegaWalletProvider = ({ children }: VegaWalletProviderProps) => { if (!connector.current) { throw new Error('No connector'); } - return connector.current.sendTx(pubkey, transaction); }, []); diff --git a/libs/wallet/src/storage.ts b/libs/wallet/src/storage.ts index 620849c40..e8c3eedbb 100644 --- a/libs/wallet/src/storage.ts +++ b/libs/wallet/src/storage.ts @@ -2,7 +2,7 @@ import { LocalStorage } from '@vegaprotocol/utils'; interface ConnectorConfig { token: string | null; - connector: 'rest' | 'jsonRpc' | 'view'; + connector: 'injected' | 'rest' | 'jsonRpc' | 'view'; url: string | null; } diff --git a/libs/wallet/src/test-helpers.ts b/libs/wallet/src/test-helpers.ts new file mode 100644 index 000000000..df65070dc --- /dev/null +++ b/libs/wallet/src/test-helpers.ts @@ -0,0 +1,39 @@ +export function mockBrowserWallet(overrides?: Partial) { + const vega: Vega = { + getChainId: jest.fn().mockReturnValue(Promise.resolve({ chainID: '1' })), + connectWallet: jest.fn().mockReturnValue(Promise.resolve(null)), + disconnectWallet: jest.fn().mockReturnValue(Promise.resolve()), + listKeys: jest + .fn() + .mockReturnValue({ keys: [{ name: 'test key', publicKey: '0x123' }] }), + sendTransaction: jest.fn().mockReturnValue({ + code: 1, + data: '', + height: '1', + log: '', + success: true, + txHash: '0x123', + }), + ...overrides, + }; + // @ts-ignore globalThis has no index signature + globalThis.vega = vega; + return vega; +} + +export function clearBrowserWallet() { + // @ts-ignore no index signature on globalThis + delete globalThis['vega']; +} + +export function delayedResolve(result: T, delay = 0): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(result), delay); + }); +} + +export function delayedReject(result: T, delay = 0): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject(result), delay); + }); +} diff --git a/libs/wallet/src/use-injected-connector.spec.tsx b/libs/wallet/src/use-injected-connector.spec.tsx new file mode 100644 index 000000000..bb3d2dbb0 --- /dev/null +++ b/libs/wallet/src/use-injected-connector.spec.tsx @@ -0,0 +1,104 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Status, useInjectedConnector } from './use-injected-connector'; +import type { ReactNode } from 'react'; +import { VegaWalletProvider } from './provider'; +import { InjectedConnector } from './connectors'; +import { mockBrowserWallet } from './test-helpers'; +import { useEnvironment } from '@vegaprotocol/environment'; +import { Networks } from '@vegaprotocol/environment'; + +jest.mock('@vegaprotocol/environment'); + +const setup = (callback = jest.fn()) => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useInjectedConnector(callback), { wrapper }); +}; + +const injected = new InjectedConnector(); + +describe('useInjectedConnector', () => { + beforeEach(() => { + // @ts-ignore useEnvironment has been mocked + useEnvironment.mockImplementation(() => ({ VEGA_ENV: Networks.TESTNET })); + }); + it('attempts connection', async () => { + const { result } = setup(); + expect(typeof result.current.connect).toBe('function'); + expect(result.current.status).toBe(Status.Idle); + expect(result.current.error).toBe(null); + }); + + it('errors if vega not injected', async () => { + const { result } = setup(); + await act(async () => { + result.current.connect(injected, '1'); + }); + expect(result.current.error?.message).toBe('window.vega not found'); + expect(result.current.status).toBe(Status.Error); + }); + + it('errors if chain ids dont match', async () => { + mockBrowserWallet(); + const { result } = setup(); + await act(async () => { + result.current.connect(injected, '2'); // default mock chainId is '1' + }); + expect(result.current.error?.message).toBe('Invalid chain'); + expect(result.current.status).toBe(Status.Error); + }); + + it('errors if connection throws', async () => { + const callback = jest.fn(); + mockBrowserWallet({ + getChainId: () => Promise.reject('failed'), + }); + const { result } = setup(callback); + + await act(async () => { + result.current.connect(injected, '1'); // default mock chainId is '1' + }); + expect(result.current.status).toBe(Status.Error); + expect(result.current.error?.message).toBe('injected connection failed'); + }); + + it('connects', async () => { + const callback = jest.fn(); + const vega = mockBrowserWallet(); + const { result } = setup(callback); + + act(() => { + result.current.connect(injected, '1'); // default mock chainId is '1' + }); + expect(result.current.status).toBe(Status.GettingChainId); + + await waitFor(() => { + expect(vega.connectWallet).toHaveBeenCalled(); + expect(vega.listKeys).toHaveBeenCalled(); + }); + + expect(result.current.status).toBe(Status.Connected); + expect(callback).toHaveBeenCalled(); + }); + + it('connects when aknowledgement required', async () => { + const callback = jest.fn(); + // @ts-ignore useEnvironment has been mocked + useEnvironment.mockImplementation(() => ({ VEGA_ENV: Networks.MAINNET })); + + const vega = mockBrowserWallet(); + const { result } = setup(callback); + + act(() => { + result.current.connect(injected, '1'); // default mock chainId is '1' + }); + + await waitFor(() => { + expect(vega.listKeys).toHaveBeenCalled(); + }); + + expect(result.current.status).toBe(Status.AcknowledgeNeeded); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/wallet/src/use-injected-connector.ts b/libs/wallet/src/use-injected-connector.ts new file mode 100644 index 000000000..1c0d2d58f --- /dev/null +++ b/libs/wallet/src/use-injected-connector.ts @@ -0,0 +1,61 @@ +import { useCallback, useState } from 'react'; +import type { InjectedConnector } from './connectors'; +import { useVegaWallet } from './use-vega-wallet'; + +export enum Status { + Idle = 'Idle', + GettingChainId = 'GettingChainId', + Connecting = 'Connecting', + Connected = 'Connected', + Error = 'Error', + AcknowledgeNeeded = 'AcknowledgeNeeded', +} + +export const useInjectedConnector = (onConnect: () => void) => { + const { connect, acknowledgeNeeded } = useVegaWallet(); + const [status, setStatus] = useState(Status.Idle); + const [error, setError] = useState(null); + + const attemptConnect = useCallback( + async (connector: InjectedConnector, appChainId: string) => { + try { + if (!('vega' in window)) { + throw new Error('window.vega not found'); + } + + setStatus(Status.GettingChainId); + + const { chainID } = await connector.getChainId(); + + if (chainID !== appChainId) { + throw new Error('Invalid chain'); + } + + setStatus(Status.Connecting); + await connector.connectWallet(); // authorize wallet + await connect(connector); // connect with keys + + if (acknowledgeNeeded) { + setStatus(Status.AcknowledgeNeeded); + } else { + setStatus(Status.Connected); + onConnect(); + } + } catch (err) { + if (err instanceof Error) { + setError(err); + } else { + setError(new Error('injected connection failed')); + } + setStatus(Status.Error); + } + }, + [acknowledgeNeeded, connect, onConnect] + ); + + return { + status, + error, + connect: attemptConnect, + }; +}; diff --git a/libs/wallet/src/use-json-rpc-connect.ts b/libs/wallet/src/use-json-rpc-connect.ts index 811417093..88fc5906d 100644 --- a/libs/wallet/src/use-json-rpc-connect.ts +++ b/libs/wallet/src/use-json-rpc-connect.ts @@ -11,7 +11,6 @@ export enum Status { GettingChainId = 'GettingChainId', Connecting = 'Connecting', GettingPerms = 'GettingPerms', - ListingKeys = 'ListingKeys', Connected = 'Connected', Error = 'Error', AcknowledgeNeeded = 'AcknowledgeNeeded',