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