diff --git a/src/components/DaoCreator/formComponents/EstablishEssentials.tsx b/src/components/DaoCreator/formComponents/EstablishEssentials.tsx index 1db69f86e..aa917e26c 100644 --- a/src/components/DaoCreator/formComponents/EstablishEssentials.tsx +++ b/src/components/DaoCreator/formComponents/EstablishEssentials.tsx @@ -3,6 +3,7 @@ import { CheckCircle } from '@phosphor-icons/react'; import debounce from 'lodash.debounce'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useChainId, useSwitchChain } from 'wagmi'; import { createAccountSubstring } from '../../../hooks/utils/useGetAccountName'; import { supportedNetworks, @@ -10,7 +11,7 @@ import { } from '../../../providers/NetworkConfig/useNetworkConfigStore'; import { useDaoInfoStore } from '../../../store/daoInfo/useDaoInfoStore'; import { GovernanceType, ICreationStepProps, VotingStrategyType } from '../../../types'; -import { getNetworkIcon } from '../../../utils/url'; +import { getChainIdFromPrefix, getNetworkIcon } from '../../../utils/url'; import { InputComponent, LabelComponent } from '../../ui/forms/InputComponent'; import LabelWrapper from '../../ui/forms/LabelWrapper'; import { RadioWithText } from '../../ui/forms/Radio/RadioWithText'; @@ -71,7 +72,9 @@ export function EstablishEssentials(props: ICreationStepProps) { setFieldValue('essentials.governance', value); }; - const { createOptions, setCurrentConfig, chain, getConfigByChainId } = useNetworkConfigStore(); + const { createOptions, setCurrentConfig, chain, getConfigByChainId, addressPrefix } = + useNetworkConfigStore(); + const walletChainID = useChainId(); const [snapshotENSInput, setSnapshotENSInput] = useState(''); @@ -94,6 +97,17 @@ export function EstablishEssentials(props: ICreationStepProps) { selected: chain.id === network.chain.id, })); + const { switchChain } = useSwitchChain({ + mutation: { + onError: () => { + if (chain.id !== walletChainID) { + const chainId = getChainIdFromPrefix(addressPrefix); + switchChain({ chainId }); + } + }, + }, + }); + return ( <> !!node && !!node?.safe?.address && node.safe.address === address, @@ -85,7 +91,7 @@ export function SearchDisplay({ loading, errorMessage, address, onClickView }: I { onClickView(); }} diff --git a/src/components/ui/menus/DAOSearch/index.tsx b/src/components/ui/menus/DAOSearch/index.tsx index a1a2409f0..a3dc58a0f 100644 --- a/src/components/ui/menus/DAOSearch/index.tsx +++ b/src/components/ui/menus/DAOSearch/index.tsx @@ -21,7 +21,7 @@ export function DAOSearch() { const { t } = useTranslation(['dashboard']); const [localInput, setLocalInput] = useState(''); const [typing, setTyping] = useState(false); - const { errorMessage, isLoading, address, setSearchString } = useSearchDao(); + const { errorMessage, isLoading, setSearchString, resolvedAddressesWithPrefix } = useSearchDao(); const { isOpen, onOpen, onClose } = useDisclosure(); const ref = useRef(null); @@ -64,9 +64,8 @@ export function DAOSearch() { const showResults = useMemo(() => { if (typing) return false; if (isLoading) return true; - const hasMessage = errorMessage !== undefined || address !== undefined; - return hasMessage; - }, [address, errorMessage, typing, isLoading]); + return errorMessage === undefined; + }, [errorMessage, typing, isLoading]); useEffect(() => { if (localInput) { @@ -149,12 +148,16 @@ export function DAOSearch() { w="full" position="absolute" > - + {resolvedAddressesWithPrefix.map(resolved => ( + + ))} diff --git a/src/components/ui/page/Global/index.tsx b/src/components/ui/page/Global/index.tsx index 3a2e3df2e..21e1be827 100644 --- a/src/components/ui/page/Global/index.tsx +++ b/src/components/ui/page/Global/index.tsx @@ -21,7 +21,6 @@ import { Layout } from '../Layout'; const useUserTracking = () => { const { address } = useAccount(); - useEffect(() => { Sentry.setUser(address ? { id: address } : null); if (address) { diff --git a/src/hooks/DAO/useSearchDao.ts b/src/hooks/DAO/useSearchDao.ts index 74a42a57d..5b46fda4c 100644 --- a/src/hooks/DAO/useSearchDao.ts +++ b/src/hooks/DAO/useSearchDao.ts @@ -1,38 +1,69 @@ -import { useEffect, useState } from 'react'; +import SafeApiKit from '@safe-global/api-kit'; +import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNetworkConfigStore } from '../../providers/NetworkConfig/useNetworkConfigStore'; -import { useIsSafe } from '../safe/useIsSafe'; -import useAddress from '../utils/useAddress'; +import { Address } from 'viem'; +import { useResolveAddressMultiChain } from '../utils/useResolveAddressMultiChain'; +type ResolvedAddressWithPrefix = { + address: Address; + chainId: number; +}; export const useSearchDao = () => { + const { t } = useTranslation('dashboard'); + const { resolveAddressMultiChain, isLoading: isAddressLoading } = useResolveAddressMultiChain(); const [searchString, setSearchString] = useState(''); const [errorMessage, setErrorMessage] = useState(); - const { address, isValid, isLoading: isAddressLoading } = useAddress(searchString); - const { isSafe, isSafeLoading } = useIsSafe(address); - const { t } = useTranslation('dashboard'); - const { chain } = useNetworkConfigStore(); + const [isSafeLookupLoading, setIsSafeLookupLoading] = useState(false); + const [resolvedAddressesWithPrefix, setSafeResolvedAddressesWithPrefix] = useState< + ResolvedAddressWithPrefix[] + >([]); + + const findSafes = useCallback( + async (resolvedAddressesWithChainId: { address: Address; chainId: number }[]) => { + setIsSafeLookupLoading(true); + for await (const resolved of resolvedAddressesWithChainId) { + const safeAPI = new SafeApiKit({ chainId: BigInt(resolved.chainId) }); + safeAPI.getSafeCreationInfo(resolved.address); + try { + await safeAPI.getSafeCreationInfo(resolved.address); - const isLoading = isAddressLoading === true || isSafeLoading === true; + setSafeResolvedAddressesWithPrefix(prevState => [...prevState, resolved]); + } catch (e) { + // Safe not found + continue; + } + } + setIsSafeLookupLoading(false); + }, + [], + ); + + const resolveInput = useCallback( + async (input: string) => { + const { resolved, isValid } = await resolveAddressMultiChain(input); + if (isValid) { + await findSafes(resolved); + } else { + setErrorMessage('Invalid search'); + } + }, + [findSafes, resolveAddressMultiChain], + ); useEffect(() => { setErrorMessage(undefined); - - if (searchString === '' || isLoading || isSafe || isValid === undefined) { + setSafeResolvedAddressesWithPrefix([]); + if (searchString === '') { return; } - - if (isValid === true) { - setErrorMessage(t('errorFailedSearch', { chain: chain.name })); - } else { - setErrorMessage(t('errorInvalidSearch')); - } - }, [chain.name, isLoading, isSafe, isValid, searchString, t]); + resolveInput(searchString).catch(() => setErrorMessage(t('errorInvalidSearch'))); + }, [resolveInput, searchString, t]); return { + resolvedAddressesWithPrefix, errorMessage, - isLoading, - address, + isLoading: isAddressLoading || isSafeLookupLoading, setSearchString, searchString, }; diff --git a/src/hooks/utils/useAutomaticSwitchChain.ts b/src/hooks/utils/useAutomaticSwitchChain.ts index f226cda09..ec7cd394a 100644 --- a/src/hooks/utils/useAutomaticSwitchChain.ts +++ b/src/hooks/utils/useAutomaticSwitchChain.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { useSwitchChain } from 'wagmi'; import { useNetworkConfigStore } from '../../providers/NetworkConfig/useNetworkConfigStore'; import { getChainIdFromPrefix } from '../../utils/url'; @@ -8,11 +9,25 @@ export const useAutomaticSwitchChain = ({ urlAddressPrefix: string | undefined; }) => { const { setCurrentConfig, getConfigByChainId, addressPrefix } = useNetworkConfigStore(); + const { switchChain } = useSwitchChain({ + mutation: { + onError: () => { + if (addressPrefix !== urlAddressPrefix && urlAddressPrefix !== undefined) { + const chainId = getChainIdFromPrefix(urlAddressPrefix); + switchChain({ chainId }); + } + }, + }, + }); useEffect(() => { if (urlAddressPrefix === undefined || addressPrefix === urlAddressPrefix) { return; } - setCurrentConfig(getConfigByChainId(getChainIdFromPrefix(urlAddressPrefix))); - }, [addressPrefix, setCurrentConfig, getConfigByChainId, urlAddressPrefix]); + const chainId = getChainIdFromPrefix(urlAddressPrefix); + if (addressPrefix !== urlAddressPrefix && urlAddressPrefix !== undefined) { + switchChain({ chainId }); + } + setTimeout(() => setCurrentConfig(getConfigByChainId(chainId)), 300); + }, [addressPrefix, setCurrentConfig, getConfigByChainId, urlAddressPrefix, switchChain]); }; diff --git a/src/hooks/utils/useResolveAddressMultiChain.ts b/src/hooks/utils/useResolveAddressMultiChain.ts new file mode 100644 index 000000000..f3926b1e5 --- /dev/null +++ b/src/hooks/utils/useResolveAddressMultiChain.ts @@ -0,0 +1,72 @@ +import { useState, useCallback } from 'react'; +import { Address, createPublicClient, http, isAddress, getAddress } from 'viem'; +import { normalize } from 'viem/ens'; +import { supportedNetworks } from '../../providers/NetworkConfig/useNetworkConfigStore'; + +type ResolveAddressReturnType = { + resolved: { + address: Address; + chainId: number; + }[]; + isValid: boolean; +}; +export const useResolveAddressMultiChain = () => { + const [isLoading, setIsLoading] = useState(false); + + const resolveAddressMultiChain = useCallback( + async (input: string): Promise => { + setIsLoading(true); + + const returnedResult: ResolveAddressReturnType = { + resolved: [], + isValid: false, + }; + + if (input === '') { + throw new Error('ENS name is empty'); + } + + if (isAddress(input)) { + // @dev if its a valid address, its valid on all networks + returnedResult.isValid = true; + returnedResult.resolved = supportedNetworks.map(network => ({ + address: getAddress(input), + chainId: network.chain.id, + })); + setIsLoading(false); + return returnedResult; + } + + // @dev if its not an address, try to resolve as possible ENS name on all networks + let normalizedName: string; + try { + normalizedName = normalize(input); + } catch { + setIsLoading(false); + return returnedResult; + } + for (const network of supportedNetworks) { + const client = createPublicClient({ + chain: network.chain, + transport: http(network.rpcEndpoint), + }); + try { + const resolvedAddress = await client.getEnsAddress({ name: normalizedName }); + if (resolvedAddress) { + returnedResult.resolved.push({ + address: resolvedAddress, + chainId: network.chain.id, + }); + returnedResult.isValid = true; + } + } catch { + // do nothing + } + } + setIsLoading(false); + return returnedResult; + }, + [], + ); + return { resolveAddressMultiChain, isLoading }; +}; diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json index 0c2d403a4..567b53e56 100644 --- a/src/i18n/locales/en/dashboard.json +++ b/src/i18n/locales/en/dashboard.json @@ -2,7 +2,7 @@ "emptyFavorites": "You haven't added any DAOs yet.", "loadingFavorite": "Loading DAO Name", "errorInvalidSearch": "Oops! This Ethereum address is invalid.", - "errorFailedSearch": "Sorry, this address is not a DAO on {{chain}}.", + "errorFailedSearch": "Sorry, this address is not a DAO on any supported chain.", "searchDAOPlaceholder": "Enter Address or ENS Name", "titleGovernance": "Governance", "titleType": "Type", diff --git a/src/pages/LoadingProblem.tsx b/src/pages/LoadingProblem.tsx index f75597274..1f45e6b78 100644 --- a/src/pages/LoadingProblem.tsx +++ b/src/pages/LoadingProblem.tsx @@ -7,7 +7,7 @@ import { import { CONTENT_MAXW } from '../constants/common'; import { useNetworkConfigStore } from '../providers/NetworkConfig/useNetworkConfigStore'; -function LoadingProblem({ type }: { type: 'invalidSafe' | 'wrongNetwork' | 'badQueryParam' }) { +function LoadingProblem({ type }: { type: 'invalidSafe' | 'badQueryParam' }) { const { chain } = useNetworkConfigStore(); const { t } = useTranslation('common'); diff --git a/src/pages/dao/SafeController.tsx b/src/pages/dao/SafeController.tsx index 9e5763700..52dd46844 100644 --- a/src/pages/dao/SafeController.tsx +++ b/src/pages/dao/SafeController.tsx @@ -49,8 +49,6 @@ export function SafeController() { // the order of the if blocks of these next three error states matters if (invalidQuery) { return ; - } else if (wrongNetwork) { - return ; } else if (errorLoading) { return ; } diff --git a/src/providers/NetworkConfig/web3-modal.config.ts b/src/providers/NetworkConfig/web3-modal.config.ts index f721c48b1..2d9e4e2d6 100644 --- a/src/providers/NetworkConfig/web3-modal.config.ts +++ b/src/providers/NetworkConfig/web3-modal.config.ts @@ -12,7 +12,7 @@ const supportedWagmiChains = supportedNetworks.map(network => network.chain); export const walletConnectProjectId = import.meta.env.VITE_APP_WALLET_CONNECT_PROJECT_ID; export const queryClient = new QueryClient(); -const wagmiMetadata = { +const metadata = { name: import.meta.env.VITE_APP_NAME, description: 'Are you outgrowing your Multisig? Decent extends Safe treasuries into on-chain hierarchies of permissions, token flows, and governance.', @@ -20,7 +20,10 @@ const wagmiMetadata = { icons: [`${import.meta.env.VITE_APP_SITE_URL}/favicon-96x96.png`], }; -const transportsReducer = (accumulator: Record, network: NetworkConfig) => { +export const transportsReducer = ( + accumulator: Record, + network: NetworkConfig, +) => { accumulator[network.chain.id] = http(network.rpcEndpoint); return accumulator; }; @@ -28,7 +31,7 @@ const transportsReducer = (accumulator: Record, network: export const wagmiConfig = defaultWagmiConfig({ chains: supportedWagmiChains as [Chain, ...Chain[]], projectId: walletConnectProjectId, - metadata: wagmiMetadata, + metadata, transports: supportedNetworks.reduce(transportsReducer, {}), batch: { multicall: true, @@ -36,5 +39,5 @@ export const wagmiConfig = defaultWagmiConfig({ }); if (walletConnectProjectId) { - createWeb3Modal({ wagmiConfig, projectId: walletConnectProjectId }); + createWeb3Modal({ wagmiConfig, projectId: walletConnectProjectId, metadata: metadata }); }