Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/epic/multichain-safes' into mult…
Browse files Browse the repository at this point in the history
…ichain-analytics
  • Loading branch information
usame-algan committed Sep 23, 2024
2 parents 931cee8 + d450f1c commit 71eeec7
Show file tree
Hide file tree
Showing 45 changed files with 1,999 additions and 1,442 deletions.
12 changes: 8 additions & 4 deletions src/components/address-book/EntryDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import ModalDialog from '@/components/common/ModalDialog'
import NameInput from '@/components/common/NameInput'
import useChainId from '@/hooks/useChainId'
import { useAppDispatch } from '@/store'
import { upsertAddressBookEntry } from '@/store/addressBookSlice'
import { upsertAddressBookEntry, upsertMultichainAddressBookEntry } from '@/store/addressBookSlice'
import madProps from '@/utils/mad-props'

export type AddressEntry = {
Expand All @@ -22,13 +22,13 @@ function EntryDialog({
address: '',
},
disableAddressInput = false,
chainId,
chainIds,
currentChainId,
}: {
handleClose: () => void
defaultValues?: AddressEntry
disableAddressInput?: boolean
chainId?: string
chainIds?: string[]
currentChainId: string
}): ReactElement {
const dispatch = useAppDispatch()
Expand All @@ -41,7 +41,11 @@ function EntryDialog({
const { handleSubmit, formState } = methods

const submitCallback = handleSubmit((data: AddressEntry) => {
dispatch(upsertAddressBookEntry({ ...data, chainId: chainId || currentChainId }))
if (chainIds) {
dispatch(upsertMultichainAddressBookEntry({ ...data, chainIds }))
} else {
dispatch(upsertAddressBookEntry({ ...data, chainId: currentChainId }))
}
handleClose()
})

Expand Down
86 changes: 80 additions & 6 deletions src/components/common/CheckWallet/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { render } from '@/tests/test-utils'
import { getByLabelText, render } from '@/tests/test-utils'
import CheckWallet from '.'
import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary'
import useIsSafeOwner from '@/hooks/useIsSafeOwner'
import useIsWrongChain from '@/hooks/useIsWrongChain'
import useWallet from '@/hooks/wallets/useWallet'
import { chainBuilder } from '@/tests/builders/chains'
import { useIsWalletDelegate } from '@/hooks/useDelegates'
import { faker } from '@faker-js/faker'
import { extendedSafeInfoBuilder } from '@/tests/builders/safe'
import useSafeInfo from '@/hooks/useSafeInfo'

// mock useWallet
jest.mock('@/hooks/wallets/useWallet', () => ({
Expand Down Expand Up @@ -38,6 +42,25 @@ jest.mock('@/hooks/useIsWrongChain', () => ({
default: jest.fn(() => false),
}))

jest.mock('@/hooks/useDelegates', () => ({
__esModule: true,
useIsWalletDelegate: jest.fn(() => false),
}))

jest.mock('@/hooks/useSafeInfo', () => ({
__esModule: true,
default: jest.fn(() => {
const safeAddress = faker.finance.ethereumAddress()
return {
safeAddress,
safe: extendedSafeInfoBuilder()
.with({ address: { value: safeAddress } })
.with({ deployed: true })
.build(),
}
}),
}))

const renderButton = () =>
render(<CheckWallet checkNetwork={false}>{(isOk) => <button disabled={!isOk}>Continue</button>}</CheckWallet>)

Expand All @@ -62,7 +85,7 @@ describe('CheckWallet', () => {
expect(container.querySelector('button')).toBeDisabled()

// Check the tooltip text
expect(container.querySelector('span[aria-label]')).toHaveAttribute('aria-label', 'Please connect your wallet')
getByLabelText(container, 'Please connect your wallet')
})

it('should disable the button when the wallet is connected to the right chain but is not an owner', () => {
Expand Down Expand Up @@ -98,10 +121,7 @@ describe('CheckWallet', () => {
const { container } = renderButton()

expect(container.querySelector('button')).toBeDisabled()
expect(container.querySelector('span[aria-label]')).toHaveAttribute(
'aria-label',
'Your connected wallet is not a signer of this Safe Account',
)
getByLabelText(container, 'Your connected wallet is not a signer of this Safe Account')

const { container: allowContainer } = render(
<CheckWallet allowSpendingLimit>{(isOk) => <button disabled={!isOk}>Continue</button>}</CheckWallet>,
Expand All @@ -110,6 +130,60 @@ describe('CheckWallet', () => {
expect(allowContainer.querySelector('button')).not.toBeDisabled()
})

it('should not disable the button for delegates', () => {
;(useIsSafeOwner as jest.MockedFunction<typeof useIsSafeOwner>).mockReturnValueOnce(false)
;(useIsWalletDelegate as jest.MockedFunction<typeof useIsWalletDelegate>).mockReturnValueOnce(true)

const { container } = renderButton()

expect(container.querySelector('button')).not.toBeDisabled()
})

it('should disable the button for counterfactual Safes', () => {
;(useIsSafeOwner as jest.MockedFunction<typeof useIsSafeOwner>).mockReturnValueOnce(true)

const safeAddress = faker.finance.ethereumAddress()
const mockSafeInfo = {
safeAddress,
safe: extendedSafeInfoBuilder()
.with({ address: { value: safeAddress } })
.with({ deployed: false })
.build(),
}

;(useSafeInfo as jest.MockedFunction<typeof useSafeInfo>).mockReturnValueOnce(
mockSafeInfo as unknown as ReturnType<typeof useSafeInfo>,
)

const { container } = renderButton()

expect(container.querySelector('button')).toBeDisabled()
getByLabelText(container, 'You need to activate the Safe before transacting')
})

it('should enable the button for counterfactual Safes if allowed', () => {
;(useIsSafeOwner as jest.MockedFunction<typeof useIsSafeOwner>).mockReturnValueOnce(true)

const safeAddress = faker.finance.ethereumAddress()
const mockSafeInfo = {
safeAddress,
safe: extendedSafeInfoBuilder()
.with({ address: { value: safeAddress } })
.with({ deployed: false })
.build(),
}

;(useSafeInfo as jest.MockedFunction<typeof useSafeInfo>).mockReturnValueOnce(
mockSafeInfo as unknown as ReturnType<typeof useSafeInfo>,
)

const { container } = render(
<CheckWallet allowUndeployedSafe>{(isOk) => <button disabled={!isOk}>Continue</button>}</CheckWallet>,
)

expect(container.querySelector('button')).toBeEnabled()
})

it('should allow non-owners if specified', () => {
;(useIsSafeOwner as jest.MockedFunction<typeof useIsSafeOwner>).mockReturnValueOnce(false)

Expand Down
40 changes: 26 additions & 14 deletions src/components/common/CheckWallet/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useIsWalletDelegate } from '@/hooks/useDelegates'
import { type ReactElement } from 'react'
import { useMemo, type ReactElement } from 'react'
import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary'
import useIsSafeOwner from '@/hooks/useIsSafeOwner'
import useWallet from '@/hooks/wallets/useWallet'
Expand All @@ -14,12 +14,13 @@ type CheckWalletProps = {
allowNonOwner?: boolean
noTooltip?: boolean
checkNetwork?: boolean
allowUndeployedSafe?: boolean
}

enum Message {
WalletNotConnected = 'Please connect your wallet',
NotSafeOwner = 'Your connected wallet is not a signer of this Safe Account',
CounterfactualMultisig = 'You need to activate the Safe before transacting',
SafeNotActivated = 'You need to activate the Safe before transacting',
}

const CheckWallet = ({
Expand All @@ -28,28 +29,39 @@ const CheckWallet = ({
allowNonOwner,
noTooltip,
checkNetwork = false,
allowUndeployedSafe = false,
}: CheckWalletProps): ReactElement => {
const wallet = useWallet()
const isSafeOwner = useIsSafeOwner()
const isSpendingLimit = useIsOnlySpendingLimitBeneficiary()
const isOnlySpendingLimit = useIsOnlySpendingLimitBeneficiary()
const connectWallet = useConnectWallet()
const isWrongChain = useIsWrongChain()
const isDelegate = useIsWalletDelegate()

const { safe } = useSafeInfo()

const isCounterfactualMultiSig = !allowNonOwner && !safe.deployed && safe.threshold > 1
const isUndeployedSafe = !safe.deployed

const message =
wallet &&
(isSafeOwner || allowNonOwner || (isSpendingLimit && allowSpendingLimit) || isDelegate) &&
!isCounterfactualMultiSig
? ''
: !wallet
? Message.WalletNotConnected
: isCounterfactualMultiSig
? Message.CounterfactualMultisig
: Message.NotSafeOwner
const message = useMemo(() => {
if (!wallet) {
return Message.WalletNotConnected
}
if (isUndeployedSafe && !allowUndeployedSafe) {
return Message.SafeNotActivated
}
if ((!allowNonOwner && !isSafeOwner && !isDelegate) || (isOnlySpendingLimit && !allowSpendingLimit)) {
return Message.NotSafeOwner
}
}, [
allowNonOwner,
allowSpendingLimit,
allowUndeployedSafe,
isDelegate,
isOnlySpendingLimit,
isSafeOwner,
isUndeployedSafe,
wallet,
])

if (checkNetwork && isWrongChain) return children(false)
if (!message) return children(true)
Expand Down
17 changes: 11 additions & 6 deletions src/components/common/NetworkInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,24 @@ const NetworkInput = ({
}: {
name: string
required?: boolean
chainConfigs: ChainInfo[]
chainConfigs: (ChainInfo & { available: boolean })[]
}): ReactElement => {
const isDarkMode = useDarkMode()
const theme = useTheme()
const [testNets, prodNets] = useMemo(() => partition(chainConfigs, (config) => config.isTestnet), [chainConfigs])
const { control } = useFormContext() || {}

const renderMenuItem = useCallback(
(chainId: string, isSelected: boolean) => {
(chainId: string, isDisabled: boolean) => {
const chain = chainConfigs.find((chain) => chain.chainId === chainId)
if (!chain) return null
return (
<MenuItem key={chainId} value={chainId} sx={{ '&:hover': { backgroundColor: 'inherit' } }}>
<MenuItem
disabled={isDisabled}
key={chainId}
value={chainId}
sx={{ '&:hover': { backgroundColor: 'inherit' } }}
>
<ChainIndicator chainId={chain.chainId} />
</MenuItem>
)
Expand All @@ -51,7 +56,7 @@ const NetworkInput = ({
fullWidth
label="Network"
IconComponent={ExpandMoreIcon}
renderValue={(value) => renderMenuItem(value, true)}
renderValue={(value) => renderMenuItem(value, false)}
MenuProps={{
sx: {
'& .MuiPaper-root': {
Expand All @@ -67,11 +72,11 @@ const NetworkInput = ({
},
}}
>
{prodNets.map((chain) => renderMenuItem(chain.chainId, false))}
{prodNets.map((chain) => renderMenuItem(chain.chainId, !chain.available))}

{testNets.length > 0 && <ListSubheader className={css.listSubHeader}>Testnets</ListSubheader>}

{testNets.map((chain) => renderMenuItem(chain.chainId, false))}
{testNets.map((chain) => renderMenuItem(chain.chainId, !chain.available))}
</Select>
</FormControl>
)}
Expand Down
38 changes: 26 additions & 12 deletions src/components/common/NetworkSelector/NetworkMultiSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import useChains from '@/hooks/useChains'
import useSafeAddress from '@/hooks/useSafeAddress'
import { useCallback, type ReactElement } from 'react'
import { Checkbox, Autocomplete, TextField, Chip } from '@mui/material'
import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
Expand All @@ -7,10 +8,11 @@ import css from './styles.module.css'
import { Controller, useFormContext, useWatch } from 'react-hook-form'
import { useRouter } from 'next/router'
import { getNetworkLink } from '.'
import useWallet from '@/hooks/wallets/useWallet'
import { SetNameStepFields } from '@/components/new-safe/create/steps/SetNameStep'
import { getSafeSingletonDeployment } from '@safe-global/safe-deployments'
import { getSafeSingletonDeployments } from '@safe-global/safe-deployments'
import { getLatestSafeVersion } from '@/utils/chains'
import { hasCanonicalDeployment } from '@/services/contracts/deployments'
import { hasMultiChainCreationFeatures } from '@/components/welcome/MyAccounts/utils/multiChainSafe'

const NetworkMultiSelector = ({
name,
Expand All @@ -21,7 +23,7 @@ const NetworkMultiSelector = ({
}): ReactElement => {
const { configs } = useChains()
const router = useRouter()
const isWalletConnected = !!useWallet()
const safeAddress = useSafeAddress()

const {
formState: { errors },
Expand All @@ -36,10 +38,10 @@ const NetworkMultiSelector = ({
(chains: ChainInfo[]) => {
if (chains.length !== 1) return
const shortName = chains[0].shortName
const networkLink = getNetworkLink(router, shortName, isWalletConnected)
const networkLink = getNetworkLink(router, safeAddress, shortName)
router.replace(networkLink)
},
[isWalletConnected, router],
[router, safeAddress],
)

const handleDelete = useCallback(
Expand All @@ -54,23 +56,35 @@ const NetworkMultiSelector = ({

const isOptionDisabled = useCallback(
(optionNetwork: ChainInfo) => {
if (selectedNetworks.length === 0) return false
// Initially all networks are always available
if (selectedNetworks.length === 0) {
return false
}

const firstSelectedNetwork = selectedNetworks[0]

// do not allow multi chain safes for advanced setup flow.
if (isAdvancedFlow) return optionNetwork.chainId != firstSelectedNetwork.chainId

const optionHasCanonicalSingletonDeployment = Boolean(
getSafeSingletonDeployment({
// Check required feature toggles
if (!hasMultiChainCreationFeatures(optionNetwork) || !hasMultiChainCreationFeatures(firstSelectedNetwork)) {
return true
}

// Check if required deployments are available
const optionHasCanonicalSingletonDeployment = hasCanonicalDeployment(
getSafeSingletonDeployments({
network: optionNetwork.chainId,
version: getLatestSafeVersion(firstSelectedNetwork),
})?.deployments.canonical,
}),
optionNetwork.chainId,
)
const selectedHasCanonicalSingletonDeployment = Boolean(
getSafeSingletonDeployment({
const selectedHasCanonicalSingletonDeployment = hasCanonicalDeployment(
getSafeSingletonDeployments({
network: firstSelectedNetwork.chainId,
version: getLatestSafeVersion(firstSelectedNetwork),
})?.deployments.canonical,
}),
firstSelectedNetwork.chainId,
)

// Only 1.4.1 safes with canonical deployment addresses can be deployed as part of a multichain group
Expand Down
Loading

0 comments on commit 71eeec7

Please sign in to comment.