diff --git a/apps/governance/.env b/apps/governance/.env index 367b8fcda..cd2e19bdc 100644 --- a/apps/governance/.env +++ b/apps/governance/.env @@ -29,3 +29,4 @@ LC_ALL="en_US.UTF-8" # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true +NX_METAMASK_SNAPS=true \ No newline at end of file diff --git a/apps/governance/.env.capsule b/apps/governance/.env.capsule index 4c371f3e2..780e7394f 100644 --- a/apps/governance/.env.capsule +++ b/apps/governance/.env.capsule @@ -30,3 +30,4 @@ CYPRESS_FAIRGROUND=false # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false +NX_METAMASK_SNAPS=false \ No newline at end of file diff --git a/apps/governance/.env.devnet b/apps/governance/.env.devnet index 71625ea77..7f9908cc0 100644 --- a/apps/governance/.env.devnet +++ b/apps/governance/.env.devnet @@ -22,3 +22,4 @@ NX_TENDERMINT_WEBSOCKET_URL=wss://be.devnet1.vega.xyz/websocket # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true +NX_METAMASK_SNAPS=true \ No newline at end of file diff --git a/apps/governance/.env.mainnet b/apps/governance/.env.mainnet index 7a2e15d5c..982a605bf 100644 --- a/apps/governance/.env.mainnet +++ b/apps/governance/.env.mainnet @@ -22,3 +22,4 @@ NX_TENDERMINT_WEBSOCKET_URL=wss://be.vega.community/websocket # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false +NX_METAMASK_SNAPS=false diff --git a/apps/governance/.env.mainnet-mirror b/apps/governance/.env.mainnet-mirror index a847576e3..0ef3d4076 100644 --- a/apps/governance/.env.mainnet-mirror +++ b/apps/governance/.env.mainnet-mirror @@ -21,3 +21,4 @@ NX_TENDERMINT_WEBSOCKET_URL=wss://be.mainnet-mirror.vega.rocks/websocket # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false +NX_METAMASK_SNAPS=false diff --git a/apps/governance/.env.stagnet1 b/apps/governance/.env.stagnet1 index a83edd54e..70cb17afe 100644 --- a/apps/governance/.env.stagnet1 +++ b/apps/governance/.env.stagnet1 @@ -18,3 +18,4 @@ NX_TENDERMINT_WEBSOCKET_URL=wss://tm.n01.stagnet1.vega.xyz/websocket # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true +NX_METAMASK_SNAPS=true diff --git a/apps/governance/.env.testnet b/apps/governance/.env.testnet index 2e64b5662..2135c6f19 100644 --- a/apps/governance/.env.testnet +++ b/apps/governance/.env.testnet @@ -23,3 +23,4 @@ NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true +NX_METAMASK_SNAPS=true diff --git a/apps/governance/.env.validators-testnet b/apps/governance/.env.validators-testnet index 17e335022..5ca68a594 100644 --- a/apps/governance/.env.validators-testnet +++ b/apps/governance/.env.validators-testnet @@ -20,3 +20,4 @@ NX_TENDERMINT_WEBSOCKET_URL=wss://be.validators-testnet.vega. # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false +NX_METAMASK_SNAPS=false diff --git a/apps/governance/src/lib/vega-connectors.ts b/apps/governance/src/lib/vega-connectors.ts index 32b305f55..c3007921e 100644 --- a/apps/governance/src/lib/vega-connectors.ts +++ b/apps/governance/src/lib/vega-connectors.ts @@ -1,17 +1,26 @@ +import { ENV } from '@vegaprotocol/environment'; import { JsonRpcConnector, ViewConnector, InjectedConnector, + SnapConnector, + DEFAULT_SNAP_ID, } from '@vegaprotocol/wallet'; const urlParams = new URLSearchParams(window.location.search); -export const injected = new InjectedConnector(); export const jsonRpc = new JsonRpcConnector(); +export const injected = new InjectedConnector(); export const view = new ViewConnector(urlParams.get('address')); +export const snap = new SnapConnector( + ENV.VEGA_URL ? new URL(ENV.VEGA_URL).origin : undefined, + DEFAULT_SNAP_ID +); + export const Connectors = { injected, jsonRpc, view, + snap, }; diff --git a/apps/trading/.env b/apps/trading/.env index 21058b8bb..637a69b14 100644 --- a/apps/trading/.env +++ b/apps/trading/.env @@ -19,6 +19,7 @@ NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=true NX_TENDERMINT_URL=https://tm.n01.stagnet1.vega.rocks NX_TENDERMINT_WEBSOCKET_URL=wss://tm.n01.stagnet1.vega.xyz/websocket diff --git a/apps/trading/.env.capsule b/apps/trading/.env.capsule index 149f153bd..4638a051a 100644 --- a/apps/trading/.env.capsule +++ b/apps/trading/.env.capsule @@ -23,6 +23,7 @@ NX_SUCCESSOR_MARKETS=false NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=http://localhost:26617 NX_TENDERMINT_WEBSOCKET_URL=wss://localhost:26617/websocket diff --git a/apps/trading/.env.devnet b/apps/trading/.env.devnet index e4c6958a0..9462cb56c 100644 --- a/apps/trading/.env.devnet +++ b/apps/trading/.env.devnet @@ -21,6 +21,7 @@ NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=true NX_TENDERMINT_URL=https://tm.be.devnet1.vega.xyz/ NX_TENDERMINT_WEBSOCKET_URL=wss://be.devnet1.vega.xyz/websocket diff --git a/apps/trading/.env.mainnet b/apps/trading/.env.mainnet index 708a34153..44e6c8447 100644 --- a/apps/trading/.env.mainnet +++ b/apps/trading/.env.mainnet @@ -23,6 +23,7 @@ NX_SUCCESSOR_MARKETS=false NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=https://be.vega.community NX_TENDERMINT_WEBSOCKET_URL=wss://be.vega.community/websocket diff --git a/apps/trading/.env.mainnet-mirror b/apps/trading/.env.mainnet-mirror index e28f506fe..aebcf5e35 100644 --- a/apps/trading/.env.mainnet-mirror +++ b/apps/trading/.env.mainnet-mirror @@ -23,6 +23,7 @@ NX_SUCCESSOR_MARKETS=false NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=https://be.mainnet-mirror.vega.rocks NX_TENDERMINT_WEBSOCKET_URL=wss://be.mainnet-mirror.vega.rocks/websocket diff --git a/apps/trading/.env.stagnet1 b/apps/trading/.env.stagnet1 index 91425161b..0294d0d0b 100644 --- a/apps/trading/.env.stagnet1 +++ b/apps/trading/.env.stagnet1 @@ -21,3 +21,4 @@ NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=true diff --git a/apps/trading/.env.testnet b/apps/trading/.env.testnet index bf23f97a2..c673562b1 100644 --- a/apps/trading/.env.testnet +++ b/apps/trading/.env.testnet @@ -22,6 +22,7 @@ NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true NX_ICEBERG_ORDERS=true # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=https://tm.be.testnet.vega.xyz NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket diff --git a/apps/trading/.env.validators-testnet b/apps/trading/.env.validators-testnet index 11f5f217e..c31b364b1 100644 --- a/apps/trading/.env.validators-testnet +++ b/apps/trading/.env.validators-testnet @@ -23,6 +23,7 @@ NX_SUCCESSOR_MARKETS=false NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=https://tm.be.validators-testnet.vega.rocks NX_TENDERMINT_WEBSOCKET_URL=wss://be.validators-testnet.vega.xyz/websocket diff --git a/apps/trading/lib/vega-connectors.ts b/apps/trading/lib/vega-connectors.ts index b1dcf762b..bf953d652 100644 --- a/apps/trading/lib/vega-connectors.ts +++ b/apps/trading/lib/vega-connectors.ts @@ -1,7 +1,10 @@ +import { ENV } from '@vegaprotocol/environment'; import { JsonRpcConnector, ViewConnector, InjectedConnector, + SnapConnector, + DEFAULT_SNAP_ID, } from '@vegaprotocol/wallet'; export const jsonRpc = new JsonRpcConnector(); @@ -15,8 +18,14 @@ if (typeof window !== 'undefined') { view = new ViewConnector(); } +export const snap = new SnapConnector( + ENV.VEGA_URL ? new URL(ENV.VEGA_URL).origin : undefined, + DEFAULT_SNAP_ID +); + export const Connectors = { injected, jsonRpc, view, + snap, }; diff --git a/libs/environment/src/hooks/use-environment.ts b/libs/environment/src/hooks/use-environment.ts index 2a39c6625..deb5b4b2b 100644 --- a/libs/environment/src/hooks/use-environment.ts +++ b/libs/environment/src/hooks/use-environment.ts @@ -408,6 +408,12 @@ function compileFeatureFlags(): FeatureFlags { process.env['NX_PRODUCT_PERPETUALS'] ) as string ), + METAMASK_SNAPS: TRUTHY.includes( + windowOrDefault( + 'NX_METAMASK_SNAPS', + process.env['NX_METAMASK_SNAPS'] + ) as string + ), }; const EXPLORER_FLAGS = { EXPLORER_ASSETS: TRUTHY.includes( diff --git a/libs/environment/src/types.ts b/libs/environment/src/types.ts index 2c5437ed4..b1b971888 100644 --- a/libs/environment/src/types.ts +++ b/libs/environment/src/types.ts @@ -18,7 +18,11 @@ export type Environment = z.infer; export type FeatureFlags = z.infer; export type CosmicElevatorFlags = Pick< FeatureFlags, - 'ICEBERG_ORDERS' | 'STOP_ORDERS' | 'SUCCESSOR_MARKETS' | 'PRODUCT_PERPETUALS' + | 'ICEBERG_ORDERS' + | 'STOP_ORDERS' + | 'SUCCESSOR_MARKETS' + | 'PRODUCT_PERPETUALS' + | 'METAMASK_SNAPS' >; export type Configuration = z.infer; export const CUSTOM_NODE_KEY = 'custom' as const; diff --git a/libs/environment/src/utils/validate-environment.ts b/libs/environment/src/utils/validate-environment.ts index baf102b14..c0589e73f 100644 --- a/libs/environment/src/utils/validate-environment.ts +++ b/libs/environment/src/utils/validate-environment.ts @@ -77,6 +77,7 @@ const COSMIC_ELEVATOR_FLAGS = { STOP_ORDERS: z.optional(z.boolean()), ICEBERG_ORDERS: z.optional(z.boolean()), PRODUCT_PERPETUALS: z.optional(z.boolean()), + METAMASK_SNAPS: z.optional(z.boolean()), }; const EXPLORER_FLAGS = { diff --git a/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-metamask.tsx b/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-metamask.tsx new file mode 100644 index 000000000..e3976cf43 --- /dev/null +++ b/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-metamask.tsx @@ -0,0 +1,102 @@ +export const IconMetaMask = ({ size = 16 }: { size: number }) => ( + + + + + + + + + + + + + + + + +); diff --git a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts index 60ee1f1ad..e8069b90f 100644 --- a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts +++ b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts @@ -1,8 +1,8 @@ import { IconArrowDown } from './svg-icons/icon-arrow-down'; import { IconArrowLeft } from './svg-icons/icon-arrow-left'; -import { IconArrowUp } from './svg-icons/icon-arrow-up'; import { IconArrowRight } from './svg-icons/icon-arrow-right'; import { IconArrowTopRight } from './svg-icons/icon-arrow-top-right'; +import { IconArrowUp } from './svg-icons/icon-arrow-up'; import { IconBreakdown } from './svg-icons/icon-breakdown'; import { IconBullet } from './svg-icons/icon-bullet'; import { IconChevronDown } from './svg-icons/icon-chevron-down'; @@ -20,28 +20,29 @@ import { IconGlobe } from './svg-icons/icon-globe'; import { IconInfo } from './svg-icons/icon-info'; import { IconKebab } from './svg-icons/icon-kebab'; import { IconLinkedIn } from './svg-icons/icon-linkedin'; +import { IconMetaMask } from './svg-icons/icon-metamask'; import { IconMinus } from './svg-icons/icon-minus'; import { IconMoon } from './svg-icons/icon-moon'; import { IconOpenExternal } from './svg-icons/icon-open-external'; -import { IconQuestionMark } from './svg-icons/icon-question-mark'; import { IconPlus } from './svg-icons/icon-plus'; +import { IconQuestionMark } from './svg-icons/icon-question-mark'; +import { IconSearch } from './svg-icons/icon-search'; import { IconStar } from './svg-icons/icon-star'; import { IconTick } from './svg-icons/icon-tick'; import { IconTicket } from './svg-icons/icon-ticket'; import { IconTransfer } from './svg-icons/icon-transfer'; -import { IconTrendUp } from './svg-icons/icon-trend-up'; import { IconTrendDown } from './svg-icons/icon-trend-down'; +import { IconTrendUp } from './svg-icons/icon-trend-up'; import { IconTwitter } from './svg-icons/icon-twitter'; import { IconVote } from './svg-icons/icon-vote'; import { IconWithdraw } from './svg-icons/icon-withdraw'; -import { IconSearch } from './svg-icons/icon-search'; export enum VegaIconNames { ARROW_DOWN = 'arrow-down', ARROW_LEFT = 'arrow-left', - ARROW_UP = 'arrow-up', ARROW_RIGHT = 'arrow-right', ARROW_TOP_RIGHT = 'arrow-top-right', + ARROW_UP = 'arrow-up', BREAKDOWN = 'breakdown', BULLET = 'bullet', CHEVRON_DOWN = 'chevron-down', @@ -59,18 +60,19 @@ export enum VegaIconNames { INFO = 'info', KEBAB = 'kebab', LINKEDIN = 'linkedin', + METAMASK = 'metamask', MINUS = 'minus', MOON = 'moon', OPEN_EXTERNAL = 'open-external', - QUESTION_MARK = 'question-mark', PLUS = 'plus', + QUESTION_MARK = 'question-mark', SEARCH = 'search', STAR = 'star', TICK = 'tick', TICKET = 'ticket', TRANSFER = 'transfer', - TREND_UP = 'trend-up', TREND_DOWN = 'trend-down', + TREND_UP = 'trend-up', TWITTER = 'twitter', VOTE = 'vote', WITHDRAW = 'withdraw', @@ -82,38 +84,39 @@ export const VegaIconNameMap: Record< > = { 'arrow-down': IconArrowDown, 'arrow-left': IconArrowLeft, - 'arrow-up': IconArrowUp, 'arrow-right': IconArrowRight, 'arrow-top-right': IconArrowTopRight, - breakdown: IconBreakdown, - bullet: IconBullet, + 'arrow-up': IconArrowUp, 'chevron-down': IconChevronDown, 'chevron-left': IconChevronLeft, 'chevron-up': IconChevronUp, + 'exclaimation-mark': IconExclaimationMark, + 'open-external': IconOpenExternal, + 'question-mark': IconQuestionMark, + 'trend-down': IconTrendDown, + 'trend-up': IconTrendUp, + breakdown: IconBreakdown, + bullet: IconBullet, cog: IconCog, copy: IconCopy, cross: IconCross, deposit: IconDeposit, edit: IconEdit, - 'exclaimation-mark': IconExclaimationMark, eye: IconEye, forum: IconForum, globe: IconGlobe, info: IconInfo, kebab: IconKebab, linkedin: IconLinkedIn, + metamask: IconMetaMask, minus: IconMinus, moon: IconMoon, - 'open-external': IconOpenExternal, plus: IconPlus, - 'question-mark': IconQuestionMark, search: IconSearch, star: IconStar, tick: IconTick, ticket: IconTicket, transfer: IconTransfer, - 'trend-up': IconTrendUp, - 'trend-down': IconTrendDown, twitter: IconTwitter, vote: IconVote, withdraw: IconWithdraw, diff --git a/libs/wallet/src/connect-dialog/connect-dialog.tsx b/libs/wallet/src/connect-dialog/connect-dialog.tsx index acd3ea870..222de6265 100644 --- a/libs/wallet/src/connect-dialog/connect-dialog.tsx +++ b/libs/wallet/src/connect-dialog/connect-dialog.tsx @@ -16,13 +16,16 @@ import type { WalletClientError } from '@vegaprotocol/wallet-client'; import { t } from '@vegaprotocol/i18n'; import type { VegaConnector } from '../connectors'; import { + DEFAULT_SNAP_ID, InjectedConnector, JsonRpcConnector, + SnapConnector, ViewConnector, + requestSnap, } from '../connectors'; import { JsonRpcConnectorForm } from './json-rpc-connector-form'; import { ViewConnectorForm } from './view-connector-form'; -import { useEnvironment } from '@vegaprotocol/environment'; +import { FLAGS, useEnvironment } from '@vegaprotocol/environment'; import { BrowserIcon, ConnectDialogContent, @@ -38,10 +41,11 @@ import { useVegaWallet } from '../use-vega-wallet'; import { InjectedConnectorForm } from './injected-connector-form'; import { isBrowserWalletInstalled } from '../utils'; import { useIsWalletServiceRunning } from '../use-is-wallet-service-running'; +import { useIsSnapRunning } from '../use-is-snap-running'; export const CLOSE_DELAY = 1700; type Connectors = { [key: string]: VegaConnector }; -export type WalletType = 'injected' | 'jsonRpc' | 'view'; +export type WalletType = 'injected' | 'jsonRpc' | 'view' | 'snap'; export interface VegaConnectDialogProps { connectors: Connectors; @@ -156,7 +160,10 @@ const ConnectDialogContainer = ({ // for rest because we need to show an authentication form if (connector instanceof JsonRpcConnector) { jsonRpcConnect(connector, appChainId); - } else if (connector instanceof InjectedConnector) { + } else if ( + connector instanceof InjectedConnector || + connector instanceof SnapConnector + ) { injectedConnect(connector, appChainId); } }; @@ -166,6 +173,8 @@ const ConnectDialogContainer = ({ appChainId ); + const isSnapRunning = useIsSnapRunning(DEFAULT_SNAP_ID); + return ( <> @@ -185,6 +194,7 @@ const ConnectDialogContainer = ({ setWalletUrl={setWalletUrl} onSelect={handleSelect} isDesktopWalletRunning={isDesktopWalletRunning} + isSnapRunning={isSnapRunning} /> )} @@ -198,11 +208,13 @@ const ConnectorList = ({ walletUrl, setWalletUrl, isDesktopWalletRunning, + isSnapRunning, }: { onSelect: (type: WalletType) => void; walletUrl: string; setWalletUrl: (value: string) => void; isDesktopWalletRunning: boolean | null; + isSnapRunning: boolean | null; }) => { const { pubKey } = useVegaWallet(); const title = isBrowserWalletInstalled() @@ -238,6 +250,45 @@ const ConnectorList = ({ )} + {FLAGS.METAMASK_SNAPS ? ( +
+ {isSnapRunning ? ( + +
+ {t('Connect via Vega MetaMask Snap')} +
+
+ +
+ + } + onClick={() => { + onSelect('snap'); + }} + /> + ) : ( + +
+ {t('Install Vega MetaMask Snap')} +
+
+ +
+ + } + onClick={() => { + requestSnap(DEFAULT_SNAP_ID); + }} + /> + )} +
+ ) : null}
void; riskMessage?: ReactNode; }) => { - if (connector instanceof InjectedConnector) { + if ( + connector instanceof InjectedConnector || + connector instanceof SnapConnector + ) { return ( { return ( -
{children}
+
{children}
); }; @@ -131,22 +131,30 @@ const Error = ({ ); if (error) { - if (error.message === 'Invalid chain') { + if (error.message === InjectedConnectorErrors.INVALID_CHAIN.message) { 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') { + } else if ( + error.message === InjectedConnectorErrors.VEGA_UNDEFINED.message + ) { title = t('No wallet detected'); text = t('Vega browser extension not installed'); + } else if ( + error.message === SnapConnectorErrors.ETHEREUM_UNDEFINED.message || + error.message === SnapConnectorErrors.NODE_ADDRESS_NOT_SET.message + ) { + title = t('Snap failed'); + text = t('Could not connect to Vega MetaMask Snap'); } } return ( <> {title} -

{text}

+

{text}

{tryAgain} ); diff --git a/libs/wallet/src/connectors/index.ts b/libs/wallet/src/connectors/index.ts index 37b96be0f..f39c1effb 100644 --- a/libs/wallet/src/connectors/index.ts +++ b/libs/wallet/src/connectors/index.ts @@ -1,4 +1,5 @@ -export * from './vega-connector'; export * from './injected-connector'; export * from './json-rpc-connector'; +export * from './snap-connector'; +export * from './vega-connector'; export * from './view-connector'; diff --git a/libs/wallet/src/connectors/injected-connector.ts b/libs/wallet/src/connectors/injected-connector.ts index f0d8754f0..8ebc66b3e 100644 --- a/libs/wallet/src/connectors/injected-connector.ts +++ b/libs/wallet/src/connectors/injected-connector.ts @@ -41,6 +41,11 @@ declare global { } } +export const InjectedConnectorErrors = { + VEGA_UNDEFINED: new Error('window.vega not found'), + INVALID_CHAIN: new Error('Invalid chain'), +}; + export class InjectedConnector implements VegaConnector { description = 'Connects using the Vega wallet browser extension'; diff --git a/libs/wallet/src/connectors/snap-connector.ts b/libs/wallet/src/connectors/snap-connector.ts new file mode 100644 index 000000000..ef8ac880a --- /dev/null +++ b/libs/wallet/src/connectors/snap-connector.ts @@ -0,0 +1,236 @@ +import { + WalletError, + type PubKey, + type Transaction, + type VegaConnector, +} from './vega-connector'; +import { clearConfig, setConfig } from '../storage'; + +type RequestArguments = { + method: string; + params?: unknown[] | object; +}; +type WindowEthereumProvider = { + isMetaMask: boolean; + request(args: RequestArguments): Promise; +}; + +export const SnapConnectorErrors = { + ETHEREUM_UNDEFINED: new Error('MetaMask extension could not be found'), + NODE_ADDRESS_NOT_SET: new Error('nodeAddress is not set'), + SNAP_ID_NOT_SET: new Error('snapId is not set'), + TRANSACTION_PARSE: new Error('could not parse transaction data'), +}; + +const ethereumRequest = (args: RequestArguments): Promise => { + // can't declare `EthereumProvider` here because of the conflict with + // type definitions of `@web3-react` + if ( + 'ethereum' in window && + typeof window.ethereum === 'object' && + window.ethereum && + 'request' in window.ethereum && + 'isMetaMask' in window.ethereum && + window.ethereum.isMetaMask && + typeof window.ethereum.request === 'function' + ) { + return (window.ethereum as WindowEthereumProvider).request(args); + } + throw SnapConnectorErrors.ETHEREUM_UNDEFINED; +}; + +export const LOCAL_SNAP_ID = 'local:http://localhost:8080'; +export const DEFAULT_SNAP_ID = 'npm:@vegaprotocol/snap'; + +type GetSnapsResponse = Record; + +type Snap = { + id: string; + initialPermissions?: Record; + version: string; + enables: boolean; + blocked: boolean; +}; + +type InvokeSnapRequest = { + method: string; + params?: object; +}; + +type SendTransactionResponse = + | { + transactionHash: string; + receivedAt: string; + sentAt: string; + transaction?: { + signature?: { + value: string; + }; + }; + } + | { + error: Error & { + code: number; + data: unknown; + }; + }; +type GetChainIdResponse = { + chainID: string; +}; +type ListKeysResponse = { keys: PubKey[] }; + +/** + * 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 + */ +export const requestSnap = async ( + snapId: string, + params: Record<'version' | string, unknown> = {} +) => { + try { + await ethereumRequest({ + method: 'wallet_requestSnaps', + params: { + [snapId]: params, + }, + }); + } catch (err) { + // NOOP - rejected by user + } +}; + +/** + * Gets the list of all installed snaps. + * More information here: https://docs.metamask.io/snaps/reference/rpc-api/#wallet_getsnaps + */ +export const getSnaps = async (): Promise => { + return (await ethereumRequest({ + method: 'wallet_getSnaps', + })) as GetSnapsResponse; +}; + +/** + * Gets the requested snap by `snapId` and an optional `version` + */ +export const getSnap = async ( + snapId: string, + version?: string +): Promise => { + try { + const snaps = await getSnaps(); + return Object.values(snaps).find( + (snap) => snap.id === snapId && (!version || snap.version === version) + ); + } catch (e) { + return undefined; + } +}; + +export const invokeSnap = async ( + snapId: string, + request: InvokeSnapRequest +) => { + const req = { + method: 'wallet_invokeSnap', + params: { + snapId, + request, + }, + }; + return await ethereumRequest(req); +}; + +export class SnapConnector implements VegaConnector { + description = "Connects using Vega Protocol's MetaMask snap"; + snapId: string | undefined = undefined; + nodeAddress: string | undefined = undefined; + + constructor(nodeAddress?: string, snapId = DEFAULT_SNAP_ID) { + this.nodeAddress = nodeAddress; + this.snapId = snapId; + } + + async listKeys() { + if (!this.snapId) throw SnapConnectorErrors.SNAP_ID_NOT_SET; + return await invokeSnap(this.snapId, { + method: 'client.list_keys', + }); + } + + async connect() { + const res = await this.listKeys(); + setConfig({ + connector: 'snap', + token: null, // no token required for snap + url: null, // no url required for snap + }); + return res?.keys; + } + + async sendTx(pubKey: string, transaction: Transaction) { + if (!this.nodeAddress) throw SnapConnectorErrors.NODE_ADDRESS_NOT_SET; + if (!this.snapId) throw SnapConnectorErrors.SNAP_ID_NOT_SET; + + // This step is needed to strip the transaction object from any additional + // properties, such as `__proto__`, etc. + let txData = null; + try { + txData = JSON.parse(JSON.stringify(transaction)); + } catch (err) { + throw SnapConnectorErrors.TRANSACTION_PARSE; + } + + const payload = { + method: 'client.send_transaction', + params: { + sendingMode: 'TYPE_SYNC', + transaction: txData, + publicKey: pubKey, + networkEndpoints: [this.nodeAddress], + }, + }; + + const result = await invokeSnap( + this.snapId, + payload + ); + + if ('error' in result) { + const { message, code, data } = result.error; + throw new WalletError( + message, + code, + typeof data === 'string' ? data : '' + ); + } + + if (!result?.transaction?.signature) { + throw new Error('could not retrieve transaction siganture'); + } + + return { + transactionHash: result.transactionHash, + signature: result?.transaction?.signature?.value, + receivedAt: result.receivedAt, + sentAt: result.sentAt, + }; + } + + async getChainId(): Promise { + if (!this.nodeAddress) throw SnapConnectorErrors.NODE_ADDRESS_NOT_SET; + if (!this.snapId) throw SnapConnectorErrors.SNAP_ID_NOT_SET; + + const response = await invokeSnap(this.snapId, { + method: 'client.get_chain_id', + params: { networkEndpoints: [this.nodeAddress] }, + }); + + return response; + } + + async disconnect() { + clearConfig(); + } +} diff --git a/libs/wallet/src/storage.ts b/libs/wallet/src/storage.ts index 50ee11494..238775a34 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: 'injected' | 'jsonRpc' | 'view'; + connector: 'injected' | 'jsonRpc' | 'view' | 'snap'; url: string | null; } diff --git a/libs/wallet/src/use-eager-connect.ts b/libs/wallet/src/use-eager-connect.ts index 205534d17..07010ab6a 100644 --- a/libs/wallet/src/use-eager-connect.ts +++ b/libs/wallet/src/use-eager-connect.ts @@ -1,11 +1,14 @@ +import type { SnapConnector } from './'; import { useVegaWallet } from './'; import { useEffect, useState } from 'react'; import type { VegaConnector } from './connectors/vega-connector'; import { getConfig } from './storage'; +import { useEnvironment } from '@vegaprotocol/environment'; export function useEagerConnect(Connectors: { [connector: string]: VegaConnector; }) { + const { VEGA_URL } = useEnvironment(); const [connecting, setConnecting] = useState(true); const { connect, acknowledgeNeeded } = useVegaWallet(); @@ -36,6 +39,12 @@ export function useEagerConnect(Connectors: { // @ts-ignore only injected wallet has connectWallet method await injectedInstance.connectWallet(); await connect(injectedInstance); + } else if (cfg.connector === 'snap') { + const snapInstance = Connectors[cfg.connector] as SnapConnector; + if (VEGA_URL) { + snapInstance.nodeAddress = new URL(VEGA_URL).origin; + await connect(snapInstance); + } } else { await connect(Connectors[cfg.connector]); } @@ -49,7 +58,7 @@ export function useEagerConnect(Connectors: { if (typeof window !== 'undefined') { attemptConnect(); } - }, [connect, Connectors, acknowledgeNeeded]); + }, [connect, Connectors, acknowledgeNeeded, VEGA_URL]); return connecting; } diff --git a/libs/wallet/src/use-injected-connector.ts b/libs/wallet/src/use-injected-connector.ts index 1c0d2d58f..44e771312 100644 --- a/libs/wallet/src/use-injected-connector.ts +++ b/libs/wallet/src/use-injected-connector.ts @@ -1,6 +1,12 @@ import { useCallback, useState } from 'react'; -import type { InjectedConnector } from './connectors'; +import { + InjectedConnectorErrors, + SnapConnector, + SnapConnectorErrors, +} from './connectors'; +import { InjectedConnector } from './connectors'; import { useVegaWallet } from './use-vega-wallet'; +import { useEnvironment } from '@vegaprotocol/environment'; export enum Status { Idle = 'Idle', @@ -15,24 +21,39 @@ export const useInjectedConnector = (onConnect: () => void) => { const { connect, acknowledgeNeeded } = useVegaWallet(); const [status, setStatus] = useState(Status.Idle); const [error, setError] = useState(null); + const { VEGA_URL } = useEnvironment(); const attemptConnect = useCallback( - async (connector: InjectedConnector, appChainId: string) => { + async ( + connector: InjectedConnector | SnapConnector, + appChainId: string + ) => { try { - if (!('vega' in window)) { - throw new Error('window.vega not found'); + if (connector instanceof InjectedConnector && !('vega' in window)) { + throw InjectedConnectorErrors.VEGA_UNDEFINED; + } + if (connector instanceof SnapConnector) { + if (!('ethereum' in window)) { + throw SnapConnectorErrors.ETHEREUM_UNDEFINED; + } + if (!VEGA_URL) { + throw SnapConnectorErrors.NODE_ADDRESS_NOT_SET; + } + connector.nodeAddress = new URL(VEGA_URL).origin; } setStatus(Status.GettingChainId); const { chainID } = await connector.getChainId(); - if (chainID !== appChainId) { - throw new Error('Invalid chain'); + throw InjectedConnectorErrors.INVALID_CHAIN; } setStatus(Status.Connecting); - await connector.connectWallet(); // authorize wallet + if (connector instanceof InjectedConnector) { + // extra step for injected connector - authorize wallet + await connector.connectWallet(); + } await connect(connector); // connect with keys if (acknowledgeNeeded) { @@ -50,7 +71,7 @@ export const useInjectedConnector = (onConnect: () => void) => { setStatus(Status.Error); } }, - [acknowledgeNeeded, connect, onConnect] + [VEGA_URL, acknowledgeNeeded, connect, onConnect] ); return { diff --git a/libs/wallet/src/use-is-snap-running.ts b/libs/wallet/src/use-is-snap-running.ts new file mode 100644 index 000000000..a8bf0593d --- /dev/null +++ b/libs/wallet/src/use-is-snap-running.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; +import { getSnap } from './connectors'; + +const INTERVAL = 2_000; + +export const useIsSnapRunning = (snapId: string) => { + const [running, setRunning] = useState(false); + useEffect(() => { + const checkState = async () => { + const snap = await getSnap(snapId); + setRunning(!!snap); + }; + const i = setInterval(() => { + checkState(); + }, INTERVAL); + checkState(); + return () => { + clearInterval(i); + }; + }, [snapId]); + return running; +}; diff --git a/libs/wallet/src/use-vega-transaction-store.tsx b/libs/wallet/src/use-vega-transaction-store.tsx index 884856834..10aa9d2e1 100644 --- a/libs/wallet/src/use-vega-transaction-store.tsx +++ b/libs/wallet/src/use-vega-transaction-store.tsx @@ -73,6 +73,7 @@ export const useVegaTransactionStore = create()( order, }; set({ transactions: transactions.concat(transaction) }); + return transaction.id; }, update: (index: number, update: Partial) => { diff --git a/libs/wallet/src/use-vega-transaction.tsx b/libs/wallet/src/use-vega-transaction.tsx index 0256e25e7..8c463ff39 100644 --- a/libs/wallet/src/use-vega-transaction.tsx +++ b/libs/wallet/src/use-vega-transaction.tsx @@ -9,7 +9,7 @@ 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 type { WalletError } from './connectors'; +import { WalletError } from './connectors'; import { ClientErrors } from './connectors'; export interface DialogProps { @@ -44,7 +44,7 @@ export const initialState = { }; export const orderErrorResolve = (err: Error | unknown): Error => { - if (err instanceof WalletClientError) { + if (err instanceof WalletClientError || err instanceof WalletError) { return err; } else if (err instanceof WalletHttpError) { return ClientErrors.UNKNOWN; diff --git a/libs/web3/src/lib/use-vega-transaction-toasts.tsx b/libs/web3/src/lib/use-vega-transaction-toasts.tsx index ed1eebffd..b418e60ad 100644 --- a/libs/web3/src/lib/use-vega-transaction-toasts.tsx +++ b/libs/web3/src/lib/use-vega-transaction-toasts.tsx @@ -758,11 +758,11 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => { const VegaTxErrorToastContent = ({ tx }: VegaTxToastContentProps) => { let label = t('Error occurred'); - let errorMessage = `${tx.error?.message} ${ - tx.error instanceof WalletError && tx.error?.data - ? `: ${tx.error?.data}` - : '' - }`; + let errorMessage = + tx.error instanceof WalletError + ? `${tx.error.title}: ${tx.error.data}` + : tx.error?.message; + const reconnectVegaWallet = useReconnectVegaWallet(); const orderRejection = tx.order && getRejectionReason(tx.order); @@ -773,6 +773,7 @@ const VegaTxErrorToastContent = ({ tx }: VegaTxToastContentProps) => { const walletError = tx.error instanceof WalletError && walletNoConnectionCodes.includes(tx.error.code); + if (orderRejection) { label = getOrderToastTitle(tx.order?.status) || t('Order rejected'); errorMessage = t('Your order has been rejected because: %s', [ diff --git a/libs/web3/src/lib/web3-connect-dialog.tsx b/libs/web3/src/lib/web3-connect-dialog.tsx index bee86e392..ae62c226f 100644 --- a/libs/web3/src/lib/web3-connect-dialog.tsx +++ b/libs/web3/src/lib/web3-connect-dialog.tsx @@ -1,6 +1,11 @@ import { t } from '@vegaprotocol/i18n'; import { useLocalStorage } from '@vegaprotocol/react-helpers'; -import { Dialog, Intent } from '@vegaprotocol/ui-toolkit'; +import { + Dialog, + Intent, + VegaIcon, + VegaIconNames, +} from '@vegaprotocol/ui-toolkit'; import { MetaMask } from '@web3-react/metamask'; import { WalletConnect } from '@web3-react/walletconnect-v2'; import { WalletConnect as WalletConnectLegacy } from '@web3-react/walletconnect'; @@ -118,7 +123,7 @@ export const Web3ConnectUncontrolledDialog = () => { function getConnectorInfo(connector: Connector) { if (connector instanceof MetaMask) { return { - icon: , + icon: , name: 'MetaMask', text: t('MetaMask'), alt: t('MetaMask, Brave or other injected web wallet'), @@ -126,14 +131,14 @@ function getConnectorInfo(connector: Connector) { } if (connector instanceof CoinbaseWallet) { return { - icon: , + icon: , name: 'CoinbaseWallet', text: t('Coinbase'), }; } if (connector instanceof WalletConnect) { return { - icon: , + icon: , name: 'WalletConnect', text: t('WalletConnect'), alt: t('WalletConnect v2'), @@ -143,7 +148,7 @@ function getConnectorInfo(connector: Connector) { return { icon: ( ), @@ -160,115 +165,6 @@ type IconProps = { height?: number; }; -const MetaMaskIcon = ({ width, height }: IconProps) => ( - - - - - - - - - - - - - - - - -); - const CoinbaseWalletIcon = ({ width, height }: IconProps) => (