diff --git a/.env b/.env index 3e16ff104..c2c7bf583 100644 --- a/.env +++ b/.env @@ -9,6 +9,7 @@ REACT_APP_FEATURE_FLAG_MULTI_APP_STAKING=true REACT_APP_FEATURE_FLAG_FEEDBACK_MODULE=false REACT_APP_FEATURE_FLAG_POSTHOG=false REACT_APP_FEATURE_FLAG_SENTRY=$SENTRY_SUPPORT +REACT_APP_FEATURE_FLAG_LEDGER_LIVE=true REACT_APP_SENTRY_DSN=$SENTRY_DSN REACT_APP_ELECTRUM_PROTOCOL=$ELECTRUM_PROTOCOL diff --git a/.env.production b/.env.production index 8977adb36..d836378d8 100644 --- a/.env.production +++ b/.env.production @@ -10,6 +10,7 @@ REACT_APP_FEATURE_FLAG_POSTHOG=$POSTHOG_SUPPORT REACT_APP_POSTHOG_API_KEY=$POSTHOG_API_KEY REACT_APP_POSTHOG_HOSTNAME_HTTP=$POSTHOG_HOSTNAME_HTTP REACT_APP_FEATURE_FLAG_SENTRY=$SENTRY_SUPPORT +REACT_APP_FEATURE_FLAG_LEDGER_LIVE=true REACT_APP_SENTRY_DSN=$SENTRY_DSN REACT_APP_ELECTRUM_PROTOCOL=$ELECTRUM_PROTOCOL diff --git a/package.json b/package.json index b273657cd..2b0c091fb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@keep-network/tbtc": "development", "@keep-network/tbtc-v2": "development", "@keep-network/tbtc-v2.ts": "development", + "@ledgerhq/connect-kit-loader": "^1.1.2", "@reduxjs/toolkit": "^1.6.1", "@rehooks/local-storage": "^2.4.4", "@sentry/react": "^7.33.0", diff --git a/src/components/Modal/SelectWalletModal/ConnectLedgerLive.tsx b/src/components/Modal/SelectWalletModal/ConnectLedgerLive.tsx new file mode 100644 index 000000000..87c055205 --- /dev/null +++ b/src/components/Modal/SelectWalletModal/ConnectLedgerLive.tsx @@ -0,0 +1,43 @@ +import { FC } from "react" +import { useWeb3React } from "@web3-react/core" +import { ledgerLive } from "../../../web3/connectors" +import { WalletConnectionModalBase } from "./components" +import { ConnectionError, WalletType } from "../../../enums" +import doesErrorInclude from "../../../web3/utils/doesErrorInclude" +import { LedgerLight } from "../../../static/icons/LedgerLight" +import { LedgerDark } from "../../../static/icons/LedgerDark" +import { useColorModeValue } from "@threshold-network/components" + +const ConnectLedgerLive: FC<{ goBack: () => void; closeModal: () => void }> = ({ + goBack, + closeModal, +}) => { + const { activate, error } = useWeb3React() + + const connectionRejected = doesErrorInclude( + error, + ConnectionError.RejectedMetamaskConnection + ) + + const walletIcon = useColorModeValue(LedgerLight, LedgerDark) + + return ( + activate(ledgerLive) : undefined} + walletType={WalletType.LedgerLive} + shouldForceCloseModal + /> + ) +} + +export default ConnectLedgerLive diff --git a/src/components/Modal/SelectWalletModal/ConnectWalletConnect.tsx b/src/components/Modal/SelectWalletModal/ConnectWalletConnect.tsx index 3f689645c..22edd6c52 100644 --- a/src/components/Modal/SelectWalletModal/ConnectWalletConnect.tsx +++ b/src/components/Modal/SelectWalletModal/ConnectWalletConnect.tsx @@ -36,6 +36,7 @@ const ConnectWalletConnect: FC<{ activate(walletConnect) }} walletType={WalletType.WalletConnect} + shouldForceCloseModal > = ({ walletOptions, onSelect }) => { return ( }> - {walletOptions.map((opt) => ( - - ))} + + ) + })} ) } diff --git a/src/components/Modal/SelectWalletModal/components/WalletConnectionModalBase.tsx b/src/components/Modal/SelectWalletModal/components/WalletConnectionModalBase.tsx index 69ccb94c2..1045c54cf 100644 --- a/src/components/Modal/SelectWalletModal/components/WalletConnectionModalBase.tsx +++ b/src/components/Modal/SelectWalletModal/components/WalletConnectionModalBase.tsx @@ -27,6 +27,14 @@ interface Props extends BaseModalProps { goBack: () => void connector?: AbstractConnector walletType: WalletType + /** + * This is required for some of the providers (for example WalletConnect v2), + * because they have their own modal that is being opened. In that case we + * can't display our loading modal because it has larger z-index than + * provider's one and it's too problematic to change that. + * + */ + shouldForceCloseModal?: boolean } const WalletConnectionModalBase: FC = ({ @@ -40,6 +48,7 @@ const WalletConnectionModalBase: FC = ({ onContinue, connector, walletType, + shouldForceCloseModal, }) => { const { activate, active, account } = useWeb3React() const captureWalletConnected = useCapture(PosthogEvent.WalletConnected) @@ -49,8 +58,14 @@ const WalletConnectionModalBase: FC = ({ captureWalletConnected({ walletType }) activate(connector) - if (walletType === WalletType.WalletConnect) closeModal() - }, [activate, connector, captureWalletConnected, walletType]) + if (shouldForceCloseModal) closeModal() + }, [ + activate, + connector, + captureWalletConnected, + walletType, + shouldForceCloseModal, + ]) return ( <> diff --git a/src/components/Modal/SelectWalletModal/index.tsx b/src/components/Modal/SelectWalletModal/index.tsx index edff2c74b..b179ca3cd 100644 --- a/src/components/Modal/SelectWalletModal/index.tsx +++ b/src/components/Modal/SelectWalletModal/index.tsx @@ -16,27 +16,55 @@ import { CoinbaseWallet } from "../../../static/icons/CoinbaseWallet" import { useModal } from "../../../hooks/useModal" import ModalCloseButton from "../ModalCloseButton" import ConnectTaho from "./ConnectTaho" +import ConnectLedgerLive from "./ConnectLedgerLive" +import { LedgerLight } from "../../../static/icons/LedgerLight" +import { LedgerDark } from "../../../static/icons/LedgerDark" +import { featureFlags } from "../../../constants" const walletOptions: WalletOption[] = [ { id: WalletType.TAHO, title: "Taho", - icon: Taho, + icon: { + light: Taho, + dark: Taho, + }, }, { id: WalletType.Metamask, title: "MetaMask", - icon: MetaMaskIcon, + icon: { + light: MetaMaskIcon, + dark: MetaMaskIcon, + }, }, + ...(featureFlags.LEDGER_LIVE + ? [ + { + id: WalletType.LedgerLive, + title: "Ledger Live", + icon: { + light: LedgerLight, + dark: LedgerDark, + }, + }, + ] + : []), { id: WalletType.WalletConnect, title: "WalletConnect", - icon: WalletConnectIcon, + icon: { + light: WalletConnectIcon, + dark: WalletConnectIcon, + }, }, { id: WalletType.Coinbase, title: "Coinbase Wallet", - icon: CoinbaseWallet, + icon: { + light: CoinbaseWallet, + dark: CoinbaseWallet, + }, }, ] @@ -94,6 +122,8 @@ const ConnectWallet: FC<{ return case WalletType.Coinbase: return + case WalletType.LedgerLive: + return default: return <> } diff --git a/src/components/Navbar/WalletConnectionAlert.tsx b/src/components/Navbar/WalletConnectionAlert.tsx index 4827c05ac..24d986e89 100644 --- a/src/components/Navbar/WalletConnectionAlert.tsx +++ b/src/components/Navbar/WalletConnectionAlert.tsx @@ -4,41 +4,49 @@ import { AlertIcon, CloseButton, } from "@chakra-ui/react" -import { FC, useEffect, useMemo, useState } from "react" +import { FC, useEffect, useState } from "react" import isSupportedNetwork from "../../utils/isSupportedNetwork" import chainIdToNetworkName from "../../utils/chainIdToNetworkName" import { supportedChainId } from "../../utils/getEnvVariable" +import { useWeb3React } from "@web3-react/core" const WalletConnectionAlert: FC<{ account?: string | null chainId?: number }> = ({ account, chainId }) => { const [hideAlert, setHideAlert] = useState(false) + const { error } = useWeb3React() + const [alertDescription, setAlertDescription] = useState("") - const alertDescription = useMemo(() => { - if (!account) { + const errorMessage = error?.message + + useEffect(() => { + if (errorMessage) { + setAlertDescription(errorMessage) setHideAlert(false) return } - if (!isSupportedNetwork(chainId)) { - return `Your wallet is on an unsupported network. Switch to the ${chainIdToNetworkName( - supportedChainId - )} network` - } - }, [account, chainId]) - - useEffect(() => { if (!account || (account && isSupportedNetwork(chainId))) { setHideAlert(true) return } if (!isSupportedNetwork(chainId)) { + setAlertDescription( + `Your wallet is on an unsupported network. Switch to the ${chainIdToNetworkName( + supportedChainId + )} network` + ) setHideAlert(false) return } - }, [account, chainId]) + }, [account, chainId, errorMessage]) + + const resetAlert = () => { + setHideAlert(true) + setAlertDescription("") + } if (hideAlert) { return null @@ -60,7 +68,7 @@ const WalletConnectionAlert: FC<{ position="absolute" right="8px" top="8px" - onClick={() => setHideAlert(true)} + onClick={resetAlert} /> ) diff --git a/src/constants/featureFlags.ts b/src/constants/featureFlags.ts index 4a2f61b3a..816c3d935 100644 --- a/src/constants/featureFlags.ts +++ b/src/constants/featureFlags.ts @@ -18,3 +18,6 @@ export const SENTRY = getEnvVariable(EnvVariable.FEATURE_FLAG_SENTRY) === "true" export const TBTC_V2_REDEMPTION = getEnvVariable(EnvVariable.FEATURE_FLAG_TBTC_V2_REDEMPTION) === "true" + +export const LEDGER_LIVE = + getEnvVariable(EnvVariable.FEATURE_FLAG_LEDGER_LIVE) === "true" diff --git a/src/enums/env.ts b/src/enums/env.ts index 39add5215..5e60a1208 100644 --- a/src/enums/env.ts +++ b/src/enums/env.ts @@ -7,6 +7,7 @@ const envVariables = [ "FEATURE_FLAG_MULTI_APP_STAKING", "FEATURE_FLAG_POSTHOG", "FEATURE_FLAG_FEEDBACK_MODULE", + "FEATURE_FLAG_LEDGER_LIVE", "POSTHOG_HOSTNAME_HTTP", "POSTHOG_API_KEY", "ELECTRUM_PROTOCOL", diff --git a/src/enums/web3.ts b/src/enums/web3.ts index 6b81b023c..e233c24f9 100644 --- a/src/enums/web3.ts +++ b/src/enums/web3.ts @@ -16,4 +16,5 @@ export enum WalletType { Metamask = "METAMASK", WalletConnect = "WALLET_CONNECT", Coinbase = "COINBASE", + LedgerLive = "LEDGER_LIVE", } diff --git a/src/static/icons/LedgerDark.tsx b/src/static/icons/LedgerDark.tsx new file mode 100644 index 000000000..ff038742d --- /dev/null +++ b/src/static/icons/LedgerDark.tsx @@ -0,0 +1,21 @@ +import { createIcon } from "@chakra-ui/icons" + +export const LedgerDark = createIcon({ + displayName: "Ledger", + viewBox: "0 0 160 160", + path: ( + + + + + ), +}) diff --git a/src/static/icons/LedgerLight.tsx b/src/static/icons/LedgerLight.tsx new file mode 100644 index 000000000..b02aef425 --- /dev/null +++ b/src/static/icons/LedgerLight.tsx @@ -0,0 +1,21 @@ +import { createIcon } from "@chakra-ui/icons" + +export const LedgerLight = createIcon({ + displayName: "Ledger", + viewBox: "0 0 160 160", + path: ( + + + + + ), +}) diff --git a/src/types/wallet.ts b/src/types/wallet.ts index a4734522d..370ad410a 100644 --- a/src/types/wallet.ts +++ b/src/types/wallet.ts @@ -3,6 +3,9 @@ import { WalletType } from "../enums" export interface WalletOption { id: WalletType - icon: FC title: string + icon: { + light: FC + dark: FC + } } diff --git a/src/web3/connectors/index.ts b/src/web3/connectors/index.ts index 5d55574ec..880fcef65 100644 --- a/src/web3/connectors/index.ts +++ b/src/web3/connectors/index.ts @@ -2,5 +2,6 @@ export * from "./taho" export * from "./metamask" export * from "./walletConnect" export * from "./coinbaseWallet" +export * from "./ledgerLive" export { AbstractConnector } from "@web3-react/abstract-connector" export { UserRejectedRequestError } from "@web3-react/injected-connector" diff --git a/src/web3/connectors/ledgerLive.ts b/src/web3/connectors/ledgerLive.ts new file mode 100644 index 000000000..4b411b4ce --- /dev/null +++ b/src/web3/connectors/ledgerLive.ts @@ -0,0 +1,148 @@ +import { AbstractConnector } from "@web3-react/abstract-connector" +import { AbstractConnectorArguments, ConnectorUpdate } from "@web3-react/types" +import { getEnvVariable, supportedChainId } from "../../utils/getEnvVariable" +import { + LedgerConnectKit, + SupportedProviders, + loadConnectKit, + EthereumProvider, +} from "@ledgerhq/connect-kit-loader" +import { EnvVariable } from "../../enums" +import chainIdToNetworkName from "../../utils/chainIdToNetworkName" + +interface LedgerLiveConnectorArguments extends AbstractConnectorArguments { + rpc: { + [chainId: number]: string + } + walletConnectProjectId: string +} + +export class LedgerLiveConnector extends AbstractConnector { + private rpc: LedgerLiveConnectorArguments["rpc"] + private provider?: EthereumProvider + private connectKitPromise: Promise + private walletConnectProjectId: string + + constructor(args: Required) { + super({ + supportedChainIds: Object.keys(args.rpc).map((chainId) => + Number(chainId) + ), + }) + + this.rpc = args.rpc + this.walletConnectProjectId = args.walletConnectProjectId + + this.handleNetworkChanged = this.handleNetworkChanged.bind(this) + this.handleChainChanged = this.handleChainChanged.bind(this) + this.handleAccountsChanged = this.handleAccountsChanged.bind(this) + this.handleClose = this.handleClose.bind(this) + + this.connectKitPromise = loadConnectKit() + } + + private handleNetworkChanged(networkId: string): void { + this.emitUpdate({ provider: this.provider, chainId: networkId }) + } + + private handleChainChanged(chainId: string): void { + this.emitUpdate({ chainId }) + } + + private handleAccountsChanged(accounts: string[]): void { + this.emitUpdate({ account: accounts.length === 0 ? null : accounts[0] }) + } + + private handleClose(): void { + this.emitDeactivate() + } + + public async activate(): Promise { + if (!this.supportedChainIds) { + throw new Error("Supported chain ids are not defined.") + } + // Removes all local storage entries that starts with "wc@2" + // This is a workaround for "Cannot convert undefined or null to object" + // error that sometime occur with WalletConnect + Object.keys(localStorage) + .filter((x) => x.startsWith("wc@2")) + .forEach((x) => localStorage.removeItem(x)) + let account = "" + const connectKit = await this.connectKitPromise + const chainId = this.supportedChainIds[0] + const checkSupportResult = connectKit.checkSupport({ + chains: [chainId], + walletConnectVersion: 2, + projectId: this.walletConnectProjectId, + providerType: SupportedProviders.Ethereum, + rpcMap: this.rpc, + }) + + if (!checkSupportResult.isChainIdSupported) { + throw new Error( + `The ${chainIdToNetworkName( + chainId + )} network is not supported for LedgerLive.` + ) + } + + this.provider = (await connectKit.getProvider()) as EthereumProvider + + this.provider.on("networkChanged", this.handleNetworkChanged) + this.provider.on("chainChanged", this.handleChainChanged) + this.provider.on("accountsChanged", this.handleAccountsChanged) + this.provider.on("close", this.handleClose) + + const accounts = (await this.provider.request({ + method: "eth_requestAccounts", + })) as string[] + account = accounts[0] + + return { provider: this.provider, account } + } + + public async getProvider(): Promise { + return this.provider + } + + public async getChainId(): Promise { + if (!this.provider) throw new ConnectorNotAcivatedError() + + return this.provider.request({ method: "eth_chainId" }) + } + + public async getAccount(): Promise { + if (!this.provider) throw new ConnectorNotAcivatedError() + + const accounts = (await this.provider.request({ + method: "eth_requestAccounts", + })) as string[] + return accounts[0] + } + + public deactivate() { + if (!this.provider) throw new ConnectorNotAcivatedError() + + this.provider.removeListener("networkChanged", this.handleNetworkChanged) + this.provider.removeListener("chainChanged", this.handleChainChanged) + this.provider.removeListener("accountsChanged", this.handleAccountsChanged) + this.provider.removeListener("close", this.handleClose) + } +} + +const rpcUrl = getEnvVariable(EnvVariable.ETH_HOSTNAME_HTTP) +const chainId = +supportedChainId + +export const ledgerLive = new LedgerLiveConnector({ + supportedChainIds: [chainId], + rpc: { + [Number(supportedChainId)]: rpcUrl as string, + }, + walletConnectProjectId: getEnvVariable(EnvVariable.WALLET_CONNECT_PROJECT_ID), +}) + +class ConnectorNotAcivatedError extends Error { + constructor() { + super("Connector not activated!") + } +} diff --git a/src/web3/connectors/walletConnect.ts b/src/web3/connectors/walletConnect.ts index b8c0ed45c..4ecf19ae4 100644 --- a/src/web3/connectors/walletConnect.ts +++ b/src/web3/connectors/walletConnect.ts @@ -82,6 +82,12 @@ export class WalletConnectConnector extends AbstractConnector { } public async activate(): Promise { + // Removes all local storage entries that starts with "wc@2" + // This is a workaround for "Cannot convert undefined or null to object" + // error that sometime occur with WalletConnect + Object.keys(localStorage) + .filter((x) => x.startsWith("wc@2")) + .forEach((x) => localStorage.removeItem(x)) if (!this.provider) { const chains = getSupportedChains(this.config) if (chains.length === 0) throw new Error("Chains not specified!") diff --git a/yarn.lock b/yarn.lock index cd6f32a72..72a35131d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3245,6 +3245,11 @@ "@summa-tx/relay-sol" "^2.0.2" openzeppelin-solidity "2.3.0" +"@ledgerhq/connect-kit-loader@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@ledgerhq/connect-kit-loader/-/connect-kit-loader-1.1.2.tgz#d550e3c1f046e4c796f32a75324b03606b7e226a" + integrity sha512-mscwGroSJQrCTjtNGBu+18FQbZYA4+q6Tyx6K7CXHl6AwgZKbWfZYdgP2F+fyZcRUdGRsMX8QtvU61VcGGtO1A== + "@ledgerhq/cryptoassets@^5.53.0": version "5.53.0" resolved "https://registry.yarnpkg.com/@ledgerhq/cryptoassets/-/cryptoassets-5.53.0.tgz#11dcc93211960c6fd6620392e4dd91896aaabe58" @@ -6673,15 +6678,10 @@ bufio@^1.0.6: resolved "https://registry.yarnpkg.com/bufio/-/bufio-1.2.0.tgz#b9ad1c06b0d9010363c387c39d2810a7086d143f" integrity sha512-UlFk8z/PwdhYQTXSQQagwGAdtRI83gib2n4uy4rQnenxUM2yQi8lBDzF230BNk+3wAoZDxYRoBwVVUPgHa9MCA== -"bufio@git+https://github.com/bcoin-org/bufio.git#semver:~1.0.6": +"bufio@git+https://github.com/bcoin-org/bufio.git#semver:~1.0.6", bufio@~1.0.7: version "1.0.7" resolved "git+https://github.com/bcoin-org/bufio.git#91ae6c93899ff9fad7d7cee9afd2a1c4933ca984" -bufio@~1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/bufio/-/bufio-1.0.7.tgz#b7f63a1369a0829ed64cc14edf0573b3e382a33e" - integrity sha512-bd1dDQhiC+bEbEfg56IdBv7faWa6OipMs/AFFFvtFnB3wAYjlwQpQRZ0pm6ZkgtfL0pILRXhKxOiQj6UzoMR7A== - builtin-modules@^3.1.0: version "3.3.0" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" @@ -12708,15 +12708,10 @@ loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" -"loady@git+https://github.com/chjj/loady.git#semver:~0.0.1": +"loady@git+https://github.com/chjj/loady.git#semver:~0.0.1", loady@~0.0.1, loady@~0.0.5: version "0.0.5" resolved "git+https://github.com/chjj/loady.git#b94958b7ee061518f4b85ea6da380e7ee93222d5" -loady@~0.0.1, loady@~0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/loady/-/loady-0.0.5.tgz#b17adb52d2fb7e743f107b0928ba0b591da5d881" - integrity sha512-uxKD2HIj042/HBx77NBcmEPsD+hxCgAtjEWlYNScuUjIsh/62Uyu39GOR68TBR68v+jqDL9zfftCWoUo4y03sQ== - locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"