diff --git a/.env b/.env index 78db6017c0..85871d7e3c 100644 --- a/.env +++ b/.env @@ -5,6 +5,7 @@ REACT_APP_AWS_API_REGION="us-east-2" REACT_APP_AWS_API_ENDPOINT="https://beta.api.uniswap.org/v1/graphql" REACT_APP_BNB_RPC_URL="https://rough-sleek-hill.bsc.quiknode.pro/413cc98cbc776cda8fdf1d0f47003583ff73d9bf" REACT_APP_INFURA_KEY="4bf032f2d38a4ed6bb975b80d6340847" +REACT_APP_QUICKNODE_MAINNET_RPC_URL="https://magical-alien-tab.quiknode.pro/669e87e569a8277d3fbd9e202f9df93189f19f4c" REACT_APP_MOONPAY_API="https://api.moonpay.com" REACT_APP_MOONPAY_LINK="https://us-central1-uniswap-mobile.cloudfunctions.net/signMoonpayLinkV2?platform=web&env=staging" REACT_APP_MOONPAY_PUBLISHABLE_KEY="pk_test_DycfESRid31UaSxhI5yWKe1r5E5kKSz" diff --git a/.env.production b/.env.production index 3584f41e5d..47965b2417 100644 --- a/.env.production +++ b/.env.production @@ -12,4 +12,5 @@ REACT_APP_MOONPAY_PUBLISHABLE_KEY="pk_live_uQG4BJC4w3cxnqpcSqAfohdBFDTsY6E" REACT_APP_SENTRY_ENABLED=true REACT_APP_SENTRY_TRACES_SAMPLE_RATE=0.00003 REACT_APP_STATSIG_PROXY_URL="https://api.uniswap.org/v1/statsig-proxy" +REACT_APP_QUICKNODE_MAINNET_RPC_URL="https://ultra-blue-flower.quiknode.pro/770b22d5f362c537bc8fe19b034c45b22958f880" THE_GRAPH_SCHEMA_ENDPOINT="https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3?source=uniswap" diff --git a/cypress/e2e/swap/unconnected.test.ts b/cypress/e2e/swap/unconnected.test.ts deleted file mode 100644 index 44bfe93dfd..0000000000 --- a/cypress/e2e/swap/unconnected.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { SwapEventName } from '@uniswap/analytics-events' -import { USDC_MAINNET } from 'constants/tokens' - -import { getTestSelector } from '../../utils' - -describe('Swap inputs with no wallet connected', () => { - it('can input and load a quote with no wallet connected', () => { - cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`) - - cy.get(getTestSelector('web3-status-connected')).click() - // click twice, first time to show confirmation, second to confirm - cy.get(getTestSelector('wallet-disconnect')).click() - cy.get(getTestSelector('wallet-disconnect')).should('contain', 'Disconnect') - cy.get(getTestSelector('wallet-disconnect')).click() - cy.get(getTestSelector('close-account-drawer')).click() - - // Enter amount to swap - cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', '1') - cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '') - // Verify logging - cy.waitForAmplitudeEvent(SwapEventName.SWAP_QUOTE_RECEIVED).then((event: any) => { - cy.wrap(event.event_properties).should('have.property', 'quote_latency_milliseconds') - cy.wrap(event.event_properties.quote_latency_milliseconds).should('be.a', 'number') - cy.wrap(event.event_properties.quote_latency_milliseconds).should('be.gte', 0) - }) - }) -}) diff --git a/cypress/support/setupTests.ts b/cypress/support/setupTests.ts index 80c46df59c..c66a2869c4 100644 --- a/cypress/support/setupTests.ts +++ b/cypress/support/setupTests.ts @@ -9,8 +9,9 @@ beforeEach(() => { req.headers['origin'] = 'https://app.uniswap.org' }) - // Infura is disabled for cypress tests - calls should be routed through the connected wallet instead. + // Network RPCs are disabled for cypress tests - calls should be routed through the connected wallet instead. cy.intercept(/infura.io/, { statusCode: 404 }) + cy.intercept(/quiknode.pro/, { statusCode: 404 }) // Log requests to hardhat. cy.intercept(/:8545/, logJsonRpc) diff --git a/src/components/AccountDrawer/MiniPortfolio/Pools/hooks.ts b/src/components/AccountDrawer/MiniPortfolio/Pools/hooks.ts index 517f63543f..937eb1d6b3 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Pools/hooks.ts +++ b/src/components/AccountDrawer/MiniPortfolio/Pools/hooks.ts @@ -9,8 +9,9 @@ import MulticallJSON from '@uniswap/v3-periphery/artifacts/contracts/lens/Uniswa import NFTPositionManagerJSON from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json' import { useWeb3React } from '@web3-react/core' import { isSupportedChain } from 'constants/chains' -import { RPC_PROVIDERS } from 'constants/providers' +import { DEPRECATED_RPC_PROVIDERS, RPC_PROVIDERS } from 'constants/providers' import { BaseContract } from 'ethers/lib/ethers' +import { useFallbackProviderEnabled } from 'featureFlags/flags/fallbackProvider' import { ContractInput, useUniswapPricesQuery } from 'graphql/data/__generated__/types-and-hooks' import { toContractInput } from 'graphql/data/util' import useStablecoinPrice from 'hooks/useStablecoinPrice' @@ -31,6 +32,8 @@ function useContractMultichain( ): ContractMap { const { chainId: walletChainId, provider: walletProvider } = useWeb3React() + const networkProviders = useFallbackProviderEnabled() ? RPC_PROVIDERS : DEPRECATED_RPC_PROVIDERS + return useMemo(() => { const relevantChains = chainIds ?? @@ -43,14 +46,14 @@ function useContractMultichain( walletProvider && walletChainId === chainId ? walletProvider : isSupportedChain(chainId) - ? RPC_PROVIDERS[chainId] + ? networkProviders[chainId] : undefined if (provider) { acc[chainId] = getContract(addressMap[chainId] ?? '', ABI, provider) as T } return acc }, {}) - }, [ABI, addressMap, chainIds, walletChainId, walletProvider]) + }, [ABI, addressMap, chainIds, networkProviders, walletChainId, walletProvider]) } export function useV3ManagerContracts(chainIds: ChainId[]): ContractMap { diff --git a/src/components/FeatureFlagModal/FeatureFlagModal.tsx b/src/components/FeatureFlagModal/FeatureFlagModal.tsx index 597fa54e59..b649f1b750 100644 --- a/src/components/FeatureFlagModal/FeatureFlagModal.tsx +++ b/src/components/FeatureFlagModal/FeatureFlagModal.tsx @@ -1,6 +1,7 @@ import Column from 'components/Column' import { BaseVariant, FeatureFlag, featureFlagSettings, useUpdateFlag } from 'featureFlags' import { useCurrencyConversionFlag } from 'featureFlags/flags/currencyConversion' +import { useFallbackProviderEnabledFlag } from 'featureFlags/flags/fallbackProvider' import { useFotAdjustmentsFlag } from 'featureFlags/flags/fotAdjustments' import { useInfoExploreFlag } from 'featureFlags/flags/infoExplore' import { useInfoLiveViewsFlag } from 'featureFlags/flags/infoLiveViews' @@ -229,6 +230,12 @@ export default function FeatureFlagModal() { + void const { connector, chainId } = useWeb3React() const { activationState } = useActivationState() - + const fallbackProviderEnabled = useFallbackProviderEnabled() // Keep the network connector in sync with any active user connector to prevent chain-switching on wallet disconnection. useEffect(() => { if (chainId && isSupportedChain(chainId) && connector !== networkConnection.connector) { - networkConnection.connector.activate(chainId) + if (fallbackProviderEnabled) { + networkConnection.connector.activate(chainId) + } else { + deprecatedNetworkConnection.connector.activate(chainId) + } } - }, [chainId, connector]) + }, [chainId, connector, fallbackProviderEnabled]) return ( diff --git a/src/components/Web3Provider/index.tsx b/src/components/Web3Provider/index.tsx index 1ec68deff5..965c280e24 100644 --- a/src/components/Web3Provider/index.tsx +++ b/src/components/Web3Provider/index.tsx @@ -4,7 +4,8 @@ import { Connector } from '@web3-react/types' import { sendAnalyticsEvent, user, useTrace } from 'analytics' import { connections, getConnection } from 'connection' import { isSupportedChain } from 'constants/chains' -import { RPC_PROVIDERS } from 'constants/providers' +import { DEPRECATED_RPC_PROVIDERS, RPC_PROVIDERS } from 'constants/providers' +import { useFallbackProviderEnabled } from 'featureFlags/flags/fallbackProvider' import { TraceJsonRpcVariant, useTraceJsonRpcFlag } from 'featureFlags/flags/traceJsonRpc' import usePrevious from 'hooks/usePrevious' import { ReactNode, useEffect } from 'react' @@ -31,8 +32,10 @@ function Updater() { const currentPage = getCurrentPageFromLocation(pathname) const analyticsContext = useTrace() + const providers = useFallbackProviderEnabled() ? RPC_PROVIDERS : DEPRECATED_RPC_PROVIDERS + // Trace RPC calls (for debugging). - const networkProvider = isSupportedChain(chainId) ? RPC_PROVIDERS[chainId] : undefined + const networkProvider = isSupportedChain(chainId) ? providers[chainId] : undefined const shouldTrace = useTraceJsonRpcFlag() === TraceJsonRpcVariant.Enabled useEffect(() => { if (shouldTrace) { diff --git a/src/connection/eagerlyConnect.ts b/src/connection/eagerlyConnect.ts index 0a7602f080..88c82fd885 100644 --- a/src/connection/eagerlyConnect.ts +++ b/src/connection/eagerlyConnect.ts @@ -1,7 +1,7 @@ import { Connector } from '@web3-react/types' import { useSyncExternalStore } from 'react' -import { getConnection, gnosisSafeConnection, networkConnection } from './index' +import { deprecatedNetworkConnection, getConnection, gnosisSafeConnection } from './index' import { deletePersistedConnectionMeta, getPersistedConnectionMeta } from './meta' import { ConnectionType } from './types' @@ -43,8 +43,7 @@ async function connect(connector: Connector, type: ConnectionType) { if (window !== window.parent) { connect(gnosisSafeConnection.connector, ConnectionType.GNOSIS_SAFE) } - -connect(networkConnection.connector, ConnectionType.NETWORK) +connect(deprecatedNetworkConnection.connector, ConnectionType.DEPRECATED_NETWORK) // Get the persisted wallet type from the last session. const meta = getPersistedConnectionMeta() diff --git a/src/connection/index.ts b/src/connection/index.ts index 4cbd608ce5..08b823cfdb 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -14,7 +14,7 @@ import { useSyncExternalStore } from 'react' import { isMobile, isNonIOSPhone } from 'utils/userAgent' import { RPC_URLS } from '../constants/networks' -import { RPC_PROVIDERS } from '../constants/providers' +import { DEPRECATED_RPC_PROVIDERS, RPC_PROVIDERS } from '../constants/providers' import { Connection, ConnectionType } from './types' import { getInjection, getIsCoinbaseWallet, getIsInjected, getIsMetaMaskWallet } from './utils' import { UniwalletConnect as UniwalletWCV2Connect, WalletConnectV2 } from './WalletConnectV2' @@ -34,6 +34,17 @@ export const networkConnection: Connection = { shouldDisplay: () => false, } +const [deprecatedWeb3Network, deprecatedWeb3NetworkHooks] = initializeConnector( + (actions) => new Network({ actions, urlMap: DEPRECATED_RPC_PROVIDERS, defaultChainId: 1 }) +) +export const deprecatedNetworkConnection: Connection = { + getName: () => 'Network', + connector: deprecatedWeb3Network, + hooks: deprecatedWeb3NetworkHooks, + type: ConnectionType.NETWORK, + shouldDisplay: () => false, +} + const getIsCoinbaseWalletBrowser = () => isMobile && getIsCoinbaseWallet() const getIsMetaMaskBrowser = () => isMobile && getIsMetaMaskWallet() const getIsInjectedMobileBrowser = () => getIsCoinbaseWalletBrowser() || getIsMetaMaskBrowser() @@ -179,6 +190,7 @@ export const connections = [ walletConnectV2Connection, coinbaseWalletConnection, networkConnection, + deprecatedNetworkConnection, ] export function getConnection(c: Connector | ConnectionType) { @@ -200,6 +212,8 @@ export function getConnection(c: Connector | ConnectionType) { return uniwalletWCV2ConnectConnection case ConnectionType.NETWORK: return networkConnection + case ConnectionType.DEPRECATED_NETWORK: + return deprecatedNetworkConnection case ConnectionType.GNOSIS_SAFE: return gnosisSafeConnection } diff --git a/src/connection/types.ts b/src/connection/types.ts index c34415eeea..e82e16e62c 100644 --- a/src/connection/types.ts +++ b/src/connection/types.ts @@ -9,6 +9,7 @@ export enum ConnectionType { WALLET_CONNECT_V2 = 'WALLET_CONNECT_V2', NETWORK = 'NETWORK', GNOSIS_SAFE = 'GNOSIS_SAFE', + DEPRECATED_NETWORK = 'DEPRECATED_NETWORK', } export function toConnectionType(value = ''): ConnectionType | undefined { diff --git a/src/constants/networks.ts b/src/constants/networks.ts index f0cec7f27c..d7cbb103fb 100644 --- a/src/constants/networks.ts +++ b/src/constants/networks.ts @@ -4,6 +4,10 @@ const INFURA_KEY = process.env.REACT_APP_INFURA_KEY if (typeof INFURA_KEY === 'undefined') { throw new Error(`REACT_APP_INFURA_KEY must be a defined environment variable`) } +const QUICKNODE_MAINNET_RPC_URL = process.env.REACT_APP_QUICKNODE_MAINNET_RPC_URL +if (typeof QUICKNODE_MAINNET_RPC_URL === 'undefined') { + throw new Error(`REACT_APP_QUICKNODE_MAINNET_RPC_URL must be a defined environment variable`) +} const QUICKNODE_BNB_RPC_URL = process.env.REACT_APP_BNB_RPC_URL if (typeof QUICKNODE_BNB_RPC_URL === 'undefined') { throw new Error(`REACT_APP_BNB_RPC_URL must be a defined environment variable`) @@ -122,7 +126,11 @@ export const FALLBACK_URLS = { * These are the URLs used by the interface when there is not another available source of chain data. */ export const RPC_URLS = { - [ChainId.MAINNET]: [`https://mainnet.infura.io/v3/${INFURA_KEY}`, ...FALLBACK_URLS[ChainId.MAINNET]], + [ChainId.MAINNET]: [ + `https://mainnet.infura.io/v3/${INFURA_KEY}`, + QUICKNODE_MAINNET_RPC_URL, + ...FALLBACK_URLS[ChainId.MAINNET], + ], [ChainId.GOERLI]: [`https://goerli.infura.io/v3/${INFURA_KEY}`, ...FALLBACK_URLS[ChainId.GOERLI]], [ChainId.SEPOLIA]: [`https://sepolia.infura.io/v3/${INFURA_KEY}`, ...FALLBACK_URLS[ChainId.SEPOLIA]], [ChainId.OPTIMISM]: [`https://optimism-mainnet.infura.io/v3/${INFURA_KEY}`, ...FALLBACK_URLS[ChainId.OPTIMISM]], diff --git a/src/constants/providers.ts b/src/constants/providers.ts index fabf388e3e..5d902e374f 100644 --- a/src/constants/providers.ts +++ b/src/constants/providers.ts @@ -1,75 +1,50 @@ -import { deepCopy } from '@ethersproject/properties' -// This is the only file which should instantiate new Providers. -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { StaticJsonRpcProvider } from '@ethersproject/providers' -import { isPlain } from '@reduxjs/toolkit' import { ChainId } from '@uniswap/sdk-core' +import AppRpcProvider from 'rpc/AppRpcProvider' +import AppStaticJsonRpcProvider from 'rpc/StaticJsonRpcProvider' +import StaticJsonRpcProvider from 'rpc/StaticJsonRpcProvider' -import { AVERAGE_L1_BLOCK_TIME } from './chainInfo' -import { CHAIN_IDS_TO_NAMES, SupportedInterfaceChain } from './chains' +import { SupportedInterfaceChain } from './chains' import { RPC_URLS } from './networks' -class AppJsonRpcProvider extends StaticJsonRpcProvider { - private _blockCache = new Map>() - get blockCache() { - // If the blockCache has not yet been initialized this block, do so by - // setting a listener to clear it on the next block. - if (!this._blockCache.size) { - this.once('block', () => this._blockCache.clear()) - } - return this._blockCache - } - - constructor(chainId: SupportedInterfaceChain) { - // Including networkish allows ethers to skip the initial detectNetwork call. - super(RPC_URLS[chainId][0], /* networkish= */ { chainId, name: CHAIN_IDS_TO_NAMES[chainId] }) - - // NB: Third-party providers (eg MetaMask) will have their own polling intervals, - // which should be left as-is to allow operations (eg transaction confirmation) to resolve faster. - // Network providers (eg AppJsonRpcProvider) need to update less frequently to be considered responsive. - this.pollingInterval = AVERAGE_L1_BLOCK_TIME - } - - send(method: string, params: Array): Promise { - // Only cache eth_call's. - if (method !== 'eth_call') return super.send(method, params) - - // Only cache if params are serializable. - if (!isPlain(params)) return super.send(method, params) - - const key = `call:${JSON.stringify(params)}` - const cached = this.blockCache.get(key) - if (cached) { - this.emit('debug', { - action: 'request', - request: deepCopy({ method, params, id: 'cache' }), - provider: this, - }) - return cached - } - - const result = super.send(method, params) - this.blockCache.set(key, result) - return result - } -} +const providerFactory = (chainId: SupportedInterfaceChain, i = 0) => + new AppStaticJsonRpcProvider(chainId, RPC_URLS[chainId][i]) /** * These are the only JsonRpcProviders used directly by the interface. */ export const RPC_PROVIDERS: { [key in SupportedInterfaceChain]: StaticJsonRpcProvider } = { - [ChainId.MAINNET]: new AppJsonRpcProvider(ChainId.MAINNET), - [ChainId.GOERLI]: new AppJsonRpcProvider(ChainId.GOERLI), - [ChainId.SEPOLIA]: new AppJsonRpcProvider(ChainId.SEPOLIA), - [ChainId.OPTIMISM]: new AppJsonRpcProvider(ChainId.OPTIMISM), - [ChainId.OPTIMISM_GOERLI]: new AppJsonRpcProvider(ChainId.OPTIMISM_GOERLI), - [ChainId.ARBITRUM_ONE]: new AppJsonRpcProvider(ChainId.ARBITRUM_ONE), - [ChainId.ARBITRUM_GOERLI]: new AppJsonRpcProvider(ChainId.ARBITRUM_GOERLI), - [ChainId.POLYGON]: new AppJsonRpcProvider(ChainId.POLYGON), - [ChainId.POLYGON_MUMBAI]: new AppJsonRpcProvider(ChainId.POLYGON_MUMBAI), - [ChainId.CELO]: new AppJsonRpcProvider(ChainId.CELO), - [ChainId.CELO_ALFAJORES]: new AppJsonRpcProvider(ChainId.CELO_ALFAJORES), - [ChainId.BNB]: new AppJsonRpcProvider(ChainId.BNB), - [ChainId.AVALANCHE]: new AppJsonRpcProvider(ChainId.AVALANCHE), - [ChainId.BASE]: new AppJsonRpcProvider(ChainId.BASE), + [ChainId.MAINNET]: new AppRpcProvider(ChainId.MAINNET, [ + providerFactory(ChainId.MAINNET), + providerFactory(ChainId.MAINNET, 1), + ]), + [ChainId.GOERLI]: providerFactory(ChainId.GOERLI), + [ChainId.SEPOLIA]: providerFactory(ChainId.SEPOLIA), + [ChainId.OPTIMISM]: providerFactory(ChainId.OPTIMISM), + [ChainId.OPTIMISM_GOERLI]: providerFactory(ChainId.OPTIMISM_GOERLI), + [ChainId.ARBITRUM_ONE]: providerFactory(ChainId.ARBITRUM_ONE), + [ChainId.ARBITRUM_GOERLI]: providerFactory(ChainId.ARBITRUM_GOERLI), + [ChainId.POLYGON]: providerFactory(ChainId.POLYGON), + [ChainId.POLYGON_MUMBAI]: providerFactory(ChainId.POLYGON_MUMBAI), + [ChainId.CELO]: providerFactory(ChainId.CELO), + [ChainId.CELO_ALFAJORES]: providerFactory(ChainId.CELO_ALFAJORES), + [ChainId.BNB]: providerFactory(ChainId.BNB), + [ChainId.AVALANCHE]: providerFactory(ChainId.AVALANCHE), + [ChainId.BASE]: providerFactory(ChainId.BASE), +} + +export const DEPRECATED_RPC_PROVIDERS: { [key in SupportedInterfaceChain]: AppStaticJsonRpcProvider } = { + [ChainId.MAINNET]: providerFactory(ChainId.MAINNET), + [ChainId.GOERLI]: providerFactory(ChainId.GOERLI), + [ChainId.SEPOLIA]: providerFactory(ChainId.SEPOLIA), + [ChainId.OPTIMISM]: providerFactory(ChainId.OPTIMISM), + [ChainId.OPTIMISM_GOERLI]: providerFactory(ChainId.OPTIMISM_GOERLI), + [ChainId.ARBITRUM_ONE]: providerFactory(ChainId.ARBITRUM_ONE), + [ChainId.ARBITRUM_GOERLI]: providerFactory(ChainId.ARBITRUM_GOERLI), + [ChainId.POLYGON]: providerFactory(ChainId.POLYGON), + [ChainId.POLYGON_MUMBAI]: providerFactory(ChainId.POLYGON_MUMBAI), + [ChainId.CELO]: providerFactory(ChainId.CELO), + [ChainId.CELO_ALFAJORES]: providerFactory(ChainId.CELO_ALFAJORES), + [ChainId.BNB]: providerFactory(ChainId.BNB), + [ChainId.AVALANCHE]: providerFactory(ChainId.AVALANCHE), + [ChainId.BASE]: providerFactory(ChainId.BASE), } diff --git a/src/featureFlags/flags/fallbackProvider.ts b/src/featureFlags/flags/fallbackProvider.ts new file mode 100644 index 0000000000..caab31f2c6 --- /dev/null +++ b/src/featureFlags/flags/fallbackProvider.ts @@ -0,0 +1,9 @@ +import { BaseVariant, FeatureFlag, useBaseFlag } from '../index' + +export function useFallbackProviderEnabledFlag(): BaseVariant { + return useBaseFlag(FeatureFlag.fallbackProvider) +} + +export function useFallbackProviderEnabled(): boolean { + return useFallbackProviderEnabledFlag() === BaseVariant.Enabled +} diff --git a/src/featureFlags/index.tsx b/src/featureFlags/index.tsx index f255cbfea9..a97a8b9b32 100644 --- a/src/featureFlags/index.tsx +++ b/src/featureFlags/index.tsx @@ -8,6 +8,7 @@ import { useGate } from 'statsig-react' export enum FeatureFlag { traceJsonRpc = 'traceJsonRpc', debounceSwapQuote = 'debounce_swap_quote', + fallbackProvider = 'fallback_provider', uniswapXSyntheticQuote = 'uniswapx_synthetic_quote', uniswapXEthOutputEnabled = 'uniswapx_eth_output_enabled', uniswapXExactOutputEnabled = 'uniswapx_exact_output_enabled', diff --git a/src/hooks/useContract.ts b/src/hooks/useContract.ts index 5133fea035..77de1a6e9d 100644 --- a/src/hooks/useContract.ts +++ b/src/hooks/useContract.ts @@ -28,8 +28,9 @@ import ERC1155_ABI from 'abis/erc1155.json' import { ArgentWalletDetector, EnsPublicResolver, EnsRegistrar, Erc20, Erc721, Erc1155, Weth } from 'abis/types' import WETH_ABI from 'abis/weth.json' import { sendAnalyticsEvent } from 'analytics' -import { RPC_PROVIDERS } from 'constants/providers' +import { DEPRECATED_RPC_PROVIDERS, RPC_PROVIDERS } from 'constants/providers' import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens' +import { useFallbackProviderEnabled } from 'featureFlags/flags/fallbackProvider' import { useEffect, useMemo } from 'react' import { NonfungiblePositionManager, TickLens, UniswapInterfaceMulticall } from 'types/v3' import { V3Migrator } from 'types/v3/V3Migrator' @@ -69,17 +70,19 @@ function useMainnetContract(address: string | und const { chainId } = useWeb3React() const isMainnet = chainId === ChainId.MAINNET const contract = useContract(isMainnet ? address : undefined, ABI, false) + const providers = useFallbackProviderEnabled() ? RPC_PROVIDERS : DEPRECATED_RPC_PROVIDERS + return useMemo(() => { if (isMainnet) return contract if (!address) return null - const provider = RPC_PROVIDERS[ChainId.MAINNET] + const provider = providers[ChainId.MAINNET] try { return getContract(address, ABI, provider) } catch (error) { console.error('Failed to get mainnet contract', error) return null } - }, [address, ABI, contract, isMainnet]) as T + }, [isMainnet, contract, address, providers, ABI]) as T } export function useV2MigratorContract() { diff --git a/src/hooks/useFetchListCallback.ts b/src/hooks/useFetchListCallback.ts index 6cbeb62af4..b8ddd34099 100644 --- a/src/hooks/useFetchListCallback.ts +++ b/src/hooks/useFetchListCallback.ts @@ -1,7 +1,8 @@ import { nanoid } from '@reduxjs/toolkit' import { ChainId } from '@uniswap/sdk-core' import { TokenList } from '@uniswap/token-lists' -import { RPC_PROVIDERS } from 'constants/providers' +import { DEPRECATED_RPC_PROVIDERS, RPC_PROVIDERS } from 'constants/providers' +import { useFallbackProviderEnabled } from 'featureFlags/flags/fallbackProvider' import getTokenList from 'lib/hooks/useTokenList/fetchTokenList' import resolveENSContentHash from 'lib/utils/resolveENSContentHash' import { useCallback } from 'react' @@ -11,6 +12,7 @@ import { fetchTokenList } from '../state/lists/actions' export function useFetchListCallback(): (listUrl: string, skipValidation?: boolean) => Promise { const dispatch = useAppDispatch() + const providers = useFallbackProviderEnabled() ? RPC_PROVIDERS : DEPRECATED_RPC_PROVIDERS return useCallback( async (listUrl: string, skipValidation?: boolean) => { @@ -18,7 +20,7 @@ export function useFetchListCallback(): (listUrl: string, skipValidation?: boole dispatch(fetchTokenList.pending({ requestId, url: listUrl })) return getTokenList( listUrl, - (ensName: string) => resolveENSContentHash(ensName, RPC_PROVIDERS[ChainId.MAINNET]), + (ensName: string) => resolveENSContentHash(ensName, providers[ChainId.MAINNET]), skipValidation ) .then((tokenList) => { @@ -31,6 +33,6 @@ export function useFetchListCallback(): (listUrl: string, skipValidation?: boole throw error }) }, - [dispatch] + [dispatch, providers] ) } diff --git a/src/hooks/useSwitchChain.ts b/src/hooks/useSwitchChain.ts index d14e262b56..c3be3e5481 100644 --- a/src/hooks/useSwitchChain.ts +++ b/src/hooks/useSwitchChain.ts @@ -1,6 +1,11 @@ import { ChainId } from '@uniswap/sdk-core' import { Connector } from '@web3-react/types' -import { networkConnection, uniwalletWCV2ConnectConnection, walletConnectV2Connection } from 'connection' +import { + deprecatedNetworkConnection, + networkConnection, + uniwalletWCV2ConnectConnection, + walletConnectV2Connection, +} from 'connection' import { getChainInfo } from 'constants/chainInfo' import { isSupportedChain, SupportedInterfaceChain } from 'constants/chains' import { FALLBACK_URLS, RPC_URLS } from 'constants/networks' @@ -37,6 +42,7 @@ export function useSwitchChain() { walletConnectV2Connection.connector, uniwalletWCV2ConnectConnection.connector, networkConnection.connector, + deprecatedNetworkConnection.connector, ].includes(connector) ) { await connector.activate(chainId) diff --git a/src/lib/hooks/routing/clientSideSmartOrderRouter.ts b/src/lib/hooks/routing/clientSideSmartOrderRouter.ts index a58476a005..17a5133928 100644 --- a/src/lib/hooks/routing/clientSideSmartOrderRouter.ts +++ b/src/lib/hooks/routing/clientSideSmartOrderRouter.ts @@ -3,7 +3,7 @@ import { BigintIsh, ChainId, CurrencyAmount, Token, TradeType } from '@uniswap/s // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { AlphaRouter, AlphaRouterConfig } from '@uniswap/smart-order-router' import { asSupportedChain } from 'constants/chains' -import { RPC_PROVIDERS } from 'constants/providers' +import { DEPRECATED_RPC_PROVIDERS } from 'constants/providers' import { nativeOnChain } from 'constants/tokens' import JSBI from 'jsbi' import { GetQuoteArgs, QuoteResult, QuoteState, SwapRouterNativeAssets } from 'state/routing/types' @@ -16,7 +16,7 @@ export function getRouter(chainId: ChainId): AlphaRouter { const supportedChainId = asSupportedChain(chainId) if (supportedChainId) { - const provider = RPC_PROVIDERS[supportedChainId] + const provider = DEPRECATED_RPC_PROVIDERS[supportedChainId] const router = new AlphaRouter({ chainId, provider }) routers.set(chainId, router) return router diff --git a/src/lib/hooks/useBlockNumber.tsx b/src/lib/hooks/useBlockNumber.tsx index 1a076fd21a..bcb1990633 100644 --- a/src/lib/hooks/useBlockNumber.tsx +++ b/src/lib/hooks/useBlockNumber.tsx @@ -1,6 +1,7 @@ import { ChainId } from '@uniswap/sdk-core' import { useWeb3React } from '@web3-react/core' -import { RPC_PROVIDERS } from 'constants/providers' +import { DEPRECATED_RPC_PROVIDERS, RPC_PROVIDERS } from 'constants/providers' +import { useFallbackProviderEnabled } from 'featureFlags/flags/fallbackProvider' import useIsWindowVisible from 'hooks/useIsWindowVisible' import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react' @@ -92,9 +93,11 @@ export function BlockNumberProvider({ children }: { children: ReactNode }) { return void 0 }, [activeChainId, provider, windowVisible, onChainBlock]) + const networkProviders = useFallbackProviderEnabled() ? RPC_PROVIDERS : DEPRECATED_RPC_PROVIDERS + useEffect(() => { if (mainnetBlock === undefined) { - RPC_PROVIDERS[ChainId.MAINNET] + networkProviders[ChainId.MAINNET] .getBlockNumber() .then((block) => { onChainBlock(ChainId.MAINNET, block) @@ -102,7 +105,7 @@ export function BlockNumberProvider({ children }: { children: ReactNode }) { // swallow errors - it's ok if this fails, as we'll try again if we activate mainnet .catch(() => undefined) } - }, [mainnetBlock, onChainBlock]) + }, [mainnetBlock, networkProviders, onChainBlock]) const value = useMemo( () => ({ diff --git a/src/rpc/AppRpcProvider.test.ts b/src/rpc/AppRpcProvider.test.ts new file mode 100644 index 0000000000..84ec1d87e7 --- /dev/null +++ b/src/rpc/AppRpcProvider.test.ts @@ -0,0 +1,121 @@ +import { TransactionResponse } from '@ethersproject/abstract-provider' +import { JsonRpcProvider, Network, Provider } from '@ethersproject/providers' +import { ChainId } from '@uniswap/sdk-core' + +import AppRpcProvider from './AppRpcProvider' + +jest.mock('@ethersproject/providers') + +describe('AppRpcProvider', () => { + let mockProviders: JsonRpcProvider[] + let mockProvider1: jest.Mocked + let mockProvider2: jest.Mocked + let mockIsProvider: jest.SpyInstance + + beforeEach(() => { + mockProvider1 = new JsonRpcProvider() as jest.Mocked + mockProvider2 = new JsonRpcProvider() as jest.Mocked + + mockIsProvider = jest.spyOn(Provider, 'isProvider').mockReturnValue(true) + mockProviders = [mockProvider1, mockProvider2] + mockProviders.forEach((provider) => { + // override readonly property + // @ts-expect-error + provider.network = { + name: 'homestead', + chainId: 1, + } as Network + provider.getNetwork = jest.fn().mockReturnValue({ + name: 'homestead', + chainId: 1, + } as Network) + // override readonly property + // @ts-expect-error + provider.connection = { url: '' } + }) + }) + + test('constructor initializes with valid providers', () => { + expect(() => new AppRpcProvider(ChainId.MAINNET, mockProviders)).not.toThrow() + }) + + test('constructor throws with empty providers array', () => { + expect(() => new AppRpcProvider(ChainId.MAINNET, [])).toThrow('providers array empty') + }) + + test('constructor throws with network mismatch', () => { + mockProviders[0].network.chainId = 2 + expect(() => new AppRpcProvider(ChainId.MAINNET, mockProviders)).toThrow('networks mismatch') + }) + + test('constructor throws with invalid providers', () => { + mockIsProvider.mockReturnValueOnce(false) + expect(() => new AppRpcProvider(ChainId.MAINNET, [{} as JsonRpcProvider])).toThrow('invalid provider') + }) + + test('handles sendTransaction', async () => { + const hash = '0x123' + mockProvider1.sendTransaction.mockResolvedValue({ hash } as TransactionResponse) + const provider = new AppRpcProvider(ChainId.MAINNET, [mockProvider1]) + + const result = await provider.perform('sendTransaction', { signedTransaction: '0xabc' }) + expect(result).toBe(hash) + }) + + test('handles call', async () => { + const hash = '0x123' + mockProvider1.perform.mockResolvedValue({ hash } as TransactionResponse) + const provider = new AppRpcProvider(ChainId.MAINNET, [mockProvider1]) + + const { hash: result } = await provider.perform('call', [{ hash }]) + expect(result).toBe(hash) + }) + + test('should sort faster providers before slower providers', async () => { + const SLOW = 500 + mockProvider1.getBlockNumber = jest.fn(() => new Promise((resolve) => setTimeout(() => resolve(1), SLOW))) + + const FAST = 1 + mockProvider2.getBlockNumber = jest.fn(() => new Promise((resolve) => setTimeout(() => resolve(1), FAST))) + + const appRpcProvider = new AppRpcProvider(ChainId.MAINNET, mockProviders) + + // Evaluate all providers + const evaluationPromises = appRpcProvider.providerEvaluations.map(appRpcProvider.evaluateProvider) + await Promise.all(evaluationPromises) + + // Validate that the providers are sorted correctly by latency + const [fastProvider, slowProvider] = AppRpcProvider.sortProviders(appRpcProvider.providerEvaluations.slice()) + + expect(fastProvider.performance.latency).toBeLessThan(slowProvider.performance.latency) + }) + + test('should sort failing providers after successful providers', async () => { + mockProvider1.getBlockNumber = jest.fn( + () => new Promise((_resolve, reject) => setTimeout(() => reject('fail'), 50)) + ) + mockProvider2.getBlockNumber = jest.fn(() => new Promise((resolve) => setTimeout(() => resolve(1), 50))) + + const appRpcProvider = new AppRpcProvider(ChainId.MAINNET, mockProviders) + + // Evaluate all providers + const evaluationPromises = appRpcProvider.providerEvaluations.map(appRpcProvider.evaluateProvider) + await Promise.all(evaluationPromises) + + // Validate that the providers are sorted correctly by latency + const [provider, failingProvider] = AppRpcProvider.sortProviders(appRpcProvider.providerEvaluations.slice()) + expect(provider.performance.failureCount).toBeLessThan(failingProvider.performance.failureCount) + }) + + test('should increment failureCount on provider failure', async () => { + mockProvider1.getBlockNumber.mockRejectedValue(new Error('Failed')) + + const appRpcProvider = new AppRpcProvider(ChainId.MAINNET, mockProviders) + + // Evaluate the failing provider + await appRpcProvider.evaluateProvider(appRpcProvider.providerEvaluations[0]) + + // Validate that the failureCount was incremented + expect(appRpcProvider.providerEvaluations[0].performance.failureCount).toBe(1) + }) +}) diff --git a/src/rpc/AppRpcProvider.ts b/src/rpc/AppRpcProvider.ts new file mode 100644 index 0000000000..dd86548622 --- /dev/null +++ b/src/rpc/AppRpcProvider.ts @@ -0,0 +1,158 @@ +import { Network } from '@ethersproject/networks' +import { JsonRpcProvider, Provider } from '@ethersproject/providers' +import { SupportedInterfaceChain } from 'constants/chains' + +import AppStaticJsonRpcProvider from './StaticJsonRpcProvider' + +function checkNetworks(networks: Array): Network | null { + let result: Network | null = null + + for (let i = 0; i < networks.length; i++) { + const network = networks[i] + + // Null! We do not know our network; bail. + if (network == null) { + throw new Error('unknown network') + } + + if (result) { + // Make sure the network matches the previous networks + if ( + !( + result.name === network.name && + result.chainId === network.chainId && + (result.ensAddress === network.ensAddress || (result.ensAddress == null && network.ensAddress == null)) + ) + ) { + throw new Error('networks mismatch') + } + } else { + result = network + } + } + + return result +} + +interface ProviderPerformance { + callCount: number + latency: number + failureCount: number + lastEvaluated: number +} + +interface FallbackProviderEvaluation { + provider: JsonRpcProvider + performance: ProviderPerformance +} + +/** + * This provider balances requests among multiple JSON-RPC endpoints. + */ +export default class AppRpcProvider extends AppStaticJsonRpcProvider { + providerEvaluations: ReadonlyArray + readonly evaluationIntervalMs: number + + constructor(chainId: SupportedInterfaceChain, providers: JsonRpcProvider[], evaluationIntervalMs = 30000) { + if (providers.length === 0) throw new Error('providers array empty') + providers.forEach((provider, i) => { + if (!Provider.isProvider(provider)) throw new Error(`invalid provider ${i}`) + }) + checkNetworks(providers.map((p) => p.network)) + + super(chainId, providers[0].connection.url) + this.providerEvaluations = providers.map((provider) => ({ + provider, + performance: { + callCount: 0, + latency: 1, + failureCount: 0, + lastEvaluated: 0, + }, + })) + + this.evaluationIntervalMs = evaluationIntervalMs + } + + /** + * Perform a JSON-RPC request. + * Throws an error if all providers fail to perform the operation. + */ + async perform(method: string, params: { [name: string]: any }): Promise { + // Periodically evaluate all providers + const currentTime = Date.now() + // Note that this async action will not affect the current perform call + this.providerEvaluations.forEach((providerEval) => { + if (currentTime - providerEval.performance.lastEvaluated >= this.evaluationIntervalMs) { + this.evaluateProvider(providerEval) + } + }) + + this.providerEvaluations = AppRpcProvider.sortProviders(this.providerEvaluations.slice()) + + // Always broadcast "sendTransaction" to all backends + if (method === 'sendTransaction') { + const results: Array = await Promise.all( + this.providerEvaluations.map(({ performance, provider }) => { + performance.callCount++ + return provider.sendTransaction(params.signedTransaction).then( + (result) => result.hash, + (error) => error + ) + }) + ) + + // Any success is good enough + for (let i = 0; i < results.length; i++) { + if (typeof results[i] === 'string') return results[i] + } + + // They were all an error; pick the first error + throw results[0] + } else { + for (const { provider, performance } of this.providerEvaluations) { + performance.callCount++ + try { + return await provider.perform(method, params) + } catch (error) { + performance.failureCount++ + console.warn('rpc action failed', error) + } + } + throw new Error('All providers failed to perform the operation.') + } + } + + /** + * Evaluates the performance of a provider. Updates latency and failure count metrics. + */ + async evaluateProvider(config: FallbackProviderEvaluation): Promise { + const startTime = Date.now() + config.performance.callCount++ + try { + await config.provider.getBlockNumber() + } catch (error) { + config.performance.failureCount++ + } + const latency = Date.now() - startTime + config.performance.latency += latency + + config.performance.lastEvaluated = Date.now() + } + + static sortProviders(providerEvaluations: FallbackProviderEvaluation[]) { + return providerEvaluations.sort((a, b) => { + // Provider a calculations + const aAverageLatency = a.performance.latency / (a.performance.callCount || 1) + const aFailRate = (a.performance.failureCount || 0.01) / (a.performance.callCount || 1) + + // Provider b calculations + const bAverageLatency = b.performance.latency / (b.performance.callCount || 1) + const bFailRate = (b.performance.failureCount || 0.01) / (b.performance.callCount || 1) + + if (aFailRate < bFailRate) return -1 + if (aAverageLatency < bAverageLatency) return -1 + return 1 + }) + } +} diff --git a/src/rpc/StaticJsonRpcProvider.ts b/src/rpc/StaticJsonRpcProvider.ts new file mode 100644 index 0000000000..76a606cddb --- /dev/null +++ b/src/rpc/StaticJsonRpcProvider.ts @@ -0,0 +1,53 @@ +import { deepCopy } from '@ethersproject/properties' +// This is the only file which should instantiate new Providers. +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { StaticJsonRpcProvider } from '@ethersproject/providers' +import { isPlain } from '@reduxjs/toolkit' + +import { AVERAGE_L1_BLOCK_TIME } from '../constants/chainInfo' +import { CHAIN_IDS_TO_NAMES, SupportedInterfaceChain } from '../constants/chains' + +export default class AppStaticJsonRpcProvider extends StaticJsonRpcProvider { + private _blockCache = new Map>() + get blockCache() { + // If the blockCache has not yet been initialized this block, do so by + // setting a listener to clear it on the next block. + if (!this._blockCache.size) { + this.once('block', () => this._blockCache.clear()) + } + return this._blockCache + } + + constructor(chainId: SupportedInterfaceChain, url: string) { + // Including networkish allows ethers to skip the initial detectNetwork call. + super(url, /* networkish= */ { chainId, name: CHAIN_IDS_TO_NAMES[chainId] }) + + // NB: Third-party providers (eg MetaMask) will have their own polling intervals, + // which should be left as-is to allow operations (eg transaction confirmation) to resolve faster. + // Network providers (eg AppStaticJsonRpcProvider) need to update less frequently to be considered responsive. + this.pollingInterval = AVERAGE_L1_BLOCK_TIME + } + + send(method: string, params: Array): Promise { + // Only cache eth_call's. + if (method !== 'eth_call') return super.send(method, params) + + // Only cache if params are serializable. + if (!isPlain(params)) return super.send(method, params) + + const key = `call:${JSON.stringify(params)}` + const cached = this.blockCache.get(key) + if (cached) { + this.emit('debug', { + action: 'request', + request: deepCopy({ method, params, id: 'cache' }), + provider: this, + }) + return cached + } + + const result = super.send(method, params) + this.blockCache.set(key, result) + return result + } +} diff --git a/src/state/routing/gas.ts b/src/state/routing/gas.ts index 80921d0612..e09369e9b3 100644 --- a/src/state/routing/gas.ts +++ b/src/state/routing/gas.ts @@ -4,7 +4,7 @@ import ERC20_ABI from 'abis/erc20.json' import { Erc20, Weth } from 'abis/types' import WETH_ABI from 'abis/weth.json' import { SupportedInterfaceChain } from 'constants/chains' -import { RPC_PROVIDERS } from 'constants/providers' +import { DEPRECATED_RPC_PROVIDERS } from 'constants/providers' import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens' import { getContract } from 'utils' @@ -26,7 +26,7 @@ export async function getApproveInfo( // If any of these arguments aren't provided, then we cannot generate approval cost info if (!account || !usdCostPerGas) return { needsApprove: false } - const provider = RPC_PROVIDERS[currency.chainId as SupportedInterfaceChain] + const provider = DEPRECATED_RPC_PROVIDERS[currency.chainId as SupportedInterfaceChain] const tokenContract = getContract(currency.address, ERC20_ABI, provider) as Erc20 let approveGasUseEstimate @@ -58,7 +58,7 @@ export async function getWrapInfo( ): Promise { if (!needsWrap) return { needsWrap: false } - const provider = RPC_PROVIDERS[chainId] + const provider = DEPRECATED_RPC_PROVIDERS[chainId] const wethAddress = WRAPPED_NATIVE_CURRENCY[chainId]?.address // If any of these arguments aren't provided, then we cannot generate wrap cost info diff --git a/src/utils/getContract.ts b/src/utils/getContract.ts index 732033af14..8a82098625 100644 --- a/src/utils/getContract.ts +++ b/src/utils/getContract.ts @@ -1,11 +1,10 @@ +import { Signer } from '@ethersproject/abstract-signer' import { AddressZero } from '@ethersproject/constants' import { Contract } from '@ethersproject/contracts' -import { JsonRpcProvider, JsonRpcSigner } from '@ethersproject/providers' +import { JsonRpcProvider, Provider } from '@ethersproject/providers' import { isAddress } from './addresses' -// account is optional - export function getContract(address: string, ABI: any, provider: JsonRpcProvider, account?: string): Contract { if (!isAddress(address) || address === AddressZero) { throw Error(`Invalid 'address' parameter '${address}'.`) @@ -13,11 +12,8 @@ export function getContract(address: string, ABI: any, provider: JsonRpcProvider return new Contract(address, ABI, getProviderOrSigner(provider, account) as any) } -// account is not optional -function getSigner(provider: JsonRpcProvider, account: string): JsonRpcSigner { - return provider.getSigner(account).connectUnchecked() -} + // account is optional -function getProviderOrSigner(provider: JsonRpcProvider, account?: string): JsonRpcProvider | JsonRpcSigner { - return account ? getSigner(provider, account) : provider +function getProviderOrSigner(provider: JsonRpcProvider, account?: string): Provider | Signer { + return account ? provider.getSigner(account).connectUnchecked() : provider }