diff --git a/apps/ui/.eslintrc.json b/apps/ui/.eslintrc.json index e9521f186..0c4b1d533 100644 --- a/apps/ui/.eslintrc.json +++ b/apps/ui/.eslintrc.json @@ -78,7 +78,10 @@ } }, { - "files": ["src/**/*.stories.@(js|jsx|ts|tsx)"], + "files": [ + "src/**/*.stories.@(js|jsx|ts|tsx)", + "src/**/*.test.@(js|jsx|ts|tsx)" + ], "rules": { "functional/immutable-data": "off" } diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index edd921934..5121db0ac 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -7,6 +7,7 @@ import "./App.scss"; import { AppCrashed, NewVersionAlert } from "./components/AppCrashed"; import { Layout } from "./components/Layout"; import Notification from "./components/Notification"; +import WalletsMonitor from "./components/WalletsMonitor"; import { AppContext } from "./contexts"; import CollectiblesPage from "./pages/CollectiblesPage"; import HelpPage from "./pages/HelpPage"; @@ -102,6 +103,7 @@ function App(): ReactElement { + ); } diff --git a/apps/ui/src/components/ConnectButton.scss b/apps/ui/src/components/ConnectButton.scss index 3d48cbb91..3ba8d933e 100644 --- a/apps/ui/src/components/ConnectButton.scss +++ b/apps/ui/src/components/ConnectButton.scss @@ -1,23 +1,13 @@ -@import "../eui"; +.multiConnectButton { + img { + margin: 0 3px; -// Used on ConnectButton and on MultiWalletModal -.connect-button { - .exit-icon { - display: none; - margin-left: 5px; - } - - // only used on MultiWalletModal service buttons for visibility - &.connected-service { - border: 1.5px solid $euiColorSuccess; - } + &:first-of-type { + margin-left: 0; + } - &.connected:hover { - .exit-icon { - display: inline-block; + &:last-of-type { + margin-right: 6px; } - background-color: $euiColorDanger !important; - color: $euiColorGhost; - border: none; } } diff --git a/apps/ui/src/components/ConnectButton.tsx b/apps/ui/src/components/ConnectButton.tsx index 0535e3aaf..4d417e1d3 100644 --- a/apps/ui/src/components/ConnectButton.tsx +++ b/apps/ui/src/components/ConnectButton.tsx @@ -5,71 +5,65 @@ import { useState } from "react"; import shallow from "zustand/shallow.js"; import { EcosystemId } from "../config"; -import { selectConfig } from "../core/selectors"; -import { useEnvironment } from "../core/store"; +import { selectConfig, selectWalletAdapterApi } from "../core/selectors"; +import { useEnvironment, useWalletAdapter } from "../core/store"; import { useWallets } from "../hooks"; -import AVALANCHE_SVG from "../images/ecosystems/avalanche.svg"; -import BSC_SVG from "../images/ecosystems/bsc.svg"; -import ETHEREUM_SVG from "../images/ecosystems/ethereum.svg"; -import POLYGON_SVG from "../images/ecosystems/polygon.svg"; -import SOLANA_SVG from "../images/ecosystems/solana.svg"; -import { shortenAddress } from "../utils"; +import type { WalletService } from "../models"; +import { deduplicate, isNotNull, shortenAddress } from "../utils"; import { MultiWalletModal } from "./MultiWalletModal"; +import type { PlainConnectButtonProps } from "./PlainConnectButton"; +import { PlainConnectButton } from "./PlainConnectButton"; import "./ConnectButton.scss"; -export interface ConnectButtonProps extends PropsForButton { +export type ConnectButtonProps = Omit< + PlainConnectButtonProps, + "children" | "connected" | "onClick" +> & { readonly ecosystemId: EcosystemId; -} +}; export const ConnectButton = ({ - children, ecosystemId, ...rest }: ConnectButtonProps): ReactElement => { const { ecosystems } = useEnvironment(selectConfig, shallow); + const { disconnectService } = useWalletAdapter( + selectWalletAdapterApi, + shallow, + ); if (ecosystemId === EcosystemId.Terra) { throw new Error("Unsupported ecosystem"); } const ecosystem = ecosystems[ecosystemId]; const wallets = useWallets(); - const { connected, select, address, wallet } = wallets[ecosystemId]; + const { connected, select, address } = wallets[ecosystemId]; const disconnect = (): void => { - void wallet?.disconnect(); + void disconnectService(ecosystem.protocol); }; const handleClick = connected ? disconnect : select; return ( - - - {connected ? ( - children ? ( - children - ) : address ? ( - shortenAddress(address) - ) : ( - "" - ) - ) : ( - <> - Connect - - Connect {ecosystem.displayName} - - - )} - - - + {connected && address ? ( + shortenAddress(address) + ) : ( + <> + Connect + + Connect {ecosystem.displayName} + + + )} + ); }; @@ -81,41 +75,41 @@ export const MultiConnectButton = ({ const openModal = (): void => setIsWalletModalOpen(true); const { - solana: { connected: isSolanaConnected }, - ethereum: { connected: isEthereumConnected }, - bsc: { connected: isBscConnected }, - avalanche: { connected: isAvalancheConnected }, - polygon: { connected: isPolygonConnected }, + solana: { connected: isSolanaConnected, service: solanaService }, + ethereum: { connected: isEthereumConnected, service: ethereumService }, + bsc: { connected: isBscConnected, service: bscService }, + avalanche: { connected: isAvalancheConnected, service: avalanceService }, + polygon: { connected: isPolygonConnected, service: polygonService }, } = useWallets(); - const connectedStatuses = [ - isSolanaConnected, - isEthereumConnected, - isBscConnected, - isAvalancheConnected, - isPolygonConnected, - ]; - const nConnected = connectedStatuses.filter(Boolean).length; + const connectedServices = [ + isSolanaConnected ? solanaService : null, + isEthereumConnected ? ethereumService : null, + isBscConnected ? bscService : null, + isAvalancheConnected ? avalanceService : null, + isPolygonConnected ? polygonService : null, + ].filter(isNotNull); + + const uniqueServices = deduplicate( + (walletService) => walletService.id, + connectedServices, + ); + const nConnected = uniqueServices.length; const label = nConnected > 0 ? ( <> - {isSolanaConnected && } - {isEthereumConnected && } - {isBscConnected && } - {isAvalancheConnected && } - {isPolygonConnected && } + {uniqueServices.map((walletService) => ( + + ))}  {nConnected}  connected ) : ( <> - - - - {/* TODO: Consider adding these: - - */} -   Connect Connect Wallets @@ -123,7 +117,11 @@ export const MultiConnectButton = ({ return ( <> - + {label} {isWalletModalOpen && } diff --git a/apps/ui/src/components/MultiWalletModal.scss b/apps/ui/src/components/MultiWalletModal.scss new file mode 100644 index 000000000..03651cf95 --- /dev/null +++ b/apps/ui/src/components/MultiWalletModal.scss @@ -0,0 +1,28 @@ +@import "../eui"; + +.protocolWalletOptionsList { + min-width: 180px; + align-items: flex-start; + + .plainConnectButton { + white-space: nowrap; + } + + .plainConnectButton--connected { + border: 1.5px solid $euiColorSuccess; + } + + &__ecosystems { + & > li { + margin-bottom: 15px; + + img { + margin-right: 15px; + } + + &:last-of-type { + margin-bottom: 0; + } + } + } +} diff --git a/apps/ui/src/components/MultiWalletModal.tsx b/apps/ui/src/components/MultiWalletModal.tsx index eab8491fd..a60638c24 100644 --- a/apps/ui/src/components/MultiWalletModal.tsx +++ b/apps/ui/src/components/MultiWalletModal.tsx @@ -1,134 +1,156 @@ import { EuiButtonEmpty, + EuiButtonIcon, EuiFlexGrid, EuiFlexItem, EuiIcon, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle, + EuiPopover, EuiSpacer, EuiTitle, } from "@elastic/eui"; import type { ReactElement } from "react"; -import { Fragment } from "react"; -import shallow from "zustand/shallow.js"; - -import { EcosystemId } from "../config"; -import { selectConfig } from "../core/selectors"; -import { useEnvironment } from "../core/store"; -import { useWallets } from "../hooks"; -import AVALANCHE_SVG from "../images/ecosystems/avalanche.svg"; -import BSC_SVG from "../images/ecosystems/bsc.svg"; -import ETHEREUM_SVG from "../images/ecosystems/ethereum.svg"; -import POLYGON_SVG from "../images/ecosystems/polygon.svg"; +import { useState } from "react"; + +import { + Protocol, + ecosystems, + getEcosystemsForProtocol, + isEcosystemEnabled, + protocolNames, +} from "../config"; +import { useWalletService, useWallets } from "../hooks"; +import EVM_SVG from "../images/ecosystems/ethereum-color.svg"; import SOLANA_SVG from "../images/ecosystems/solana.svg"; +import { WALLET_SERVICES } from "../models"; import { - AVALANCHE_WALLET_SERVICES, - BSC_WALLET_SERVICES, - ETHEREUM_WALLET_SERVICES, - POLYGON_WALLET_SERVICES, - SOLANA_WALLET_SERVICES, -} from "../models"; -import type { WalletService } from "../models"; -import { isUserOnMobileDevice, shortenAddress } from "../utils"; + filterMap, + findOrThrow, + groupBy, + isUserOnMobileDevice, + shortenAddress, +} from "../utils"; import { CustomModal } from "./CustomModal"; import { MobileDeviceDisclaimer } from "./MobileDeviceDisclaimer"; +import { PlainConnectButton } from "./PlainConnectButton"; -import "./ConnectButton.scss"; - -interface WalletServiceButtonProps { - readonly service: W; - readonly onClick: () => void; - readonly disconnect: () => void; - readonly serviceConnected: boolean; - readonly address: string | null; -} - -const WalletServiceButton = ({ - service, - disconnect, - onClick, - serviceConnected, - address, -}: WalletServiceButtonProps): ReactElement => { - const { - info: { icon, name, helpText }, - } = service; - return ( - - - - {serviceConnected && address ? shortenAddress(address) : name} - - {helpText && <>{helpText}} - - - - ); -}; +import "./MultiWalletModal.scss"; -interface EcosystemWalletOptionsListProps< - W extends WalletService = WalletService, -> { - readonly address: string | null; - readonly connected: boolean; +interface ProtocolWalletOptionsListProps { readonly icon: string; - readonly ecosystemName: string; - readonly walletServices: readonly W[]; - readonly ecosystemId: EcosystemId; - readonly createServiceClickHandler: (service: W) => () => void; + readonly protocol: Protocol; } -const EcosystemWalletOptionsList = ({ - address, +const ProtocolWalletOptionsList = ({ icon, - connected, - ecosystemName, - walletServices, - ecosystemId, - createServiceClickHandler, -}: EcosystemWalletOptionsListProps): ReactElement => { - // needed for wallet extraction to work - if (ecosystemId === EcosystemId.Terra) { - throw new Error("Unsupported ecosystem"); - } + protocol, +}: ProtocolWalletOptionsListProps): ReactElement => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const wallets = useWallets(); - const { wallet, service: currentService } = wallets[ecosystemId]; + const { connectService, disconnectService } = useWalletService(); + const ecosystemIds = getEcosystemsForProtocol(protocol); + const protocolWalletServices = ecosystemIds.flatMap( + (ecosystemId) => WALLET_SERVICES[ecosystemId], + ); + const protocolWalletServicesByServiceId = groupBy( + protocolWalletServices, + (protocolWalletService) => protocolWalletService.id, + ); + const protocolWallets = ecosystemIds.map( + (ecosystemId) => wallets[ecosystemId], + ); + + const connectedWallets = protocolWallets.filter((wallet) => wallet.connected); const disconnect = (): void => { - void wallet?.disconnect(); + void disconnectService(protocol); + }; + + const connect = (serviceId: string) => { + void connectService(serviceId, protocol); + + protocolWalletServicesByServiceId[serviceId].forEach((walletService) => { + const ecosystemId = walletService.info.ecosystem.id; + const wallet = wallets[ecosystemId]; + + if (wallet.setServiceId) { + wallet.setServiceId(serviceId); + } + }); }; + const handleButtonClick = () => setIsPopoverOpen((prev: boolean) => !prev); + const handlePopoverClose = () => setIsPopoverOpen(false); + + const infoButton = ( + + ); + + const popover = ( + + {ecosystemIds.length > 1 && ( +
    + {filterMap( + isEcosystemEnabled, + (ecosystemId) => ( +
  • + + {ecosystems[ecosystemId].displayName} +
  • + ), + ecosystemIds, + )} +
+ )} +
+ ); + return ( - +

- {ecosystemName} + {protocolNames[protocol]} + {ecosystemIds.length > 1 ? {popover} : null}

- {walletServices.map((service) => { + {Object.keys(protocolWalletServicesByServiceId).map((serviceId) => { + const service = findOrThrow( + protocolWalletServices, + (walletService) => walletService.id === serviceId, + ); + + const connectedWallet = + connectedWallets.find((wallet) => wallet.service?.id === serviceId) ?? + null; + return ( - - - + connect(service.id)} + color={connectedWallet ? "success" : "primary"} + iconType={service.info.icon} + ButtonComponent={EuiButtonEmpty} + connected={!!connectedWallet} + helpText={service.info.helpText} + > + {connectedWallet && connectedWallet.address + ? shortenAddress(connectedWallet.address) + : service.info.name} + ); })}
@@ -142,15 +164,6 @@ export interface MultiWalletModalProps { export const MultiWalletModal = ({ handleClose, }: MultiWalletModalProps): ReactElement => { - const { solana, ethereum, bsc, avalanche, polygon } = useWallets(); - - const { ecosystems } = useEnvironment(selectConfig, shallow); - const solanaEcosystem = ecosystems[EcosystemId.Solana]; - const ethereumEcosystem = ecosystems[EcosystemId.Ethereum]; - const bscEcosystem = ecosystems[EcosystemId.Bsc]; - const avalancheEcosystem = ecosystems[EcosystemId.Avalanche]; - const polygonEcosystem = ecosystems[EcosystemId.Polygon]; - return ( @@ -162,52 +175,12 @@ export const MultiWalletModal = ({ {isUserOnMobileDevice() ? : ""} - - + - - - - + diff --git a/apps/ui/src/components/PlainConnectButton.scss b/apps/ui/src/components/PlainConnectButton.scss new file mode 100644 index 000000000..fb43a0d81 --- /dev/null +++ b/apps/ui/src/components/PlainConnectButton.scss @@ -0,0 +1,20 @@ +@import "../eui"; + +.plainConnectButton { + $self: &; + + &__exitIcon { + display: none; + margin-left: 5px; + } + + &--connected:hover { + background-color: $euiColorDanger !important; + color: $euiColorGhost; + border: none; + + #{ $self }__exitIcon { + display: inline-block; + } + } +} diff --git a/apps/ui/src/components/PlainConnectButton.tsx b/apps/ui/src/components/PlainConnectButton.tsx new file mode 100644 index 000000000..993aff28d --- /dev/null +++ b/apps/ui/src/components/PlainConnectButton.tsx @@ -0,0 +1,44 @@ +import type { + EuiButtonEmpty, + EuiButtonProps, + PropsForButton, +} from "@elastic/eui"; +import { EuiButton, EuiIcon } from "@elastic/eui"; +import type { ReactElement } from "react"; + +import "./PlainConnectButton.scss"; + +export type PlainConnectButtonProps = Pick< + PropsForButton, + "onClick" | "iconType" | "children" | "className" | "fullWidth" | "size" +> & { + readonly color?: "success" | "primary"; + readonly connected: boolean; + readonly helpText?: ReactElement; + readonly ButtonComponent?: typeof EuiButton | typeof EuiButtonEmpty; +}; + +export const PlainConnectButton = ({ + ButtonComponent = EuiButton, + children, + connected, + className, + helpText, + ...rest +}: PlainConnectButtonProps): ReactElement => ( + + {children} + {helpText && <>{helpText}} + + +); diff --git a/apps/ui/src/components/SingleWalletModal.tsx b/apps/ui/src/components/SingleWalletModal.tsx index 01c3bf4b1..5736eb130 100644 --- a/apps/ui/src/components/SingleWalletModal.tsx +++ b/apps/ui/src/components/SingleWalletModal.tsx @@ -9,7 +9,9 @@ import { import type { ReactElement } from "react"; import { Fragment } from "react"; -import type { WalletService } from "../models"; +import type { Protocol } from "../config"; +import { useWalletService } from "../hooks/wallets"; +import type { WalletAdapter, WalletService } from "../models"; import { isUserOnMobileDevice } from "../utils"; import { CustomModal } from "./CustomModal"; @@ -19,48 +21,56 @@ export interface SingleWalletModalProps< W extends WalletService = WalletService, > { readonly currentService: string; + readonly protocol: Protocol; readonly services: readonly W[]; readonly handleClose: () => void; - readonly createServiceClickHandler: ( - service: W, - callback?: () => any, - ) => () => void; + readonly setServiceId: ( + serviceId: WalletService["id"], + ) => void; } export const SingleWalletModal = ({ currentService, + protocol, services, handleClose, - createServiceClickHandler, -}: SingleWalletModalProps): ReactElement => ( - - - -

Select Wallet

-
-
+ setServiceId, +}: SingleWalletModalProps): ReactElement => { + const { connectService } = useWalletService(); + return ( + + + +

Select Wallet

+
+
- - {isUserOnMobileDevice() ? : ""} - {services.map((service) => { - const { - id, - info: { icon, name, helpText }, - } = service; - return ( - - - - {name} - - {helpText && <>{helpText}} - - - ); - })} - -
-); + + {isUserOnMobileDevice() ? : ""} + {services.map((service) => { + const { + id, + info: { icon, name, helpText }, + } = service; + return ( + + { + void connectService(service.id, protocol); + setServiceId(service.id); + handleClose(); + }} + > + + {name} + + {helpText && <>{helpText}} + + + ); + })} + +
+ ); +}; diff --git a/apps/ui/src/components/WalletsMonitor.tsx b/apps/ui/src/components/WalletsMonitor.tsx new file mode 100644 index 000000000..61970d56e --- /dev/null +++ b/apps/ui/src/components/WalletsMonitor.tsx @@ -0,0 +1,8 @@ +import { useWalletsMonitor } from "../hooks/wallets"; + +const WalletsMonitor = (): null => { + useWalletsMonitor(); + return null; +}; + +export default WalletsMonitor; diff --git a/apps/ui/src/config/ecosystem.ts b/apps/ui/src/config/ecosystem.ts index 04781a06b..876aa3143 100644 --- a/apps/ui/src/config/ecosystem.ts +++ b/apps/ui/src/config/ecosystem.ts @@ -9,6 +9,7 @@ import POLYGON_SVG from "../images/ecosystems/polygon.svg"; import SOLANA_SVG from "../images/ecosystems/solana.svg"; import TERRA_SVG from "../images/ecosystems/terra.svg"; import type { ReadonlyRecord } from "../utils"; +import { filterMap } from "../utils"; import { WormholeChainId } from "./wormhole"; @@ -18,6 +19,12 @@ export const enum Protocol { Cosmos = "cosmos", } +export const protocolNames: Record = { + [Protocol.Solana]: "Solana", + [Protocol.Evm]: "EVM", + [Protocol.Cosmos]: "Cosmos", +}; + /** * Maps 1:1 onto @certusone/wormhole-sdk ChainId * For a given Env, this encodes both Protocol and Protocol-specific ChainId @@ -46,6 +53,27 @@ export const enum EcosystemId { Acala = "acala", } +export const isEcosystemEnabled = (ecosystemId: EcosystemId): boolean => { + switch (ecosystemId) { + case EcosystemId.Solana: + case EcosystemId.Ethereum: + case EcosystemId.Bsc: + case EcosystemId.Avalanche: + case EcosystemId.Polygon: + return true; + case EcosystemId.Aurora: + return !!process.env.REACT_APP_ENABLE_AURORA; + case EcosystemId.Fantom: + return !!process.env.REACT_APP_ENABLE_FANTOM; + case EcosystemId.Karura: + return !!process.env.REACT_APP_ENABLE_KARURA; + case EcosystemId.Acala: + return !!process.env.REACT_APP_ENABLE_ACALA; + default: + return false; + } +}; + export type SolanaEcosystemId = Extract; export type EvmEcosystemId = Extract< @@ -77,6 +105,7 @@ export const isEvmEcosystemId = ( export type CosmosEcosystemId = Extract; export interface Ecosystem { + readonly id: EcosystemId; readonly protocol: Protocol; readonly wormholeChainId: WormholeChainId; readonly displayName: string; @@ -84,75 +113,100 @@ export interface Ecosystem { readonly nativeTokenSymbol: string; } -export const ecosystems: ReadonlyRecord = { - [EcosystemId.Solana]: { +export const ecosystemList: readonly Ecosystem[] = [ + { + id: EcosystemId.Solana, protocol: Protocol.Solana, wormholeChainId: WormholeChainId.Solana, displayName: "Solana", logo: SOLANA_SVG, nativeTokenSymbol: "SOL", }, - [EcosystemId.Ethereum]: { + { + id: EcosystemId.Ethereum, protocol: Protocol.Evm, wormholeChainId: WormholeChainId.Ethereum, displayName: "Ethereum", logo: ETHEREUM_SVG, nativeTokenSymbol: "ETH", }, - [EcosystemId.Terra]: { + { + id: EcosystemId.Terra, protocol: Protocol.Cosmos, wormholeChainId: WormholeChainId.Terra, displayName: "Terra", logo: TERRA_SVG, nativeTokenSymbol: "LUNA", }, - [EcosystemId.Bsc]: { + { + id: EcosystemId.Bsc, protocol: Protocol.Evm, wormholeChainId: WormholeChainId.Bsc, displayName: "BNB Chain", logo: BSC_SVG, nativeTokenSymbol: "BNB", }, - [EcosystemId.Avalanche]: { + { + id: EcosystemId.Avalanche, protocol: Protocol.Evm, wormholeChainId: WormholeChainId.Avalanche, displayName: "Avalanche", logo: AVALANCHE_SVG, nativeTokenSymbol: "AVAX", }, - [EcosystemId.Polygon]: { + { + id: EcosystemId.Polygon, protocol: Protocol.Evm, wormholeChainId: WormholeChainId.Polygon, displayName: "Polygon", logo: POLYGON_SVG, nativeTokenSymbol: "MATIC", }, - [EcosystemId.Aurora]: { + { + id: EcosystemId.Aurora, protocol: Protocol.Evm, wormholeChainId: WormholeChainId.Aurora, displayName: "Aurora", logo: AURORA_SVG, nativeTokenSymbol: "ETH", }, - [EcosystemId.Fantom]: { + { + id: EcosystemId.Fantom, protocol: Protocol.Evm, wormholeChainId: WormholeChainId.Fantom, displayName: "Fantom", logo: FANTOM_SVG, nativeTokenSymbol: "FTM", }, - [EcosystemId.Karura]: { + { + id: EcosystemId.Karura, protocol: Protocol.Evm, wormholeChainId: WormholeChainId.Karura, displayName: "Karura", logo: KARURA_SVG, nativeTokenSymbol: "KAR", }, - [EcosystemId.Acala]: { + { + id: EcosystemId.Acala, protocol: Protocol.Evm, wormholeChainId: WormholeChainId.Acala, displayName: "Acala", logo: ACALA_SVG, nativeTokenSymbol: "ACA", }, +]; + +export const ecosystems: ReadonlyRecord = + Object.fromEntries( + ecosystemList.map((ecosystem) => [ecosystem.id, ecosystem]), + ) as ReadonlyRecord; + +export const getEcosystemsForProtocol = ( + protocol: Protocol, +): readonly EcosystemId[] => { + return filterMap( + (ecosystem: Ecosystem) => ecosystem.protocol === protocol, + (ecosystem) => ecosystem.id, + ecosystemList, + ); }; diff --git a/apps/ui/src/contexts/evmWallet.tsx b/apps/ui/src/contexts/evmWallet.tsx index b2518bd47..983dd9e1d 100644 --- a/apps/ui/src/contexts/evmWallet.tsx +++ b/apps/ui/src/contexts/evmWallet.tsx @@ -1,18 +1,12 @@ import type { ReactElement, ReactNode } from "react"; -import { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import * as React from "react"; import { SingleWalletModal } from "../components/SingleWalletModal"; import type { EvmEcosystemId } from "../config"; -import { EcosystemId, Env, EvmChainId } from "../config"; -import { useEnvironment, useNotification } from "../core/store"; +import { EcosystemId, Protocol } from "../config"; +import { selectEvmAdapter } from "../core/selectors"; +import { useWalletAdapter } from "../core/store"; import { useLocalStorageState } from "../hooks/browser"; import type { EvmWalletAdapter, WalletService } from "../models"; import { @@ -26,53 +20,6 @@ import { POLYGON_WALLET_SERVICES, } from "../models"; import type { ReadonlyRecord } from "../utils"; -import { shortenAddress } from "../utils"; - -const envToEcosystemToChainId: ReadonlyRecord< - Env, - ReadonlyRecord -> = { - [Env.Mainnet]: { - [EcosystemId.Ethereum]: EvmChainId.EthereumMainnet, - [EcosystemId.Bsc]: EvmChainId.BscMainnet, - [EcosystemId.Avalanche]: EvmChainId.AvalancheMainnet, - [EcosystemId.Polygon]: EvmChainId.PolygonMainnet, - [EcosystemId.Aurora]: EvmChainId.AuroraMainnet, - [EcosystemId.Fantom]: EvmChainId.FantomMainnet, - [EcosystemId.Karura]: EvmChainId.KaruraMainnet, - [EcosystemId.Acala]: EvmChainId.AcalaMainnet, - }, - [Env.Devnet]: { - [EcosystemId.Ethereum]: EvmChainId.EthereumGoerli, - [EcosystemId.Bsc]: EvmChainId.BscTestnet, - [EcosystemId.Avalanche]: EvmChainId.AvalancheTestnet, - [EcosystemId.Polygon]: EvmChainId.PolygonTestnet, - [EcosystemId.Aurora]: EvmChainId.AuroraTestnet, - [EcosystemId.Fantom]: EvmChainId.FantomTestnet, - [EcosystemId.Karura]: EvmChainId.KaruraTestnet, - [EcosystemId.Acala]: EvmChainId.AcalaTestnet, - }, - [Env.Localnet]: { - [EcosystemId.Ethereum]: EvmChainId.EthereumLocalnet, - [EcosystemId.Bsc]: EvmChainId.BscLocalnet, - [EcosystemId.Avalanche]: EvmChainId.AvalancheLocalnet, - [EcosystemId.Polygon]: EvmChainId.PolygonLocalnet, - [EcosystemId.Aurora]: EvmChainId.AuroraLocalnet, - [EcosystemId.Fantom]: EvmChainId.FantomLocalnet, - [EcosystemId.Karura]: EvmChainId.KaruraLocalnet, - [EcosystemId.Acala]: EvmChainId.AcalaLocalnet, - }, - [Env.CustomLocalnet]: { - [EcosystemId.Ethereum]: EvmChainId.EthereumLocalnet, - [EcosystemId.Bsc]: EvmChainId.BscLocalnet, - [EcosystemId.Avalanche]: EvmChainId.AvalancheLocalnet, - [EcosystemId.Polygon]: EvmChainId.PolygonLocalnet, - [EcosystemId.Aurora]: EvmChainId.AuroraLocalnet, - [EcosystemId.Fantom]: EvmChainId.FantomLocalnet, - [EcosystemId.Karura]: EvmChainId.KaruraLocalnet, - [EcosystemId.Acala]: EvmChainId.AcalaLocalnet, - }, -}; const ecosystemToWalletServices: ReadonlyRecord< EvmEcosystemId, @@ -105,7 +52,7 @@ export interface EvmWalletContextInterface { readonly connected: boolean; readonly select: () => void; readonly service: WalletService | null; - readonly createServiceClickHandler: (service: WalletService) => () => void; + readonly setServiceId: (serviceId: WalletService["id"]) => void; } const defaultEvmWalletContext: EvmWalletContextInterface = { @@ -114,7 +61,7 @@ const defaultEvmWalletContext: EvmWalletContextInterface = { connected: false, service: null, select: () => {}, - createServiceClickHandler: () => () => {}, + setServiceId: () => {}, }; const [ @@ -161,97 +108,50 @@ export const EvmWalletProvider = ({ ecosystemId, children, }: EvmWalletProviderProps): ReactElement => { - const { notify } = useNotification(); - - const { env } = useEnvironment(); const [connected, setConnected] = useState(false); - const [autoConnect, setAutoConnect] = useState(false); - const [serviceId, setServiceId] = useLocalStorageState( ecosystemToLocalStorageKey[ecosystemId], ); - const chainId = envToEcosystemToChainId[env][ecosystemId]; const services = ecosystemToWalletServices[ecosystemId]; const service = useMemo( () => services.find(({ id }) => id === serviceId) ?? null, [serviceId, services], ); - const wallet = useMemo(() => { - if (!service?.adapter) { - return null; - } - return new service.adapter(chainId); - }, [chainId, service]); - const previousWalletRef = useRef(wallet); + const evm = useWalletAdapter(selectEvmAdapter); + + const wallet: EvmWalletAdapter | null = (service && evm) || null; const address = wallet?.address ?? null; useEffect(() => { - const previousWallet = previousWalletRef.current; if (wallet) { - if (wallet !== previousWallet) { - previousWallet?.disconnect().catch(console.error); - setConnected(false); - // eslint-disable-next-line functional/immutable-data - previousWalletRef.current = wallet; - } - const handleConnect = (): void => { - if (wallet.address) { - setConnected(true); - notify( - "Wallet update", - `Connected to wallet ${shortenAddress(wallet.address)}`, - "info", - 7000, - ); - } + setConnected(true); }; + const handleDisconnect = (): void => { setConnected(false); - notify("Wallet update", "Disconnected from wallet", "warning"); - }; - const handleError = (title: string, description: string): void => { - notify(title, description, "error"); }; wallet.on("connect", handleConnect); wallet.on("disconnect", handleDisconnect); - wallet.on("error", handleError); return () => { wallet.removeListener("connect", handleConnect); wallet.removeListener("disconnect", handleDisconnect); - wallet.removeListener("error", handleError); }; } return () => { setConnected(false); - // eslint-disable-next-line functional/immutable-data - previousWalletRef.current = wallet; }; - }, [wallet, notify]); - - useEffect(() => { - if (wallet && autoConnect) { - wallet.connect().catch(console.error); - setAutoConnect(false); - } - }, [wallet, autoConnect]); + }, [wallet]); const [isModalVisible, setIsModalVisible] = useState(false); const select = useCallback(() => setIsModalVisible(true), []); const closeModal = useCallback(() => setIsModalVisible(false), []); - const createServiceClickHandler = - ({ id }: WalletService, callback?: () => any) => - (): void => { - setServiceId(id); - setAutoConnect(true); - callback?.(); - }; const EvmWalletContext = ecosystemToContext[ecosystemId]; @@ -263,16 +163,17 @@ export const EvmWalletProvider = ({ connected, select, service, - createServiceClickHandler, + setServiceId, }} > {children} {isModalVisible && ( )} diff --git a/apps/ui/src/contexts/solanaWallet.tsx b/apps/ui/src/contexts/solanaWallet.tsx index 7bcf04b46..dba731864 100644 --- a/apps/ui/src/contexts/solanaWallet.tsx +++ b/apps/ui/src/contexts/solanaWallet.tsx @@ -1,4 +1,3 @@ -import Wallet from "@project-serum/sol-wallet-adapter"; import type { ReactElement, ReactNode } from "react"; import { createContext, @@ -6,19 +5,20 @@ import { useContext, useEffect, useMemo, - useRef, useState, } from "react"; -import shallow from "zustand/shallow.js"; import { SingleWalletModal } from "../components/SingleWalletModal"; import { Protocol } from "../config"; -import { selectConfig } from "../core/selectors"; -import { useEnvironment, useNotification } from "../core/store"; +import { selectSolanaAdapter } from "../core/selectors"; +import { useWalletAdapter } from "../core/store"; import { useLocalStorageState } from "../hooks/browser"; -import type { SolanaWalletAdapter, SolanaWalletService } from "../models"; +import type { + SolanaWalletAdapter, + SolanaWalletService, + WalletService, +} from "../models"; import { SOLANA_WALLET_SERVICES } from "../models"; -import { shortenAddress } from "../utils"; export interface SolanaWalletContextInterface { readonly wallet: SolanaWalletAdapter | null; @@ -26,9 +26,7 @@ export interface SolanaWalletContextInterface { readonly connected: boolean; readonly select: () => void; readonly service: SolanaWalletService | null; - readonly createServiceClickHandler: ( - service: SolanaWalletService, - ) => () => void; + readonly setServiceId: (serviceId: WalletService["id"]) => void; } const defaultSolanaWalletContext: SolanaWalletContextInterface = { @@ -37,7 +35,7 @@ const defaultSolanaWalletContext: SolanaWalletContextInterface = { connected: false, select() {}, service: null, - createServiceClickHandler: () => () => {}, + setServiceId: () => {}, }; const SolanaWalletContext = createContext( @@ -51,12 +49,7 @@ interface SolanaWalletProviderProps { export const SolanaWalletProvider = ({ children, }: SolanaWalletProviderProps): ReactElement => { - const { chains } = useEnvironment(selectConfig, shallow); - const [{ endpoint }] = chains[Protocol.Solana]; - const { notify } = useNotification(); - const [connected, setConnected] = useState(false); - const [autoConnect, setAutoConnect] = useState(false); const [serviceId, setServiceId] = useLocalStorageState( "solanaWalletService", ); @@ -66,82 +59,38 @@ export const SolanaWalletProvider = ({ [serviceId], ); - const wallet = useMemo(() => { - if (!service) { - return null; - } - return service.adapter - ? new service.adapter() - : new Wallet(service.info.url, endpoint); - }, [service, endpoint]); - const previousWalletRef = useRef(wallet); - const address = wallet?.publicKey?.toBase58() ?? null; + const solana = useWalletAdapter(selectSolanaAdapter); + + const wallet: SolanaWalletAdapter | null = (service && solana) || null; + const address = wallet?.address ?? null; useEffect(() => { - const previousWallet = previousWalletRef.current; if (wallet) { - if (wallet !== previousWallet) { - previousWallet?.disconnect().catch(console.error); - setConnected(false); - // eslint-disable-next-line functional/immutable-data - previousWalletRef.current = wallet; - } - const handleConnect = (): void => { - const { publicKey } = wallet; - if (publicKey) { - setConnected(true); - notify( - "Wallet update", - `Connected to wallet ${shortenAddress(publicKey.toBase58())}`, - "info", - 7000, - ); - } + setConnected(true); }; const handleDisconnect = (): void => { setConnected(false); - notify("Wallet update", "Disconnected from wallet", "warning"); - }; - const handleError = (title: string, description: string): void => { - notify(title, description, "error"); }; + wallet.on("connect", handleConnect); wallet.on("disconnect", handleDisconnect); - wallet.on("error", handleError); return () => { wallet.removeListener("connect", handleConnect); wallet.removeListener("disconnect", handleDisconnect); - wallet.removeListener("error", handleError); }; } return () => { setConnected(false); - // eslint-disable-next-line functional/immutable-data - previousWalletRef.current = wallet; }; - }, [wallet, notify]); - - useEffect(() => { - if (wallet && autoConnect) { - wallet.connect().catch(console.error); - setAutoConnect(false); - } - }, [wallet, autoConnect]); + }, [wallet]); const [isModalVisible, setIsModalVisible] = useState(false); const select = useCallback(() => setIsModalVisible(true), []); const closeModal = useCallback(() => setIsModalVisible(false), []); - const createServiceClickHandler = - ({ id }: SolanaWalletService, callback?: () => any) => - (): void => { - setServiceId(id); - setAutoConnect(true); - callback?.(); - }; return ( {children} {isModalVisible && ( )} diff --git a/apps/ui/src/core/selectors/index.ts b/apps/ui/src/core/selectors/index.ts index f77f0a628..c185dba27 100644 --- a/apps/ui/src/core/selectors/index.ts +++ b/apps/ui/src/core/selectors/index.ts @@ -1,2 +1,3 @@ export * from "./environment"; +export * from "./walletAdapter"; export * from "./interactionState"; diff --git a/apps/ui/src/core/selectors/walletAdapter.ts b/apps/ui/src/core/selectors/walletAdapter.ts new file mode 100644 index 000000000..eb2c7b69a --- /dev/null +++ b/apps/ui/src/core/selectors/walletAdapter.ts @@ -0,0 +1,10 @@ +import type { WalletAdapterState } from "../store"; + +export const selectWalletAdapterApi = (state: WalletAdapterState) => ({ + connectService: state.connectService, + disconnectService: state.disconnectService, +}); + +export const selectEvmAdapter = (state: WalletAdapterState) => state.evm; + +export const selectSolanaAdapter = (state: WalletAdapterState) => state.solana; diff --git a/apps/ui/src/core/store/index.ts b/apps/ui/src/core/store/index.ts index 0029a1149..4b5785a1f 100644 --- a/apps/ui/src/core/store/index.ts +++ b/apps/ui/src/core/store/index.ts @@ -1,3 +1,4 @@ export * from "./useNotification"; export * from "./useEnvironment"; +export * from "./useWalletAdapter"; export * from "./useInteractionState"; diff --git a/apps/ui/src/core/store/tests/useWalletAdapter.test.ts b/apps/ui/src/core/store/tests/useWalletAdapter.test.ts new file mode 100644 index 000000000..f611458bc --- /dev/null +++ b/apps/ui/src/core/store/tests/useWalletAdapter.test.ts @@ -0,0 +1,87 @@ +import { act, renderHook } from "@testing-library/react-hooks"; + +import { useWalletAdapter } from ".."; +import { Protocol } from "../../../config"; +import { EvmWeb3WalletAdapter } from "../../../models/wallets/adapters/evm"; +import { PhantomAdapter } from "../../../models/wallets/adapters/solana/PhantomAdapter"; +import type { WalletAdapterState } from "../useWalletAdapter"; + +const getProtocolAdapter = (state: WalletAdapterState, protocol: Protocol) => + protocol === Protocol.Evm ? state.evm : state.solana; + +const createWalletAdapter = (protocol: Protocol) => + protocol === Protocol.Evm + ? new EvmWeb3WalletAdapter("serviceName", "serviceUrl", () => null) + : new PhantomAdapter(); + +describe("useWalletAdapter", () => { + it("initially returns empty adapters for EVM and Solana", async () => { + const { result } = renderHook(() => useWalletAdapter()); + expect(result.current.evm).toBeNull(); + expect(result.current.solana).toBeNull(); + }); + + [Protocol.Evm, Protocol.Solana].forEach((protocol) => { + describe(`Protocol ${protocol}`, () => { + it("connects to a service/protocol", async () => { + const { result } = renderHook(() => useWalletAdapter()); + + const adapter = createWalletAdapter(protocol); + const connectSpy = jest + .spyOn(adapter, "connect") + .mockImplementation(() => Promise.resolve()); + + await act(() => result.current.connectService(protocol, adapter)); + + expect(getProtocolAdapter(result.current, protocol)).toEqual(adapter); + expect(connectSpy).toHaveBeenCalledTimes(1); + }); + + it("connects to a service/protocol and disconnects the existing adapter", async () => { + const { result } = renderHook(() => useWalletAdapter()); + + const adapter = createWalletAdapter(protocol); + jest + .spyOn(adapter, "connect") + .mockImplementation(() => Promise.resolve()); + const disconnectSpy = jest + .spyOn(adapter, "disconnect") + .mockImplementation(() => Promise.resolve()); + + await act(() => result.current.connectService(protocol, adapter)); + + const secondAdapter = createWalletAdapter(protocol); + jest + .spyOn(adapter, "connect") + .mockImplementation(() => Promise.resolve()); + + await act(() => result.current.connectService(protocol, secondAdapter)); + + expect(getProtocolAdapter(result.current, protocol)).toEqual( + secondAdapter, + ); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + }); + + it("disconnects the protocol's adapter", async () => { + const { result } = renderHook(() => useWalletAdapter()); + + const adapter = createWalletAdapter(protocol); + jest + .spyOn(adapter, "connect") + .mockImplementation(() => Promise.resolve()); + + const disconnectSpy = jest + .spyOn(adapter, "disconnect") + .mockImplementation(() => Promise.resolve()); + + await act(() => result.current.connectService(protocol, adapter)); + + await act(() => result.current.disconnectService(protocol)); + + expect(getProtocolAdapter(result.current, protocol)).toBeNull(); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/apps/ui/src/core/store/useWalletAdapter.ts b/apps/ui/src/core/store/useWalletAdapter.ts new file mode 100644 index 000000000..a8ec17b72 --- /dev/null +++ b/apps/ui/src/core/store/useWalletAdapter.ts @@ -0,0 +1,74 @@ +/* eslint-disable functional/immutable-data */ +import { produce } from "immer"; +import type { GetState, SetState } from "zustand"; +import create from "zustand"; + +import { Protocol } from "../../config"; +import type { + EvmWalletAdapter, + SolanaWalletAdapter, + WalletAdapter, +} from "../../models"; + +export interface WalletAdapterState { + readonly evm: EvmWalletAdapter | null; + readonly solana: SolanaWalletAdapter | null; + readonly connectService: ( + protocol: Protocol, + adapter: WalletAdapter, + ) => Promise; + readonly disconnectService: (protocol: Protocol) => Promise; +} + +export const useWalletAdapter = create( + (set: SetState, get: GetState) => ({ + evm: null, + solana: null, + connectService: async (protocol, adapter) => { + const state = get(); + const previous = protocol === Protocol.Evm ? state.evm : state.solana; + + if (previous) await state.disconnectService(protocol); + + set( + produce((draft) => { + switch (adapter.protocol) { + case Protocol.Evm: { + draft.evm = adapter; + break; + } + case Protocol.Solana: { + draft.solana = adapter; + break; + } + } + }), + ); + + await adapter.connect().catch(console.error); + }, + disconnectService: async (protocol) => { + const state = get(); + const adapter = protocol === Protocol.Evm ? state.evm : state.solana; + await adapter?.disconnect().catch(console.error); + + set( + produce((draft) => { + switch (protocol) { + case Protocol.Evm: { + draft.evm = null; + break; + } + case Protocol.Solana: { + draft.solana = null; + break; + } + case Protocol.Cosmos: { + throw new Error(`Cosmos disconnect not implemented`); + } + } + }), + ); + }, + }), +); diff --git a/apps/ui/src/hooks/crossEcosystem/useWallets.ts b/apps/ui/src/hooks/crossEcosystem/useWallets.ts index 10c372357..177ea5a50 100644 --- a/apps/ui/src/hooks/crossEcosystem/useWallets.ts +++ b/apps/ui/src/hooks/crossEcosystem/useWallets.ts @@ -13,6 +13,9 @@ export interface Wallets extends ReadonlyRecord { readonly [EcosystemId.Terra]: { readonly address: null; readonly connected: false; + readonly service: null; + readonly wallet: null; + readonly setServiceId: null; }; readonly [EcosystemId.Bsc]: EvmWalletContextInterface; readonly [EcosystemId.Avalanche]: EvmWalletContextInterface; @@ -26,7 +29,13 @@ export interface Wallets extends ReadonlyRecord { export const useWallets = (): Wallets => ({ [EcosystemId.Solana]: useSolanaWallet(), [EcosystemId.Ethereum]: useEvmWallet(EcosystemId.Ethereum), - [EcosystemId.Terra]: { address: null, connected: false }, + [EcosystemId.Terra]: { + address: null, + connected: false, + service: null, + setServiceId: null, + wallet: null, + }, [EcosystemId.Bsc]: useEvmWallet(EcosystemId.Bsc), [EcosystemId.Avalanche]: useEvmWallet(EcosystemId.Avalanche), [EcosystemId.Polygon]: useEvmWallet(EcosystemId.Polygon), diff --git a/apps/ui/src/hooks/evm/useEvmChainId.ts b/apps/ui/src/hooks/evm/useEvmChainId.ts new file mode 100644 index 000000000..6117cc9b8 --- /dev/null +++ b/apps/ui/src/hooks/evm/useEvmChainId.ts @@ -0,0 +1,12 @@ +import type { EvmChainId, EvmEcosystemId } from "../../config"; +import { Protocol, chains } from "../../config"; +import { useEnvironment } from "../../core/store"; +import { findOrThrow } from "../../utils"; + +export const useEvmChainId = (ecosystemId: EvmEcosystemId): EvmChainId => { + const { env } = useEnvironment(); + return findOrThrow( + chains[env][Protocol.Evm], + (evmSpec) => evmSpec.ecosystem === ecosystemId, + ).chainId; +}; diff --git a/apps/ui/src/hooks/evm/useRegisterErc20Token.ts b/apps/ui/src/hooks/evm/useRegisterErc20Token.ts index 3c95386b0..de019a276 100644 --- a/apps/ui/src/hooks/evm/useRegisterErc20Token.ts +++ b/apps/ui/src/hooks/evm/useRegisterErc20Token.ts @@ -5,6 +5,8 @@ import { ecosystems } from "../../config"; import { useEvmWallet } from "../../contexts"; import { useNotification } from "../../core/store"; +import { useEvmChainId } from "./useEvmChainId"; + interface RegisterErc20TokenResult { readonly showPrompt: (tokenSpec: TokenSpec) => Promise; } @@ -14,6 +16,7 @@ export const useRegisterErc20Token = ( ): RegisterErc20TokenResult => { const { notify } = useNotification(); const { wallet } = useEvmWallet(ecosystemId); + const evmChainId = useEvmChainId(ecosystemId); const showPrompt = async (tokenSpec: TokenSpec): Promise => { if (!wallet) { @@ -26,7 +29,7 @@ export const useRegisterErc20Token = ( } try { - await wallet.registerToken(tokenSpec); + await wallet.registerToken(tokenSpec, ecosystemId, evmChainId); } catch (error) { notify("Error", "Failed to add token", "error"); Sentry.captureException(error); diff --git a/apps/ui/src/hooks/index.ts b/apps/ui/src/hooks/index.ts index 3b4b7be60..45cd724b0 100644 --- a/apps/ui/src/hooks/index.ts +++ b/apps/ui/src/hooks/index.ts @@ -4,4 +4,5 @@ export * from "./evm"; export * from "./solana"; export * from "./swim"; export * from "./utils"; +export * from "./wallets"; export * from "./wormhole"; diff --git a/apps/ui/src/hooks/interaction/useCreateInteractionState.test.ts b/apps/ui/src/hooks/interaction/useCreateInteractionState.test.ts index 06bd48c2c..9040e5cac 100644 --- a/apps/ui/src/hooks/interaction/useCreateInteractionState.test.ts +++ b/apps/ui/src/hooks/interaction/useCreateInteractionState.test.ts @@ -21,7 +21,6 @@ import { mockOf, renderHookWithAppContext } from "../../testUtils"; import { useCreateInteractionState } from "./useCreateInteractionState"; -// eslint-disable-next-line functional/immutable-data Object.defineProperty(global.self, "crypto", { value: { getRandomValues: (arr: string | readonly any[]) => diff --git a/apps/ui/src/hooks/wallets/index.ts b/apps/ui/src/hooks/wallets/index.ts new file mode 100644 index 000000000..e1ab029cc --- /dev/null +++ b/apps/ui/src/hooks/wallets/index.ts @@ -0,0 +1,2 @@ +export * from "./useWalletsMonitor"; +export * from "./useWalletService"; diff --git a/apps/ui/src/hooks/wallets/tests/useWalletService.test.ts b/apps/ui/src/hooks/wallets/tests/useWalletService.test.ts new file mode 100644 index 000000000..1005b2a6c --- /dev/null +++ b/apps/ui/src/hooks/wallets/tests/useWalletService.test.ts @@ -0,0 +1,78 @@ +import { act, renderHook } from "@testing-library/react-hooks"; + +import { Protocol, configs } from "../../../config"; +import { Env, useEnvironment, useWalletAdapter } from "../../../core/store"; +import { createAdapter } from "../../../models/wallets/services"; +import { mockOf } from "../../../testUtils"; +import { useWalletService } from "../useWalletService"; + +jest.mock("../../../core/store", () => ({ + ...jest.requireActual("../../../core/store"), + useEnvironment: jest.fn(), + useWalletAdapter: jest.fn(), +})); + +jest.mock("../../../models/wallets/services", () => ({ + createAdapter: jest.fn(), +})); + +// Make typescript happy with jest +const useEnvironmentMock = mockOf(useEnvironment); +const useWalletAdapterMock = mockOf(useWalletAdapter); +const createAdapterMock = mockOf(createAdapter); + +describe("useWalletService", () => { + beforeEach(() => { + useEnvironmentMock.mockReturnValue(configs[Env.Localnet]); + }); + + it("should call useWalletAdapter connectService with the correct createAdapter", async () => { + const config = configs[Env.Localnet]; + const [{ endpoint }] = config.chains[Protocol.Solana]; + + const connectServiceMock = jest.fn(); + const disconnectServiceMock = jest.fn(); + useWalletAdapterMock.mockReturnValue({ + connectService: connectServiceMock, + disconnectService: disconnectServiceMock, + }); + const serviceId = "metamask"; + const protocol = Protocol.Evm; + const mockAdapter = {}; + createAdapterMock.mockReturnValue(mockAdapter); + + const { result } = renderHook(() => useWalletService()); + + await act(async () => { + await result.current.connectService(serviceId, protocol); + }); + + expect(connectServiceMock).toBeCalledTimes(1); + expect(connectServiceMock).toBeCalledWith(protocol, mockAdapter); + expect(createAdapterMock).toBeCalledTimes(1); + expect(createAdapterMock).toHaveBeenCalledWith( + "metamask", + Protocol.Evm, + endpoint, + ); + }); + + it("should call useWalletAdapter disconnectService", async () => { + const connectServiceMock = jest.fn(); + const disconnectServiceMock = jest.fn(); + useWalletAdapterMock.mockReturnValue({ + connectService: connectServiceMock, + disconnectService: disconnectServiceMock, + }); + const protocol = Protocol.Evm; + + const { result } = renderHook(() => useWalletService()); + + await act(async () => { + await result.current.disconnectService(protocol); + }); + + expect(disconnectServiceMock).toBeCalledTimes(1); + expect(disconnectServiceMock).toBeCalledWith(protocol); + }); +}); diff --git a/apps/ui/src/hooks/wallets/tests/useWalletsMonitor.test.ts b/apps/ui/src/hooks/wallets/tests/useWalletsMonitor.test.ts new file mode 100644 index 000000000..a9dc5dcbe --- /dev/null +++ b/apps/ui/src/hooks/wallets/tests/useWalletsMonitor.test.ts @@ -0,0 +1,96 @@ +import { act, renderHook } from "@testing-library/react-hooks"; + +import { useNotification, useWalletAdapter } from "../../../core/store"; +import { EvmWeb3WalletAdapter } from "../../../models/wallets/adapters/evm"; +import { mockOf } from "../../../testUtils"; +import { useWalletsMonitor } from "../useWalletsMonitor"; + +jest.mock("../../../core/store", () => ({ + useNotification: jest.fn(), + useWalletAdapter: jest.fn(), +})); + +// Make typescript happy with jest +const useNotificationMock = mockOf(useNotification); +const useWalletAdapterMock = mockOf(useWalletAdapter); + +describe("useWalletsMonitor", () => { + it("should notify on every event", () => { + const notify = jest.fn(); + useNotificationMock.mockReturnValue({ notify }); + + const adapter = new EvmWeb3WalletAdapter( + "serviceName", + "serviceUrl", + () => null, + ); + // we use the address in the connect notification + adapter.address = "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1"; + + useWalletAdapterMock.mockReturnValue({ evm: adapter, solana: null }); + + renderHook(() => useWalletsMonitor()); + + act(() => { + adapter.emit("connect"); + }); + + expect(notify).toBeCalledTimes(1); + expect(notify.mock.calls[0]).toEqual([ + "Wallet update", + expect.stringContaining("Connected to wallet"), + "info", + 7000, + ]); + + act(() => { + adapter.emit("disconnect"); + }); + + expect(notify).toBeCalledTimes(2); + expect(notify.mock.calls[1]).toEqual([ + "Wallet update", + "Disconnected from wallet", + "warning", + ]); + + const errorTitle = "error title"; + const errorDescription = "error description"; + + act(() => { + adapter.emit("error", errorTitle, errorDescription); + }); + + expect(notify).toBeCalledTimes(3); + expect(notify.mock.calls[2]).toEqual([ + "error title", + "error description", + "error", + ]); + }); + + it("should not notify when wallet adapter is stale", () => { + const notify = jest.fn(); + useNotificationMock.mockReturnValue({ notify }); + + const adapter = new EvmWeb3WalletAdapter( + "serviceName", + "serviceUrl", + () => null, + ); + + useWalletAdapterMock.mockReturnValue({ evm: adapter, solana: null }); + + const { rerender } = renderHook(() => useWalletsMonitor()); + + useWalletAdapterMock.mockReturnValue({ evm: null, solana: null }); + + rerender(); + + act(() => { + adapter.emit("disconnect"); + }); + + expect(notify).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ui/src/hooks/wallets/useWalletService.ts b/apps/ui/src/hooks/wallets/useWalletService.ts new file mode 100644 index 000000000..4f9735663 --- /dev/null +++ b/apps/ui/src/hooks/wallets/useWalletService.ts @@ -0,0 +1,30 @@ +import shallow from "zustand/shallow.js"; + +import { Protocol } from "../../config"; +import { selectConfig, selectWalletAdapterApi } from "../../core/selectors"; +import type { WalletAdapterState } from "../../core/store"; +import { useEnvironment, useWalletAdapter } from "../../core/store"; +import type { WalletService } from "../../models"; +import { createAdapter } from "../../models"; + +type WalletServiceApi = Pick & { + readonly connectService: ( + serviceId: WalletService["id"], + protocol: Protocol, + ) => Promise; +}; + +export const useWalletService = (): WalletServiceApi => { + const { connectService, disconnectService } = useWalletAdapter( + selectWalletAdapterApi, + shallow, + ); + const { chains } = useEnvironment(selectConfig, shallow); + const [{ endpoint }] = chains[Protocol.Solana]; + + return { + connectService: (serviceId: WalletService["id"], protocol: Protocol) => + connectService(protocol, createAdapter(serviceId, protocol, endpoint)), + disconnectService, + }; +}; diff --git a/apps/ui/src/hooks/wallets/useWalletsMonitor.ts b/apps/ui/src/hooks/wallets/useWalletsMonitor.ts new file mode 100644 index 000000000..e3e54f651 --- /dev/null +++ b/apps/ui/src/hooks/wallets/useWalletsMonitor.ts @@ -0,0 +1,61 @@ +import { useEffect } from "react"; + +import { useNotification, useWalletAdapter } from "../../core/store"; +import { isNotNull, shortenAddress } from "../../utils"; + +type WalletAdapterListeners = { + readonly connect: () => void; + readonly disconnect: () => void; + readonly error: (title: string, description: string) => void; +}; + +export const useWalletsMonitor = (): void => { + const { notify } = useNotification(); + const { evm, solana } = useWalletAdapter(); + + useEffect(() => { + const walletAdapters = [evm, solana].filter(isNotNull); + + if (!walletAdapters.length) return; + + const listeners: readonly WalletAdapterListeners[] = walletAdapters.map( + (walletAdapter) => { + const handleConnect = (): void => { + if (walletAdapter.address) { + notify( + "Wallet update", + `Connected to wallet ${shortenAddress(walletAdapter.address)}`, + "info", + 7000, + ); + } + }; + const handleDisconnect = (): void => { + notify("Wallet update", "Disconnected from wallet", "warning"); + }; + const handleError = (title: string, description: string): void => { + notify(title, description, "error"); + }; + + walletAdapter.on("connect", handleConnect); + walletAdapter.on("disconnect", handleDisconnect); + walletAdapter.on("error", handleError); + + return { + connect: handleConnect, + disconnect: handleDisconnect, + error: handleError, + }; + }, + ); + + return () => { + walletAdapters.forEach((walletAdapter, index) => { + const { connect, disconnect, error } = listeners[index]; + walletAdapter.off("connect", connect); + walletAdapter.off("disconnect", disconnect); + walletAdapter.off("error", error); + }); + }; + }, [evm, solana, notify]); +}; diff --git a/apps/ui/src/images/ecosystems/ethereum-color.svg b/apps/ui/src/images/ecosystems/ethereum-color.svg new file mode 100644 index 000000000..83f801d8e --- /dev/null +++ b/apps/ui/src/images/ecosystems/ethereum-color.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ui/src/models/wallets/adapters/evm/EvmWalletAdapter.ts b/apps/ui/src/models/wallets/adapters/evm/EvmWalletAdapter.ts index 42c1bb1b2..718d3650f 100644 --- a/apps/ui/src/models/wallets/adapters/evm/EvmWalletAdapter.ts +++ b/apps/ui/src/models/wallets/adapters/evm/EvmWalletAdapter.ts @@ -3,13 +3,8 @@ import type { Signer } from "ethers"; import { ethers } from "ethers"; import EventEmitter from "eventemitter3"; -import type { EvmChainId, EvmEcosystemId, TokenSpec } from "../../../../config"; -import { - Protocol, - allUniqueChains, - ecosystems, - evmChainIdToEcosystem, -} from "../../../../config"; +import type { EcosystemId, EvmChainId, TokenSpec } from "../../../../config"; +import { Protocol, allUniqueChains, ecosystems } from "../../../../config"; import { sleep } from "../../../../utils"; type Web3Provider = ethers.providers.Web3Provider; @@ -23,51 +18,51 @@ const METAMASK_methodNotFound = -32601; // TODO: Make this class wallet-agnostic, currently assumes MetaMask. export interface EvmWalletAdapter extends EventEmitter { - readonly chainId: EvmChainId; readonly signer: Signer | null; readonly address: string | null; + readonly connected: boolean; readonly connect: () => Promise; readonly disconnect: () => Promise; - readonly switchNetwork: () => Promise; - readonly registerToken: (tokenSpec: TokenSpec) => Promise; + readonly switchNetwork: (chainId: EvmChainId) => Promise; + readonly registerToken: ( + tokenSpec: TokenSpec, + ecosystemId: EcosystemId, + chainId: EvmChainId, + ) => Promise; + readonly protocol: Protocol.Evm; } export class EvmWeb3WalletAdapter extends EventEmitter implements EvmWalletAdapter { - readonly chainId: EvmChainId; readonly serviceName: string; readonly serviceUrl: string; + readonly protocol: Protocol.Evm; address: string | null; private readonly getWalletProvider: () => Web3Provider | null; private connecting: boolean; constructor( - chainId: EvmChainId, serviceName: string, serviceUrl: string, getWalletProvider: () => Web3Provider | null, ) { super(); - this.chainId = chainId; this.serviceName = serviceName; this.serviceUrl = serviceUrl; this.getWalletProvider = getWalletProvider; this.address = null; this.connecting = false; + this.protocol = Protocol.Evm; } - public get signer(): Signer | null { - return this.getWalletProvider()?.getSigner() ?? null; + public get connected(): boolean { + return !!this.address; } - private get ecosystem(): EvmEcosystemId { - return evmChainIdToEcosystem[this.chainId]; - } - - private get sentryContextKey(): string { - return `${ecosystems[this.ecosystem].displayName} Wallet`; + public get signer(): Signer | null { + return this.getWalletProvider()?.getSigner() ?? null; } private get walletProvider(): Web3Provider | null { @@ -96,13 +91,14 @@ export class EvmWeb3WalletAdapter this.address = address; this.emit("connect", this.address); - Sentry.setContext(this.sentryContextKey, { + const sentryContextKey = await this.sentryContextKey(); + Sentry.setContext(sentryContextKey, { walletName: this.serviceName, address: this.address, }); Sentry.addBreadcrumb({ category: "wallet", - message: `Connected to ${this.sentryContextKey} ${this.address}`, + message: `Connected to ${sentryContextKey} ${this.address}`, level: Sentry.Severity.Info, }); } catch (error) { @@ -119,28 +115,29 @@ export class EvmWeb3WalletAdapter this.address = null; this.emit("disconnect"); - Sentry.setContext(this.sentryContextKey, {}); + const sentryContextKey = await this.sentryContextKey(); + Sentry.setContext(sentryContextKey, {}); Sentry.addBreadcrumb({ category: "wallet", - message: `Disconnected from ${this.sentryContextKey}`, + message: `Disconnected from ${sentryContextKey}`, level: Sentry.Severity.Info, }); } } - async switchNetwork(): Promise { + async switchNetwork(chainId: EvmChainId): Promise { if (!this.walletProvider) { throw new Error("No wallet provider"); } try { await this.walletProvider.send("wallet_switchEthereumChain", [ - { chainId: hexValue(this.chainId) }, + { chainId: hexValue(chainId) }, ]); } catch (switchError: any) { if (switchError.code === METAMASK_unrecognizedChainId) { const evmSpec = allUniqueChains[Protocol.Evm].find( - (spec) => spec.chainId === this.chainId, + (spec) => spec.chainId === chainId, ); if (!evmSpec) { throw new Error("No EVM spec found for chain ID"); @@ -148,7 +145,7 @@ export class EvmWeb3WalletAdapter // this also asks to switch to that chain afterwards await this.walletProvider.send("wallet_addEthereumChain", [ { - chainId: hexValue(this.chainId), + chainId: hexValue(chainId), chainName: evmSpec.chainName, nativeCurrency: evmSpec.nativeCurrency, rpcUrls: evmSpec.rpcUrls, @@ -166,17 +163,21 @@ export class EvmWeb3WalletAdapter } } - async registerToken(tokenSpec: TokenSpec): Promise { + async registerToken( + tokenSpec: TokenSpec, + ecosystemId: EcosystemId, + chainId: EvmChainId, + ): Promise { if (!this.walletProvider) { throw new Error("No wallet provider"); } - const details = tokenSpec.detailsByEcosystem.get(this.ecosystem); + const details = tokenSpec.detailsByEcosystem.get(ecosystemId); if (!details) { throw new Error( - `No ${ecosystems[this.ecosystem].displayName} details for token`, + `No ${ecosystems[ecosystemId].displayName} details for token`, ); } - await this.switchNetwork(); + await this.switchNetwork(chainId); await sleep(200); // Sleep briefly, otherwise Metamask ignores the second prompt @@ -194,4 +195,13 @@ export class EvmWeb3WalletAdapter ); return wasAdded; } + + private async sentryContextKey(): Promise { + try { + const network = await this.getWalletProvider()?.getNetwork(); + return `${network?.name || "Unknown Network"} Wallet`; + } catch { + return `Unknown Network Wallet`; + } + } } diff --git a/apps/ui/src/models/wallets/adapters/evm/MathWalletAdapter.ts b/apps/ui/src/models/wallets/adapters/evm/MathWalletAdapter.ts index fd196b420..3d6a24650 100644 --- a/apps/ui/src/models/wallets/adapters/evm/MathWalletAdapter.ts +++ b/apps/ui/src/models/wallets/adapters/evm/MathWalletAdapter.ts @@ -8,12 +8,7 @@ const getMathWalletService = (): ethers.providers.Web3Provider | null => : null; export class MathWalletAdapter extends EvmWeb3WalletAdapter { - constructor(chainId: number) { - super( - chainId, - "MathWallet", - "https://mathwallet.org", - getMathWalletService, - ); + constructor() { + super("MathWallet", "https://mathwallet.org", getMathWalletService); } } diff --git a/apps/ui/src/models/wallets/adapters/evm/MetaMaskAdapter.ts b/apps/ui/src/models/wallets/adapters/evm/MetaMaskAdapter.ts index 3e0ac683a..96f20c925 100644 --- a/apps/ui/src/models/wallets/adapters/evm/MetaMaskAdapter.ts +++ b/apps/ui/src/models/wallets/adapters/evm/MetaMaskAdapter.ts @@ -8,8 +8,8 @@ const getMetaMaskService = (): ethers.providers.Web3Provider | null => : null; export class MetaMaskAdapter extends EvmWeb3WalletAdapter { - constructor(chainId: number) { - super(chainId, "MetaMask", "https://metamask.io", getMetaMaskService); + constructor() { + super("MetaMask", "https://metamask.io", getMetaMaskService); } async connect(): Promise { diff --git a/apps/ui/src/models/wallets/adapters/solana/SolanaDefaultWalletAdapter.ts b/apps/ui/src/models/wallets/adapters/solana/SolanaDefaultWalletAdapter.ts new file mode 100644 index 000000000..94caaa864 --- /dev/null +++ b/apps/ui/src/models/wallets/adapters/solana/SolanaDefaultWalletAdapter.ts @@ -0,0 +1,21 @@ +import Wallet from "@project-serum/sol-wallet-adapter"; + +import { Protocol } from "../../../../config"; + +import type { SolanaWalletAdapter } from "./SolanaWalletAdapter"; + +export class SolanaDefaultWalletAdapter + extends Wallet + implements SolanaWalletAdapter +{ + readonly protocol: Protocol.Solana; + + constructor(provider: unknown, network: string) { + super(provider, network); + this.protocol = Protocol.Solana; + } + + public get address(): string | null { + return this.publicKey?.toBase58() || null; + } +} diff --git a/apps/ui/src/models/wallets/adapters/solana/SolanaWalletAdapter.ts b/apps/ui/src/models/wallets/adapters/solana/SolanaWalletAdapter.ts index 22b0a6c0e..5dae46d9d 100644 --- a/apps/ui/src/models/wallets/adapters/solana/SolanaWalletAdapter.ts +++ b/apps/ui/src/models/wallets/adapters/solana/SolanaWalletAdapter.ts @@ -3,11 +3,14 @@ import type { Transaction } from "@solana/web3.js"; import { PublicKey } from "@solana/web3.js"; import EventEmitter from "eventemitter3"; +import { Protocol } from "../../../../config"; import { SolanaWalletError } from "../../../../errors"; // TODO: Migrate to @solana/wallet-adapter. export interface SolanaWalletAdapter extends EventEmitter { readonly publicKey: PublicKey | null; + readonly address: string | null; + readonly connected: boolean; readonly signTransaction: (transaction: Transaction) => Promise; readonly signAllTransactions: ( // eslint-disable-next-line functional/prefer-readonly-type @@ -16,6 +19,7 @@ export interface SolanaWalletAdapter extends EventEmitter { ) => Promise; readonly connect: () => Promise; readonly disconnect: () => Promise; + readonly protocol: Protocol.Solana; } export class SolanaWeb3WalletAdapter @@ -25,6 +29,7 @@ export class SolanaWeb3WalletAdapter serviceName: string; serviceUrl: string; publicKey: PublicKey | null; + readonly protocol: Protocol.Solana; protected getService: () => any; protected connecting: boolean; @@ -35,10 +40,19 @@ export class SolanaWeb3WalletAdapter this.getService = getService; this.publicKey = null; this.connecting = false; + this.protocol = Protocol.Solana; this.on("connect", this.onPublicKeySet); } + public get address(): string | null { + return this.publicKey?.toBase58() || null; + } + + public get connected(): boolean { + return !!this.address; + } + protected get service(): any { return this.getService(); } diff --git a/apps/ui/src/models/wallets/adapters/solana/index.ts b/apps/ui/src/models/wallets/adapters/solana/index.ts index bd0f03a45..02c9beeba 100644 --- a/apps/ui/src/models/wallets/adapters/solana/index.ts +++ b/apps/ui/src/models/wallets/adapters/solana/index.ts @@ -1,5 +1,6 @@ import { MathWalletAdapter } from "./MathWalletAdapter"; import { PhantomAdapter } from "./PhantomAdapter"; +import { SolanaDefaultWalletAdapter } from "./SolanaDefaultWalletAdapter"; import { SolongAdapter } from "./SolongAdapter"; import { LedgerWalletAdapter } from "./ledger"; @@ -10,4 +11,5 @@ export const solanaAdapters = { MathWalletAdapter, PhantomAdapter, SolongAdapter, + SolanaDefaultWalletAdapter, }; diff --git a/apps/ui/src/models/wallets/adapters/solana/ledger/index.ts b/apps/ui/src/models/wallets/adapters/solana/ledger/index.ts index bf33fe46d..451e0ccee 100644 --- a/apps/ui/src/models/wallets/adapters/solana/ledger/index.ts +++ b/apps/ui/src/models/wallets/adapters/solana/ledger/index.ts @@ -3,6 +3,7 @@ import TransportWebUSB from "@ledgerhq/hw-transport-webusb"; import type { PublicKey, Transaction } from "@solana/web3.js"; import EventEmitter from "eventemitter3"; +import { Protocol } from "../../../../../config"; import type { SolanaWalletAdapter } from "../SolanaWalletAdapter"; import { getPublicKey, signTransaction } from "./core"; @@ -14,18 +15,28 @@ export class LedgerWalletAdapter _connecting: boolean; _publicKey: PublicKey | null; _transport: Transport | null; + readonly protocol: Protocol.Solana; constructor() { super(); this._connecting = false; this._publicKey = null; this._transport = null; + this.protocol = Protocol.Solana; } get publicKey(): PublicKey | null { return this._publicKey; } + public get address(): string | null { + return this._publicKey?.toBase58() || null; + } + + public get connected(): boolean { + return !!this.address; + } + async signTransaction(transaction: Transaction): Promise { if (!this._transport || !this._publicKey) { throw new Error("Not connected to Ledger"); diff --git a/apps/ui/src/models/wallets/services.tsx b/apps/ui/src/models/wallets/services.tsx index 6e652497f..be6942e58 100644 --- a/apps/ui/src/models/wallets/services.tsx +++ b/apps/ui/src/models/wallets/services.tsx @@ -2,11 +2,17 @@ import { EuiButtonIcon } from "@elastic/eui"; import type { ReactElement } from "react"; import type { Ecosystem } from "../../config"; -import { EcosystemId, ecosystems } from "../../config"; +import { + EcosystemId, + Protocol, + ecosystems, + getEcosystemsForProtocol, +} from "../../config"; import LEDGER_ICON from "../../images/wallets/ledger.svg"; import MATHWALLET_ICON from "../../images/wallets/mathwallet.svg"; import METAMASK_ICON from "../../images/wallets/metamask.svg"; import PHANTOM_ICON from "../../images/wallets/phantom.svg"; +import { findOrThrow } from "../../utils"; import type { EvmWalletAdapter, @@ -20,12 +26,13 @@ export interface WalletServiceInfo { readonly url: string; readonly icon: string; readonly helpText?: ReactElement; + readonly ecosystem: Ecosystem; } export interface WalletService { readonly id: string; readonly info: WalletServiceInfo; - readonly adapter?: new (chainId: number) => T; + readonly adapter?: new () => T; } export interface SolanaWalletService< @@ -41,47 +48,54 @@ const solletInfo: WalletServiceInfo = { name: "Sollet", url: "https://www.sollet.io", icon: `${OYSTER_ASSETS_URL}sollet.svg`, + ecosystem: ecosystems[EcosystemId.Solana], }; const solongInfo: WalletServiceInfo = { name: "Solong", url: "https://solongwallet.com", icon: `${OYSTER_ASSETS_URL}solong.png`, + ecosystem: ecosystems[EcosystemId.Solana], }; const solflareInfo: WalletServiceInfo = { name: "Solflare", url: "https://solflare.com/access-wallet", icon: `${OYSTER_ASSETS_URL}solflare.svg`, + ecosystem: ecosystems[EcosystemId.Solana], }; const mathWalletInfo: WalletServiceInfo = { name: "MathWallet", url: "https://www.mathwallet.org", icon: MATHWALLET_ICON, + ecosystem: ecosystems[EcosystemId.Solana], }; const ledgerInfo: WalletServiceInfo = { name: "Ledger", url: "https://www.ledger.com", icon: LEDGER_ICON, + ecosystem: ecosystems[EcosystemId.Solana], }; const phantomInfo: WalletServiceInfo = { name: "Phantom", url: "https://phantom.app", icon: PHANTOM_ICON, + ecosystem: ecosystems[EcosystemId.Solana], }; -const metaMaskInfo: WalletServiceInfo = { +const metaMaskInfo: Omit = { name: "Metamask", url: "https://metamask.io", icon: METAMASK_ICON, }; -const addHelpTextToMetaMaskInfo = ( - info: WalletServiceInfo, +const addMetaMaskEcosystemInfo = ( + info: Omit, ecosystem: Ecosystem, url: string, ): WalletServiceInfo => { const title = `How to add ${ecosystem.displayName} to Metamask`; return { ...info, + ecosystem, helpText: ( [ [ { id: "metamask", - info: metaMaskInfo, + info: ethereumMetaMaskInfo, adapter: ethereum.MetaMaskAdapter, }, ]; @@ -233,3 +252,46 @@ export const WALLET_SERVICES: Record = { [EcosystemId.Karura]: KARURA_WALLET_SERVICES, [EcosystemId.Acala]: ACALA_WALLET_SERVICES, }; + +const findServiceForProtocol = ( + serviceId: string, + protocol: Protocol, +): WalletService => { + const ecosystemIds = getEcosystemsForProtocol(protocol); + const protocolWalletServices = ecosystemIds.flatMap( + (ecosystemId) => WALLET_SERVICES[ecosystemId], + ); + return findOrThrow( + protocolWalletServices, + (service) => service.id === serviceId, + ); +}; + +export const createAdapter = ( + serviceId: WalletService["id"], + protocol: Protocol, + solanaEndpoint: string, +): WalletAdapter => { + const service = findServiceForProtocol(serviceId, protocol); + + switch (protocol) { + case Protocol.Evm: { + if (!service.adapter) + throw new Error(`Adapter is required for protocol ${protocol}`); + return new service.adapter(); + } + case Protocol.Solana: { + if (service.adapter) { + return new service.adapter(); + } else { + return new adapters.solana.SolanaDefaultWalletAdapter( + service.info.url, + solanaEndpoint, + ); + } + } + case Protocol.Cosmos: { + throw new Error(`Cosmos adapters not implemented yet`); + } + } +}; diff --git a/apps/ui/src/models/wormhole/attestation.ts b/apps/ui/src/models/wormhole/attestation.ts index 355cc32c3..50047cc72 100644 --- a/apps/ui/src/models/wormhole/attestation.ts +++ b/apps/ui/src/models/wormhole/attestation.ts @@ -76,7 +76,7 @@ export const setUpSplTokensOnEvm = async ( throw new Error("No EVM chain wallet signer"); } - await evmWallet.switchNetwork(); + await evmWallet.switchNetwork(evmChain.chainId); const attestations = await Promise.all( mintAddresses.map((mintAddress) => @@ -129,7 +129,7 @@ export const attestErc20Token = async ( throw new Error("No EVM chain wallet signer"); } - await evmWallet.switchNetwork(); + await evmWallet.switchNetwork(evmChain.chainId); const receipt = await attestFromEth( evmChain.wormhole.tokenBridge, diff --git a/apps/ui/src/models/wormhole/evm.ts b/apps/ui/src/models/wormhole/evm.ts index 5450c1b22..b3ecaf1cf 100644 --- a/apps/ui/src/models/wormhole/evm.ts +++ b/apps/ui/src/models/wormhole/evm.ts @@ -68,7 +68,7 @@ export const lockEvmToken = async ({ throw new Error("No EVM signer"); } - await evmWallet.switchNetwork(); + await evmWallet.switchNetwork(evmChain.chainId); const transferAmountAtomicString = amount.toAtomicString(evmChain.ecosystem); const allowance = await getAllowanceEth( @@ -142,7 +142,7 @@ export const unlockEvmToken = async ( throw new Error("Missing EVM signer"); } - await evmWallet.switchNetwork(); + await evmWallet.switchNetwork(evmChain.chainId); const redeemResponse = await redeemOnEth( interactionId, diff --git a/apps/ui/src/pages/HomePage.tsx b/apps/ui/src/pages/HomePage.tsx index ceb12babc..11ddc5c76 100644 --- a/apps/ui/src/pages/HomePage.tsx +++ b/apps/ui/src/pages/HomePage.tsx @@ -2,23 +2,27 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, + EuiHideFor, EuiIcon, EuiImage, EuiPage, EuiPageBody, EuiPanel, + EuiShowFor, EuiSpacer, EuiText, EuiTextColor, EuiTitle, } from "@elastic/eui"; -import type { ReactElement } from "react"; +import type { ReactElement, VFC } from "react"; import { useHistory } from "react-router"; import { SwimIconType } from "../components/CustomIconType"; import { GlassPanel } from "../components/GlassPanel"; import { InvestorsList } from "../components/InvestorsList"; import { Roadmap } from "../components/Roadmap"; +import type { Ecosystem } from "../config"; +import { EcosystemId, ecosystems } from "../config"; import { useTitle } from "../hooks"; import DIAGRAM from "../images/diagram.svg"; import DISCORD_SVG from "../images/social/discord.svg"; @@ -29,6 +33,14 @@ import "./HomePage.scss"; const HomePage = (): ReactElement => { useTitle(""); const history = useHistory(); + const promotedEcosystems = [ + ecosystems[EcosystemId.Solana], + ecosystems[EcosystemId.Ethereum], + ecosystems[EcosystemId.Bsc], + ecosystems[EcosystemId.Avalanche], + ecosystems[EcosystemId.Polygon], + ]; + return ( @@ -37,6 +49,20 @@ const HomePage = (): ReactElement => { + + + + {promotedEcosystems.map((ecosystem) => ( + + ))} + + + +
@@ -553,3 +579,18 @@ const HomePage = (): ReactElement => { }; export default HomePage; + +type PromotedEcosystemProps = { + readonly ecosystem: Ecosystem; +}; + +const PromotedEcosystem: VFC = ({ ecosystem }) => ( + + + + + + + + +); diff --git a/apps/ui/src/pages/TestPage.tsx b/apps/ui/src/pages/TestPage.tsx index 6c572efd2..84c388034 100644 --- a/apps/ui/src/pages/TestPage.tsx +++ b/apps/ui/src/pages/TestPage.tsx @@ -40,7 +40,7 @@ import { setUpErc20Tokens, setUpSplTokensOnEvm, } from "../models"; -import { shortenAddress, sleep } from "../utils"; +import { sleep } from "../utils"; const SWIM_POOL_FEE_DECIMALS = 6; @@ -165,10 +165,11 @@ const TestPage = (): ReactElement => { throw new Error(`No ${ecosystem} wallet`); } - await evmWallet.switchNetwork(); - const evmChain = ecosystem === EcosystemId.Ethereum ? ethereumChain : bscChain; + + await evmWallet.switchNetwork(evmChain.chainId); + const splTokenSetupResult = await setUpSplTokensOnEvm( wormholeConfig, solanaChain.wormhole, @@ -280,7 +281,7 @@ const TestPage = (): ReactElement => { const hugeAmount = "1" + "0".repeat(32); - await ethereumWallet.switchNetwork(); + await ethereumWallet.switchNetwork(ethereumChain.chainId); for (const token of tokens) { const ethereumDetails = token.detailsByEcosystem.get( EcosystemId.Ethereum, @@ -297,7 +298,7 @@ const TestPage = (): ReactElement => { console.info(receipt.transactionHash); } - await bscWallet.switchNetwork(); + await bscWallet.switchNetwork(bscChain.chainId); for (const token of tokens) { const bscDetails = token.detailsByEcosystem.get(EcosystemId.Bsc); if (!bscDetails) { @@ -320,7 +321,7 @@ const TestPage = (): ReactElement => { const zero = "0"; - await ethereumWallet.switchNetwork(); + await ethereumWallet.switchNetwork(ethereumChain.chainId); for (const token of tokens) { const ethereumDetails = token.detailsByEcosystem.get( EcosystemId.Ethereum, @@ -337,7 +338,7 @@ const TestPage = (): ReactElement => { console.info(receipt.transactionHash); } - await bscWallet.switchNetwork(); + await bscWallet.switchNetwork(bscChain.chainId); for (const token of tokens) { const bscDetails = token.detailsByEcosystem.get(EcosystemId.Bsc); if (!bscDetails) { @@ -401,17 +402,11 @@ const TestPage = (): ReactElement => { - - {solanaAddress && shortenAddress(solanaAddress)} - +   - - {ethereumAddress && shortenAddress(ethereumAddress)} - +   - - {bscAddress && shortenAddress(bscAddress)} - +   Notify