fix(wallet): wallet connection and error fixes (#5927)

Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
Co-authored-by: bwallacee <ben@vega.xyz>
This commit is contained in:
Matthew Russell 2024-03-07 14:01:46 +00:00 committed by GitHub
parent 93643f1737
commit 6504912284
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 269 additions and 168 deletions

View File

@ -26,9 +26,9 @@ export const AppLoader = ({ children }: { children: React.ReactElement }) => {
const { token, staking, vesting } = useContracts();
const setAssociatedBalances = useRefreshAssociatedBalances();
const [balancesLoaded, setBalancesLoaded] = React.useState(false);
const vegaConnecting = useEagerConnect();
const vegaWalletStatus = useEagerConnect();
const loaded = balancesLoaded && !vegaConnecting;
const loaded = balancesLoaded && vegaWalletStatus !== 'connecting';
React.useEffect(() => {
const run = async () => {
@ -169,3 +169,5 @@ export const AppLoader = ({ children }: { children: React.ReactElement }) => {
}
return <Suspense fallback={loading}>{children}</Suspense>;
};
AppLoader.displayName = 'AppLoader';

View File

@ -111,3 +111,4 @@ export const ContractsProvider = ({ children }: { children: JSX.Element }) => {
</ContractsContext.Provider>
);
};
ContractsProvider.displayName = 'ContractsProvider';

View File

@ -50,8 +50,6 @@ def truncate_middle(market_id, start=6, end=4):
def change_keys(page: Page, vega: VegaServiceNull, key_name):
page.get_by_test_id("manage-vega-wallet").click()
page.get_by_test_id("key-" + vega.wallet.public_key(key_name)).click()
page.click(
f'data-testid=key-{vega.wallet.public_key(key_name)} >> .inline-flex')
page.reload()

View File

@ -7,6 +7,7 @@
"Get MetaMask": "Get MetaMask",
"Get the Vega Wallet": "Get the Vega Wallet",
"I agree": "I agree",
"Once you have the added the extension, <0>refresh</0> you browser.": "Once you have the added the extension, <0>refresh</0> you browser.",
"Successfully connected": "Successfully connected",
"Transaction was not successful": "Transaction was not successful",
"Wallet rejected transaction": "Wallet rejected transaction"

View File

@ -17,8 +17,8 @@ export const InputError = ({
...props
}: InputErrorProps) => {
const effectiveClassName = classNames(
'text-sm flex items-center first-letter:uppercase',
'mt-2',
'text-sm block items-center first-letter:capitalize',
'mt-2 min-w-0 break-words',
{
'border-danger': intent === 'danger',
'border-warning': intent === 'warning',

View File

@ -1,4 +1,9 @@
import { type ReactNode, type FunctionComponent, forwardRef } from 'react';
import {
type ReactNode,
type FunctionComponent,
forwardRef,
useState,
} from 'react';
import {
ConnectorErrors,
isBrowserWalletInstalled,
@ -12,6 +17,7 @@ import { useConnect } from '../../hooks/use-connect';
import { Links } from '../../constants';
import { ConnectorIcon } from './connector-icon';
import { useUserAgent } from '@vegaprotocol/react-helpers';
import { Trans } from 'react-i18next';
const vegaExtensionsLinks = {
chrome: Links.chromeExtension,
@ -29,48 +35,67 @@ export const ConnectionOptions = ({
onConnect: (id: ConnectorType) => void;
}) => {
const t = useT();
const error = useWallet((store) => store.error);
const { connectors } = useConnect();
const error = useWallet((store) => store.error);
const [isInstalling, setIsInstalling] = useState(false);
return (
<div className="flex flex-col items-start gap-4">
<h2 className="text-xl">{t('Connect to Vega')}</h2>
<ul
className="grid grid-cols-1 sm:grid-cols-2 gap-1 -mx-2"
data-testid="connectors-list"
>
{connectors.map((c) => {
const ConnectionOption = ConnectionOptionRecord[c.id];
const props = {
id: c.id,
name: c.name,
description: c.description,
showDescription: false,
onClick: () => onConnect(c.id),
};
if (ConnectionOption) {
return (
<li key={c.id}>
<ConnectionOption {...props} />
</li>
);
}
return (
<li key={c.id}>
<ConnectionOptionDefault {...props} />
</li>
);
})}
</ul>
{error && error.code !== ConnectorErrors.userRejected.code && (
<p
className="text-danger text-sm first-letter:uppercase"
data-testid="connection-error"
>
{error.message}
{isInstalling ? (
<p className="text-warning">
<Trans
i18nKey="Once you have the added the extension, <0>refresh</0> you browser."
components={[
<button
onClick={() => window.location.reload()}
className="underline underline-offset-4"
/>,
]}
/>
</p>
) : (
<>
<ul
className="grid grid-cols-1 sm:grid-cols-2 gap-1 -mx-2"
data-testid="connectors-list"
>
{connectors.map((c) => {
const ConnectionOption = ConnectionOptionRecord[c.id];
const props = {
id: c.id,
name: c.name,
description: c.description,
showDescription: false,
onClick: () => onConnect(c.id),
onInstall: () => setIsInstalling(true),
};
if (ConnectionOption) {
return (
<li key={c.id}>
<ConnectionOption {...props} />
</li>
);
}
return (
<li key={c.id}>
<ConnectionOptionDefault {...props} />
</li>
);
})}
</ul>
{error && error.code !== ConnectorErrors.userRejected.code && (
<p
className="text-danger text-sm first-letter:uppercase"
data-testid="connection-error"
>
{error.message}
{error.data ? `: ${error.data}` : ''}
</p>
)}
</>
)}
<a
href={Links.walletOverview}
@ -90,6 +115,7 @@ interface ConnectionOptionProps {
description: string;
showDescription?: boolean;
onClick: () => void;
onInstall?: () => void;
}
const CONNECTION_OPTION_CLASSES =
@ -142,6 +168,7 @@ export const ConnectionOptionInjected = ({
description,
showDescription = false,
onClick,
onInstall,
}: ConnectionOptionProps) => {
const t = useT();
const userAgent = useUserAgent();
@ -158,7 +185,11 @@ export const ConnectionOptionInjected = ({
</span>
</ConnectionOptionButtonWithDescription>
) : (
<ConnectionOptionLinkWithDescription id={id} href={link}>
<ConnectionOptionLinkWithDescription
id={id}
href={link}
onClick={onInstall}
>
<span className="flex flex-col justify-start text-left">
<span className="capitalize leading-5">
{t('Get the Vega Wallet')}
@ -183,7 +214,7 @@ export const ConnectionOptionInjected = ({
{name}
</ConnectionOptionButton>
) : (
<ConnectionOptionLink id={id} href={link}>
<ConnectionOptionLink id={id} href={link} onClick={onInstall}>
{t('Get the Vega Wallet')}
</ConnectionOptionLink>
)}
@ -275,8 +306,9 @@ const ConnectionOptionLink = forwardRef<
children: ReactNode;
id: ConnectorType;
href: string;
onClick?: () => void;
}
>(({ children, id, href }, ref) => {
>(({ children, id, href, onClick }, ref) => {
return (
<a
href={href}
@ -285,6 +317,7 @@ const ConnectionOptionLink = forwardRef<
className={CONNECTION_OPTION_CLASSES}
data-testid={`connector-${id}`}
ref={ref}
onClick={onClick}
>
<ConnectorIcon id={id} />
{children}
@ -320,8 +353,10 @@ const ConnectionOptionLinkWithDescription = forwardRef<
children: ReactNode;
id: ConnectorType;
href: string;
onClick?: () => void;
}
>(({ children, id, href }, ref) => {
>(({ children, id, href, onClick }, ref) => {
return (
<a
ref={ref}
@ -329,6 +364,7 @@ const ConnectionOptionLinkWithDescription = forwardRef<
href={href}
target="_blank"
rel="noreferrer"
onClick={onClick}
>
<span>
<ConnectorIcon id={id} />

View File

@ -1,17 +1,20 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useWallet } from './use-wallet';
import { useConnect } from './use-connect';
export function useEagerConnect() {
const current = useWallet((store) => store.current);
const status = useWallet((store) => store.status);
const { connect } = useConnect();
const [connecting, setConnecting] = useState(true);
useEffect(() => {
const attemptConnect = async () => {
// No stored config, or config was malformed or no risk accepted
if (!current) {
setConnecting(false);
return;
}
if (status !== 'disconnected') {
return;
}
@ -19,15 +22,13 @@ export function useEagerConnect() {
await connect(current);
} catch {
console.warn(`Failed to connect with connector: ${current}`);
} finally {
setConnecting(false);
}
};
if (typeof window !== 'undefined') {
attemptConnect();
}
}, [connect, current, connecting]);
}, [status, connect, current]);
return connecting;
return status;
}

View File

@ -65,12 +65,12 @@ export const useSimpleTransaction = (opts?: Options) => {
if (err.code === ConnectorErrors.userRejected.code) {
setStatus('idle');
} else {
setError(err.message);
setError(`${err.message}${err.data ? `: ${err.data}` : ''}`);
setStatus('idle');
opts?.onError?.(err.message);
}
} else {
const msg = t('Wallet rejected transaction');
const msg = t('Something went wrong');
setError(msg);
setStatus('idle');
opts?.onError?.(msg);

View File

@ -7,6 +7,7 @@ import {
listKeysError,
noWalletError,
sendTransactionError,
userRejectedError,
} from '../errors';
import {
type TransactionParams,
@ -14,6 +15,19 @@ import {
type VegaWalletEvent,
} from '../types';
interface InjectedError {
message: string;
code: number;
data:
| {
message: string;
code: number;
}
| string;
}
const USER_REJECTED_CODE = -4;
export class InjectedConnector implements Connector {
readonly id = 'injected';
readonly name = 'Vega Wallet';
@ -85,15 +99,55 @@ export class InjectedConnector implements Connector {
sentAt: res.sentAt,
};
} catch (err) {
if (this.isInjectedError(err)) {
if (err.code === USER_REJECTED_CODE) {
throw userRejectedError();
}
if (typeof err.data === 'string') {
throw sendTransactionError(err.data);
} else {
throw sendTransactionError(err.data.message);
}
}
throw sendTransactionError();
}
}
on(event: VegaWalletEvent, callback: () => void) {
window.vega.on(event, callback);
// Check for on/off in case user is on older versions which don't support it
// We can remove this check once FF is at the latest version
if (
typeof window.vega !== 'undefined' &&
typeof window.vega.on === 'function'
) {
window.vega.on(event, callback);
}
}
off(event: VegaWalletEvent, callback: () => void) {
window.vega.off(event, callback);
// Check for on/off in case user is on older versions which don't support it
// We can remove this check once FF is at the latest version
if (
typeof window.vega !== 'undefined' &&
typeof window.vega.off === 'function'
) {
window.vega.off(event, callback);
}
}
private isInjectedError(obj: unknown): obj is InjectedError {
if (
obj !== undefined &&
obj !== null &&
typeof obj === 'object' &&
'code' in obj &&
'message' in obj &&
'data' in obj
) {
return true;
}
return false;
}
}

View File

@ -17,6 +17,8 @@ import {
type JsonRpcConnectorConfig = { url: string; token?: string };
const USER_REJECTED_CODE = 3001;
export class JsonRpcConnector implements Connector {
readonly id = 'jsonRpc';
readonly name = 'Command Line Wallet';
@ -27,7 +29,7 @@ export class JsonRpcConnector implements Connector {
requestId: number = 0;
store: StoreApi<Store> | undefined;
pollRef: NodeJS.Timer | undefined;
ee: EventEmitter;
ee: InstanceType<typeof EventEmitter>;
constructor(config: JsonRpcConnectorConfig) {
this.url = config.url;
@ -63,7 +65,7 @@ export class JsonRpcConnector implements Connector {
const token = response.headers.get('Authorization');
if (!response.ok) {
if ('error' in data && data.error.code === 3001) {
if ('error' in data && data.error.code === USER_REJECTED_CODE) {
throw userRejectedError();
}
throw connectError('response not ok');
@ -137,7 +139,7 @@ export class JsonRpcConnector implements Connector {
if (!response.ok) {
if ('error' in data) {
if (data.error.code === 3001) {
if (data.error.code === USER_REJECTED_CODE) {
throw userRejectedError();
}

View File

@ -1,4 +1,3 @@
import EventEmitter from 'eventemitter3';
import {
ConnectorError,
chainIdError,
@ -6,13 +5,13 @@ import {
listKeysError,
noWalletError,
sendTransactionError,
userRejectedError,
} from '../errors';
import { type Transaction } from '../transaction-types';
import {
JsonRpcMethod,
type Connector,
type TransactionParams,
type VegaWalletEvent,
} from '../types';
enum EthereumMethod {
@ -43,7 +42,6 @@ declare global {
type WindowEthereumProvider = {
isMetaMask: boolean;
request<T = unknown>(args: RequestArguments): Promise<T>;
selectedAddress: string | null;
};
interface Window {
@ -52,6 +50,16 @@ declare global {
}
}
interface SnapRPCError {
code: number;
message: string;
data?: {
originalError: { code: number };
};
}
const USER_REJECTED_CODE = -4;
export class SnapConnector implements Connector {
readonly id = 'snap';
readonly name = 'MetaMask Snap';
@ -61,8 +69,6 @@ export class SnapConnector implements Connector {
node: string;
version: string;
snapId: string;
pollRef: NodeJS.Timer | undefined;
ee: EventEmitter;
// Note: apps may not know which node is selected on start up so its up
// to the app to make sure class intances are renewed if the node changes
@ -70,14 +76,21 @@ export class SnapConnector implements Connector {
this.node = config.node;
this.version = config.version;
this.snapId = config.snapId;
this.ee = new EventEmitter();
}
bindStore() {}
async connectWallet(desiredChainId: string) {
try {
await this.requestSnap();
const res = await this.requestSnap();
if (res[this.snapId].blocked) {
throw connectError('snap is blocked');
}
if (!res[this.snapId].enabled) {
throw connectError('snap is not enabled');
}
const { chainId } = await this.getChainId();
@ -87,7 +100,6 @@ export class SnapConnector implements Connector {
);
}
this.startPoll();
return { success: true };
} catch (err) {
if (err instanceof ConnectorError) {
@ -98,57 +110,66 @@ export class SnapConnector implements Connector {
}
}
async disconnectWallet() {
this.stopPoll();
}
async disconnectWallet() {}
// deprecated, pass chain on connect
async getChainId() {
try {
const res = await this.invokeSnap<{ chainID: string }>(
const data = await this.invokeSnap<{ chainID: string }>(
JsonRpcMethod.GetChainId,
{
networkEndpoints: [this.node],
}
);
return { chainId: res.chainID };
if ('error' in data) {
throw chainIdError(data.error.message);
}
return { chainId: data.chainID };
} catch (err) {
this.stopPoll();
if (err instanceof ConnectorError) {
throw err;
}
throw chainIdError();
}
}
async listKeys() {
try {
const res = await this.invokeSnap<{
const data = await this.invokeSnap<{
keys: Array<{ publicKey: string; name: string }>;
}>(JsonRpcMethod.ListKeys);
return res.keys;
if ('error' in data) {
throw listKeysError(data.error.message);
}
return data.keys;
} catch (err) {
this.stopPoll();
if (err instanceof ConnectorError) {
throw err;
}
throw listKeysError();
}
}
async isConnected() {
try {
// Check if metamask is unlocked
if (!window.ethereum.selectedAddress) {
throw noWalletError();
}
// If this throws its likely the snap is disabled or has been uninstalled
await this.listKeys();
return { connected: true };
} catch (err) {
this.stopPoll();
return { connected: false };
}
}
async sendTransaction(params: TransactionParams) {
try {
const res = await this.invokeSnap<{
// If the transaction is invalid this will throw with SnapRPCError
// but if its rejected it will resolve with 'error' in data
const data = await this.invokeSnap<{
transactionHash: string;
transaction: { signature: { value: string } };
receivedAt: string;
@ -160,115 +181,99 @@ export class SnapConnector implements Connector {
networkEndpoints: [this.node],
});
if ('error' in data) {
if (data.error.code === USER_REJECTED_CODE) {
throw userRejectedError();
}
throw sendTransactionError(`${data.error.message}: ${data.error.data}`);
}
return {
transactionHash: res.transactionHash,
signature: res.transaction.signature.value,
receivedAt: res.receivedAt,
sentAt: res.sentAt,
transactionHash: data.transactionHash,
signature: data.transaction.signature.value,
receivedAt: data.receivedAt,
sentAt: data.sentAt,
};
} catch (err) {
if (err instanceof ConnectorError) {
throw err;
}
if (this.isSnapRPCError(err)) {
throw sendTransactionError(err.message);
}
throw sendTransactionError();
}
}
on(event: VegaWalletEvent, callback: () => void) {
this.ee.on(event, callback);
}
on() {}
off() {}
off(event: VegaWalletEvent, callback?: () => void) {
this.ee.off(event, callback);
}
////////////////////////////////////
// Snap methods
////////////////////////////////////
private startPoll() {
// This only event we need to poll for right now is client.disconnect,
// if more events get added we will need more logic here
this.pollRef = setInterval(async () => {
const result = await this.isConnected();
if (result.connected) return;
this.ee.emit('client.disconnected');
}, 2000);
}
private stopPoll() {
if (this.pollRef) {
clearInterval(this.pollRef);
}
}
/**
* Requests permission for a website to communicate with the specified snaps
* and attempts to install them if they're not already installed.
* If the installation of any snap fails, returns the error that caused the failure.
* More informations here: https://docs.metamask.io/snaps/reference/rpc-api/#wallet_requestsnaps
*/
private async requestSnap() {
await this.request(EthereumMethod.RequestSnaps, {
[this.snapId]: {
version: this.version,
private async requestSnap(): Promise<{
[snapId: string]: {
blocked: boolean;
enabled: boolean;
id: string;
version: string;
};
}> {
return window.ethereum.request({
method: EthereumMethod.RequestSnaps,
params: {
[this.snapId]: {
version: this.version,
},
},
});
}
// TODO: check if this is needed, its used in use-snap-status
//
//
// /**
// * Gets the list of all installed snaps.
// * More information here: https://docs.metamask.io/snaps/reference/rpc-api/#wallet_getsnaps
// */
// async getSnap() {
// const snaps = await this.request(EthereumMethod.GetSnaps);
// return Object.values(snaps).find(
// (s) => s.id === this.snapId && s.version === this.version
// );
// }
/**
* Calls a method on the specified snap, always vega in this case
* should always be npm:@vegaprotocol/snap
*/
private async invokeSnap<TResult>(
method: JsonRpcMethod,
params?: SnapInvocationParams
): Promise<TResult> {
return await this.request(EthereumMethod.InvokeSnap, {
snapId: this.snapId,
request: {
method,
params,
params: SnapInvocationParams = {}
): Promise<TResult | { error: SnapRPCError }> {
// MetaMask in Firefox doesn't like undefined properties or some properties
// on __proto__ so we need to strip them out with JSON.strinfify
params = JSON.parse(JSON.stringify(params));
return window.ethereum.request({
method: EthereumMethod.InvokeSnap,
params: {
snapId: this.snapId,
request: {
method,
params,
},
},
});
}
/**
* Calls window.ethereum.request with method and params
*/
private async request<TResult>(
method: EthereumMethod,
params?: object
): Promise<TResult> {
if (window.ethereum?.request && window.ethereum?.isMetaMask) {
// MetaMask in Firefox doesn't like undefined properties or some properties
// on __proto__ so we need to strip them out with JSON.strinfify
try {
params = JSON.parse(JSON.stringify(params));
} catch (err) {
throw sendTransactionError();
}
return window.ethereum.request({
method,
params,
});
private isSnapRPCError(obj: unknown): obj is SnapRPCError {
if (
obj !== undefined &&
obj !== null &&
typeof obj === 'object' &&
'code' in obj &&
'message' in obj
) {
return true;
}
throw noWalletError();
return false;
}
}

View File

@ -12,7 +12,7 @@ export class ConnectorError extends Error {
export const ConnectorErrors = {
userRejected: { message: 'user rejected', code: 0 },
noConnector: { message: 'no connector', code: 1 },
noConnector: { message: 'not connected', code: 1 },
connect: { message: 'failed to connect', code: 2 },
disconnect: { message: 'failed to disconnect', code: 3 },
chainId: { message: 'incorrect chain id', code: 4 },

View File

@ -93,7 +93,6 @@ describe('disconnect', () => {
expect(result).toEqual({ status: 'disconnected' });
expect(config.store.getState()).toMatchObject({
status: 'disconnected',
error: noConnectorError(),
current: undefined,
keys: [],
pubKey: undefined,
@ -130,7 +129,7 @@ describe('refresh keys', () => {
it('handles invalid connector', async () => {
await config.refreshKeys();
expect(config.store.getState()).toMatchObject({
error: noConnectorError(),
error: undefined,
});
});

View File

@ -132,18 +132,20 @@ export function createConfig(cfg: Config): Wallet {
store.setState(getInitialState(), true);
return { status: 'disconnected' as const };
} catch (err) {
store.setState({
...getInitialState(),
error: err instanceof ConnectorError ? err : unknownError(),
});
store.setState(getInitialState(), true);
return { status: 'disconnected' as const };
}
}
async function refreshKeys() {
const connector = connectors
.getState()
.find((x) => x.id === store.getState().current);
const state = store.getState();
const connector = connectors.getState().find((x) => x.id === state.current);
// Only refresh keys if connnected. If you aren't connect when you connect
// you will get the latest keys
if (state.status !== 'connected') {
return;
}
try {
if (!connector) {