diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c5cb5880..f6c9ef46 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -7,7 +7,6 @@ "addToTokenSet": "Import", "balance": "Balance", "failedToFetchAllowances": "Failed to fetch token allowances", - "failedToFetchAllowancesCta": "Please reload the page", "tokenBalances": "Token balances", "unsupportedToken": "Not yet supported" }, diff --git a/src/app/store.ts b/src/app/store.ts index 26b0412c..7688f4d2 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -8,7 +8,6 @@ import gasCostReducer from "../features/gasCost/gasCostSlice"; import indexerReducer from "../features/indexer/indexerSlice"; import makeOtcReducer from "../features/makeOtc/makeOtcSlice"; import metadataReducer from "../features/metadata/metadataSlice"; -import { subscribeToSavedTokenChangesForLocalStoragePersisting } from "../features/metadata/metadataSubscriber"; import myOrdersReducer from "../features/myOrders/myOrdersSlice"; import ordersReducer from "../features/orders/ordersSlice"; import quotesReducer from "../features/quotes/quotesSlice"; @@ -39,8 +38,6 @@ export const store = configureStore({ }, }); -subscribeToSavedTokenChangesForLocalStoragePersisting(); - export type AppDispatch = typeof store.dispatch; export type RootState = ReturnType; export type AppThunk = ThunkAction< diff --git a/src/components/@widgets/MakeWidget/subcomponents/InfoSection/InfoSection.tsx b/src/components/@widgets/MakeWidget/subcomponents/InfoSection/InfoSection.tsx index 48c169ac..635c5c1a 100644 --- a/src/components/@widgets/MakeWidget/subcomponents/InfoSection/InfoSection.tsx +++ b/src/components/@widgets/MakeWidget/subcomponents/InfoSection/InfoSection.tsx @@ -34,9 +34,6 @@ const InfoSection: FC = ({ {t("balances.failedToFetchAllowances")} - - {t("balances.failedToFetchAllowancesCta")} - ); } diff --git a/src/components/@widgets/OrderDetailWidget/OrderDetailWidget.tsx b/src/components/@widgets/OrderDetailWidget/OrderDetailWidget.tsx index 7d585dec..a8dba0ad 100644 --- a/src/components/@widgets/OrderDetailWidget/OrderDetailWidget.tsx +++ b/src/components/@widgets/OrderDetailWidget/OrderDetailWidget.tsx @@ -99,10 +99,12 @@ const OrderDetailWidget: FC = ({ order }) => { ); const [orderStatus, isOrderStatusLoading] = useOrderStatus(order); const [senderToken, isSenderTokenLoading] = useTakerTokenInfo( - order.senderToken + order.senderToken, + order.chainId ); const [signerToken, isSignerTokenLoading] = useTakerTokenInfo( - order.signerToken + order.signerToken, + order.chainId ); const isBalanceLoading = useBalanceLoading(); const senderAmount = useFormattedTokenAmount( diff --git a/src/components/@widgets/OrderDetailWidget/helpers/index.ts b/src/components/@widgets/OrderDetailWidget/helpers/index.ts index 95b8bdfc..bd884a63 100644 --- a/src/components/@widgets/OrderDetailWidget/helpers/index.ts +++ b/src/components/@widgets/OrderDetailWidget/helpers/index.ts @@ -22,7 +22,6 @@ export const getFullOrderERC20WarningTranslation = ( if (isAllowancesFailed) { return { heading: i18n.t("balances.failedToFetchAllowances"), - subHeading: i18n.t("balances.failedToFetchAllowancesCta"), }; } diff --git a/src/components/@widgets/OrderDetailWidget/hooks/useTakerTokenInfo.tsx b/src/components/@widgets/OrderDetailWidget/hooks/useTakerTokenInfo.tsx index d3eb40ad..66e7bb18 100644 --- a/src/components/@widgets/OrderDetailWidget/hooks/useTakerTokenInfo.tsx +++ b/src/components/@widgets/OrderDetailWidget/hooks/useTakerTokenInfo.tsx @@ -1,97 +1,66 @@ import { useEffect, useState } from "react"; import { TokenInfo } from "@airswap/utils"; -import { Web3Provider } from "@ethersproject/providers"; -import { useWeb3React } from "@web3-react/core"; import { useAppDispatch, useAppSelector } from "../../../../app/hooks"; -import { getAllTokensFromLocalStorage } from "../../../../features/metadata/metadataApi"; import { addActiveToken, - addTokenInfo, + fetchUnkownTokens, +} from "../../../../features/metadata/metadataActions"; +import { + selectActiveTokenAddresses, selectAllTokens, } from "../../../../features/metadata/metadataSlice"; import { selectTakeOtcReducer } from "../../../../features/takeOtc/takeOtcSlice"; import findEthOrTokenByAddress from "../../../../helpers/findEthOrTokenByAddress"; -import scrapeToken from "../../../../helpers/scrapeToken"; +import useDefaultLibrary from "../../../../hooks/useDefaultLibrary"; +import useJsonRpcProvider from "../../../../hooks/useJsonRpcProvider"; // OTC Taker version of useTokenInfo. Look at chainId of the active FullOrderERC20 instead // of active wallet chainId. This way we don't need to connect a wallet to show order tokens. const useTakerTokenInfo = ( - address: string | null + address: string | null, + chainId: number ): [TokenInfo | null, boolean] => { const dispatch = useAppDispatch(); - const { provider: library } = useWeb3React(); - const { isActive } = useAppSelector((state) => state.web3); + // Using JsonRpcProvider for unconnected wallets or for wallets connected to a different chain + const library = useJsonRpcProvider(chainId); const allTokens = useAppSelector(selectAllTokens); + const activeTokenAddresses = useAppSelector(selectActiveTokenAddresses); const { activeOrder } = useAppSelector(selectTakeOtcReducer); const [token, setToken] = useState(); - const [scrapedToken, setScrapedToken] = useState(); - const [isCallScrapeTokenLoading, setIsCallScrapeTokenLoading] = - useState(false); - const [isCallScrapeTokenSuccess, setIsCallScrapeTokenSuccess] = - useState(false); useEffect(() => { - if (scrapedToken) { - dispatch(addTokenInfo(scrapedToken)); - // Add active token so balance will be fetched - dispatch(addActiveToken(scrapedToken.address)); + if ( + address && + allTokens.find((token) => token.address === address) && + !activeTokenAddresses.includes(address) + ) { + // Add as active token so balance and token info will be fetched + dispatch(addActiveToken(address)); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scrapedToken]); + }, [address, allTokens]); useEffect(() => { - if (!activeOrder || !address || !allTokens.length) { + if (!address || !allTokens.length || token || !library) { return; } - const chainId = activeOrder.chainId; - - // If wallet is not connected the metadata tokens can't be filled yet because it gets chainId from - // the wallet. But in this case we have the chainId from the order already. So we can get tokens from - // localStorage directly so we don't have to wait for the wallet getting connected. - - const tokensObject = getAllTokensFromLocalStorage(chainId); - - const tokens = [ - ...allTokens, - ...(!isActive ? Object.values(tokensObject) : []), - ]; - - const callScrapeToken = async () => { - setIsCallScrapeTokenLoading(true); - - if (library) { - const result = await scrapeToken(address, library); - if (result) { - setScrapedToken(result); - } - setIsCallScrapeTokenSuccess(true); - } else { - setIsCallScrapeTokenSuccess(false); - } - setIsCallScrapeTokenLoading(false); - }; - - const tokenFromStore = findEthOrTokenByAddress(address, tokens, chainId); + const tokenFromStore = findEthOrTokenByAddress(address, allTokens, chainId); if (tokenFromStore) { setToken(tokenFromStore); - } else if ( - !tokenFromStore && - !isCallScrapeTokenLoading && - !isCallScrapeTokenSuccess - ) { - callScrapeToken(); + } else { + dispatch(addActiveToken(address)); + dispatch(fetchUnkownTokens({ provider: library, tokens: [address] })); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allTokens, address, activeOrder, isActive]); + }, [address, activeOrder, allTokens.length]); - return [token || scrapedToken || null, isCallScrapeTokenLoading]; + return [token || null, false]; }; export default useTakerTokenInfo; diff --git a/src/components/@widgets/SwapWidget/helpers/getTokenOrFallback.ts b/src/components/@widgets/SwapWidget/helpers/getTokenOrFallback.ts index 9020287e..80a6d115 100644 --- a/src/components/@widgets/SwapWidget/helpers/getTokenOrFallback.ts +++ b/src/components/@widgets/SwapWidget/helpers/getTokenOrFallback.ts @@ -3,11 +3,9 @@ import { UserTokenPair } from "../../../../features/userSettings/userSettingsSli export default function getTokenOrFallback( token: string | undefined, pairedToken: string | undefined, - userToken: UserTokenPair["tokenTo"] | UserTokenPair["tokenFrom"], pairedUserToken: UserTokenPair["tokenTo"] | UserTokenPair["tokenFrom"], defaultTokenAddress: string | null, - defaultPairedTokenAddress: string | null, - customTokens: string[] + defaultPairedTokenAddress: string | null ): string | null { if (token) { return token; @@ -18,11 +16,6 @@ export default function getTokenOrFallback( return null; } - // Else get the user token from store (if it's not a custom token) - if (userToken && !customTokens.includes(userToken)) { - return userToken; - } - // Check if the paired token is not already the default token address if (pairedUserToken === defaultTokenAddress) { return defaultPairedTokenAddress; diff --git a/src/components/@widgets/SwapWidget/hooks/useTokenOrFallback.ts b/src/components/@widgets/SwapWidget/hooks/useTokenOrFallback.ts index 88ba0d49..da1eba2e 100644 --- a/src/components/@widgets/SwapWidget/hooks/useTokenOrFallback.ts +++ b/src/components/@widgets/SwapWidget/hooks/useTokenOrFallback.ts @@ -2,7 +2,6 @@ import { useMemo } from "react"; import { useAppSelector } from "../../../../app/hooks"; import nativeCurrency from "../../../../constants/nativeCurrency"; -import { selectCustomTokenAddresses } from "../../../../features/metadata/metadataSlice"; import { selectUserTokens } from "../../../../features/userSettings/userSettingsSlice"; import useTokenAddress from "../../../../hooks/useTokenAddress"; import getTokenOrFallback from "../helpers/getTokenOrFallback"; @@ -15,7 +14,6 @@ const useTokenOrFallback = ( isFrom?: boolean ): string | null => { const userTokens = useAppSelector(selectUserTokens); - const customTokens = useAppSelector(selectCustomTokenAddresses); const { chainId } = useAppSelector((state) => state.web3); const defaultBaseTokenAddress = useTokenAddress("USDT"); @@ -25,11 +23,9 @@ const useTokenOrFallback = ( return getTokenOrFallback( isFrom ? tokenFrom : tokenTo, isFrom ? tokenTo : tokenFrom, - isFrom ? userTokens.tokenFrom : userTokens.tokenTo, isFrom ? userTokens.tokenTo : userTokens.tokenFrom, isFrom ? defaultBaseTokenAddress : defaultQuoteTokenAddress, - isFrom ? defaultQuoteTokenAddress : defaultBaseTokenAddress, - customTokens + isFrom ? defaultQuoteTokenAddress : defaultBaseTokenAddress ); }, [ userTokens, @@ -38,7 +34,6 @@ const useTokenOrFallback = ( isFrom, defaultBaseTokenAddress, defaultQuoteTokenAddress, - customTokens, ]); }; diff --git a/src/components/TokenList/TokenList.tsx b/src/components/TokenList/TokenList.tsx index da9e8b7c..d8870695 100644 --- a/src/components/TokenList/TokenList.tsx +++ b/src/components/TokenList/TokenList.tsx @@ -11,10 +11,8 @@ import nativeCurrency from "../../constants/nativeCurrency"; import { BalancesState } from "../../features/balances/balancesSlice"; import { addActiveToken, - addCustomToken, removeActiveToken, - removeCustomToken, -} from "../../features/metadata/metadataSlice"; +} from "../../features/metadata/metadataActions"; import useWindowSize from "../../hooks/useWindowSize"; import { OverlayActionButton } from "../ModalOverlay/ModalOverlay.styles"; import { InfoHeading } from "../Typography/Typography"; @@ -146,12 +144,7 @@ const TokenList = ({ ]); const handleAddToken = async (address: string) => { - const isCustomToken = scrapedToken?.address === address; - if (library && account) { - if (isCustomToken) { - dispatch(addCustomToken(address)); - } await dispatch(addActiveToken(address)); onAfterAddActiveToken && onAfterAddActiveToken(address); @@ -161,7 +154,6 @@ const TokenList = ({ const handleRemoveActiveToken = (address: string) => { if (library) { dispatch(removeActiveToken(address)); - dispatch(removeCustomToken(address)); onAfterRemoveActiveToken && onAfterRemoveActiveToken(address); } diff --git a/src/components/TokenList/hooks/useScrapeToken.ts b/src/components/TokenList/hooks/useScrapeToken.ts index 3ee68d20..d201a9df 100644 --- a/src/components/TokenList/hooks/useScrapeToken.ts +++ b/src/components/TokenList/hooks/useScrapeToken.ts @@ -5,7 +5,7 @@ import { TokenInfo } from "@airswap/utils"; import { Web3Provider } from "@ethersproject/providers"; import { useWeb3React } from "@web3-react/core"; -import { addTokenInfo } from "../../../features/metadata/metadataSlice"; +import { addUnknownTokenInfo } from "../../../features/metadata/metadataActions"; import scrapeToken from "../../../helpers/scrapeToken"; const useScrapeToken = ( @@ -19,7 +19,7 @@ const useScrapeToken = ( useEffect(() => { if (scrapedToken) { - dispatch(addTokenInfo(scrapedToken)); + dispatch(addUnknownTokenInfo(scrapedToken)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrapedToken]); diff --git a/src/features/balances/balancesHooks.ts b/src/features/balances/balancesHooks.ts index e7095e59..323d7bdc 100644 --- a/src/features/balances/balancesHooks.ts +++ b/src/features/balances/balancesHooks.ts @@ -5,7 +5,6 @@ import { useWeb3React } from "@web3-react/core"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { TransactionTypes } from "../../types/transactionTypes"; -import { fetchUnkownTokens } from "../metadata/metadataActions"; import { selectActiveTokens } from "../metadata/metadataSlice"; import useLatestSucceededTransaction from "../transactions/hooks/useLatestSucceededTransaction"; import { @@ -34,8 +33,10 @@ export const useBalances = () => { if ( activeAccount === account && activeChainId === chainId && - activeTokens.length === activeTokensLength + activeTokens.length <= activeTokensLength ) { + setActiveTokensLength(activeTokens.length); + return; } @@ -46,9 +47,12 @@ export const useBalances = () => { dispatch(requestActiveTokenBalances({ provider: library })); dispatch(requestActiveTokenAllowancesSwap({ provider: library })); dispatch(requestActiveTokenAllowancesWrapper({ provider: library })); - dispatch(fetchUnkownTokens({ provider: library })); }, [account, chainId, library, activeTokens]); + useEffect(() => { + setActiveTokensLength(activeTokens.length); + }, [account, chainId]); + useEffect(() => { if (!isActive) { setActiveAccount(undefined); @@ -64,6 +68,10 @@ export const useBalances = () => { const { type } = latestSuccessfulTransaction; + if (latestSuccessfulTransaction.type === TransactionTypes.order) { + return; + } + if (type === TransactionTypes.order) { dispatch(requestActiveTokenBalances({ provider: library })); dispatch(requestActiveTokenAllowancesSwap({ provider: library })); diff --git a/src/features/balances/balancesSlice.ts b/src/features/balances/balancesSlice.ts index 77942ddc..a904b093 100644 --- a/src/features/balances/balancesSlice.ts +++ b/src/features/balances/balancesSlice.ts @@ -84,8 +84,7 @@ const getThunk: ( ? getWethAddress(chainId) : undefined; const activeTokensAddresses = [ - ...state.metadata.tokens.active, - ...state.metadata.tokens.custom, + ...state.metadata.activeTokens, ...(wrappedNativeToken ? [wrappedNativeToken] : []), ADDRESS_ZERO, ]; @@ -123,7 +122,7 @@ const getThunk: ( // If we're not fetching, definitely continue if (sliceState.status !== "fetching") return true; if (sliceState.inFlightFetchTokens) { - const tokensToFetch = getState().metadata.tokens.active; + const tokensToFetch = getState().metadata.activeTokens; // only fetch if new list is larger. return tokensToFetch.length > sliceState.inFlightFetchTokens.length; } diff --git a/src/features/metadata/metadataActions.ts b/src/features/metadata/metadataActions.ts index 2ea1b869..9b5b1684 100644 --- a/src/features/metadata/metadataActions.ts +++ b/src/features/metadata/metadataActions.ts @@ -5,48 +5,65 @@ import { createAsyncThunk } from "@reduxjs/toolkit"; import * as ethers from "ethers"; import { AppDispatch, RootState } from "../../app/store"; -import { getProtocolFee, getUnknownTokens } from "./metadataApi"; +import { getUniqueSingleDimensionArray } from "../../helpers/array"; +import { Web3State } from "../web3/web3Slice"; +import { + getActiveTokensLocalStorageKey, + getProtocolFee, + getUnknownTokens, + getUnknownTokensLocalStorageKey, +} from "./metadataApi"; +import { + setActiveTokens, + MetadataTokenInfoMap, + setUnknownTokens, +} from "./metadataSlice"; + +const transformTokenInfoArrayToMap = (tokens: TokenInfo[]) => { + return tokens.reduce((acc, token) => { + const address = token.address.toLowerCase(); + + acc[address] = { ...token, address }; + return acc; + }, {} as MetadataTokenInfoMap); +}; export const fetchAllTokens = createAsyncThunk< - TokenInfo[], // Return type - number, // First argument + MetadataTokenInfoMap, + number, { - // thunkApi dispatch: AppDispatch; state: RootState; } ->("metadata/getKnownTokens", async (chainId, thunkApi) => { +>("metadata/getKnownTokens", async (chainId) => { const response = await getKnownTokens(chainId); if (response.errors.length) { console.error("Errors fetching metadata", response.errors); - return []; + return {}; } - return response.tokens; + return transformTokenInfoArrayToMap(response.tokens); }); export const fetchUnkownTokens = createAsyncThunk< - TokenInfo[], // Return type + MetadataTokenInfoMap, { - // First argument provider: ethers.providers.BaseProvider; + tokens: string[]; }, { - // thunkApi dispatch: AppDispatch; state: RootState; } ->("metadata/fetchUnknownTokens", async ({ provider }, thunkApi) => { - const { registry, metadata, web3 } = thunkApi.getState(); - if (!web3.chainId) return []; +>("metadata/fetchUnknownTokens", async ({ provider, tokens }, thunkApi) => { + const response = await getUnknownTokens(provider, tokens); - return await getUnknownTokens( - web3.chainId!, - registry.allSupportedTokens, - Object.values(metadata.tokens.all), - provider - ); + if (!response) { + return {}; + } + + return transformTokenInfoArrayToMap(response); }); export const fetchProtocolFee = createAsyncThunk< number, @@ -57,3 +74,93 @@ export const fetchProtocolFee = createAsyncThunk< >("metadata/fetchProtocolFee", async ({ provider, chainId }) => getProtocolFee(chainId, provider) ); + +const writeActiveTokensToLocalStorage = ( + activeTokens: string[], + web3: Web3State +) => { + if (!web3.account || !web3.chainId) { + return; + } + + const localStorageKey = getActiveTokensLocalStorageKey( + web3.account, + web3.chainId + ); + + localStorage.setItem(localStorageKey, JSON.stringify(activeTokens)); +}; + +const writeUnknownTokensToLocalStorage = ( + unknownTokens: MetadataTokenInfoMap, + web3: Web3State +) => { + if (!web3.chainId) { + return; + } + + const localStorageKey = getUnknownTokensLocalStorageKey(web3.chainId); + + localStorage.setItem(localStorageKey, JSON.stringify(unknownTokens)); +}; + +export const addActiveToken = createAsyncThunk< + void, + string, + { + dispatch: AppDispatch; + state: RootState; + } +>("metadata/addActiveToken", async (token, { dispatch, getState }) => { + const { metadata, web3 } = getState(); + + const activeTokens = [...metadata.activeTokens, token.toLowerCase()].filter( + getUniqueSingleDimensionArray + ); + + writeActiveTokensToLocalStorage(activeTokens, web3); + dispatch(setActiveTokens(activeTokens)); +}); + +export const removeActiveToken = createAsyncThunk< + void, + string, + { + dispatch: AppDispatch; + state: RootState; + } +>("metadata/removeActiveToken", async (token, { dispatch, getState }) => { + const { metadata, web3 } = getState(); + + const activeTokens = metadata.activeTokens.filter( + (t) => t !== token.toLowerCase() + ); + + writeActiveTokensToLocalStorage(activeTokens, web3); + dispatch(setActiveTokens(activeTokens)); +}); + +export const addUnknownTokenInfo = createAsyncThunk< + void, + TokenInfo, + { + dispatch: AppDispatch; + state: RootState; + } +>("metadata/addUnknownTokenInfo", async (tokenInfo, { dispatch, getState }) => { + const { metadata, web3 } = getState(); + + const unknownToken = { + ...tokenInfo, + address: tokenInfo.address.toLowerCase(), + }; + + const unknownTokens = { + ...metadata.unknownTokens, + [unknownToken.address]: unknownToken, + }; + + writeUnknownTokensToLocalStorage(unknownTokens, web3); + + dispatch(setUnknownTokens(unknownTokens)); +}); diff --git a/src/features/metadata/metadataApi.ts b/src/features/metadata/metadataApi.ts index d4cb3e7b..7fc9ad92 100644 --- a/src/features/metadata/metadataApi.ts +++ b/src/features/metadata/metadataApi.ts @@ -4,7 +4,7 @@ import { Web3Provider } from "@ethersproject/providers"; import * as ethers from "ethers"; import { getSwapErc20Contract } from "../../helpers/swapErc20"; -import { MetadataTokens } from "./metadataSlice"; +import { MetadataTokenInfoMap } from "./metadataSlice"; export const getActiveTokensLocalStorageKey: ( account: string, @@ -12,76 +12,45 @@ export const getActiveTokensLocalStorageKey: ( ) => string = (account, chainId) => `airswap/activeTokens/${account}/${chainId}`; -export const getCustomTokensLocalStorageKey: ( - account: string, - chainId: number -) => string = (account, chainId) => - `airswap/customTokens/${account}/${chainId}`; - -export const getAllTokensLocalStorageKey = (chainId: number): string => - `airswap/metadataCache/${chainId}`; +export const getUnknownTokensLocalStorageKey: (chainId: number) => string = ( + chainId +) => `airswap/unknownTokens/${chainId}`; export const getUnknownTokens = async ( - chainId: number, - supportedTokenAddresses: string[], - allTokens: TokenInfo[], - provider: ethers.providers.BaseProvider + provider: ethers.providers.BaseProvider, + tokens: string[] ): Promise => { - // Determine tokens we still don't know about. - const allTokenAddresses = allTokens.map((token) => token.address); - const unknownTokens = supportedTokenAddresses.filter( - (supportedTokenAddr) => !allTokenAddresses.includes(supportedTokenAddr) - ); - - let scrapedTokens: TokenInfo[] = []; - if (unknownTokens.length) { - const scrapePromises = unknownTokens.map((t) => getTokenInfo(provider, t)); - const results = await Promise.allSettled(scrapePromises); - scrapedTokens = results - .filter((r) => r.status === "fulfilled") - .map((r) => { - const tokenInfo = (r as PromiseFulfilledResult).value; - return { - ...tokenInfo, - address: tokenInfo.address.toLowerCase(), - }; - }); - } - - return scrapedTokens; + const scrapePromises = tokens.map((t) => getTokenInfo(provider, t)); + const results = await Promise.allSettled(scrapePromises); + + return results + .filter((r) => r.status === "fulfilled") + .map((r) => { + const tokenInfo = (r as PromiseFulfilledResult).value; + return { + ...tokenInfo, + address: tokenInfo.address.toLowerCase(), + }; + }); }; export const getActiveTokensFromLocalStorage = ( account: string, chainId: number -): MetadataTokens["active"] => { - const savedTokens = ( - localStorage.getItem(getActiveTokensLocalStorageKey(account, chainId)) || "" - ) - .split(",") - .filter((address) => address.length); - return (savedTokens.length && savedTokens) || []; -}; +): string[] | undefined => { + const savedTokenString = localStorage.getItem( + getActiveTokensLocalStorageKey(account, chainId) + ); -export const getCustomTokensFromLocalStorage = ( - account: string, - chainId: number -): MetadataTokens["custom"] => { - const savedTokens = ( - localStorage.getItem(getCustomTokensLocalStorageKey(account, chainId)) || "" - ) - .split(",") - .filter((address) => address.length); - return (savedTokens.length && savedTokens) || []; + return savedTokenString ? JSON.parse(savedTokenString) : undefined; }; -export const getAllTokensFromLocalStorage = ( +export const getUnknownTokensFromLocalStorage = ( chainId: number -): MetadataTokens["all"] => { - const localStorageItem = localStorage.getItem( - getAllTokensLocalStorageKey(chainId) +): MetadataTokenInfoMap => { + return JSON.parse( + localStorage.getItem(getUnknownTokensLocalStorageKey(chainId)) || "{}" ); - return localStorageItem ? JSON.parse(localStorageItem) : {}; }; export const getProtocolFee = async ( diff --git a/src/features/metadata/metadataHooks.ts b/src/features/metadata/metadataHooks.ts index fd2cebfa..9fd68745 100644 --- a/src/features/metadata/metadataHooks.ts +++ b/src/features/metadata/metadataHooks.ts @@ -3,46 +3,71 @@ import { useEffect, useState } from "react"; import { useWeb3React } from "@web3-react/core"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { getUniqueSingleDimensionArray } from "../../helpers/array"; import { fetchSupportedTokens } from "../registry/registryActions"; -import { fetchAllTokens, fetchProtocolFee } from "./metadataActions"; +import { selectAllSupportedTokens } from "../registry/registrySlice"; +import { + fetchAllTokens, + fetchProtocolFee, + fetchUnkownTokens, +} from "./metadataActions"; import { getActiveTokensFromLocalStorage, - getAllTokensFromLocalStorage, - getCustomTokensFromLocalStorage, + getUnknownTokensFromLocalStorage, } from "./metadataApi"; -import { MetadataTokens, setTokens } from "./metadataSlice"; +import { + selectActiveTokenAddresses, + selectAllTokens, + setActiveTokens, + setUnknownTokens, +} from "./metadataSlice"; const useMetadata = () => { const dispatch = useAppDispatch(); const { provider } = useWeb3React(); + const allTokens = useAppSelector(selectAllTokens); + const activeTokenAddresses = useAppSelector(selectActiveTokenAddresses); + const supportedTokenAddresses = useAppSelector(selectAllSupportedTokens); const { isActive, account, chainId } = useAppSelector((state) => state.web3); - const { tokens } = useAppSelector((state) => state.metadata); + const { isFetchingAllTokensSuccess } = useAppSelector( + (state) => state.metadata + ); + const { isFetchingSupportedTokensSuccess } = useAppSelector( + (state) => state.registry + ); const [activeAccount, setActiveAccount] = useState(); const [activeAccountChainId, setActiveAccountChainId] = useState(); const [activeChainId, setActiveChainId] = useState(); + const [activeSupportedTokens, setActiveSupportedTokens] = useState( + [] + ); useEffect(() => { if (!account || !chainId || !provider) { return; } - if (activeAccount === account && activeAccountChainId === chainId) { + if ( + activeAccount === account && + activeAccountChainId === chainId && + JSON.stringify(activeSupportedTokens) === + JSON.stringify(supportedTokenAddresses) + ) { return; } setActiveAccount(account); setActiveAccountChainId(chainId); + setActiveSupportedTokens(supportedTokenAddresses); + + const newActiveTokens = getActiveTokensFromLocalStorage(account, chainId); - const tokens: MetadataTokens = { - all: getAllTokensFromLocalStorage(chainId), - active: getActiveTokensFromLocalStorage(account, chainId), - custom: getCustomTokensFromLocalStorage(account, chainId), - }; + dispatch(setActiveTokens(newActiveTokens || supportedTokenAddresses)); + }, [account, chainId, provider, isFetchingSupportedTokensSuccess]); - dispatch(setTokens(tokens)); - }, [account, chainId, provider]); + // TODO: Fetch unknown when active tokens are added useEffect(() => { if (!chainId || !provider || !account) { @@ -53,13 +78,43 @@ const useMetadata = () => { return; } + const unknownTokens = getUnknownTokensFromLocalStorage(chainId); + setActiveChainId(chainId); + if (Object.keys(unknownTokens).length) { + dispatch(setUnknownTokens(unknownTokens)); + } + dispatch(fetchAllTokens(chainId)); dispatch(fetchSupportedTokens({ account, chainId, provider })); dispatch(fetchProtocolFee({ chainId, provider })); }, [chainId, provider]); + useEffect(() => { + if ( + !chainId || + !provider || + !isFetchingAllTokensSuccess || + !isFetchingSupportedTokensSuccess + ) { + return; + } + + const allTokenAddresses = allTokens.map((token) => token.address); + const activeAndSupportedTokenAddresses = [ + ...activeTokenAddresses, + ...supportedTokenAddresses, + ].filter(getUniqueSingleDimensionArray); + const tokensMissingInfo = activeAndSupportedTokenAddresses.filter( + (address) => !allTokenAddresses.includes(address) + ); + + if (tokensMissingInfo.length) { + dispatch(fetchUnkownTokens({ provider, tokens: tokensMissingInfo })); + } + }, [isFetchingAllTokensSuccess, isFetchingSupportedTokensSuccess]); + useEffect(() => { if (!isActive) { setActiveAccount(undefined); diff --git a/src/features/metadata/metadataSlice.ts b/src/features/metadata/metadataSlice.ts index 59ce6b28..59f7c5eb 100644 --- a/src/features/metadata/metadataSlice.ts +++ b/src/features/metadata/metadataSlice.ts @@ -2,8 +2,12 @@ import { TokenInfo } from "@airswap/utils"; import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { RootState } from "../../app/store"; -import { fetchSupportedTokens } from "../registry/registryActions"; -import { walletDisconnected } from "../web3/web3Actions"; +import { getUniqueSingleDimensionArray } from "../../helpers/array"; +import { + chainIdChanged, + walletChanged, + walletDisconnected, +} from "../web3/web3Actions"; import { selectChainId } from "../web3/web3Slice"; import { fetchAllTokens, @@ -11,63 +15,43 @@ import { fetchUnkownTokens, } from "./metadataActions"; -export interface MetadataTokens { - all: { - [address: string]: TokenInfo; - }; - active: string[]; - custom: string[]; -} +export type MetadataTokenInfoMap = { + [address: string]: TokenInfo; +}; export interface MetadataState { isFetchingAllTokens: boolean; + isFetchingAllTokensSuccess: boolean; + knownTokens: MetadataTokenInfoMap; + unknownTokens: MetadataTokenInfoMap; protocolFee: number; - tokens: MetadataTokens; + activeTokens: string[]; } const initialState: MetadataState = { isFetchingAllTokens: false, + isFetchingAllTokensSuccess: false, + knownTokens: {}, + unknownTokens: {}, protocolFee: 0, - tokens: { - all: {}, - active: [], - custom: [], - }, + activeTokens: [], }; export const metadataSlice = createSlice({ name: "metadata", initialState, reducers: { - addActiveToken: (state, action: PayloadAction) => { - const lowerCasedToken = action.payload.trim().toLowerCase(); - if (!state.tokens.active.includes(lowerCasedToken)) { - state.tokens.active.push(lowerCasedToken); - } - }, - addCustomToken: (state, action: PayloadAction) => { - const lowerCasedToken = action.payload.trim().toLowerCase(); - if (!state.tokens.custom.includes(lowerCasedToken)) { - state.tokens.custom.push(lowerCasedToken); - } - }, - addTokenInfo: (state, action: PayloadAction) => { - state.tokens.all[action.payload.address] = action.payload; - }, - removeActiveToken: (state, action: PayloadAction) => { - state.tokens.active = state.tokens.active.filter( - (tokenAddress) => tokenAddress !== action.payload - ); - }, - removeCustomToken: (state, action: PayloadAction) => { - state.tokens.custom = state.tokens.active.filter( - (tokenAddress) => tokenAddress !== action.payload - ); + setActiveTokens: (state, action: PayloadAction) => { + return { + ...state, + isInitialized: true, + activeTokens: action.payload.map((token) => token.toLowerCase()), + }; }, - setTokens: (state, action: PayloadAction) => { + setUnknownTokens: (state, action: PayloadAction) => { return { ...state, - tokens: action.payload, + unknownTokens: action.payload, }; }, }, @@ -77,65 +61,54 @@ export const metadataSlice = createSlice({ return { ...state, isFetchingAllTokens: true, + isFetchingAllTokensSuccess: false, }; }) .addCase(fetchAllTokens.fulfilled, (state, action): MetadataState => { - const { payload: tokenInfo } = action; - const newAllTokens = tokenInfo.reduce( - (allTokens: MetadataTokens["all"], token) => { - const address = token.address.toLowerCase(); - if (!allTokens[address]) { - allTokens[address] = { - ...token, - address: token.address.toLowerCase(), - }; - } - return allTokens; - }, - {} - ); - - const stateAllTokens = Object.keys(state.tokens.all).reduce( - (allTokens: MetadataTokens["all"], token) => { - return { - ...allTokens, - [token.toLowerCase()]: state.tokens.all[token], - }; - }, - {} - ); - - const tokens = { - ...state.tokens, - all: { - ...stateAllTokens, - ...newAllTokens, - }, - }; - return { ...state, isFetchingAllTokens: false, - tokens, + isFetchingAllTokensSuccess: true, + knownTokens: { + ...state.knownTokens, + ...action.payload, + }, }; }) - .addCase(fetchAllTokens.rejected, (state, action): MetadataState => { + .addCase(fetchAllTokens.rejected, (state): MetadataState => { return { ...state, isFetchingAllTokens: false, }; }) - .addCase(fetchSupportedTokens.fulfilled, (state, action) => { - if (!state.tokens.active?.length) - state.tokens.active = action.payload.activeTokens || []; - }) .addCase(fetchUnkownTokens.fulfilled, (state, action) => { - action.payload.forEach((token) => { - state.tokens.all[token.address] = token; - }); + return { + ...state, + unknownTokens: { + ...state.unknownTokens, + ...action.payload, + }, + }; }) .addCase(fetchProtocolFee.fulfilled, (state, action) => { - state.protocolFee = action.payload; + return { + ...state, + protocolFee: action.payload, + }; + }) + .addCase(walletChanged, (state): MetadataState => { + return { + ...state, + activeTokens: [], + }; + }) + .addCase(chainIdChanged, (state): MetadataState => { + return { + ...state, + knownTokens: {}, + unknownTokens: {}, + activeTokens: [], + }; }) .addCase(walletDisconnected, (): MetadataState => { return initialState; @@ -143,21 +116,13 @@ export const metadataSlice = createSlice({ }, }); -export const { - addActiveToken, - addCustomToken, - addTokenInfo, - removeActiveToken, - removeCustomToken, - setTokens, -} = metadataSlice.actions; +export const { setActiveTokens, setUnknownTokens } = metadataSlice.actions; -const selectActiveTokenAddresses = (state: RootState) => - state.metadata.tokens.active; -export const selectCustomTokenAddresses = (state: RootState) => - state.metadata.tokens.custom; +export const selectActiveTokenAddresses = (state: RootState) => + state.metadata.activeTokens; export const selectAllTokens = (state: RootState) => [ - ...Object.values(state.metadata.tokens.all), + ...Object.values(state.metadata.knownTokens), + ...Object.values(state.metadata.unknownTokens), ]; export const selectAllTokenInfo = createSelector( [selectAllTokens, selectChainId], @@ -173,16 +138,6 @@ export const selectActiveTokens = createSelector( ); } ); -export const selectActiveTokensWithoutCustomTokens = createSelector( - [selectActiveTokenAddresses, selectCustomTokenAddresses, selectAllTokenInfo], - (activeTokenAddresses, customTokenAddresses, allTokenInfo) => { - return Object.values(allTokenInfo).filter( - (tokenInfo) => - activeTokenAddresses.includes(tokenInfo.address) && - !customTokenAddresses.includes(tokenInfo.address) - ); - } -); export const selectMetaDataReducer = (state: RootState) => state.metadata; export const selectProtocolFee = (state: RootState) => state.metadata.protocolFee; diff --git a/src/features/metadata/metadataSubscriber.ts b/src/features/metadata/metadataSubscriber.ts deleted file mode 100644 index 1e51a85c..00000000 --- a/src/features/metadata/metadataSubscriber.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { TokenInfo } from "@airswap/utils"; - -import { store } from "../../app/store"; -import { - getActiveTokensLocalStorageKey, - getAllTokensLocalStorageKey, - getCustomTokensLocalStorageKey, -} from "./metadataApi"; - -interface TokensCache { - [address: string]: { - [chainId: number]: string[]; - }; -} - -interface AllTokensCache { - [chainId: number]: { - [address: string]: TokenInfo; - }; -} - -const compareAndWriteTokensToLocalStorage = ( - tokensCache: TokensCache, - tokens: string[], - address: string, - chainId: number, - localStorageKey: string -) => { - if (!tokensCache[address]) { - tokensCache[address] = {}; - } - const cachedActiveTokensForActiveWallet = tokensCache[address][chainId]; - if (tokens.length && cachedActiveTokensForActiveWallet !== tokens) { - // active tokens have changed, persist to local storage. - tokensCache[address][chainId] = tokens; - localStorage.setItem(localStorageKey, tokens.join(",")); - } -}; - -export const subscribeToSavedTokenChangesForLocalStoragePersisting = () => { - const activeTokensCache: TokensCache = {}; - const customTokensCache: TokensCache = {}; - const allTokensCache: AllTokensCache = {}; - - store.subscribe(() => { - const { web3, metadata, transactions } = store.getState(); - if (!web3.isActive) return; - - // All tokens - if (!allTokensCache[web3.chainId!]) { - allTokensCache[web3.chainId!] = {}; - } - - const cachedAllTokensForChain = allTokensCache[web3.chainId!]; - - if ( - Object.values(metadata.tokens.all).length !== - Object.values(cachedAllTokensForChain).length - ) { - // all tokens have changed, persist to local storage. - - allTokensCache[web3.chainId!] = metadata.tokens.all; - localStorage.setItem( - getAllTokensLocalStorageKey(web3.chainId!), - JSON.stringify(metadata.tokens.all) - ); - } - - if (!web3.account || !web3.chainId) { - return; - } - - // Active tokens - compareAndWriteTokensToLocalStorage( - activeTokensCache, - metadata.tokens.active, - web3.account, - web3.chainId, - getActiveTokensLocalStorageKey(web3.account, web3.chainId) - ); - - // Custom tokens - compareAndWriteTokensToLocalStorage( - customTokensCache, - metadata.tokens.custom, - web3.account, - web3.chainId, - getCustomTokensLocalStorageKey(web3.account, web3.chainId) - ); - }); -}; diff --git a/src/features/registry/registryActions.ts b/src/features/registry/registryActions.ts index de67671b..5fb23099 100644 --- a/src/features/registry/registryActions.ts +++ b/src/features/registry/registryActions.ts @@ -4,14 +4,12 @@ import { providers } from "ethers"; import uniqBy from "lodash.uniqby"; import { AppDispatch, RootState } from "../../app/store"; -import { getActiveTokensFromLocalStorage } from "../metadata/metadataApi"; import { getStakerTokens } from "./registryApi"; export const fetchSupportedTokens = createAsyncThunk< { allSupportedTokens: string[]; stakerTokens: Record; - activeTokens: string[]; }, { account: string; @@ -19,25 +17,16 @@ export const fetchSupportedTokens = createAsyncThunk< provider: providers.Provider; }, { - // Optional fields for defining thunkApi field types dispatch: AppDispatch; state: RootState; } ->("registry/fetchSupportedTokens", async ({ account, chainId, provider }) => { +>("registry/fetchSupportedTokens", async ({ chainId, provider }) => { const stakerTokens = await getStakerTokens(chainId, provider); - // Combine token lists from all makers and flatten them. const allSupportedTokens = uniqBy( Object.values(stakerTokens).flat(), (i) => i ); - const activeTokensLocalStorage = getActiveTokensFromLocalStorage( - account, - chainId - ); - const activeTokens = - (activeTokensLocalStorage.length && activeTokensLocalStorage) || - allSupportedTokens || - []; - return { stakerTokens, allSupportedTokens, activeTokens }; + + return { stakerTokens, allSupportedTokens }; }); diff --git a/src/features/registry/registryApi.ts b/src/features/registry/registryApi.ts index 1ab38322..e3745e14 100644 --- a/src/features/registry/registryApi.ts +++ b/src/features/registry/registryApi.ts @@ -45,9 +45,10 @@ async function getStakerTokens( ); return stakers.reduce((acc, staker, index) => { - const stakerTokens = tokensForStakers[index][0]; + const stakerTokens = tokensForStakers[index].map((t) => t.toLowerCase()); + const address = staker.toLowerCase(); - return { ...acc, [staker]: stakerTokens }; + return { ...acc, [address]: stakerTokens }; }, {}); } diff --git a/src/features/registry/registrySlice.ts b/src/features/registry/registrySlice.ts index 0302cf16..ffda9030 100644 --- a/src/features/registry/registrySlice.ts +++ b/src/features/registry/registrySlice.ts @@ -1,16 +1,22 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { RootState } from "../../app/store"; -import { walletDisconnected } from "../web3/web3Actions"; +import { + chainIdChanged, + walletChanged, + walletDisconnected, +} from "../web3/web3Actions"; import { fetchSupportedTokens } from "./registryActions"; export interface RegistryState { + isFetchingSupportedTokensSuccess: boolean; stakerTokens: Record; allSupportedTokens: string[]; status: "idle" | "fetching" | "failed"; } const initialState: RegistryState = { + isFetchingSupportedTokensSuccess: false, stakerTokens: {}, allSupportedTokens: [], status: "idle", @@ -20,15 +26,6 @@ export const registrySlice = createSlice({ name: "registry", initialState, reducers: { - setStakerTokens: ( - state, - action: PayloadAction> - ) => { - state.stakerTokens = { ...action.payload }; - }, - setAllSupportedTokens: (state, action: PayloadAction) => { - state.allSupportedTokens = [...action.payload]; - }, reset: () => { return { ...initialState }; }, @@ -36,22 +33,33 @@ export const registrySlice = createSlice({ extraReducers: (builder) => { builder .addCase(fetchSupportedTokens.pending, (state) => { - state.status = "fetching"; + return { + ...state, + isFetchingSupportedTokensSuccess: false, + status: "fetching", + }; }) .addCase(fetchSupportedTokens.fulfilled, (state, action) => { - state.status = "idle"; - state.allSupportedTokens = [...action.payload.allSupportedTokens]; - state.stakerTokens = { ...action.payload.stakerTokens }; + return { + ...state, + isFetchingSupportedTokensSuccess: true, + status: "idle", + allSupportedTokens: action.payload.allSupportedTokens, + stakerTokens: action.payload.stakerTokens, + }; }) .addCase(fetchSupportedTokens.rejected, (state) => { - state.status = "failed"; + return { + ...state, + status: "failed", + }; }) - .addCase(walletDisconnected, () => initialState); + .addCase(walletDisconnected, () => initialState) + .addCase(chainIdChanged, () => initialState); }, }); -export const { setStakerTokens, setAllSupportedTokens, reset } = - registrySlice.actions; +export const { reset } = registrySlice.actions; export const selectAllSupportedTokens = (state: RootState) => state.registry.allSupportedTokens; export default registrySlice.reducer; diff --git a/src/features/transactions/hooks/useLatestTransaction.ts b/src/features/transactions/hooks/useLatestTransaction.ts index 20e50ecb..fa8b3aae 100644 --- a/src/features/transactions/hooks/useLatestTransaction.ts +++ b/src/features/transactions/hooks/useLatestTransaction.ts @@ -36,6 +36,10 @@ const useLatestTransaction = (storeTransactions: SubmittedTransaction[]) => { }, [account, chainId, storeTransactions]); useEffect(() => { + if (!activeAccount) { + return; + } + const stateHashes = stateTransactions.map( (transaction) => transaction.hash ); diff --git a/src/features/web3/web3Actions.ts b/src/features/web3/web3Actions.ts index b7c85224..b2997709 100644 --- a/src/features/web3/web3Actions.ts +++ b/src/features/web3/web3Actions.ts @@ -3,3 +3,5 @@ import { createAction } from "@reduxjs/toolkit"; export const walletDisconnected = createAction("web3/walletDisconnected"); export const walletChanged = createAction("web3/walletChanged"); + +export const chainIdChanged = createAction("web3/chainIdChanged"); diff --git a/src/features/web3/web3Hooks.ts b/src/features/web3/web3Hooks.ts index 21decdc2..5906412e 100644 --- a/src/features/web3/web3Hooks.ts +++ b/src/features/web3/web3Hooks.ts @@ -1,11 +1,15 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useDebounce } from "react-use"; import { useWeb3React } from "@web3-react/core"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { clearedCachedLibrary, setCachedLibrary } from "../../helpers/ethers"; -import { walletChanged, walletDisconnected } from "./web3Actions"; +import { + chainIdChanged, + walletChanged, + walletDisconnected, +} from "./web3Actions"; import { setLibraries, setWeb3Data } from "./web3Slice"; const useWeb3ReactRouteDebounceDuration = 100; @@ -69,6 +73,12 @@ const useWeb3 = (): void => { dispatch(walletChanged()); } }, [account]); + + useEffect(() => { + if (isInitialized && isActive && chainId) { + dispatch(chainIdChanged()); + } + }, [chainId]); }; export default useWeb3; diff --git a/src/hooks/useJsonRpcProvider.ts b/src/hooks/useJsonRpcProvider.ts new file mode 100644 index 00000000..f69b0189 --- /dev/null +++ b/src/hooks/useJsonRpcProvider.ts @@ -0,0 +1,19 @@ +import { useMemo } from "react"; + +import { + BaseProvider, + Web3Provider, + JsonRpcProvider, +} from "@ethersproject/providers"; + +import { getRpcUrl } from "../helpers/getRpcUrl"; + +const useJsonRpcProvider = ( + chainId: number +): Web3Provider | BaseProvider | undefined => { + return useMemo(() => { + return new JsonRpcProvider(getRpcUrl(chainId)); + }, [chainId]); +}; + +export default useJsonRpcProvider;