diff --git a/package.json b/package.json index 812b105a..b853b67b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@safe-global/safe-apps-sdk": "^8.1.0", "@safe-global/safe-gateway-typescript-sdk": "^3.13.2", "@safe-global/safe-react-components": "2.0.5", + "@types/jest": "^29.5.12", "ace-builds": "^1.15.0", "bignumber.js": "^9.1.2", "ethers": "^5.7.2", @@ -48,6 +49,7 @@ "react-svg": "^16.1.34", "react-virtualized-auto-sizer": "^1.0.6", "react-window": "^1.8.9", + "swr": "^2.2.5", "tslib": "^2.6.1", "typescript": "~5.0.4" }, diff --git a/src/App.tsx b/src/App.tsx index 675ddb87..64071c97 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,10 +16,9 @@ import { AssetTransfer, CollectibleTransfer, useCsvParser } from "./hooks/useCsv import { useEnsResolver } from "./hooks/useEnsResolver"; import CheckIcon from "./static/check.svg"; import AppIcon from "./static/logo.svg"; -import { useGetAssetBalanceQuery, useGetAllNFTsQuery } from "./stores/api/balanceApi"; import { setupParserListener } from "./stores/middleware/parseListener"; import { setSafeInfo } from "./stores/slices/safeInfoSlice"; -import { RootState, startAppListening } from "./stores/store"; +import { RootState, selectIsLoading, startAppListening, useAppSelector } from "./stores/store"; import { buildAssetTransfers, buildCollectibleTransfers } from "./transfers/transfers"; import "./styles/globals.css"; @@ -28,8 +27,6 @@ const App: React.FC = () => { const theme = useTheme(); const { isLoading } = useTokenList(); const { sdk, safe } = useSafeAppsSDK(); - const assetBalanceQuery = useGetAssetBalanceQuery(); - const nftBalanceQuery = useGetAllNFTsQuery(); const { messages } = useSelector((state: RootState) => state.messages); const { transfers, parsing } = useSelector((state: RootState) => state.csvEditor); @@ -39,6 +36,8 @@ const App: React.FC = () => { const ensResolver = useEnsResolver(); const dispatch = useDispatch(); + const isDataLoading = useAppSelector(selectIsLoading); + useEffect(() => { dispatch(setSafeInfo(safe)); const subscriptions: Unsubscribe[] = [setupParserListener(startAppListening, parseCsv, ensResolver)]; @@ -78,7 +77,7 @@ const App: React.FC = () => { { <> - {isLoading || assetBalanceQuery.isLoading || nftBalanceQuery.isLoading ? ( + {isLoading || isDataLoading ? ( ) : ( @@ -91,7 +90,7 @@ const App: React.FC = () => { - + check { + const { safe } = useSafeAppsSDK(); + useLoadChains(); + useLoadAssets(safe); + useLoadCollectibles(safe); + return <>; +}; diff --git a/src/AppWrapper.tsx b/src/AppWrapper.tsx index cbcf0b17..c0373bb4 100644 --- a/src/AppWrapper.tsx +++ b/src/AppWrapper.tsx @@ -4,6 +4,7 @@ import { SafeThemeProvider } from "@safe-global/safe-react-components"; import { Provider as ReduxProvider } from "react-redux"; import App from "./App"; +import { AppInitializer } from "./AppInitializer"; import { useDarkMode } from "./hooks/useDarkMode"; import errorIcon from "./static/error-icon.svg"; import { store } from "./stores/store"; @@ -42,6 +43,7 @@ export const AppWrapper = () => { } > + diff --git a/src/__tests__/balanceCheck.test.ts b/src/__tests__/balanceCheck.test.ts index 70b195b8..3c4917e0 100644 --- a/src/__tests__/balanceCheck.test.ts +++ b/src/__tests__/balanceCheck.test.ts @@ -1,4 +1,5 @@ -import { AssetBalance, NFTBalance } from "src/stores/api/balanceApi"; +import { AssetBalance } from "src/stores/slices/assetBalanceSlice"; +import { NFTBalance } from "src/stores/slices/collectiblesSlice"; import { AssetTransfer, CollectibleTransfer } from "../hooks/useCsvParser"; import { assetTransfersToSummary, checkAllBalances } from "../parser/balanceCheck"; @@ -488,6 +489,8 @@ describe("transferToSummary and check balances", () => { }, ], next: null, + count: 2, + previous: null, }; const biggerBalance: NFTBalance = { results: [ @@ -517,6 +520,8 @@ describe("transferToSummary and check balances", () => { }, ], next: null, + count: 3, + previous: null, }; const smallerBalance: NFTBalance = { results: [ @@ -530,11 +535,13 @@ describe("transferToSummary and check balances", () => { }, ], next: null, + count: 1, + previous: null, }; - expect(checkAllBalances(undefined, exactBalance, transfers)).toHaveLength(0); - expect(checkAllBalances(undefined, biggerBalance, transfers)).toHaveLength(0); - const smallBalanceCheckResult = checkAllBalances(undefined, smallerBalance, transfers); + expect(checkAllBalances(undefined, exactBalance.results, transfers)).toHaveLength(0); + expect(checkAllBalances(undefined, biggerBalance.results, transfers)).toHaveLength(0); + const smallBalanceCheckResult = checkAllBalances(undefined, smallerBalance.results, transfers); expect(smallBalanceCheckResult).toHaveLength(1); expect(smallBalanceCheckResult[0].token).toEqual("Test Collectible"); expect(smallBalanceCheckResult[0].token_type).toEqual("erc721"); @@ -575,9 +582,11 @@ describe("transferToSummary and check balances", () => { }, ], next: null, + count: 1, + previous: null, }; - const balanceCheckResult = checkAllBalances(undefined, exactBalance, transfers); + const balanceCheckResult = checkAllBalances(undefined, exactBalance.results, transfers); expect(balanceCheckResult).toHaveLength(1); expect(balanceCheckResult[0].token).toEqual("Test Collectible"); expect(balanceCheckResult[0].token_type).toEqual("erc721"); diff --git a/src/__tests__/tokenList.test.ts b/src/__tests__/tokenList.test.ts index 15a4bef1..e1180d2f 100644 --- a/src/__tests__/tokenList.test.ts +++ b/src/__tests__/tokenList.test.ts @@ -1,7 +1,7 @@ import { ethers } from "ethers"; import { fetchTokenList } from "../hooks/token"; -import { networkInfo } from "../networks"; +import { staticNetworkInfo } from "../networks"; beforeEach(() => { jest.spyOn(window, "fetch").mockImplementation(() => { @@ -68,8 +68,8 @@ describe("Mainnet tokenlist", () => { }); describe("Fetch should resolve for all networks", () => { - for (const chainId of networkInfo.keys()) { - it(`fetches tokens for ${networkInfo.get(chainId)?.name} network`, () => { + for (const chainId of staticNetworkInfo.keys()) { + it(`fetches tokens for ${staticNetworkInfo.get(chainId)?.name} network`, () => { expect(() => fetchTokenList(chainId)).not.toThrow(); }); } diff --git a/src/components/DonateDialog.tsx b/src/components/DonateDialog.tsx index 89aade2b..2c3a8c87 100644 --- a/src/components/DonateDialog.tsx +++ b/src/components/DonateDialog.tsx @@ -13,13 +13,12 @@ import { TextField, Typography, } from "@mui/material"; -import { useSafeAppsSDK } from "@safe-global/safe-apps-react-sdk"; import { BigNumber, ethers } from "ethers"; import { useEffect, useState } from "react"; import { useCsvContent } from "src/hooks/useCsvContent"; +import { useCurrentChain } from "src/hooks/useCurrentChain"; import { useDarkMode } from "src/hooks/useDarkMode"; -import { networkInfo } from "src/networks"; -import { AssetBalance } from "src/stores/api/balanceApi"; +import { AssetBalance } from "src/stores/slices/assetBalanceSlice"; import { updateCsvContent } from "src/stores/slices/csvEditorSlice"; import { useAppDispatch } from "src/stores/store"; import { DONATION_ADDRESS } from "src/utils"; @@ -36,12 +35,11 @@ export const DonateDialog = ({ onClose: () => void; assetBalance: AssetBalance; }) => { - const { safe } = useSafeAppsSDK(); const dispatch = useAppDispatch(); const csvContent = useCsvContent(); const darkMode = useDarkMode(); - - const nativeSymbol = networkInfo.get(safe.chainId)?.currencySymbol || "ETH"; + const chainConfig = useCurrentChain(); + const nativeSymbol = chainConfig?.currencySymbol || "ETH"; const items = assetBalance?.map((asset) => ({ id: asset.tokenAddress || "0x0", diff --git a/src/components/DrainSafeDialog.tsx b/src/components/DrainSafeDialog.tsx index 48bfdd85..94c0fe09 100644 --- a/src/components/DrainSafeDialog.tsx +++ b/src/components/DrainSafeDialog.tsx @@ -13,13 +13,13 @@ import { TextField, Typography, } from "@mui/material"; -import { useSafeAppsSDK } from "@safe-global/safe-apps-react-sdk"; import BigNumber from "bignumber.js"; import { utils } from "ethers"; import { useState } from "react"; +import { useCurrentChain } from "src/hooks/useCurrentChain"; import { useEnsResolver } from "src/hooks/useEnsResolver"; -import { networkInfo } from "src/networks"; -import { AssetBalance, NFTBalance } from "src/stores/api/balanceApi"; +import { AssetBalance } from "src/stores/slices/assetBalanceSlice"; +import { NFTBalance } from "src/stores/slices/collectiblesSlice"; import { updateCsvContent } from "src/stores/slices/csvEditorSlice"; import { useAppDispatch } from "src/stores/store"; import { fromWei } from "src/utils"; @@ -33,18 +33,16 @@ export const DrainSafeDialog = ({ isOpen: boolean; onClose: () => void; assetBalance: AssetBalance; - nftBalance: NFTBalance; + nftBalance: NFTBalance["results"]; }) => { const [drainAddress, setDrainAddress] = useState(""); const [resolvedAddress, setResolvedAddress] = useState(""); const [resolving, setResolving] = useState(false); - const { safe } = useSafeAppsSDK(); const dispatch = useAppDispatch(); - const selectedNetworkInfo = networkInfo.get(safe.chainId); - + const selectedNetworkInfo = useCurrentChain(); const ensResolver = useEnsResolver(); const invalidNetworkError = resolvedAddress.includes(":") @@ -75,7 +73,7 @@ export const DrainSafeDialog = ({ } }); - nftBalance?.results.forEach((collectible) => { + nftBalance.forEach((collectible) => { drainCSV += `\nnft,${collectible.address},${drainAddress},,${collectible.id}`; }); } diff --git a/src/components/GenerateTransfersMenu.tsx b/src/components/GenerateTransfersMenu.tsx index 566c6fef..99ea797f 100644 --- a/src/components/GenerateTransfersMenu.tsx +++ b/src/components/GenerateTransfersMenu.tsx @@ -1,7 +1,9 @@ import { Box, Button, Tooltip } from "@mui/material"; import { useSafeAppsSDK } from "@safe-global/safe-apps-react-sdk"; import { useState } from "react"; -import { useGetAssetBalanceQuery, useGetAllNFTsQuery } from "src/stores/api/balanceApi"; +import { selectAssetBalances } from "src/stores/slices/assetBalanceSlice"; +import { selectCollectibles } from "src/stores/slices/collectiblesSlice"; +import { useAppSelector } from "src/stores/store"; import { NETWORKS_WITH_DONATIONS_DEPLOYED } from "../networks"; @@ -9,11 +11,8 @@ import { DonateDialog } from "./DonateDialog"; import { DrainSafeDialog } from "./DrainSafeDialog"; export const GenerateTransfersMenu = () => { - const assetBalanceQuery = useGetAssetBalanceQuery(); - const nftBalanceQuery = useGetAllNFTsQuery(); - - const assetBalance = assetBalanceQuery.currentData; - const nftBalance = nftBalanceQuery.currentData; + const assetBalance = useAppSelector(selectAssetBalances); + const nftBalance = useAppSelector(selectCollectibles); const [isDrainModalOpen, setIsDrainModalOpen] = useState(false); const [isDonateModalOpen, setIsDonateModalOpen] = useState(false); diff --git a/src/hooks/collectibleTokenInfoProvider.ts b/src/hooks/collectibleTokenInfoProvider.ts index 0815daeb..1bce7ecd 100644 --- a/src/hooks/collectibleTokenInfoProvider.ts +++ b/src/hooks/collectibleTokenInfoProvider.ts @@ -3,7 +3,8 @@ import { useSafeAppsSDK } from "@safe-global/safe-apps-react-sdk"; import BigNumber from "bignumber.js"; import { ethers } from "ethers"; import { useCallback, useMemo } from "react"; -import { useGetAllNFTsQuery } from "src/stores/api/balanceApi"; +import { selectCollectibles } from "src/stores/slices/collectiblesSlice"; +import { useAppSelector } from "src/stores/store"; import { resolveIpfsUri } from "src/utils"; import { erc1155Instance } from "../transfers/erc1155"; @@ -35,9 +36,9 @@ export interface CollectibleTokenInfoProvider { export const useCollectibleTokenInfoProvider: () => CollectibleTokenInfoProvider = () => { const { safe, sdk } = useSafeAppsSDK(); + const currentNftBalance = useAppSelector(selectCollectibles); + const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [sdk, safe]); - const nftBalanceQuery = useGetAllNFTsQuery(); - const currentNftBalance = nftBalanceQuery.currentData; const collectibleContractCache = useMemo(() => new Map(), []); @@ -49,7 +50,7 @@ export const useCollectibleTokenInfoProvider: () => CollectibleTokenInfoProvider return contractInterfaceCache.get(tokenAddress) ?? [undefined]; } if (currentNftBalance) { - const tokenInfo = currentNftBalance.results.find((nftEntry) => nftEntry.address === tokenAddress); + const tokenInfo = currentNftBalance.find((nftEntry) => nftEntry.address === tokenAddress); if (tokenInfo) { return Promise.resolve(["erc721"]); } @@ -111,7 +112,7 @@ export const useCollectibleTokenInfoProvider: () => CollectibleTokenInfoProvider async (tokenAddress: string, id: BigNumber, token_type: "erc1155" | "erc721") => { if (token_type === "erc721") { if (currentNftBalance) { - const tokenInfo = currentNftBalance.results.find( + const tokenInfo = currentNftBalance.find( (nftEntry) => nftEntry.address === tokenAddress && nftEntry.id === id.toFixed(), ); if (tokenInfo && tokenInfo.imageUri && tokenInfo.name) { diff --git a/src/hooks/token.ts b/src/hooks/token.ts index 0a945bf1..0e5acedc 100644 --- a/src/hooks/token.ts +++ b/src/hooks/token.ts @@ -5,11 +5,12 @@ import { ethers, utils } from "ethers"; import xdaiTokens from "honeyswap-default-token-list"; import { useState, useEffect, useMemo } from "react"; -import { networkInfo } from "../networks"; import rinkeby from "../static/rinkebyTokens.json"; import { erc20Instance } from "../transfers/erc20"; import { TokenInfo } from "../utils"; +import { useCurrentChain } from "./useCurrentChain"; + export type TokenMap = Map; function tokenMap(tokenList: TokenInfo[]): TokenMap { @@ -33,16 +34,14 @@ export const fetchTokenList = async (chainId: number): Promise => { .catch(() => []); break; case 4: - // Hardcoded this because the list provided at - // https://github.com/Uniswap/default-token-list/blob/master/src/tokens/rinkeby.json - // Doesn't have GNO or OWL and/or many others. + // We leave this to generate data for the unit tests. tokens = rinkeby; break; case 100: tokens = xdaiTokens.tokens; break; default: - console.warn(`Unimplemented token list for ${networkInfo.get(chainId)?.name} network`); + console.warn(`Unimplemented token list for chainId ${chainId}`); tokens = []; } return tokenMap(tokens); @@ -59,6 +58,7 @@ export function useTokenList(): { const { safe } = useSafeAppsSDK(); const [tokenList, setTokenList] = useState(new Map()); const [isLoading, setIsLoading] = useState(false); + useEffect(() => { let isMounted = true; setIsLoading(true); @@ -109,6 +109,8 @@ export const useTokenInfoProvider: () => TokenInfoProvider = () => { }, [sdk.safe]); const { tokenList } = useTokenList(); + const chainConfig = useCurrentChain(); + return useMemo( () => ({ getTokenInfo: async (tokenAddress: string) => { @@ -141,9 +143,9 @@ export const useTokenInfoProvider: () => TokenInfoProvider = () => { return undefined; } }, - getNativeTokenSymbol: () => networkInfo.get(safe.chainId)?.currencySymbol ?? "ETH", - getSelectedNetworkShortname: () => networkInfo.get(safe.chainId)?.shortName, + getNativeTokenSymbol: () => chainConfig?.currencySymbol ?? "ETH", + getSelectedNetworkShortname: () => chainConfig?.shortName, }), - [balances.items, safe.chainId, tokenList, web3Provider], + [balances.items, tokenList, web3Provider, chainConfig], ); }; diff --git a/src/hooks/useBalances.ts b/src/hooks/useBalances.ts new file mode 100644 index 00000000..0f6ce026 --- /dev/null +++ b/src/hooks/useBalances.ts @@ -0,0 +1,119 @@ +import { SafeInfo } from "@safe-global/safe-apps-sdk"; +import { useEffect, useMemo } from "react"; +import { NetworkInfo } from "src/networks"; +import { AssetBalance, setAssetBalances } from "src/stores/slices/assetBalanceSlice"; +import { NFTBalance, setCollectibles } from "src/stores/slices/collectiblesSlice"; +import { useAppDispatch } from "src/stores/store"; +import useSwr from "swr"; +import useSWRInfinite from "swr/infinite"; + +import { useCurrentChain } from "./useCurrentChain"; + +const COLLECTIBLE_LIMIT = 10; +const COLLECTIBLE_MAX_PAGES = 10; + +const getBaseURL = (chainConfig: NetworkInfo, safeAddress: string, version: "v1" | "v2"): string => { + return `${chainConfig.baseAPI}/api/${version}/safes/${safeAddress}`; +}; + +const useErc20Balances = (safeAddress?: string, chainId?: number) => { + const chainConfig = useCurrentChain(); + + const { data, isLoading } = useSwr(!safeAddress || !chainConfig ? null : "erc20-balances", async () => { + if (!chainConfig || !safeAddress) { + return undefined; + } + const endpointUrl = `${getBaseURL(chainConfig, safeAddress, "v1")}/balances?trusted=false&exclude_spam=true`; + + const result = await fetch(endpointUrl).then((resp) => { + if (resp.ok) { + return resp.json() as Promise; + } + throw new Error("Error fetching collectibles"); + }); + + return result; + }); + + return { + balances: data, + isLoading, + }; +}; + +const useCollectibleBalances = (safeAddress?: string, chainId?: number) => { + const chainConfig = useCurrentChain(); + + const getKey = useMemo( + () => (pageIndex: number, previousPageData: NFTBalance) => { + if (!safeAddress || !chainConfig) { + // We cannot fetch data while the address is resolving or the chains are loading + return null; + } + if (!previousPageData) { + // Load first page + return `${getBaseURL(chainConfig, safeAddress, "v2")}/collectibles?trusted=false&exclude_spam=true&limit=10`; + } + if (previousPageData && !previousPageData.next) return null; // reached the end + + // Load next page + return previousPageData.next; + }, + [safeAddress, chainConfig], + ); + + const { data, setSize, size, isLoading } = useSWRInfinite(getKey, async (url: string) => { + const result = await fetch(url).then((resp) => { + if (resp.ok) { + return resp.json() as Promise; + } + throw new Error("Error fetching collectibles"); + }); + + return result; + }); + + // We load up to 10 pages of NFTs for performance reasons + if (data && data.length > 0) { + const totalPages = Math.max(COLLECTIBLE_MAX_PAGES, Math.ceil(data[0].count / COLLECTIBLE_LIMIT)); + if (totalPages > size) { + setSize(Math.ceil(data[0].count / COLLECTIBLE_LIMIT)); + } + } + + const flatData = useMemo(() => { + if (data === undefined) { + return []; + } + return data.flatMap((entry) => entry.results); + }, [data]); + + return { + collectibles: flatData, + isLoading, + }; +}; + +export const useLoadCollectibles = (safe?: SafeInfo) => { + const dispatch = useAppDispatch(); + const data = useCollectibleBalances(safe?.safeAddress, safe?.chainId); + + // Store in slice + useEffect(() => { + if (data) { + dispatch(setCollectibles(data)); + } + }, [data, dispatch]); +}; + +export const useLoadAssets = (safe?: SafeInfo) => { + const dispatch = useAppDispatch(); + const data = useErc20Balances(safe?.safeAddress, safe?.chainId); + + // Store in slice + useEffect(() => { + if (data) { + dispatch(setAssetBalances(data)); + } + }, [data, dispatch]); +}; diff --git a/src/hooks/useChains.ts b/src/hooks/useChains.ts new file mode 100644 index 00000000..adc3c643 --- /dev/null +++ b/src/hooks/useChains.ts @@ -0,0 +1,69 @@ +import { useEffect, useMemo } from "react"; +import { NetworkInfo, staticNetworkInfo } from "src/networks"; +import { setNetworks } from "src/stores/slices/networksSlice"; +import { useAppDispatch } from "src/stores/store"; +import useSwr from "swr"; + +const CONFIG_SERVICE_URL = "https://safe-config.safe.global/api/v1/chains"; + +type ChainEndpointResponse = { + next: string | null; + previous: string | null; + count: number; + results: { + chainId: string; + chainName: string; + shortName: string; + nativeCurrency: { + name: string; + symbol: string; + decimals: 18; + logoUri: string; + }; + transactionService: string; + }[]; +}; + +export const useLoadChains = () => { + const chains = useChains(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch( + setNetworks({ + networks: [...chains.values()], + }), + ); + }, [chains, dispatch]); +}; + +const useChains = () => { + const { data: chainConfigs, isLoading } = useSwr("chains", async (): Promise => { + const result = await fetch(CONFIG_SERVICE_URL).then((resp) => { + if (resp.ok) { + return resp.json() as Promise; + } + return Promise.reject(new Error("Unexpected error while loading chain configs. Falling back to static list.")); + }); + + return result.results.map((chainConfig) => ({ + chainID: Number(chainConfig.chainId), + name: chainConfig.chainName, + shortName: chainConfig.shortName, + currencySymbol: chainConfig.nativeCurrency.symbol, + baseAPI: chainConfig.transactionService, + })); + }); + + return useMemo(() => { + if (isLoading || chainConfigs === undefined) { + return staticNetworkInfo; + } else { + const mappedNetworks = new Map(); + chainConfigs.forEach((chainConfig) => { + mappedNetworks.set(chainConfig.chainID, chainConfig); + }); + return mappedNetworks; + } + }, [chainConfigs, isLoading]); +}; diff --git a/src/hooks/useCurrentChain.ts b/src/hooks/useCurrentChain.ts new file mode 100644 index 00000000..b2c63430 --- /dev/null +++ b/src/hooks/useCurrentChain.ts @@ -0,0 +1,13 @@ +import { useSafeAppsSDK } from "@safe-global/safe-apps-react-sdk"; +import { useMemo } from "react"; +import { selectNetworks } from "src/stores/slices/networksSlice"; +import { useAppSelector } from "src/stores/store"; + +export const useCurrentChain = () => { + const { safe } = useSafeAppsSDK(); + const chains = useAppSelector(selectNetworks); + + return useMemo(() => { + return chains.find((chain) => chain.chainID === safe.chainId); + }, [chains, safe]); +}; diff --git a/src/networks.ts b/src/networks.ts index d20ec0e0..ef22f4dc 100644 --- a/src/networks.ts +++ b/src/networks.ts @@ -1,4 +1,4 @@ -type NetworkInfo = { +export type NetworkInfo = { shortName: string; chainID: number; name: string; @@ -8,7 +8,7 @@ type NetworkInfo = { export const NETWORKS_WITH_DONATIONS_DEPLOYED = [1, 5, 56, 100, 137]; -export const networkInfo = new Map([ +export const staticNetworkInfo = new Map([ [ 1, { @@ -16,7 +16,7 @@ export const networkInfo = new Map([ name: "Ethereum", shortName: "eth", currencySymbol: "ETH", - baseAPI: "https://safe-transaction-mainnet.safe.global/api/v1", + baseAPI: "https://safe-transaction-mainnet.safe.global", }, ], [ @@ -26,7 +26,7 @@ export const networkInfo = new Map([ name: "Goerli", shortName: "gor", currencySymbol: "GOR", - baseAPI: "https://safe-transaction-goerli.safe.global/api/v1", + baseAPI: "https://safe-transaction-goerli.safe.global", }, ], [ @@ -36,7 +36,7 @@ export const networkInfo = new Map([ name: "Optimism", shortName: "oeth", currencySymbol: "OETH", - baseAPI: "https://safe-transaction-optimism.safe.global/api/v1", + baseAPI: "https://safe-transaction-optimism.safe.global", }, ], [ @@ -46,7 +46,7 @@ export const networkInfo = new Map([ name: "Binance Smart Chain", shortName: "bnb", currencySymbol: "BNB", - baseAPI: "https://safe-transaction-bsc.safe.global/api/v1", + baseAPI: "https://safe-transaction-bsc.safe.global", }, ], [ @@ -56,7 +56,7 @@ export const networkInfo = new Map([ name: "Gnosis Chain", shortName: "gno", currencySymbol: "xDAI", - baseAPI: "https://safe-transaction-gnosis-chain.safe.global/api/v1", + baseAPI: "https://safe-transaction-gnosis-chain.safe.global", }, ], [ @@ -66,7 +66,7 @@ export const networkInfo = new Map([ name: "Polygon", shortName: "matic", currencySymbol: "MATIC", - baseAPI: "https://safe-transaction-polygon.safe.global/api/v1", + baseAPI: "https://safe-transaction-polygon.safe.global", }, ], [ @@ -76,7 +76,7 @@ export const networkInfo = new Map([ name: "Zk Sync Era", shortName: "zksync", currencySymbol: "ETH", - baseAPI: "https://safe-transaction-zksync.safe.global/api/v1", + baseAPI: "https://safe-transaction-zksync.safe.global", }, ], [ @@ -86,7 +86,7 @@ export const networkInfo = new Map([ name: "Polygon zkEVM", shortName: "zkevm", currencySymbol: "ETH", - baseAPI: "https://safe-transaction-zkevm.safe.global/api/v1", + baseAPI: "https://safe-transaction-zkevm.safe.global", }, ], [ @@ -96,7 +96,7 @@ export const networkInfo = new Map([ name: "Base", shortName: "base", currencySymbol: "ETH", - baseAPI: "https://safe-transaction-base.safe.global/api/v1", + baseAPI: "https://safe-transaction-base.safe.global", }, ], [ @@ -106,7 +106,7 @@ export const networkInfo = new Map([ name: "Arbitrum One", shortName: "arb1", currencySymbol: "AETH", - baseAPI: "https://safe-transaction-arbitrum.safe.global/api/v1", + baseAPI: "https://safe-transaction-arbitrum.safe.global", }, ], [ @@ -116,7 +116,7 @@ export const networkInfo = new Map([ name: "Celo", shortName: "celo", currencySymbol: "Celo", - baseAPI: "https://safe-transaction-celo.safe.global/api/v1", + baseAPI: "https://safe-transaction-celo.safe.global", }, ], [ @@ -126,7 +126,7 @@ export const networkInfo = new Map([ name: "Avalanche", shortName: "avax", currencySymbol: "AVAX", - baseAPI: "https://safe-transaction-avalanche.safe.global/api/v1", + baseAPI: "https://safe-transaction-avalanche.safe.global", }, ], [ @@ -136,7 +136,7 @@ export const networkInfo = new Map([ name: "Volta", shortName: "vt", currencySymbol: "VT", - baseAPI: "https://safe-transaction-volta.safe.global/api/v1", + baseAPI: "https://safe-transaction-volta.safe.global", }, ], [ @@ -146,7 +146,7 @@ export const networkInfo = new Map([ name: "Sepolia", shortName: "sep", currencySymbol: "ETH", - baseAPI: "https://safe-transaction-sepolia.safe.global/api/v1", + baseAPI: "https://safe-transaction-sepolia.safe.global", }, ], ]); diff --git a/src/parser/balanceCheck.ts b/src/parser/balanceCheck.ts index 72b7be8e..a2097dc7 100644 --- a/src/parser/balanceCheck.ts +++ b/src/parser/balanceCheck.ts @@ -1,5 +1,6 @@ import { BigNumber } from "bignumber.js"; -import { AssetBalance, NFTBalance } from "src/stores/api/balanceApi"; +import { AssetBalance } from "src/stores/slices/assetBalanceSlice"; +import { NFTBalance } from "src/stores/slices/collectiblesSlice"; import { AssetTransfer, CollectibleTransfer, Transfer } from "../hooks/useCsvParser"; import { toWei } from "../utils"; @@ -65,7 +66,7 @@ export type InsufficientBalanceInfo = { export const checkAllBalances = ( assetBalance: AssetBalance | undefined, - collectibleBalance: NFTBalance | undefined, + collectibleBalance: NFTBalance["results"] | undefined, transfers: Transfer[], ): InsufficientBalanceInfo[] => { const insufficientTokens: InsufficientBalanceInfo[] = []; @@ -116,16 +117,15 @@ export const checkAllBalances = ( } for (const { tokenAddress, count, name, id } of collectibleSummary.values()) { - const tokenBalance = collectibleBalance?.results.find( + const tokenBalance = collectibleBalance?.find( (balanceEntry) => balanceEntry.address?.toLowerCase() === tokenAddress.toLowerCase() && balanceEntry.id === id, ); if (typeof tokenBalance === "undefined" || count > 1) { const tokenName = name ?? tokenBalance?.tokenName ?? - collectibleBalance?.results.find( - (balanceEntry) => balanceEntry.address?.toLowerCase() === tokenAddress.toLowerCase(), - )?.tokenName; + collectibleBalance?.find((balanceEntry) => balanceEntry.address?.toLowerCase() === tokenAddress.toLowerCase()) + ?.tokenName; insufficientTokens.push({ token: tokenName ?? tokenAddress, token_type: "erc721", diff --git a/src/stores/api/balanceApi.ts b/src/stores/api/balanceApi.ts deleted file mode 100644 index 943b3aab..00000000 --- a/src/stores/api/balanceApi.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { BaseQueryFn, createApi, FetchArgs, fetchBaseQuery, FetchBaseQueryError } from "@reduxjs/toolkit/query/react"; -import { networkInfo } from "src/networks"; - -import { RootState } from "../store"; - -/** - * Currently the tx service is rate limited to 5 requests / minute and fetching NFTs is very slow. - */ -const MAX_NFTS = 50; - -type AssetBalanceEntry = { - tokenAddress: string | null; - token: Token | null; - balance: string; - decimals: number; -}; - -type NFTBalanceEntry = { - address: string; - tokenName: string; - tokenSymbol: string; - id: string; - imageUri: string; - name: string; -}; - -type Token = { - name: string; - symbol: string; - decimals: number; -}; - -export type AssetBalance = AssetBalanceEntry[]; -export type NFTBalance = { next: string | null; results: NFTBalanceEntry[] }; - -const dynamicBaseQuery: BaseQueryFn = async ( - args, - api, - extraoptions, -) => { - const isCollectible = args.toString().startsWith("collectibles"); - const safeInfo = (api.getState() as RootState).safeInfo.safeInfo; - if (!safeInfo) { - return { - error: { - status: 400, - statusText: "Bad Request", - data: "No Safe Info received", - }, - }; - } - const { chainId, safeAddress } = safeInfo; - const baseAPI = networkInfo.get(chainId)?.baseAPI; - if (!baseAPI) { - return { - error: { - status: 400, - statusText: "Bad Request", - data: "No Base API for Chain ID found", - }, - }; - } - return fetchBaseQuery({ - baseUrl: isCollectible - ? `${networkInfo.get(chainId)?.baseAPI?.replace("v1", "v2")}/safes/${safeAddress}` - : `${networkInfo.get(chainId)?.baseAPI}/safes/${safeAddress}`, - })(args, api, extraoptions); -}; - -export const balanceApi = createApi({ - reducerPath: "balancerApi", - baseQuery: dynamicBaseQuery, - endpoints: (builder) => ({ - getAssetBalance: builder.query({ - query: () => "balances?trusted=false&exclude_spam=true", - }), - getNFTPage: builder.query({ - query: ({ offset }) => `collectibles/?trusted=false&exclude_spam=true&offset=${offset}&limit=10`, - }), - getAllNFTs: builder.query({ - queryFn: async (_args, queryApi, _extraOptions, fetchWithBQ) => { - let allNFTs: NFTBalance = { results: [], next: "initialPage" }; - let offset = 0; - while (allNFTs.next !== null) { - const { data, error } = await fetchWithBQ( - `collectibles/?trusted=false&exclude_spam=true&offset=${offset}&limit=10`, - ); - if (error) { - return Promise.resolve({ error }); - } - const nextBalance = data as NFTBalance; - allNFTs.next = nextBalance?.next ?? null; - if (offset >= MAX_NFTS) { - break; - } - offset += 10; - - if (nextBalance) { - const { results } = nextBalance; - if (results) { - allNFTs.results.push(...results); - } - } - } - return Promise.resolve({ data: allNFTs }); - }, - }), - }), -}); - -export const { useGetAssetBalanceQuery, useGetAllNFTsQuery } = balanceApi; diff --git a/src/stores/middleware/parseListener.ts b/src/stores/middleware/parseListener.ts index 76de35ff..3637cca3 100644 --- a/src/stores/middleware/parseListener.ts +++ b/src/stores/middleware/parseListener.ts @@ -2,7 +2,6 @@ import { Transfer } from "src/hooks/useCsvParser"; import { EnsResolver } from "src/hooks/useEnsResolver"; import { checkAllBalances } from "src/parser/balanceCheck"; -import { balanceApi } from "../api/balanceApi"; import { setTransfers, startParsing, stopParsing, updateCsvContent } from "../slices/csvEditorSlice"; import { CodeWarning, setCodeWarnings, setMessages } from "../slices/messageSlice"; import { AppStartListening } from "../store"; @@ -46,9 +45,13 @@ export const setupParserListener = ( listenerApi.dispatch(setCodeWarnings(codeWarnings)); const currentState = listenerApi.getState(); - const assetBalanceResult = balanceApi.endpoints.getAssetBalance.select()(currentState); - const nftBalanceResult = balanceApi.endpoints.getAllNFTs.select()(currentState); - const insufficientBalances = checkAllBalances(assetBalanceResult.data, nftBalanceResult.data, transfers); + const assetBalanceResult = currentState.assetBalance; + const nftBalanceResult = currentState.collectibles; + const insufficientBalances = checkAllBalances( + assetBalanceResult.balances, + nftBalanceResult.collectibles, + transfers, + ); listenerApi.dispatch(stopParsing()); diff --git a/src/stores/slices/assetBalanceSlice.ts b/src/stores/slices/assetBalanceSlice.ts new file mode 100644 index 00000000..571bf1d5 --- /dev/null +++ b/src/stores/slices/assetBalanceSlice.ts @@ -0,0 +1,45 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +import { RootState } from "../store"; + +type AssetBalanceEntry = { + tokenAddress: string | null; + token: Token | null; + balance: string; + decimals: number; +}; + +type Token = { + name: string; + symbol: string; + decimals: number; +}; + +export type AssetBalance = AssetBalanceEntry[]; + +export interface AssetBalanceState { + balances: AssetBalance | undefined; + isLoading: boolean; +} + +const initialState: AssetBalanceState = { + balances: [], + isLoading: false, +}; + +export const assetBalanceSlice = createSlice({ + name: "assetBalances", + initialState, + reducers: { + setAssetBalances: (state, action: PayloadAction) => { + state.balances = action.payload.balances; + state.isLoading = action.payload.isLoading; + }, + }, +}); + +export const { setAssetBalances } = assetBalanceSlice.actions; + +export default assetBalanceSlice.reducer; + +export const selectAssetBalances = ({ assetBalance }: RootState) => assetBalance.balances; diff --git a/src/stores/slices/collectiblesSlice.ts b/src/stores/slices/collectiblesSlice.ts new file mode 100644 index 00000000..f49ea1ec --- /dev/null +++ b/src/stores/slices/collectiblesSlice.ts @@ -0,0 +1,41 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +import { RootState } from "../store"; + +type NFTBalanceEntry = { + address: string; + tokenName: string; + tokenSymbol: string; + id: string; + imageUri: string; + name: string; +}; + +export type NFTBalance = { count: number; next: string | null; previous: string | null; results: NFTBalanceEntry[] }; + +export interface CollectiblesState { + collectibles: NFTBalanceEntry[] | undefined; + isLoading: boolean; +} + +const initialState: CollectiblesState = { + collectibles: [], + isLoading: false, +}; + +export const collectiblesSlice = createSlice({ + name: "collectibles", + initialState, + reducers: { + setCollectibles: (state, action: PayloadAction) => { + state.collectibles = action.payload.collectibles; + state.isLoading = action.payload.isLoading; + }, + }, +}); + +export const { setCollectibles } = collectiblesSlice.actions; + +export default collectiblesSlice.reducer; + +export const selectCollectibles = ({ collectibles }: RootState) => collectibles.collectibles; diff --git a/src/stores/slices/networksSlice.ts b/src/stores/slices/networksSlice.ts new file mode 100644 index 00000000..95098dba --- /dev/null +++ b/src/stores/slices/networksSlice.ts @@ -0,0 +1,29 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { NetworkInfo, staticNetworkInfo } from "src/networks"; + +import { RootState } from "../store"; + +export interface NetworksState { + networks: NetworkInfo[]; +} + +const initialState: NetworksState = { + // Initially we use our default networks as a fallback if the service is not reachable + networks: [...staticNetworkInfo.values()], +}; + +export const networksSlice = createSlice({ + name: "networks", + initialState, + reducers: { + setNetworks: (state, action: PayloadAction) => { + state.networks = action.payload.networks; + }, + }, +}); + +export const { setNetworks } = networksSlice.actions; + +export default networksSlice.reducer; + +export const selectNetworks = ({ networks }: RootState) => networks.networks; diff --git a/src/stores/store.ts b/src/stores/store.ts index 4f92b7e0..a85f99af 100644 --- a/src/stores/store.ts +++ b/src/stores/store.ts @@ -8,9 +8,11 @@ import { import { setupListeners } from "@reduxjs/toolkit/dist/query"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; -import { balanceApi } from "./api/balanceApi"; +import assetBalanceReducer from "./slices/assetBalanceSlice"; +import collectiblesReducer from "./slices/collectiblesSlice"; import csvReducer from "./slices/csvEditorSlice"; import messageReducer from "./slices/messageSlice"; +import networksReducer from "./slices/networksSlice"; import safeInfoReducer from "./slices/safeInfoSlice"; const listenerMiddlewareInstance = createListenerMiddleware({ @@ -22,10 +24,11 @@ export const store = configureStore({ csvEditor: csvReducer, messages: messageReducer, safeInfo: safeInfoReducer, - [balanceApi.reducerPath]: balanceApi.reducer, + networks: networksReducer, + collectibles: collectiblesReducer, + assetBalance: assetBalanceReducer, }, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().prepend(listenerMiddlewareInstance.middleware).concat(balanceApi.middleware), + middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(listenerMiddlewareInstance.middleware), }); setupListeners(store.dispatch); @@ -44,3 +47,5 @@ export const startAppListening = listenerMiddlewareInstance.startListening as Ap export const useAppDispatch = () => useDispatch(); export const useAppSelector: TypedUseSelectorHook = useSelector; + +export const selectIsLoading = (state: RootState) => state.assetBalance.isLoading || state.collectibles.isLoading; diff --git a/yarn.lock b/yarn.lock index 2f0c9c08..c02a9d0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2294,6 +2294,13 @@ "@types/node" "*" jest-mock "^27.5.1" +"@jest/expect-utils@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" + integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== + dependencies: + jest-get-type "^29.6.3" + "@jest/fake-timers@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74" @@ -2353,6 +2360,13 @@ dependencies: "@sinclair/typebox" "^0.24.1" +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + "@jest/source-map@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" @@ -2436,6 +2450,18 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" +"@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== + dependencies: + "@jest/schemas" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -2836,6 +2862,11 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.42.tgz#a74b608d494a1f4cc079738e050142a678813f52" integrity sha512-d+2AtrHGyWek2u2ITF0lHRIv6Tt7X0dEHW+0rP+5aDCEjC3fiN2RBjrLD0yU0at52BcZbRGxLbAtXiR0hFCjYw== +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -3322,6 +3353,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@^29.5.12": + version "29.5.12" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544" + integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + "@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.11" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" @@ -4871,6 +4910,11 @@ clean-css@^5.2.2: dependencies: source-map "~0.6.0" +client-only@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -5685,6 +5729,11 @@ diff-sequences@^27.5.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== +diff-sequences@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" + integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -6647,6 +6696,17 @@ expect@^27.5.1: jest-matcher-utils "^27.5.1" jest-message-util "^27.5.1" +expect@^29.0.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" + integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== + dependencies: + "@jest/expect-utils" "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + express@^4.17.3: version "4.19.2" resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" @@ -8137,6 +8197,16 @@ jest-diff@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-docblock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" @@ -8185,6 +8255,11 @@ jest-get-type@^27.5.1: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== +jest-get-type@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" + integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + jest-haste-map@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-27.5.1.tgz#9fd8bd7e7b4fa502d9c6164c5640512b4e811e7f" @@ -8246,6 +8321,16 @@ jest-matcher-utils@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== + dependencies: + chalk "^4.0.0" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-message-util@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-27.5.1.tgz#bdda72806da10d9ed6425e12afff38cd1458b6cf" @@ -8276,6 +8361,21 @@ jest-message-util@^28.1.3: slash "^3.0.0" stack-utils "^2.0.3" +jest-message-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" + integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.6.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + stack-utils "^2.0.3" + jest-mock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" @@ -8439,6 +8539,18 @@ jest-util@^28.1.3: graceful-fs "^4.2.9" picomatch "^2.2.3" +jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + jest-validate@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" @@ -10294,6 +10406,15 @@ pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.0.0, pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + pretty-quick@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/pretty-quick/-/pretty-quick-3.1.3.tgz#15281108c0ddf446675157ca40240099157b638e" @@ -11795,6 +11916,14 @@ svgo@^3.0.2: csso "^5.0.5" picocolors "^1.0.0" +swr@^2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.5.tgz#063eea0e9939f947227d5ca760cc53696f46446b" + integrity sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg== + dependencies: + client-only "^0.0.1" + use-sync-external-store "^1.2.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -12355,6 +12484,11 @@ use-sync-external-store@^1.0.0: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.0.0.tgz#d98f4a9c2e73d0f958e7e2d2c2bfb5f618cbd8fd" integrity sha512-AFVsxg5GkFg8GDcxnl+Z0lMAz9rE8DGJCc28qnBuQF7lac57B5smLcT37aXpXIIPz75rW4g3eXHPjhHwdGskOw== +use-sync-external-store@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" + integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"