diff --git a/src/components/welcome/WelcomeLogin/index.tsx b/src/components/welcome/WelcomeLogin/index.tsx index 6f1b507faf..2a8d311882 100644 --- a/src/components/welcome/WelcomeLogin/index.tsx +++ b/src/components/welcome/WelcomeLogin/index.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/router' import { CREATE_SAFE_EVENTS } from '@/services/analytics/events/createLoadSafe' import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' import useWallet from '@/hooks/wallets/useWallet' -import { useHasSafes } from '@/features/myAccounts/hooks/useAllSafes' +import useHasSafes from '@/features/myAccounts/hooks/useHasSafes' import Track from '@/components/common/Track' import { useCallback, useEffect, useState } from 'react' import WalletLogin from './WalletLogin' diff --git a/src/features/myAccounts/hooks/__tests__/useAllSafes.test.ts b/src/features/myAccounts/hooks/__tests__/useAllSafes.test.ts new file mode 100644 index 0000000000..48ef5c4660 --- /dev/null +++ b/src/features/myAccounts/hooks/__tests__/useAllSafes.test.ts @@ -0,0 +1,481 @@ +import type { UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' + +import * as allOwnedSafes from '@/features/myAccounts/hooks/useAllOwnedSafes' +import useAllSafes, { _buildSafeItem, _prepareAddresses } from '@/features/myAccounts/hooks/useAllSafes' +import * as useChains from '@/hooks/useChains' +import * as useWallet from '@/hooks/wallets/useWallet' +import { renderHook } from '@/tests/test-utils' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +describe('useAllSafes hook', () => { + beforeEach(() => { + jest.clearAllMocks() + + jest.spyOn(allOwnedSafes, 'default').mockReturnValue([undefined, undefined, false]) + jest.spyOn(useChains, 'default').mockImplementation(() => ({ + configs: [{ chainId: '1' } as ChainInfo], + })) + }) + + it('returns an empty array if there is no wallet and allOwned is undefined', () => { + jest.spyOn(useWallet, 'default').mockReturnValue(null) + jest.spyOn(allOwnedSafes, 'default').mockReturnValue([undefined, undefined, false]) + + const { result } = renderHook(() => useAllSafes()) + + expect(result.current).toEqual([]) + }) + + it('returns an empty array if the chains config is empty', () => { + jest.spyOn(useChains, 'default').mockReturnValue({ configs: [] }) + + const { result } = renderHook(() => useAllSafes()) + + expect(result.current).toEqual([]) + }) + + it('returns SafeItems for added safes', () => { + const { result } = renderHook(() => useAllSafes(), { + initialReduxState: { + addedSafes: { + '1': { + '0x123': { + owners: [], + threshold: 1, + }, + }, + }, + }, + }) + + expect(result.current).toEqual([ + { + address: '0x123', + chainId: '1', + isPinned: true, + isReadOnly: true, + lastVisited: 0, + name: undefined, + }, + ]) + }) + + it('returns SafeItems for owned safes', () => { + const mockOwnedSafes = { + '1': ['0x123', '0x456', '0x789'], + } + + jest.spyOn(allOwnedSafes, 'default').mockReturnValue([mockOwnedSafes, undefined, false]) + + const { result } = renderHook(() => useAllSafes()) + + expect(result.current).toEqual([ + { address: '0x123', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0, name: undefined }, + { address: '0x456', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0, name: undefined }, + { address: '0x789', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0, name: undefined }, + ]) + }) + + it('returns SafeItems for undeployed safes', () => { + const { result } = renderHook(() => useAllSafes(), { + initialReduxState: { + undeployedSafes: { + '1': { + '0x123': { + status: {} as UndeployedSafe['status'], + props: { + safeAccountConfig: { + owners: ['0x111'], + }, + } as UndeployedSafe['props'], + }, + }, + }, + }, + }) + + expect(result.current).toEqual([ + { + address: '0x123', + chainId: '1', + isPinned: false, + isReadOnly: true, + lastVisited: 0, + name: undefined, + }, + ]) + }) + + it('returns SafeItems for added safes and owned safes', () => { + const mockOwnedSafes = { + '1': ['0x456', '0x789'], + } + jest.spyOn(allOwnedSafes, 'default').mockReturnValue([mockOwnedSafes, undefined, false]) + + const { result } = renderHook(() => useAllSafes(), { + initialReduxState: { + addedSafes: { + '1': { + '0x123': { + owners: [], + threshold: 1, + }, + }, + }, + }, + }) + + expect(result.current).toEqual([ + { address: '0x123', chainId: '1', isPinned: true, isReadOnly: true, lastVisited: 0, name: undefined }, + { address: '0x456', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0, name: undefined }, + { address: '0x789', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0, name: undefined }, + ]) + }) + + it('returns SafeItems for added safes and undeployed safes', () => { + const { result } = renderHook(() => useAllSafes(), { + initialReduxState: { + addedSafes: { + '1': { + '0x123': { + owners: [], + threshold: 1, + }, + }, + }, + undeployedSafes: { + '1': { + '0x456': { + status: {} as UndeployedSafe['status'], + props: { + safeAccountConfig: { + owners: ['0x111'], + }, + } as UndeployedSafe['props'], + }, + }, + }, + }, + }) + + expect(result.current).toEqual([ + { address: '0x123', chainId: '1', isPinned: true, isReadOnly: true, lastVisited: 0, name: undefined }, + { address: '0x456', chainId: '1', isPinned: false, isReadOnly: true, lastVisited: 0, name: undefined }, + ]) + }) + + it('returns SafeItems for owned safes and undeployed safes', () => { + const mockOwnedSafes = { + '1': ['0x456', '0x789'], + } + jest.spyOn(allOwnedSafes, 'default').mockReturnValue([mockOwnedSafes, undefined, false]) + + const { result } = renderHook(() => useAllSafes(), { + initialReduxState: { + undeployedSafes: { + '1': { + '0x123': { + status: {} as UndeployedSafe['status'], + props: { + safeAccountConfig: { + owners: ['0x111'], + }, + } as UndeployedSafe['props'], + }, + }, + }, + }, + }) + + expect(result.current).toEqual([ + { address: '0x456', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0, name: undefined }, + { address: '0x789', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0, name: undefined }, + { address: '0x123', chainId: '1', isPinned: false, isReadOnly: true, lastVisited: 0, name: undefined }, + ]) + }) + + it('returns SafeItems for added, owned and undeployed safes', () => { + const mockOwnedSafes = { + '1': ['0x456', '0x789'], + } + jest.spyOn(allOwnedSafes, 'default').mockReturnValue([mockOwnedSafes, undefined, false]) + + const { result } = renderHook(() => useAllSafes(), { + initialReduxState: { + addedSafes: { + '1': { + '0x123': { + owners: [], + threshold: 1, + }, + }, + }, + undeployedSafes: { + '1': { + '0x321': { + status: {} as UndeployedSafe['status'], + props: { + safeAccountConfig: { + owners: ['0x111'], + }, + } as UndeployedSafe['props'], + }, + }, + }, + }, + }) + + expect(result.current).toEqual([ + { address: '0x123', chainId: '1', isPinned: true, isReadOnly: true, lastVisited: 0, name: undefined }, + { address: '0x456', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0, name: undefined }, + { address: '0x789', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0, name: undefined }, + { address: '0x321', chainId: '1', isPinned: false, isReadOnly: true, lastVisited: 0, name: undefined }, + ]) + }) + + describe('buildSafeItem', () => { + const mockAllAdded = { + '1': { + '0x123': { + owners: [], + threshold: 1, + }, + }, + } + + const mockAllOwned = { + '1': ['0x456'], + } + + const mockAllUndeployed = {} + + const mockAllVisited = {} + const mockAllSafeNames = {} + + it('returns a pinned SafeItem if its an added safe', () => { + const result = _buildSafeItem( + '1', + '0x123', + '0x111', + mockAllAdded, + mockAllOwned, + mockAllUndeployed, + mockAllVisited, + mockAllSafeNames, + ) + + expect(result).toEqual({ + address: '0x123', + chainId: '1', + isPinned: true, + isReadOnly: true, + lastVisited: 0, + name: undefined, + }) + }) + + it('returns a SafeItem with lastVisited of non-zero if there is an entry', () => { + const mockAllVisited = { + '1': { + '0x123': { + lastVisited: 123456, + }, + }, + } + + const result = _buildSafeItem( + '1', + '0x123', + '0x111', + mockAllAdded, + mockAllOwned, + mockAllUndeployed, + mockAllVisited, + mockAllSafeNames, + ) + + expect(result.lastVisited).toEqual(123456) + }) + + it('returns a SafeItem with readOnly true if its an added safe', () => { + const mockAllAdded = { + '1': { + '0x123': { + owners: [{ value: '0x222' }], + threshold: 1, + }, + }, + } + const result = _buildSafeItem( + '1', + '0x123', + '0x111', + mockAllAdded, + mockAllOwned, + mockAllUndeployed, + mockAllVisited, + mockAllSafeNames, + ) + + expect(result.isReadOnly).toEqual(true) + }) + + it('returns a SafeItem with readOnly false if wallet is an owner of undeployed safe', () => { + const mockAllUndeployed = { + '1': { + '0x123': { + status: {} as UndeployedSafe['status'], + props: { + safeAccountConfig: { + owners: ['0x111'], + }, + } as UndeployedSafe['props'], + }, + }, + } + const result = _buildSafeItem( + '1', + '0x123', + '0x111', + mockAllAdded, + mockAllOwned, + mockAllUndeployed, + mockAllVisited, + mockAllSafeNames, + ) + + expect(result.isReadOnly).toEqual(false) + }) + + it('returns a SafeItem with readOnly false if it is an owned safe', () => { + const result = _buildSafeItem( + '1', + '0x456', + '0x111', + mockAllAdded, + mockAllOwned, + mockAllUndeployed, + mockAllVisited, + mockAllSafeNames, + ) + + expect(result.isReadOnly).toEqual(false) + }) + + it('returns a SafeItem with name if it exists in the address book', () => { + const mockAllSafeNames = { + '1': { + '0x123': 'My test safe', + }, + } + const result = _buildSafeItem( + '1', + '0x123', + '0x111', + mockAllAdded, + mockAllOwned, + mockAllUndeployed, + mockAllVisited, + mockAllSafeNames, + ) + + expect(result.name).toEqual('My test safe') + }) + }) + + describe('prepareAddresses', () => { + const mockAdded = {} + const mockOwned = {} + const mockUndeployed = {} + + it('returns an empty array if there are no addresses', () => { + const result = _prepareAddresses('1', mockAdded, mockOwned, mockUndeployed) + + expect(result).toEqual([]) + }) + + it('returns added safe addresses', () => { + const mockAdded = { + '1': { + '0x123': { + owners: [{ value: '0x111' }], + threshold: 1, + }, + }, + } + + const result = _prepareAddresses('1', mockAdded, mockOwned, mockUndeployed) + + expect(result).toEqual(['0x123']) + }) + + it('returns owned safe addresses', () => { + const mockOwned = { + '1': ['0x456'], + } + const result = _prepareAddresses('1', mockAdded, mockOwned, mockUndeployed) + + expect(result).toEqual(['0x456']) + }) + + it('returns undeployed safe addresses', () => { + const mockUndeployed = { + '1': { + '0x789': {} as UndeployedSafe, + }, + } + + const result = _prepareAddresses('1', mockAdded, mockOwned, mockUndeployed) + + expect(result).toEqual(['0x789']) + }) + + it('remove duplicates', () => { + const mockAdded = { + '1': { + '0x123': { + owners: [{ value: '0x111' }], + threshold: 1, + }, + }, + } + + const mockOwned = { + '1': ['0x123'], + } + + const mockUndeployed = { + '1': { + '0x123': {} as UndeployedSafe, + }, + } + const result = _prepareAddresses('1', mockAdded, mockOwned, mockUndeployed) + + expect(result).toEqual(['0x123']) + }) + + it('concatenates safe addresses', () => { + const mockAdded = { + '1': { + '0x123': { + owners: [{ value: '0x111' }], + threshold: 1, + }, + }, + } + + const mockOwned = { + '1': ['0x456'], + } + + const mockUndeployed = { + '1': { + '0x789': {} as UndeployedSafe, + }, + } + const result = _prepareAddresses('1', mockAdded, mockOwned, mockUndeployed) + + expect(result).toEqual(['0x123', '0x456', '0x789']) + }) + }) +}) diff --git a/src/features/myAccounts/hooks/useAllOwnedSafes.ts b/src/features/myAccounts/hooks/useAllOwnedSafes.ts index b8bf4abe5b..f7cf18661b 100644 --- a/src/features/myAccounts/hooks/useAllOwnedSafes.ts +++ b/src/features/myAccounts/hooks/useAllOwnedSafes.ts @@ -1,25 +1,13 @@ import type { AllOwnedSafes } from '@safe-global/safe-gateway-typescript-sdk' import type { AsyncResult } from '@/hooks/useAsync' -import useLocalStorage from '@/services/local-storage/useLocalStorage' -import { useEffect } from 'react' import { useGetAllOwnedSafesQuery } from '@/store/api/gateway' import { asError } from '@/services/exceptions/utils' import { skipToken } from '@reduxjs/toolkit/query' -const CACHE_KEY = 'ownedSafesCache_' - const useAllOwnedSafes = (address: string): AsyncResult => { - const [cache, setCache] = useLocalStorage(CACHE_KEY + address) - const { data, error, isLoading } = useGetAllOwnedSafesQuery(address === '' ? skipToken : { walletAddress: address }) - useEffect(() => { - if (data != undefined) { - setCache(data) - } - }, [data, setCache]) - - return address ? [cache, asError(error), isLoading] : [{}, undefined, false] + return [address ? data : undefined, asError(error), isLoading] } export default useAllOwnedSafes diff --git a/src/features/myAccounts/hooks/useAllSafes.ts b/src/features/myAccounts/hooks/useAllSafes.ts index 11e1a32bbf..2059f2bf83 100644 --- a/src/features/myAccounts/hooks/useAllSafes.ts +++ b/src/features/myAccounts/hooks/useAllSafes.ts @@ -1,10 +1,11 @@ +import type { AllOwnedSafes } from '@safe-global/safe-gateway-typescript-sdk' import { useMemo } from 'react' -import uniq from 'lodash/uniq' -import isEmpty from 'lodash/isEmpty' import { useAppSelector } from '@/store' +import type { AddedSafesState } from '@/store/addedSafesSlice' import { selectAllAddedSafes } from '@/store/addedSafesSlice' import useChains from '@/hooks/useChains' import useWallet from '@/hooks/wallets/useWallet' +import type { AddressBookState, UndeployedSafesState, VisitedSafesState } from '@/store/slices' import { selectAllAddressBooks, selectAllVisitedSafes, selectUndeployedSafes } from '@/store/slices' import { sameAddress } from '@/utils/addresses' import useAllOwnedSafes from './useAllOwnedSafes' @@ -20,63 +21,79 @@ export type SafeItem = { export type SafeItems = SafeItem[] -const useAddedSafes = () => { - const allAdded = useAppSelector(selectAllAddedSafes) - return allAdded +export const _prepareAddresses = ( + chainId: string, + allAdded: AddedSafesState, + allOwned: AllOwnedSafes, + allUndeployed: UndeployedSafesState, +): string[] => { + const addedOnChain = Object.keys(allAdded[chainId] || {}) + const ownedOnChain = allOwned[chainId] || [] + const undeployedOnChain = Object.keys(allUndeployed[chainId] || {}) + + const combined = [...addedOnChain, ...ownedOnChain, ...undeployedOnChain] + + return [...new Set(combined)] } -export const useHasSafes = () => { - const { address = '' } = useWallet() || {} - const allAdded = useAddedSafes() - const hasAdded = !isEmpty(allAdded) - const [allOwned] = useAllOwnedSafes(!hasAdded ? address : '') // pass an empty string to not fetch owned safes +export const _buildSafeItem = ( + chainId: string, + address: string, + walletAddress: string, + allAdded: AddedSafesState, + allOwned: AllOwnedSafes, + allUndeployed: UndeployedSafesState, + allVisitedSafes: VisitedSafesState, + allSafeNames: AddressBookState, +): SafeItem => { + const addedSafe = allAdded[chainId]?.[address] + const isPinned = Boolean(addedSafe) // Pinning a safe means adding it to the added safes storage + const undeployedSafeOwners = allUndeployed[chainId]?.[address]?.props.safeAccountConfig.owners || [] - if (hasAdded) return { isLoaded: true, hasSafes: hasAdded } - if (!allOwned) return { isLoaded: false } + // Determine if the user is an owner + const isOwnerFromCF = undeployedSafeOwners.some((ownedAddress) => sameAddress(walletAddress, ownedAddress)) + const isOwnedSafe = (allOwned[chainId] || []).includes(address) + const isOwned = isOwnedSafe || isOwnerFromCF - const hasOwned = !isEmpty(Object.values(allOwned).flat()) - return { isLoaded: true, hasSafes: hasOwned } + const lastVisited = allVisitedSafes[chainId]?.[address]?.lastVisited || 0 + const name = allSafeNames[chainId]?.[address] + + return { + chainId, + address, + isReadOnly: !isOwned, + isPinned, + lastVisited, + name, + } } const useAllSafes = (): SafeItems | undefined => { const { address: walletAddress = '' } = useWallet() || {} - const [allOwned] = useAllOwnedSafes(walletAddress) - const allAdded = useAddedSafes() + const [allOwned = {}] = useAllOwnedSafes(walletAddress) + const { configs } = useChains() + const allAdded = useAppSelector(selectAllAddedSafes) const allUndeployed = useAppSelector(selectUndeployedSafes) const allVisitedSafes = useAppSelector(selectAllVisitedSafes) - const { configs } = useChains() const allSafeNames = useAppSelector(selectAllAddressBooks) return useMemo(() => { - if (walletAddress && allOwned === undefined) { - return [] - } - const chains = uniq(Object.keys(allOwned || {}).concat(Object.keys(allAdded), Object.keys(allUndeployed))) - chains.sort((a, b) => parseInt(a) - parseInt(b)) + const allChainIds = configs.map((config) => config.chainId) - return chains.flatMap((chainId) => { - if (!configs.some((item) => item.chainId === chainId)) return [] - const addedOnChain = Object.keys(allAdded[chainId] || {}) - const ownedOnChain = (allOwned || {})[chainId] - const undeployedOnChain = Object.keys(allUndeployed[chainId] || {}) - const uniqueAddresses = uniq(addedOnChain.concat(ownedOnChain, undeployedOnChain).filter(Boolean)) - uniqueAddresses.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) + return allChainIds.flatMap((chainId) => { + const uniqueAddresses = _prepareAddresses(chainId, allAdded, allOwned, allUndeployed) return uniqueAddresses.map((address) => { - const owners = allAdded?.[chainId]?.[address]?.owners - const isPinned = !!allAdded?.[chainId]?.[address] - const isOwner = owners?.some(({ value }) => sameAddress(walletAddress, value)) - const isOwned = (ownedOnChain || []).includes(address) || isOwner - const lastVisited = allVisitedSafes?.[chainId]?.[address]?.lastVisited || 0 - const name = allSafeNames?.[chainId]?.[address] - return { - address, + return _buildSafeItem( chainId, - isReadOnly: !isOwned, - isPinned, - lastVisited, - name, - } + address, + walletAddress, + allAdded, + allOwned, + allUndeployed, + allVisitedSafes, + allSafeNames, + ) }) }) }, [allAdded, allOwned, allUndeployed, configs, walletAddress, allVisitedSafes, allSafeNames]) diff --git a/src/features/myAccounts/hooks/useHasSafes.ts b/src/features/myAccounts/hooks/useHasSafes.ts new file mode 100644 index 0000000000..d6a4261d9f --- /dev/null +++ b/src/features/myAccounts/hooks/useHasSafes.ts @@ -0,0 +1,20 @@ +import useAllOwnedSafes from '@/features/myAccounts/hooks/useAllOwnedSafes' +import useWallet from '@/hooks/wallets/useWallet' +import { useAppSelector } from '@/store' +import { selectAllAddedSafes } from '@/store/addedSafesSlice' +import isEmpty from 'lodash/isEmpty' + +const useHasSafes = () => { + const { address = '' } = useWallet() || {} + const allAdded = useAppSelector(selectAllAddedSafes) + const hasAdded = !isEmpty(allAdded) + const [allOwned] = useAllOwnedSafes(!hasAdded ? address : '') // pass an empty string to not fetch owned safes + + if (hasAdded) return { isLoaded: true, hasSafes: hasAdded } + if (!allOwned) return { isLoaded: false } + + const hasOwned = !isEmpty(Object.values(allOwned).flat()) + return { isLoaded: true, hasSafes: hasOwned } +} + +export default useHasSafes