diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx
index 389a56ece1..82767e044a 100644
--- a/src/components/common/NetworkSelector/index.tsx
+++ b/src/components/common/NetworkSelector/index.tsx
@@ -33,13 +33,34 @@ import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesG
import useSafeAddress from '@/hooks/useSafeAddress'
import { sameAddress } from '@/utils/addresses'
import uniq from 'lodash/uniq'
-import useSafeOverviews from '@/components/welcome/MyAccounts/useSafeOverviews'
import { useCompatibleNetworks } from '@/features/multichain/hooks/useCompatibleNetworks'
import { useSafeCreationData } from '@/features/multichain/hooks/useSafeCreationData'
import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
import PlusIcon from '@/public/images/common/plus.svg'
import useAddressBook from '@/hooks/useAddressBook'
import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain'
+import { useGetSafeOverviewQuery } from '@/store/api/gateway'
+
+const ChainIndicatorWithFiatBalance = ({
+ isSelected,
+ chain,
+ safeAddress,
+}: {
+ isSelected: boolean
+ chain: ChainInfo
+ safeAddress: string
+}) => {
+ const { data: safeOverview } = useGetSafeOverviewQuery({ safeAddress, chainId: chain.chainId })
+
+ return (
+
+ )
+}
export const getNetworkLink = (router: NextRouter, safeAddress: string, networkShortName: string) => {
const isSafeOpened = safeAddress !== ''
@@ -272,12 +293,6 @@ const NetworkSelector = ({
[availableChainIds, configs],
)
- const multiChainSafes = useMemo(
- () => availableChainIds.map((chain) => ({ address: safeAddress, chainId: chain })),
- [availableChainIds, safeAddress],
- )
- const [safeOverviews] = useSafeOverviews(multiChainSafes)
-
const onChange = (event: SelectChangeEvent) => {
event.preventDefault() // Prevent the link click
@@ -293,9 +308,8 @@ const NetworkSelector = ({
const renderMenuItem = useCallback(
(chainId: string, isSelected: boolean) => {
+ useChainId
const chain = chains.data.find((chain) => chain.chainId === chainId)
- const safeOverview = safeOverviews?.find((overview) => chainId === overview.chainId)
-
if (!chain) return null
return (
@@ -305,17 +319,12 @@ const NetworkSelector = ({
onClick={onChainSelect}
className={css.item}
>
-
+
)
},
- [chains.data, onChainSelect, router, safeAddress, safeOverviews],
+ [chains.data, onChainSelect, router, safeAddress],
)
return configs.length ? (
diff --git a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx
index b92a675f2e..4f7ac6bfdc 100644
--- a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx
+++ b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx
@@ -5,8 +5,8 @@ import SafeTokenWidget from '..'
import { toBeHex } from 'ethers'
import { AppRoutes } from '@/config/routes'
import useSafeTokenAllocation, { useSafeVotingPower } from '@/hooks/useSafeTokenAllocation'
-import * as safePass from '@/store/safePass'
-import type { CampaignLeaderboardEntry } from '@/store/safePass'
+import * as safePass from '@/store/api/safePass'
+import type { CampaignLeaderboardEntry } from '@/store/api/safePass'
jest.mock('@/hooks/useChainId')
diff --git a/src/components/common/SafeTokenWidget/index.tsx b/src/components/common/SafeTokenWidget/index.tsx
index ee8a9869d1..1ca32b9663 100644
--- a/src/components/common/SafeTokenWidget/index.tsx
+++ b/src/components/common/SafeTokenWidget/index.tsx
@@ -14,7 +14,7 @@ import css from './styles.module.css'
import useSafeAddress from '@/hooks/useSafeAddress'
import { skipToken } from '@reduxjs/toolkit/query/react'
import { useDarkMode } from '@/hooks/useDarkMode'
-import { useGetOwnGlobalCampaignRankQuery } from '@/store/safePass'
+import { useGetOwnGlobalCampaignRankQuery } from '@/store/api/safePass'
import { formatAmount } from '@/utils/formatNumber'
const TOKEN_DECIMALS = 18
diff --git a/src/components/transactions/SingleTx/index.tsx b/src/components/transactions/SingleTx/index.tsx
index c2bd9e52a6..034630d3e9 100644
--- a/src/components/transactions/SingleTx/index.tsx
+++ b/src/components/transactions/SingleTx/index.tsx
@@ -13,7 +13,7 @@ import ExpandableTransactionItem, {
} from '@/components/transactions/TxListItem/ExpandableTransactionItem'
import GroupLabel from '../GroupLabel'
import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards'
-import { useGetTransactionDetailsQuery } from '@/store/gateway'
+import { useGetTransactionDetailsQuery } from '@/store/api/gateway'
import { skipToken } from '@reduxjs/toolkit/query/react'
import { asError } from '@/services/exceptions/utils'
diff --git a/src/components/transactions/TxDetails/index.tsx b/src/components/transactions/TxDetails/index.tsx
index 1387362d0b..d64a0d225f 100644
--- a/src/components/transactions/TxDetails/index.tsx
+++ b/src/components/transactions/TxDetails/index.tsx
@@ -33,7 +33,7 @@ import useIsPending from '@/hooks/useIsPending'
import { isImitation, isTrustedTx } from '@/utils/transactions'
import { useHasFeature } from '@/hooks/useChains'
import { FEATURES } from '@/utils/chains'
-import { useGetTransactionDetailsQuery } from '@/store/gateway'
+import { useGetTransactionDetailsQuery } from '@/store/api/gateway'
import { asError } from '@/services/exceptions/utils'
import { POLLING_INTERVAL } from '@/config/constants'
diff --git a/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx
index 6739daad92..9f4c838a3d 100644
--- a/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx
+++ b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx
@@ -36,7 +36,7 @@ import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletReject
import useUserNonce from '@/components/tx/AdvancedParams/useUserNonce'
import { getLatestSafeVersion } from '@/utils/chains'
import { HexEncodedData } from '@/components/transactions/HexEncodedData'
-import { useGetMultipleTransactionDetailsQuery } from '@/store/gateway'
+import { useGetMultipleTransactionDetailsQuery } from '@/store/api/gateway'
import { skipToken } from '@reduxjs/toolkit/query/react'
import NetworkWarning from '@/components/new-safe/create/NetworkWarning'
diff --git a/src/components/tx/DecodedTx/index.tsx b/src/components/tx/DecodedTx/index.tsx
index 44d8c3087a..618893fc8b 100644
--- a/src/components/tx/DecodedTx/index.tsx
+++ b/src/components/tx/DecodedTx/index.tsx
@@ -13,7 +13,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData'
import accordionCss from '@/styles/accordion.module.css'
import HelpToolTip from './HelpTooltip'
-import { useGetTransactionDetailsQuery } from '@/store/gateway'
+import { useGetTransactionDetailsQuery } from '@/store/api/gateway'
import { skipToken } from '@reduxjs/toolkit/query/react'
import { asError } from '@/services/exceptions/utils'
diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx
index 1c2a0b4a53..722f156e3f 100644
--- a/src/components/tx/SignOrExecuteForm/index.tsx
+++ b/src/components/tx/SignOrExecuteForm/index.tsx
@@ -39,7 +39,7 @@ import { MigrateToL2Information } from './MigrateToL2Information'
import { extractMigrationL2MasterCopyAddress } from '@/utils/transactions'
import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk'
-import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/gateway'
+import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/api/gateway'
import { skipToken } from '@reduxjs/toolkit/query/react'
import NetworkWarning from '@/components/new-safe/create/NetworkWarning'
diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx
index 0c05222b42..32fff77b8c 100644
--- a/src/components/welcome/MyAccounts/AccountItem.tsx
+++ b/src/components/welcome/MyAccounts/AccountItem.tsx
@@ -26,6 +26,9 @@ import FiatValue from '@/components/common/FiatValue'
import QueueActions from './QueueActions'
import { useGetHref } from './useGetHref'
import { extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features/counterfactual/utils'
+import { useGetSafeOverviewQuery } from '@/store/api/gateway'
+import useWallet from '@/hooks/wallets/useWallet'
+import { skipToken } from '@reduxjs/toolkit/query'
type AccountItemProps = {
safeItem: SafeItem
@@ -33,7 +36,7 @@ type AccountItemProps = {
onLinkClick?: () => void
}
-const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) => {
+const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => {
const { chainId, address } = safeItem
const chain = useAppSelector((state) => selectChainById(state, chainId))
const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, address))
@@ -42,6 +45,7 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps)
const router = useRouter()
const isCurrentSafe = chainId === currChainId && sameAddress(safeAddress, address)
const isWelcomePage = router.pathname === AppRoutes.welcome.accounts
+ const { address: walletAddress } = useWallet() ?? {}
const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar
@@ -61,6 +65,16 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps)
const isReplayable = !safeItem.isWatchlist && (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props))
+ const { data: safeOverview } = useGetSafeOverviewQuery(
+ undeployedSafe
+ ? skipToken
+ : {
+ chainId: safeItem.chainId,
+ safeAddress: safeItem.address,
+ walletAddress,
+ },
+ )
+
return (
{
)
}
-const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: MultiAccountItemProps) => {
+const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountItemProps) => {
const { address, safes } = multiSafeAccountItem
const undeployedSafes = useAppSelector(selectUndeployedSafes)
const safeAddress = useSafeAddress()
@@ -90,6 +93,14 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }:
return Object.values(allAddressBooks).find((ab) => ab[address] !== undefined)?.[address]
}, [address, allAddressBooks])
+ const currency = useAppSelector(selectCurrency)
+ const { address: walletAddress } = useWallet() ?? {}
+ const deployedSafes = useMemo(
+ () => safes.filter((safe) => undeployedSafes[safe.chainId]?.[safe.address] === undefined),
+ [safes, undeployedSafes],
+ )
+ const { data: safeOverviews } = useGetMultipleSafeOverviewsQuery({ currency, walletAddress, safes: deployedSafes })
+
const safeSetups = useMemo(
() => getSafeSetups(safes, safeOverviews ?? [], undeployedSafes),
[safeOverviews, safes, undeployedSafes],
@@ -111,11 +122,14 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }:
[safes, undeployedSafes],
)
- const findOverview = (item: SafeItem) => {
- return safeOverviews?.find(
- (overview) => item.chainId === overview.chainId && sameAddress(overview.address.value, item.address),
- )
- }
+ const findOverview = useCallback(
+ (item: SafeItem) => {
+ return safeOverviews?.find(
+ (overview) => item.chainId === overview.chainId && sameAddress(overview.address.value, item.address),
+ )
+ },
+ [safeOverviews],
+ )
return (
{
- const flattenedSafes = useMemo(
- () => safes.flatMap((safe) => (isMultiChainSafeItem(safe) ? safe.safes : safe)),
- [safes],
- )
- const [overviews] = useSafeOverviews(flattenedSafes)
-
- const findOverview = (item: SafeItem) => {
- return overviews?.find(
- (overview) => item.chainId === overview.chainId && sameAddress(overview.address.value, item.address),
- )
- }
-
return (
<>
{safes.map((item) =>
isMultiChainSafeItem(item) ? (
- sameAddress(overview.address.value, item.address))}
- />
+
) : (
-
+
),
)}
>
diff --git a/src/components/welcome/MyAccounts/__tests__/useSafeOverviews.test.ts b/src/components/welcome/MyAccounts/__tests__/useSafeOverviews.test.ts
deleted file mode 100644
index dc20ee92cd..0000000000
--- a/src/components/welcome/MyAccounts/__tests__/useSafeOverviews.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import useSafeOverviews from '../useSafeOverviews'
-import * as balances from '@/hooks/loadables/useLoadBalances'
-import * as sdk from '@safe-global/safe-gateway-typescript-sdk'
-import * as useWallet from '@/hooks/wallets/useWallet'
-import * as store from '@/store'
-import type { Eip1193Provider } from 'ethers'
-import { renderHook } from '@testing-library/react'
-import { act } from 'react-dom/test-utils'
-
-jest.spyOn(balances, 'useTokenListSetting').mockReturnValue(false)
-jest.spyOn(store, 'useAppSelector').mockReturnValue('USD')
-jest
- .spyOn(useWallet, 'default')
- .mockReturnValue({ label: 'MetaMask', chainId: '1', address: '0x1234', provider: null as unknown as Eip1193Provider })
-
-describe('useSafeOverviews', () => {
- it('should filter out undefined addresses', async () => {
- const spy = jest.spyOn(sdk, 'getSafeOverviews').mockResolvedValue([])
- const safes = [
- { address: '0x1234', chainId: '1' },
- { address: undefined as unknown as string, chainId: '2' },
- { address: '0x5678', chainId: '3' },
- ]
-
- renderHook(() => useSafeOverviews(safes))
-
- await act(() => Promise.resolve())
-
- expect(spy).toHaveBeenCalledWith(['1:0x1234', '3:0x5678'], {
- currency: 'USD',
- exclude_spam: false,
- trusted: true,
- wallet_address: '0x1234',
- })
- })
-
- it('should filter out undefined chain ids', async () => {
- const spy = jest.spyOn(sdk, 'getSafeOverviews').mockResolvedValue([])
- const safes = [
- { address: '0x1234', chainId: '1' },
- { address: '0x5678', chainId: undefined as unknown as string },
- { address: '0x5678', chainId: '3' },
- ]
-
- renderHook(() => useSafeOverviews(safes))
-
- await act(() => Promise.resolve())
-
- expect(spy).toHaveBeenCalledWith(['1:0x1234', '3:0x5678'], {
- currency: 'USD',
- exclude_spam: false,
- trusted: true,
- wallet_address: '0x1234',
- })
- })
-})
diff --git a/src/components/welcome/MyAccounts/useSafeOverviews.ts b/src/components/welcome/MyAccounts/useSafeOverviews.ts
deleted file mode 100644
index 263e3d98b4..0000000000
--- a/src/components/welcome/MyAccounts/useSafeOverviews.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { useMemo } from 'react'
-import { useTokenListSetting } from '@/hooks/loadables/useLoadBalances'
-import useAsync, { type AsyncResult } from '@/hooks/useAsync'
-import useWallet from '@/hooks/wallets/useWallet'
-import { useAppSelector } from '@/store'
-import { selectCurrency } from '@/store/settingsSlice'
-import { type SafeOverview, getSafeOverviews } from '@safe-global/safe-gateway-typescript-sdk'
-
-const _cache: Record = {}
-
-type SafeParams = {
- address: string
- chainId: string
-}
-
-// EIP155 address format
-const makeSafeId = ({ chainId, address }: SafeParams) => `${chainId}:${address}` as `${number}:0x${string}`
-
-const validateSafeParams = ({ chainId, address }: SafeParams) => chainId != null && address != null
-
-function useSafeOverviews(safes: Array): AsyncResult {
- const excludeSpam = useTokenListSetting() || false
- const currency = useAppSelector(selectCurrency)
- const wallet = useWallet()
- const walletAddress = wallet?.address
- const safesIds = useMemo(() => safes.filter(validateSafeParams).map(makeSafeId), [safes])
-
- const [data, error, isLoading] = useAsync(async () => {
- if (safesIds.length === 0) {
- return []
- }
- return await getSafeOverviews(safesIds, {
- trusted: true,
- exclude_spam: excludeSpam,
- currency,
- wallet_address: walletAddress,
- })
- }, [safesIds, excludeSpam, currency, walletAddress])
-
- const cacheKey = safesIds.join()
- const result = data ?? _cache[cacheKey]
-
- // Cache until the next page load
- _cache[cacheKey] = result
-
- return useMemo(() => [result, error, isLoading], [result, error, isLoading])
-}
-
-export default useSafeOverviews
diff --git a/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx b/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx
index 78df0658c2..b4ecbd2c7b 100644
--- a/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx
+++ b/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx
@@ -3,10 +3,9 @@ import useChains, { useCurrentChain } from '@/hooks/useChains'
import ErrorMessage from '@/components/tx/ErrorMessage'
import useSafeAddress from '@/hooks/useSafeAddress'
import { useAppSelector } from '@/store'
-import { selectUndeployedSafes } from '@/store/slices'
+import { selectCurrency, selectUndeployedSafes, useGetMultipleSafeOverviewsQuery } from '@/store/slices'
import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped'
import { sameAddress } from '@/utils/addresses'
-import useSafeOverviews from '@/components/welcome/MyAccounts/useSafeOverviews'
import { useMemo } from 'react'
import { getDeviatingSetups, getSafeSetups } from '@/components/welcome/MyAccounts/utils/multiChainSafe'
import { Box, Typography } from '@mui/material'
@@ -37,6 +36,7 @@ export const InconsistentSignerSetupWarning = () => {
const isMultichainSafe = useIsMultichainSafe()
const safeAddress = useSafeAddress()
const currentChain = useCurrentChain()
+ const currency = useAppSelector(selectCurrency)
const undeployedSafes = useAppSelector(selectUndeployedSafes)
const { allMultiChainSafes } = useAllSafesGrouped()
@@ -44,7 +44,11 @@ export const InconsistentSignerSetupWarning = () => {
() => allMultiChainSafes?.find((account) => sameAddress(safeAddress, account.safes[0].address))?.safes ?? [],
[allMultiChainSafes, safeAddress],
)
- const [safeOverviews] = useSafeOverviews(multiChainGroupSafes)
+ const deployedSafes = useMemo(
+ () => multiChainGroupSafes.filter((safe) => undeployedSafes[safe.chainId]?.[safe.address] === undefined),
+ [multiChainGroupSafes, undeployedSafes],
+ )
+ const { data: safeOverviews } = useGetMultipleSafeOverviewsQuery({ safes: deployedSafes, currency })
const safeSetups = useMemo(
() => getSafeSetups(multiChainGroupSafes, safeOverviews ?? [], undeployedSafes),
diff --git a/src/features/speedup/components/SpeedUpModal.tsx b/src/features/speedup/components/SpeedUpModal.tsx
index 50a641b1b1..b529480268 100644
--- a/src/features/speedup/components/SpeedUpModal.tsx
+++ b/src/features/speedup/components/SpeedUpModal.tsx
@@ -27,7 +27,7 @@ import { getTransactionTrackingType } from '@/services/analytics/tx-tracking'
import { trackError } from '@/services/exceptions'
import ErrorCodes from '@/services/exceptions/ErrorCodes'
import CheckWallet from '@/components/common/CheckWallet'
-import { useLazyGetTransactionDetailsQuery } from '@/store/gateway'
+import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway'
import NetworkWarning from '@/components/new-safe/create/NetworkWarning'
type Props = {
diff --git a/src/features/stake/components/StakePage/index.tsx b/src/features/stake/components/StakePage/index.tsx
index b42b388f44..e6c4b855ec 100644
--- a/src/features/stake/components/StakePage/index.tsx
+++ b/src/features/stake/components/StakePage/index.tsx
@@ -4,7 +4,7 @@ import WidgetDisclaimer from '@/components/common/WidgetDisclaimer'
import useStakeConsent from '@/features/stake/useStakeConsent'
import StakingWidget from '../StakingWidget'
import { useRouter } from 'next/router'
-import { useGetIsSanctionedQuery } from '@/store/ofac'
+import { useGetIsSanctionedQuery } from '@/store/api/ofac'
import { skipToken } from '@reduxjs/toolkit/query/react'
import useWallet from '@/hooks/wallets/useWallet'
import useSafeInfo from '@/hooks/useSafeInfo'
diff --git a/src/features/swap/index.tsx b/src/features/swap/index.tsx
index 9ac20f238d..12735115ba 100644
--- a/src/features/swap/index.tsx
+++ b/src/features/swap/index.tsx
@@ -37,7 +37,7 @@ import {
import { calculateFeePercentageInBps } from '@/features/swap/helpers/fee'
import { UiOrderTypeToOrderType } from '@/features/swap/helpers/utils'
import { FEATURES } from '@/utils/chains'
-import { useGetIsSanctionedQuery } from '@/store/ofac'
+import { useGetIsSanctionedQuery } from '@/store/api/ofac'
import { skipToken } from '@reduxjs/toolkit/query/react'
import { getKeyWithTrueValue } from '@/utils/helpers'
diff --git a/src/hooks/useDelegates.ts b/src/hooks/useDelegates.ts
index 74c643ce33..c669ed93c0 100644
--- a/src/hooks/useDelegates.ts
+++ b/src/hooks/useDelegates.ts
@@ -1,6 +1,6 @@
import useSafeInfo from '@/hooks/useSafeInfo'
import useWallet from '@/hooks/wallets/useWallet'
-import { useGetDelegatesQuery } from '@/store/gateway'
+import { useGetDelegatesQuery } from '@/store/api/gateway'
import { skipToken } from '@reduxjs/toolkit/query/react'
const useDelegates = () => {
diff --git a/src/hooks/useTxNotifications.ts b/src/hooks/useTxNotifications.ts
index 5311a8e8a6..c778e43d00 100644
--- a/src/hooks/useTxNotifications.ts
+++ b/src/hooks/useTxNotifications.ts
@@ -14,7 +14,7 @@ import useSafeAddress from './useSafeAddress'
import { getExplorerLink } from '@/utils/gateway'
import { isWalletRejection } from '@/utils/wallets'
import { getTxLink } from '@/utils/tx-link'
-import { useLazyGetTransactionDetailsQuery } from '@/store/gateway'
+import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway'
const TxNotifications = {
[TxEvent.SIGN_FAILED]: 'Failed to sign. Please try again.',
diff --git a/src/hooks/useTxTracking.ts b/src/hooks/useTxTracking.ts
index 44a1e570dd..836bb81be9 100644
--- a/src/hooks/useTxTracking.ts
+++ b/src/hooks/useTxTracking.ts
@@ -2,7 +2,7 @@ import { trackEvent, WALLET_EVENTS } from '@/services/analytics'
import { TxEvent, txSubscribe } from '@/services/tx/txEvents'
import { useEffect } from 'react'
import useChainId from './useChainId'
-import { useLazyGetTransactionDetailsQuery } from '@/store/gateway'
+import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway'
const events = {
[TxEvent.SIGNED]: WALLET_EVENTS.OFFCHAIN_SIGNATURE,
diff --git a/src/store/__tests__/safeOverviews.test.ts b/src/store/__tests__/safeOverviews.test.ts
new file mode 100644
index 0000000000..a7858b6ee7
--- /dev/null
+++ b/src/store/__tests__/safeOverviews.test.ts
@@ -0,0 +1,321 @@
+import { renderHook, waitFor } from '@/tests/test-utils'
+import { useGetMultipleSafeOverviewsQuery, useGetSafeOverviewQuery } from '../api/gateway'
+import { faker } from '@faker-js/faker'
+import { getSafeOverviews } from '@safe-global/safe-gateway-typescript-sdk'
+
+jest.mock('@safe-global/safe-gateway-typescript-sdk')
+
+describe('safeOverviews', () => {
+ const mockedGetSafeOverviews = getSafeOverviews as jest.MockedFunction
+
+ beforeEach(() => {
+ jest.resetAllMocks()
+ })
+
+ describe('useGetSafeOverviewQuery', () => {
+ it('should return an error if fetching fails', async () => {
+ const request = { chainId: '1', safeAddress: faker.finance.ethereumAddress() }
+ mockedGetSafeOverviews.mockRejectedValueOnce('Service unavailable')
+
+ const { result } = renderHook(() => useGetSafeOverviewQuery(request))
+
+ // Request should get queued and remain loading for the queue seconds
+ expect(result.current.isLoading).toBeTruthy()
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy()
+ expect(result.current.error).toBeDefined()
+ expect(result.current.data).toBeUndefined()
+ })
+ })
+
+ it('should return the Safe overview if fetching is successful', async () => {
+ const request = { chainId: '1', safeAddress: faker.finance.ethereumAddress() }
+
+ const mockOverview = {
+ address: { value: request.safeAddress },
+ chainId: '1',
+ awaitingConfirmation: null,
+ fiatTotal: '100',
+ owners: [{ value: faker.finance.ethereumAddress() }],
+ threshold: 1,
+ queued: 0,
+ }
+ mockedGetSafeOverviews.mockResolvedValueOnce([mockOverview])
+
+ const { result } = renderHook(() => useGetSafeOverviewQuery(request))
+
+ // Request should get queued and remain loading for the queue seconds
+ expect(result.current.isLoading).toBeTruthy()
+
+ await Promise.resolve()
+
+ await waitFor(() => {
+ expect(mockedGetSafeOverviews).toHaveBeenCalled()
+ expect(result.current.isLoading).toBeFalsy()
+ expect(result.current.error).toBeUndefined()
+ expect(result.current.data).toEqual(mockOverview)
+ })
+ })
+
+ it('should immediately process queue if BATCH SIZE elements are queued', async () => {
+ const fakeSafeAddress = faker.finance.ethereumAddress()
+ const requests = [
+ { chainId: '1', safeAddress: fakeSafeAddress },
+ { chainId: '2', safeAddress: fakeSafeAddress },
+ { chainId: '3', safeAddress: fakeSafeAddress },
+ { chainId: '4', safeAddress: fakeSafeAddress },
+ { chainId: '5', safeAddress: fakeSafeAddress },
+ { chainId: '6', safeAddress: fakeSafeAddress },
+ { chainId: '7', safeAddress: fakeSafeAddress },
+ { chainId: '8', safeAddress: fakeSafeAddress },
+ { chainId: '9', safeAddress: fakeSafeAddress },
+ { chainId: '10', safeAddress: fakeSafeAddress },
+ ]
+
+ const mockOverviews = requests.map((request, idx) => ({
+ address: { value: request.safeAddress },
+ chainId: (idx + 1).toString(),
+ awaitingConfirmation: null,
+ fiatTotal: '100',
+ owners: [{ value: faker.finance.ethereumAddress() }],
+ threshold: 1,
+ queued: 0,
+ }))
+
+ mockedGetSafeOverviews.mockResolvedValueOnce(mockOverviews)
+
+ const { result: result0 } = renderHook(() => useGetSafeOverviewQuery(requests[0]))
+ const { result: result1 } = renderHook(() => useGetSafeOverviewQuery(requests[1]))
+ const { result: result2 } = renderHook(() => useGetSafeOverviewQuery(requests[2]))
+ const { result: result3 } = renderHook(() => useGetSafeOverviewQuery(requests[3]))
+ const { result: result4 } = renderHook(() => useGetSafeOverviewQuery(requests[4]))
+ const { result: result5 } = renderHook(() => useGetSafeOverviewQuery(requests[5]))
+ const { result: result6 } = renderHook(() => useGetSafeOverviewQuery(requests[6]))
+ const { result: result7 } = renderHook(() => useGetSafeOverviewQuery(requests[7]))
+ const { result: result8 } = renderHook(() => useGetSafeOverviewQuery(requests[8]))
+
+ // After 9 requests they should all be loading
+ expect(result0.current.isLoading).toBeTruthy()
+ expect(result1.current.isLoading).toBeTruthy()
+ expect(result2.current.isLoading).toBeTruthy()
+ expect(result3.current.isLoading).toBeTruthy()
+ expect(result4.current.isLoading).toBeTruthy()
+ expect(result5.current.isLoading).toBeTruthy()
+ expect(result6.current.isLoading).toBeTruthy()
+ expect(result7.current.isLoading).toBeTruthy()
+ expect(result8.current.isLoading).toBeTruthy()
+
+ expect(mockedGetSafeOverviews).not.toHaveBeenCalled()
+
+ // Trigger the 10th hook - causing all values to load
+ const { result: result9 } = renderHook(() => useGetSafeOverviewQuery(requests[9]))
+
+ await waitFor(() => {
+ // Wait until they all resolve
+ expect(result0.current.isLoading).toBeFalsy()
+ expect(result1.current.isLoading).toBeFalsy()
+ expect(result2.current.isLoading).toBeFalsy()
+ expect(result3.current.isLoading).toBeFalsy()
+ expect(result4.current.isLoading).toBeFalsy()
+ expect(result5.current.isLoading).toBeFalsy()
+ expect(result6.current.isLoading).toBeFalsy()
+ expect(result7.current.isLoading).toBeFalsy()
+ expect(result8.current.isLoading).toBeFalsy()
+ expect(result9.current.isLoading).toBeFalsy()
+
+ // One request that batched all requests together should have happened
+ expect(mockedGetSafeOverviews).toHaveBeenCalledWith(
+ [
+ `1:${fakeSafeAddress}`,
+ `2:${fakeSafeAddress}`,
+ `3:${fakeSafeAddress}`,
+ `4:${fakeSafeAddress}`,
+ `5:${fakeSafeAddress}`,
+ `6:${fakeSafeAddress}`,
+ `7:${fakeSafeAddress}`,
+ `8:${fakeSafeAddress}`,
+ `9:${fakeSafeAddress}`,
+ `10:${fakeSafeAddress}`,
+ ],
+ {
+ currency: 'usd',
+ trusted: true,
+ exclude_spam: true,
+ },
+ )
+
+ expect(result0.current.data).toEqual(mockOverviews[0])
+ expect(result1.current.data).toEqual(mockOverviews[1])
+ expect(result2.current.data).toEqual(mockOverviews[2])
+ expect(result3.current.data).toEqual(mockOverviews[3])
+ expect(result4.current.data).toEqual(mockOverviews[4])
+ expect(result5.current.data).toEqual(mockOverviews[5])
+ expect(result6.current.data).toEqual(mockOverviews[6])
+ expect(result7.current.data).toEqual(mockOverviews[7])
+ expect(result8.current.data).toEqual(mockOverviews[8])
+ expect(result9.current.data).toEqual(mockOverviews[9])
+ })
+ })
+ })
+
+ describe('useGetMultipleSafeOverviewsQuery', () => {
+ it('Should return empty list for empty list of Safes', async () => {
+ const request = { currency: 'usd', safes: [] }
+
+ const { result } = renderHook(() => useGetMultipleSafeOverviewsQuery(request))
+
+ // Request should get queued and remain loading for the queue seconds
+ expect(result.current.isLoading).toBeTruthy()
+
+ await Promise.resolve()
+ await Promise.resolve()
+
+ await Promise.resolve()
+ await Promise.resolve()
+
+ await waitFor(() => {
+ expect(result.current.error).toBeUndefined()
+ expect(result.current.data).toEqual([])
+ expect(result.current.isLoading).toBeFalsy()
+ })
+ })
+
+ it('Should return a response for non-empty list', async () => {
+ const request = {
+ currency: 'usd',
+ safes: [
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '10', isWatchlist: false },
+ ],
+ }
+
+ const mockOverview1 = {
+ address: { value: request.safes[0].address },
+ chainId: '1',
+ awaitingConfirmation: null,
+ fiatTotal: '100',
+ owners: [{ value: faker.finance.ethereumAddress() }],
+ threshold: 1,
+ queued: 0,
+ }
+
+ const mockOverview2 = {
+ address: { value: request.safes[1].address },
+ chainId: '10',
+ awaitingConfirmation: null,
+ fiatTotal: '200',
+ owners: [{ value: faker.finance.ethereumAddress() }],
+ threshold: 1,
+ queued: 4,
+ }
+
+ mockedGetSafeOverviews.mockResolvedValueOnce([mockOverview1, mockOverview2])
+
+ const { result } = renderHook(() => useGetMultipleSafeOverviewsQuery(request))
+
+ // Request should get queued and remain loading for the queue seconds
+ expect(result.current.isLoading).toBeTruthy()
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy()
+ expect(result.current.error).toBeUndefined()
+ expect(result.current.data).toEqual([mockOverview1, mockOverview2])
+ })
+ })
+
+ it('Should return an error if fetching fails', async () => {
+ const request = {
+ currency: 'usd',
+ safes: [
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '10', isWatchlist: false },
+ ],
+ }
+
+ mockedGetSafeOverviews.mockRejectedValueOnce('Not available')
+
+ const { result } = renderHook(() => useGetMultipleSafeOverviewsQuery(request))
+
+ // Request should get queued and remain loading for the queue seconds
+ expect(result.current.isLoading).toBeTruthy()
+
+ await waitFor(async () => {
+ await Promise.resolve()
+ expect(result.current.error).toBeDefined()
+ expect(result.current.data).toBeUndefined()
+ expect(result.current.isLoading).toBeFalsy()
+ })
+ })
+
+ it('Should split big batches into multiple requests', async () => {
+ // Requests overviews for 15 Safes at once
+ const request = {
+ currency: 'usd',
+ safes: [
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false },
+ ],
+ }
+
+ const firstBatchOverviews = request.safes.slice(0, 10).map((safe) => ({
+ address: { value: safe.address },
+ chainId: '1',
+ awaitingConfirmation: null,
+ fiatTotal: faker.string.numeric({ length: { min: 1, max: 6 } }),
+ owners: [{ value: faker.finance.ethereumAddress() }],
+ threshold: 1,
+ queued: 0,
+ }))
+
+ const secondBatchOverviews = request.safes.slice(10).map((safe) => ({
+ address: { value: safe.address },
+ chainId: '1',
+ awaitingConfirmation: null,
+ fiatTotal: faker.string.numeric({ length: { min: 1, max: 6 } }),
+ owners: [{ value: faker.finance.ethereumAddress() }],
+ threshold: 1,
+ queued: 0,
+ }))
+
+ // Mock two fetch requests for the 2 batches
+ mockedGetSafeOverviews.mockResolvedValueOnce(firstBatchOverviews).mockResolvedValueOnce(secondBatchOverviews)
+
+ const { result } = renderHook(() => useGetMultipleSafeOverviewsQuery(request))
+
+ // Request should get queued and remain loading for the queue seconds
+ expect(result.current.isLoading).toBeTruthy()
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy()
+ expect(result.current.error).toBeUndefined()
+ expect(result.current.data).toEqual([...firstBatchOverviews, ...secondBatchOverviews])
+ })
+
+ // Expect that the correct requests were sent
+ expect(mockedGetSafeOverviews).toHaveBeenCalledTimes(2)
+ expect(mockedGetSafeOverviews).toHaveBeenCalledWith(
+ request.safes.slice(0, 10).map((safe) => `1:${safe.address}`),
+ { currency: 'usd', exclude_spam: true, trusted: true },
+ )
+
+ expect(mockedGetSafeOverviews).toHaveBeenCalledWith(
+ request.safes.slice(10).map((safe) => `1:${safe.address}`),
+ { currency: 'usd', exclude_spam: true, trusted: true },
+ )
+ })
+ })
+})
diff --git a/src/store/gateway.ts b/src/store/api/gateway/index.ts
similarity index 91%
rename from src/store/gateway.ts
rename to src/store/api/gateway/index.ts
index 3b027245b9..295279353a 100644
--- a/src/store/gateway.ts
+++ b/src/store/api/gateway/index.ts
@@ -5,8 +5,9 @@ import type { BaseQueryFn } from '@reduxjs/toolkit/dist/query/baseQueryTypes'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/dist/query/react'
import { getDelegates } from '@safe-global/safe-gateway-typescript-sdk'
import type { DelegateResponse } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates'
+import { safeOverviewEndpoints } from './safeOverviews'
-const noopBaseQuery: BaseQueryFn<
+export const noopBaseQuery: BaseQueryFn<
unknown, // QueryArg type
unknown, // ResultType
FetchBaseQueryError, // ErrorType
@@ -48,6 +49,7 @@ export const gatewayApi = createApi({
}
},
}),
+ ...safeOverviewEndpoints(builder),
}),
})
@@ -56,4 +58,6 @@ export const {
useGetMultipleTransactionDetailsQuery,
useLazyGetTransactionDetailsQuery,
useGetDelegatesQuery,
+ useGetSafeOverviewQuery,
+ useGetMultipleSafeOverviewsQuery,
} = gatewayApi
diff --git a/src/store/api/gateway/safeOverviews.ts b/src/store/api/gateway/safeOverviews.ts
new file mode 100644
index 0000000000..8caa1ef00a
--- /dev/null
+++ b/src/store/api/gateway/safeOverviews.ts
@@ -0,0 +1,164 @@
+import { type BaseQueryFn, type FetchBaseQueryError, type EndpointBuilder } from '@reduxjs/toolkit/query/react'
+
+import { type SafeOverview, getSafeOverviews } from '@safe-global/safe-gateway-typescript-sdk'
+import { sameAddress } from '@/utils/addresses'
+import type { RootState } from '../..'
+import { selectCurrency } from '../../settingsSlice'
+import { type SafeItem } from '@/components/welcome/MyAccounts/useAllSafes'
+
+type SafeOverviewQueueItem = {
+ safeAddress: string
+ walletAddress?: string
+ chainId: string
+ currency: string
+ callback: (result: { data: SafeOverview | undefined; error?: never } | { data?: never; error: string }) => void
+}
+
+const _BATCH_SIZE = 10
+const _FETCH_TIMEOUT = 50
+
+const makeSafeId = (chainId: string, address: string) => `${chainId}:${address}` as `${number}:0x${string}`
+
+class SafeOverviewFetcher {
+ private requestQueue: SafeOverviewQueueItem[] = []
+
+ private fetchTimeout: NodeJS.Timeout | null = null
+
+ private async fetchSafeOverviews({
+ safeIds,
+ walletAddress,
+ currency,
+ }: {
+ safeIds: `${number}:0x${string}`[]
+ walletAddress?: string
+ currency: string
+ }) {
+ return await getSafeOverviews(safeIds, {
+ trusted: true,
+ exclude_spam: true,
+ currency,
+ wallet_address: walletAddress,
+ })
+ }
+
+ private async processQueuedItems() {
+ // Dequeue the first BATCH_SIZE items
+ const nextBatch = this.requestQueue.slice(0, _BATCH_SIZE)
+ this.requestQueue = this.requestQueue.slice(_BATCH_SIZE)
+
+ let overviews: SafeOverview[]
+ try {
+ this.fetchTimeout && clearTimeout(this.fetchTimeout)
+ this.fetchTimeout = null
+
+ if (nextBatch.length === 0) {
+ // Nothing to process
+ return
+ }
+
+ const safeIds = nextBatch.map((request) => makeSafeId(request.chainId, request.safeAddress))
+ const { walletAddress, currency } = nextBatch[0]
+ overviews = await this.fetchSafeOverviews({ safeIds, currency, walletAddress })
+ } catch (err) {
+ // Overviews could not be fetched
+ nextBatch.forEach((item) => item.callback({ error: 'Could not fetch Safe overview' }))
+ return
+ }
+
+ nextBatch.forEach((item) => {
+ const overview = overviews.find(
+ (entry) => sameAddress(entry.address.value, item.safeAddress) && entry.chainId === item.chainId,
+ )
+
+ item.callback({ data: overview })
+ })
+ }
+
+ private enqueueRequest(item: SafeOverviewQueueItem) {
+ this.requestQueue.push(item)
+
+ if (this.requestQueue.length >= _BATCH_SIZE) {
+ this.processQueuedItems()
+ }
+
+ // If no timer is running start a timer
+ if (this.fetchTimeout === null) {
+ this.fetchTimeout = setTimeout(() => {
+ this.processQueuedItems()
+ }, _FETCH_TIMEOUT)
+ }
+ }
+
+ async getOverview(item: Omit) {
+ return new Promise((resolve, reject) => {
+ this.enqueueRequest({
+ ...item,
+ callback: (result) => {
+ if ('data' in result) {
+ resolve(result.data)
+ }
+ reject(result.error)
+ },
+ })
+ })
+ }
+}
+
+const batchedFetcher = new SafeOverviewFetcher()
+
+type MultiOverviewQueryParams = {
+ currency: string
+ walletAddress?: string
+ safes: SafeItem[]
+}
+
+export const safeOverviewEndpoints = (
+ builder: EndpointBuilder<
+ BaseQueryFn<
+ unknown, // QueryArg type
+ unknown, // ResultType
+ FetchBaseQueryError, // ErrorType
+ {}, // DefinitionExtraOptions
+ {} // Meta
+ >,
+ never,
+ 'gatewayApi'
+ >,
+) => ({
+ getSafeOverview: builder.query<
+ SafeOverview | undefined,
+ { safeAddress: string; walletAddress?: string; chainId: string }
+ >({
+ async queryFn({ safeAddress, walletAddress, chainId }, { getState }) {
+ const state = getState()
+ const currency = selectCurrency(state as RootState)
+
+ try {
+ const safeOverview = await batchedFetcher.getOverview({ chainId, currency, walletAddress, safeAddress })
+ return { data: safeOverview }
+ } catch (error) {
+ return { error: { status: 'CUSTOM_ERROR', error: (error as Error).message } }
+ }
+ },
+ }),
+ getMultipleSafeOverviews: builder.query({
+ async queryFn(params) {
+ const { safes, walletAddress, currency } = params
+
+ try {
+ const promisedSafeOverviews = safes.map((safe) =>
+ batchedFetcher.getOverview({
+ chainId: safe.chainId,
+ safeAddress: safe.address,
+ currency,
+ walletAddress,
+ }),
+ )
+ const safeOverviews = await Promise.all(promisedSafeOverviews)
+ return { data: safeOverviews.filter(Boolean) as SafeOverview[] }
+ } catch (error) {
+ return { error: { status: 'CUSTOM_ERROR', error: (error as Error).message } }
+ }
+ },
+ }),
+})
diff --git a/src/store/ofac.ts b/src/store/api/ofac.ts
similarity index 98%
rename from src/store/ofac.ts
rename to src/store/api/ofac.ts
index 0982ce5c9d..3e4be9d4b2 100644
--- a/src/store/ofac.ts
+++ b/src/store/api/ofac.ts
@@ -2,7 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react'
import { selectChainById } from '@/store/chainsSlice'
import { Contract } from 'ethers'
import { createWeb3ReadOnly } from '@/hooks/wallets/web3'
-import type { RootState } from '.'
+import type { RootState } from '..'
import { CHAINALYSIS_OFAC_CONTRACT } from '@/config/constants'
import chains from '@/config/chains'
diff --git a/src/store/safePass.ts b/src/store/api/safePass.ts
similarity index 100%
rename from src/store/safePass.ts
rename to src/store/api/safePass.ts
diff --git a/src/store/index.ts b/src/store/index.ts
index 70097eec6e..1dcd5903fb 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -24,8 +24,8 @@ import {
} from './slices'
import * as slices from './slices'
import * as hydrate from './useHydrateStore'
-import { ofacApi } from '@/store/ofac'
-import { safePassApi } from './safePass'
+import { ofacApi } from '@/store/api/ofac'
+import { safePassApi } from './api/safePass'
import { metadata } from '@/markdown/terms/terms.md'
const rootReducer = combineReducers({
diff --git a/src/store/slices.ts b/src/store/slices.ts
index c0dce32a5a..a36e3c1806 100644
--- a/src/store/slices.ts
+++ b/src/store/slices.ts
@@ -19,4 +19,5 @@ export * from './batchSlice'
export * from '@/features/counterfactual/store/undeployedSafesSlice'
export * from '@/features/swap/store/swapParamsSlice'
export * from './swapOrderSlice'
-export * from './gateway'
+export * from './api/gateway'
+export * from './api/gateway/safeOverviews'