From 14c17ccbcf4ca8aa6bd79316df971077c10cdf25 Mon Sep 17 00:00:00 2001 From: Zach Pomerantz Date: Wed, 12 Oct 2022 13:01:28 -0700 Subject: [PATCH] feat: add an onSwitchChain handler (#274) * feat: add initial analytics handlers (#266) * feat: handlers * feat: onPermitSign * feat: onSwapPriceUpdateAck * feat: onExpandSwapDetails * fix: trades in ack * feat: more events * test: update onAmountChange spy * fix: one destruct * fix: update fixture handlers * chore: clean up types * feat: onSwitchChain * chore: clarify todo --- src/components/Error/ErrorBoundary.tsx | 4 +- src/components/Swap/index.tsx | 15 ++----- src/components/Widget.tsx | 40 +++++-------------- src/cosmos/EventFeed.tsx | 7 +++- src/hooks/useSwitchChain.ts | 53 ++++++++++++++++++------- src/hooks/useSyncWidgetEventHandlers.ts | 13 +++++- src/hooks/web3/index.tsx | 33 ++++++++++----- src/index.tsx | 8 +++- src/theme/index.tsx | 7 ++-- 9 files changed, 101 insertions(+), 79 deletions(-) diff --git a/src/components/Error/ErrorBoundary.tsx b/src/components/Error/ErrorBoundary.tsx index 863d3d867..f1ad1d25b 100644 --- a/src/components/Error/ErrorBoundary.tsx +++ b/src/components/Error/ErrorBoundary.tsx @@ -4,10 +4,10 @@ import { Component, ErrorInfo, PropsWithChildren } from 'react' import Dialog from '../Dialog' import ErrorDialog from './ErrorDialog' -export type ErrorHandler = (error: Error, info: ErrorInfo) => void +export type OnError = (error: Error, info: ErrorInfo) => void interface ErrorBoundaryProps { - onError?: ErrorHandler + onError?: OnError } type ErrorBoundaryState = { diff --git a/src/components/Swap/index.tsx b/src/components/Swap/index.tsx index 01e47120a..c99b43041 100644 --- a/src/components/Swap/index.tsx +++ b/src/components/Swap/index.tsx @@ -8,7 +8,6 @@ import useSyncSwapEventHandlers, { SwapEventHandlers } from 'hooks/swap/useSyncS import useSyncTokenDefaults, { TokenDefaults } from 'hooks/swap/useSyncTokenDefaults' import { usePendingTransactions } from 'hooks/transactions' import useSyncBrandingSetting, { BrandingSettings, useBrandingSetting } from 'hooks/useSyncBrandingSetting' -import useSyncWidgetEventHandlers, { WidgetEventHandlers } from 'hooks/useSyncWidgetEventHandlers' import { useAtom } from 'jotai' import { useMemo, useState } from 'react' import { displayTxHashAtom } from 'state/swap' @@ -25,17 +24,10 @@ import SwapActionButton from './SwapActionButton' import Toolbar from './Toolbar' import useValidate from './useValidate' -// SwapProps also currently includes props needed for wallet connection, +// SwapProps also currently includes props needed for wallet connection (eg hideConnectionUI), // since the wallet connection component exists within the Swap component. -// This includes useSyncWidgetEventHandlers. -// TODO(zzmp): refactor WalletConnection outside of Swap component -export interface SwapProps - extends BrandingSettings, - FeeOptions, - SwapController, - SwapEventHandlers, - TokenDefaults, - WidgetEventHandlers { +// TODO(zzmp): refactor WalletConnection into Widget component +export interface SwapProps extends BrandingSettings, FeeOptions, SwapController, SwapEventHandlers, TokenDefaults { hideConnectionUI?: boolean routerUrl?: string } @@ -46,7 +38,6 @@ export default function Swap(props: SwapProps) { useSyncConvenienceFee(props as FeeOptions) useSyncSwapEventHandlers(props as SwapEventHandlers) useSyncTokenDefaults(props as TokenDefaults) - useSyncWidgetEventHandlers(props as WidgetEventHandlers) useSyncBrandingSetting(props as BrandingSettings) const [wrapper, setWrapper] = useState(null) diff --git a/src/components/Widget.tsx b/src/components/Widget.tsx index 809d34b41..df07b8ccc 100644 --- a/src/components/Widget.tsx +++ b/src/components/Widget.tsx @@ -1,13 +1,12 @@ -import { JsonRpcProvider } from '@ethersproject/providers' import { TokenInfo } from '@uniswap/token-lists' -import { Provider as Eip1193Provider } from '@web3-react/types' -import { ALL_SUPPORTED_CHAIN_IDS, SupportedChainId } from 'constants/chains' +import { Animation, Modal, Provider as DialogProvider } from 'components/Dialog' +import ErrorBoundary, { OnError } from 'components/Error/ErrorBoundary' import { DEFAULT_LOCALE, SUPPORTED_LOCALES, SupportedLocale } from 'constants/locales' import { TransactionEventHandlers, TransactionsUpdater } from 'hooks/transactions' import { BlockNumberProvider } from 'hooks/useBlockNumber' +import useSyncWidgetEventHandlers, { WidgetEventHandlers } from 'hooks/useSyncWidgetEventHandlers' import { TokenListProvider } from 'hooks/useTokenList' -import { Provider as Web3ReactProvider } from 'hooks/web3' -import { JsonRpcConnectionMap } from 'hooks/web3/useJsonRpcUrlsMap' +import { Provider as Web3Provider, ProviderProps as Web3Props } from 'hooks/web3' import { Provider as I18nProvider } from 'i18n' import { Atom, Provider as AtomProvider } from 'jotai' import { PropsWithChildren, StrictMode, useMemo, useState } from 'react' @@ -17,11 +16,6 @@ import { MulticallUpdater } from 'state/multicall' import styled, { keyframes } from 'styled-components/macro' import { Theme, ThemeProvider } from 'theme' -import { Animation, Modal, Provider as DialogProvider } from './Dialog' -import ErrorBoundary, { ErrorHandler } from './Error/ErrorBoundary' - -const DEFAULT_CHAIN_ID = SupportedChainId.MAINNET - export const WidgetWrapper = styled.div<{ width?: number | string }>` -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; @@ -94,17 +88,14 @@ export const DialogWrapper = styled.div` } ` -export interface WidgetProps extends TransactionEventHandlers { +export interface WidgetProps extends TransactionEventHandlers, Web3Props, WidgetEventHandlers { theme?: Theme locale?: SupportedLocale - provider?: Eip1193Provider | JsonRpcProvider | null - jsonRpcUrlMap?: JsonRpcConnectionMap - defaultChainId?: SupportedChainId tokenList?: string | TokenInfo[] width?: string | number dialog?: HTMLDivElement | null className?: string - onError?: ErrorHandler + onError?: OnError } export default function Widget(props: PropsWithChildren) { @@ -134,16 +125,6 @@ export function TestableWidget(props: PropsWithChildren) { } return props.locale ?? DEFAULT_LOCALE }, [props.locale]) - const defaultChainId = useMemo(() => { - if (!props.defaultChainId) return DEFAULT_CHAIN_ID - if (!ALL_SUPPORTED_CHAIN_IDS.includes(props.defaultChainId)) { - console.warn( - `Unsupported chainId: ${props.defaultChainId}. Falling back to ${DEFAULT_CHAIN_ID} (${SupportedChainId[DEFAULT_CHAIN_ID]}).` - ) - return DEFAULT_CHAIN_ID - } - return props.defaultChainId - }, [props.defaultChainId]) const [dialog, setDialog] = useState(props.dialog || null) return ( @@ -156,17 +137,13 @@ export function TestableWidget(props: PropsWithChildren) { - + {props.children} - + @@ -180,5 +157,6 @@ export function TestableWidget(props: PropsWithChildren) { /** A component in the scope of AtomProvider to set Widget-scoped state. */ function WidgetUpdater(props: WidgetProps) { + useSyncWidgetEventHandlers(props as WidgetEventHandlers) return null } diff --git a/src/cosmos/EventFeed.tsx b/src/cosmos/EventFeed.tsx index 42c9bb2a0..1c60045f6 100644 --- a/src/cosmos/EventFeed.tsx +++ b/src/cosmos/EventFeed.tsx @@ -1,4 +1,4 @@ -import { defaultTheme, SwapEventHandlers, TransactionEventHandlers } from '@uniswap/widgets' +import { defaultTheme, SwapEventHandlers, TransactionEventHandlers, WidgetEventHandlers } from '@uniswap/widgets' import Row from 'components/Row' import styled from 'styled-components/macro' import * as Type from 'theme/type' @@ -25,8 +25,10 @@ const EventData = styled.pre` margin: 0.5em 0 0; ` -export const HANDLERS: (keyof SwapEventHandlers | keyof TransactionEventHandlers)[] = [ +export const HANDLERS: (keyof SwapEventHandlers | keyof TransactionEventHandlers | keyof WidgetEventHandlers)[] = [ 'onAmountChange', + 'onConnectWalletClick', + 'onError', 'onExpandSwapDetails', 'onInitialSwapQuote', 'onSwapApprove', @@ -35,6 +37,7 @@ export const HANDLERS: (keyof SwapEventHandlers | keyof TransactionEventHandlers 'onSlippageChange', 'onSubmitSwapClick', 'onSwapPriceUpdateAck', + 'onSwitchChain', 'onSwitchTokens', 'onTokenChange', 'onTokenSelectorClick', diff --git a/src/hooks/useSwitchChain.ts b/src/hooks/useSwitchChain.ts index 70633388c..d63348e14 100644 --- a/src/hooks/useSwitchChain.ts +++ b/src/hooks/useSwitchChain.ts @@ -4,22 +4,28 @@ import { getChainInfo } from 'constants/chainInfo' import { SupportedChainId } from 'constants/chains' import { ErrorCode } from 'constants/eip1193' import useJsonRpcUrlsMap from 'hooks/web3/useJsonRpcUrlsMap' +import { atom } from 'jotai' +import { useAtomValue } from 'jotai/utils' import { useCallback } from 'react' +/** Defined by EIP-3085. */ +export interface AddEthereumChainParameter { + chainId: string + chainName: string + nativeCurrency: { name: string; symbol: string; decimals: 18 } + blockExplorerUrls: [string] + rpcUrls: string[] +} + +export type OnSwitchChain = (addChainParameter: AddEthereumChainParameter) => void | Promise +export const onSwitchChainAtom = atom(undefined) + function toHex(chainId: SupportedChainId): string { return `0x${chainId.toString(16)}` } -async function addChain(provider: Web3Provider, chainId: SupportedChainId, rpcUrls: string[]): Promise { - const { label: chainName, nativeCurrency, explorer } = getChainInfo(chainId) - const addChainParameter = { - chainId: toHex(chainId), - chainName, - nativeCurrency, - blockExplorerUrls: [explorer], - } - - for (const rpcUrl of rpcUrls) { +async function addChain(provider: Web3Provider, addChainParameter: AddEthereumChainParameter): Promise { + for (const rpcUrl of addChainParameter.rpcUrls) { try { await provider.send('wallet_addEthereumChain', [{ ...addChainParameter, rpcUrls: [rpcUrl] }]) // EIP-3085 } catch (error) { @@ -32,12 +38,16 @@ async function addChain(provider: Web3Provider, chainId: SupportedChainId, rpcUr } } -async function switchChain(provider: Web3Provider, chainId: SupportedChainId, rpcUrls: string[] = []): Promise { +async function switchChain( + provider: Web3Provider, + chainId: SupportedChainId, + addChainParameter?: AddEthereumChainParameter +): Promise { try { await provider.send('wallet_switchEthereumChain', [{ chainId: toHex(chainId) }]) // EIP-3326 (used by MetaMask) } catch (error) { - if (error?.code === ErrorCode.CHAIN_NOT_ADDED && rpcUrls.length) { - await addChain(provider, chainId, rpcUrls) + if (error?.code === ErrorCode.CHAIN_NOT_ADDED && addChainParameter?.rpcUrls.length) { + await addChain(provider, addChainParameter) return switchChain(provider, chainId) } throw error @@ -47,9 +57,22 @@ async function switchChain(provider: Web3Provider, chainId: SupportedChainId, rp export default function useSwitchChain(): (chainId: SupportedChainId) => Promise { const { connector, provider } = useWeb3React() const urlMap = useJsonRpcUrlsMap() + const onSwitchChain = useAtomValue(onSwitchChainAtom) return useCallback( async (chainId: SupportedChainId) => { + const { label: chainName, nativeCurrency, explorer } = getChainInfo(chainId) + const addChainParameter: AddEthereumChainParameter = { + chainId: toHex(chainId), + chainName, + nativeCurrency, + blockExplorerUrls: [explorer], + rpcUrls: urlMap[chainId], + } try { + // If the integrator implements onSwitchChain, use that instead. + const switching = onSwitchChain?.(addChainParameter) + if (switching) return switching + try { // A custom Connector may use a customProvider, in which case it should handle its own chain switching. if (!provider) throw new Error() @@ -58,7 +81,7 @@ export default function useSwitchChain(): (chainId: SupportedChainId) => Promise // Await both the user action (switchChain) and its result (chainChanged) // so that the callback does not resolve before the chain switch has visibly occured. new Promise((resolve) => provider.once('chainChanged', resolve)), - switchChain(provider, chainId, urlMap[chainId]), + switchChain(provider, chainId, addChainParameter), ]) } catch (error) { if (error?.code === ErrorCode.USER_REJECTED_REQUEST) return @@ -68,6 +91,6 @@ export default function useSwitchChain(): (chainId: SupportedChainId) => Promise throw new Error(`Failed to switch network: ${error}`) } }, - [connector, provider, urlMap] + [connector, onSwitchChain, provider, urlMap] ) } diff --git a/src/hooks/useSyncWidgetEventHandlers.ts b/src/hooks/useSyncWidgetEventHandlers.ts index 24420da0d..b4d342eb9 100644 --- a/src/hooks/useSyncWidgetEventHandlers.ts +++ b/src/hooks/useSyncWidgetEventHandlers.ts @@ -1,15 +1,26 @@ +import { OnError } from 'components/Error/ErrorBoundary' +import { OnSwitchChain, onSwitchChainAtom } from 'hooks/useSwitchChain' import { useUpdateAtom } from 'jotai/utils' import { useEffect } from 'react' import { OnConnectWalletClick, onConnectWalletClickAtom } from 'state/wallet' +export type { OnError } from 'components/Error/ErrorBoundary' +export type { OnSwitchChain } from 'hooks/useSwitchChain' export type { OnConnectWalletClick } from 'state/wallet' export interface WidgetEventHandlers { onConnectWalletClick?: OnConnectWalletClick + onError?: OnError + onSwitchChain?: OnSwitchChain } -export default function useSyncWidgetEventHandlers({ onConnectWalletClick }: WidgetEventHandlers): void { +export default function useSyncWidgetEventHandlers({ onConnectWalletClick, onSwitchChain }: WidgetEventHandlers): void { const setOnConnectWalletClick = useUpdateAtom(onConnectWalletClickAtom) useEffect(() => { setOnConnectWalletClick(() => onConnectWalletClick) }, [onConnectWalletClick, setOnConnectWalletClick]) + + const setOnSwitchChain = useUpdateAtom(onSwitchChainAtom) + useEffect(() => { + setOnSwitchChain(() => onSwitchChain) + }, [onSwitchChain, setOnSwitchChain]) } diff --git a/src/hooks/web3/index.tsx b/src/hooks/web3/index.tsx index a2ec117cf..d63056e39 100644 --- a/src/hooks/web3/index.tsx +++ b/src/hooks/web3/index.tsx @@ -7,6 +7,7 @@ import { Connector, Provider as Eip1193Provider } from '@web3-react/types' import { SupportedChainId } from 'constants/chains' import { PropsWithChildren, useEffect, useMemo, useRef } from 'react' import JsonRpcConnector from 'utils/JsonRpcConnector' +import { supportedChainId } from 'utils/supportedChainId' import { WalletConnectPopup, WalletConnectQR } from 'utils/WalletConnect' import { Provider as ConnectorsProvider } from './useConnectors' @@ -17,6 +18,8 @@ import { toJsonRpcUrlMap, } from './useJsonRpcUrlsMap' +const DEFAULT_CHAIN_ID = SupportedChainId.MAINNET + type Web3ReactConnector = [T, Web3ReactHooks] interface Web3ReactConnectors { @@ -27,23 +30,33 @@ interface Web3ReactConnectors { network: Web3ReactConnector } -interface ProviderProps { +export interface ProviderProps { + defaultChainId?: SupportedChainId + jsonRpcUrlMap?: JsonRpcConnectionMap /** * If null, no auto-connection (MetaMask or WalletConnect) will be attempted. * This is appropriate for integrations which wish to control the connected provider. */ provider?: Eip1193Provider | JsonRpcProvider | null - jsonRpcMap?: JsonRpcConnectionMap - defaultChainId?: SupportedChainId } export function Provider({ - defaultChainId = SupportedChainId.MAINNET, - jsonRpcMap, + defaultChainId: chainId = SupportedChainId.MAINNET, + jsonRpcUrlMap, provider, children, }: PropsWithChildren) { - const web3ReactConnectors = useWeb3ReactConnectors({ provider, jsonRpcMap, defaultChainId }) + const defaultChainId = useMemo(() => { + if (!supportedChainId(chainId)) { + console.warn( + `Unsupported chainId: ${chainId}. Falling back to ${DEFAULT_CHAIN_ID} (${SupportedChainId[DEFAULT_CHAIN_ID]}).` + ) + return DEFAULT_CHAIN_ID + } + return chainId + }, [chainId]) + + const web3ReactConnectors = useWeb3ReactConnectors({ provider, jsonRpcUrlMap, defaultChainId }) const key = useRef(0) const prioritizedConnectors = useMemo(() => { @@ -87,7 +100,7 @@ export function Provider({ return ( - + {children} @@ -104,10 +117,10 @@ function initializeWeb3ReactConnector( return [connector, hooks] } -function useWeb3ReactConnectors({ defaultChainId, provider, jsonRpcMap }: ProviderProps) { +function useWeb3ReactConnectors({ defaultChainId, provider, jsonRpcUrlMap }: ProviderProps) { const [urlMap, connectionMap] = useMemo( - () => [toJsonRpcUrlMap(jsonRpcMap), toJsonRpcConnectionMap(jsonRpcMap)], - [jsonRpcMap] + () => [toJsonRpcUrlMap(jsonRpcUrlMap), toJsonRpcConnectionMap(jsonRpcUrlMap)], + [jsonRpcUrlMap] ) const user = useMemo(() => { diff --git a/src/index.tsx b/src/index.tsx index 4602f6382..0e3015aaa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,7 +9,6 @@ export type { Currency } from '@uniswap/sdk-core' export { TradeType } from '@uniswap/sdk-core' export type { TokenInfo } from '@uniswap/token-lists' export type { Provider as Eip1193Provider } from '@web3-react/types' -export type { ErrorHandler } from 'components/Error/ErrorBoundary' export type { SwapWidgetSkeletonProps } from 'components/Swap/Skeleton' export { SwapWidgetSkeleton } from 'components/Swap/Skeleton' export { SupportedChainId } from 'constants/chains' @@ -19,7 +18,12 @@ export type { SwapController } from 'hooks/swap/useSyncController' export type { FeeOptions } from 'hooks/swap/useSyncConvenienceFee' export type { DefaultAddress, TokenDefaults } from 'hooks/swap/useSyncTokenDefaults' export type { OnTxFail, OnTxSubmit, OnTxSuccess, TransactionEventHandlers } from 'hooks/transactions' -export type { OnConnectWalletClick, WidgetEventHandlers } from 'hooks/useSyncWidgetEventHandlers' +export type { + OnConnectWalletClick, + OnError, + OnSwitchChain, + WidgetEventHandlers, +} from 'hooks/useSyncWidgetEventHandlers' export { EMPTY_TOKEN_LIST, UNISWAP_TOKEN_LIST } from 'hooks/useTokenList' export type { JsonRpcConnectionMap } from 'hooks/web3/useJsonRpcUrlsMap' export type { diff --git a/src/theme/index.tsx b/src/theme/index.tsx index c46d903fa..510f59fa2 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -2,7 +2,7 @@ import 'assets/fonts.scss' import './external' import { mix, transparentize } from 'polished' -import { createContext, ReactNode, useContext, useMemo, useState } from 'react' +import { createContext, PropsWithChildren, useContext, useMemo, useState } from 'react' import { DefaultTheme, ThemeProvider as StyledProvider } from 'styled-components/macro' import type { Colors, Theme } from './theme' @@ -92,12 +92,11 @@ export function useSystemTheme() { const ThemeContext = createContext(toDefaultTheme(defaultTheme)) -interface ThemeProviderProps { +export interface ThemeProps { theme?: Theme - children: ReactNode } -export function ThemeProvider({ theme, children }: ThemeProviderProps) { +export function ThemeProvider({ theme, children }: PropsWithChildren) { const contextTheme = useContext(ThemeContext) const value = useMemo(() => { return toDefaultTheme({