diff --git a/src/components/AppLayout/InvalidMasterCopyError/InvalidMasterCopyError.test.tsx b/src/components/AppLayout/InvalidMasterCopyError/InvalidMasterCopyError.test.tsx new file mode 100644 index 0000000000..0d92fc05a2 --- /dev/null +++ b/src/components/AppLayout/InvalidMasterCopyError/InvalidMasterCopyError.test.tsx @@ -0,0 +1,56 @@ +import { InvalidMasterCopyError } from './' +import { render, waitFor, fireEvent } from 'src/utils/test-utils' +import * as safeVersion from 'src/logic/safe/utils/safeVersion' +import * as useAsync from 'src/logic/hooks/useAsync' + +describe('InvalidMasterCopyError', () => { + it('returns null if valid master copy', async () => { + jest.spyOn(safeVersion, 'isValidMasterCopy') + jest.spyOn(useAsync, 'default').mockImplementationOnce(() => [true, undefined, false]) + + const { container } = render() + + await waitFor(() => { + expect(container.firstChild).toBeNull() + }) + }) + + it('returns null if error', async () => { + jest.spyOn(safeVersion, 'isValidMasterCopy') + jest.spyOn(useAsync, 'default').mockImplementationOnce(() => [undefined, new Error(), false]) + + const { container } = render() + + await waitFor(() => { + expect(container.firstChild).toBeNull() + }) + }) + + it('displays an error message if not a valid master copy', async () => { + jest.spyOn(safeVersion, 'isValidMasterCopy') + jest.spyOn(useAsync, 'default').mockImplementationOnce(() => [false, undefined, false]) + + const { getByText } = render() + + await waitFor(() => { + expect(getByText(/This Safe was created with an unsupported base contract/)).toBeInTheDocument() + }) + }) + + it('hides the error message on close', async () => { + jest.spyOn(safeVersion, 'isValidMasterCopy') + jest.spyOn(useAsync, 'default').mockImplementation(() => [false, undefined, false]) + + const { getByText, queryByText, getByRole } = render() + + await waitFor(() => { + expect(getByText(/This Safe was created with an unsupported base contract/)).toBeInTheDocument() + }) + + fireEvent.click(getByRole('button')) + + await waitFor(() => { + expect(queryByText(/This Safe was created with an unsupported base contract/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/components/AppLayout/InvalidMasterCopyError/index.tsx b/src/components/AppLayout/InvalidMasterCopyError/index.tsx new file mode 100644 index 0000000000..92d1423225 --- /dev/null +++ b/src/components/AppLayout/InvalidMasterCopyError/index.tsx @@ -0,0 +1,47 @@ +import { Link } from '@gnosis.pm/safe-react-components' +import { useSelector } from 'react-redux' +import { getChainInfo } from 'src/config' +import { Errors, logError } from 'src/logic/exceptions/CodedException' +import useAsync from 'src/logic/hooks/useAsync' +import { currentSafe } from 'src/logic/safe/store/selectors' +import { isValidMasterCopy } from 'src/logic/safe/utils/safeVersion' +import MuiAlert from '@material-ui/lab/Alert' +import { useState } from 'react' + +const CLI_LINK = 'https://github.com/5afe/safe-cli' + +export const InvalidMasterCopyError = (): React.ReactElement | null => { + const chainInfo = getChainInfo() + const { implementation } = useSelector(currentSafe) + const [showMasterCopyError, setShowMasterCopyError] = useState(true) + + const [validMasterCopy, error] = useAsync(async () => { + if (implementation.value) { + return await isValidMasterCopy(chainInfo.chainId, implementation.value) + } + }, [chainInfo.chainId, implementation.value]) + + if (!showMasterCopyError) { + return null + } + + if (error) { + logError(Errors._620, error.message) + return null + } + + if (typeof validMasterCopy === 'undefined' || validMasterCopy) { + return null + } + + return ( + setShowMasterCopyError(false)}> + This Safe was created with an unsupported base contract. The web interface might not work correctly. We recommend + using the{' '} + + command line interface + {' '} + instead. + + ) +} diff --git a/src/components/AppLayout/Sidebar/SafeHeader/index.tsx b/src/components/AppLayout/Sidebar/SafeHeader/index.tsx index f943596be2..ff231714f3 100644 --- a/src/components/AppLayout/Sidebar/SafeHeader/index.tsx +++ b/src/components/AppLayout/Sidebar/SafeHeader/index.tsx @@ -76,6 +76,7 @@ const IconContainer = styled.div` display: flex; gap: 8px; justify-content: space-evenly; + align-items: center; margin: 14px 0; ` const StyledButton = styled(Button)` diff --git a/src/components/AppLayout/index.tsx b/src/components/AppLayout/index.tsx index 2657dca456..625d8a090f 100644 --- a/src/components/AppLayout/index.tsx +++ b/src/components/AppLayout/index.tsx @@ -11,6 +11,7 @@ import { MobileNotSupported } from './MobileNotSupported' import { SAFE_APP_LANDING_PAGE_ROUTE, SAFE_ROUTES, WELCOME_ROUTE } from 'src/routes/routes' import useDarkMode from 'src/logic/hooks/useDarkMode' import { screenSm } from 'src/theme/variables' +import { InvalidMasterCopyError } from 'src/components/AppLayout/InvalidMasterCopyError' const Container = styled.div` height: 100vh; @@ -23,6 +24,7 @@ const Container = styled.div` const HeaderWrapper = styled.nav` height: 52px; + min-height: 52px; width: 100%; z-index: 1299; @@ -157,6 +159,8 @@ const Layout: React.FC = ({
+ + {showSideBar && ( diff --git a/src/logic/contracts/safeContracts.ts b/src/logic/contracts/safeContracts.ts index df6f6486e1..0dbe5db08b 100644 --- a/src/logic/contracts/safeContracts.ts +++ b/src/logic/contracts/safeContracts.ts @@ -6,6 +6,7 @@ import { getFallbackHandlerDeployment, getMultiSendCallOnlyDeployment, getSignMessageLibDeployment, + SingletonDeployment, } from '@gnosis.pm/safe-deployments' import Web3 from 'web3' import { AbiItem } from 'web3-utils' @@ -22,6 +23,7 @@ import { CompatibilityFallbackHandler } from 'src/types/contracts/compatibility_ import { SignMessageLib } from 'src/types/contracts/sign_message_lib.d' import { MultiSend } from 'src/types/contracts/multi_send.d' import { getSafeInfo } from 'src/logic/safe/utils/safeInformation' +import { NonPayableTransactionObject } from 'src/types/contracts/types' export const SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001' @@ -30,12 +32,13 @@ let safeMaster: GnosisSafe let fallbackHandler: CompatibilityFallbackHandler let multiSend: MultiSend -const getSafeContractDeployment = ({ safeVersion }: { safeVersion: string }) => { +const getSafeContractDeployment = ({ safeVersion }: { safeVersion: string }): SingletonDeployment | undefined => { // We check if version is prior to v1.0.0 as they are not supported but still we want to keep a minimum compatibility const useOldestContractVersion = semverSatisfies(safeVersion, '<1.0.0') // We have to check if network is L2 const networkId = _getChainId() const chainConfig = getChainById(networkId) + // We had L1 contracts in three L2 networks, xDai, EWC and Volta so even if network is L2 we have to check that safe version is after v1.3.0 const useL2ContractVersion = chainConfig.l2 && semverSatisfies(safeVersion, '>=1.3.0') const getDeployment = useL2ContractVersion ? getSafeL2SingletonDeployment : getSafeSingletonDeployment @@ -194,7 +197,7 @@ export const getMasterCopyAddressFromProxyAddress = async (proxyAddress: string) return masterCopyAddress } -export const instantiateSafeContracts = () => { +export const instantiateSafeContracts = (): void => { const web3 = getWeb3() const chainId = _getChainId() @@ -211,24 +214,24 @@ export const instantiateSafeContracts = () => { multiSend = getMultiSendContractInstance(web3, chainId) } -export const getSafeMasterContract = () => { +export const getSafeMasterContract = (): GnosisSafe => { instantiateSafeContracts() return safeMaster } -export const getSafeMasterContractAddress = () => { +export const getSafeMasterContractAddress = (): string => { return safeMaster.options.address } -export const getFallbackHandlerContractAddress = () => { +export const getFallbackHandlerContractAddress = (): string => { return fallbackHandler.options.address } -export const getMultisendContract = () => { +export const getMultisendContract = (): MultiSend => { return multiSend } -export const getMultisendContractAddress = () => { +export const getMultisendContractAddress = (): string => { return multiSend.options.address } @@ -236,7 +239,7 @@ export const getSafeDeploymentTransaction = ( safeAccounts: string[], numConfirmations: number, safeCreationSalt: number, -) => { +): NonPayableTransactionObject => { const gnosisSafeData = safeMaster.methods .setup( safeAccounts, @@ -257,7 +260,7 @@ export const estimateGasForDeployingSafe = async ( numConfirmations: number, userAccount: string, safeCreationSalt: number, -) => { +): Promise => { const proxyFactoryData = getSafeDeploymentTransaction(safeAccounts, numConfirmations, safeCreationSalt).encodeABI() return calculateGasOf({ diff --git a/src/logic/exceptions/registry.ts b/src/logic/exceptions/registry.ts index b516287475..b55bbdb84a 100644 --- a/src/logic/exceptions/registry.ts +++ b/src/logic/exceptions/registry.ts @@ -31,6 +31,7 @@ enum ErrorCodes { _617 = '617: Error fetching safeTxGas', _618 = '618: Error fetching fee history', _619 = '619: Failed to prepare a multisend tx', + _620 = '620: Failed to load master copy information', _700 = '700: Failed to read from local/session storage', _701 = '701: Failed to write to local/session storage', _702 = '702: Failed to remove from local/session storage', diff --git a/src/logic/safe/store/actions/__tests__/utils.test.ts b/src/logic/safe/store/actions/__tests__/utils.test.ts index 8666f2c0c7..a506d77f01 100644 --- a/src/logic/safe/store/actions/__tests__/utils.test.ts +++ b/src/logic/safe/store/actions/__tests__/utils.test.ts @@ -99,6 +99,12 @@ describe('extractRemoteSafeInfo', () => { 'SAFE_TX_GAS_OPTIONAL', 'SPENDING_LIMIT', ] as FEATURES[], + implementation: { + value: '0x3E5c63644E683549055b9Be8653de26E0B4CD36E', + name: 'Gnosis Safe: Mastercopy 1.3.0', + logoUri: + 'https://safe-transaction-assets.staging.gnosisdev.com/contracts/logos/0x3E5c63644E683549055b9Be8653de26E0B4CD36E.png', + }, } const remoteSafeInfo = await extractRemoteSafeInfo(remoteSafeInfoWithoutModules as any) @@ -131,6 +137,12 @@ describe('extractRemoteSafeInfo', () => { 'SAFE_TX_GAS_OPTIONAL', 'SPENDING_LIMIT', ] as FEATURES[], + implementation: { + value: '0x3E5c63644E683549055b9Be8653de26E0B4CD36E', + name: 'Gnosis Safe: Mastercopy 1.3.0', + logoUri: + 'https://safe-transaction-assets.staging.gnosisdev.com/contracts/logos/0x3E5c63644E683549055b9Be8653de26E0B4CD36E.png', + }, } const remoteSafeInfo = await extractRemoteSafeInfo(remoteSafeInfoWithModules as any) diff --git a/src/logic/safe/store/actions/mocks/safeInformation.ts b/src/logic/safe/store/actions/mocks/safeInformation.ts index f219869b19..0e900632c0 100644 --- a/src/logic/safe/store/actions/mocks/safeInformation.ts +++ b/src/logic/safe/store/actions/mocks/safeInformation.ts @@ -29,7 +29,7 @@ export const remoteSafeInfoWithModules = { implementation: { value: '0x3E5c63644E683549055b9Be8653de26E0B4CD36E', name: 'Gnosis Safe: Mastercopy 1.3.0', - logoUrl: + logoUri: 'https://safe-transaction-assets.staging.gnosisdev.com/contracts/logos/0x3E5c63644E683549055b9Be8653de26E0B4CD36E.png', }, guard: { @@ -43,7 +43,7 @@ export const remoteSafeInfoWithModules = { fallbackHandler: { value: '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4', name: 'Gnosis Safe: Default Callback Handler 1.3.0', - logoUrl: + logoUri: 'https://safe-transaction-assets.staging.gnosisdev.com/contracts/logos/0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4.png', }, version: '1.3.0', @@ -79,14 +79,14 @@ export const remoteSafeInfoWithoutModules = { implementation: { value: '0x3E5c63644E683549055b9Be8653de26E0B4CD36E', name: 'Gnosis Safe: Mastercopy 1.3.0', - logoUrl: + logoUri: 'https://safe-transaction-assets.staging.gnosisdev.com/contracts/logos/0x3E5c63644E683549055b9Be8653de26E0B4CD36E.png', }, modules: [], fallbackHandler: { value: '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4', name: 'Gnosis Safe: Default Callback Handler 1.3.0', - logoUrl: + logoUri: 'https://safe-transaction-assets.staging.gnosisdev.com/contracts/logos/0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4.png', }, version: '1.3.0', diff --git a/src/logic/safe/store/actions/utils.ts b/src/logic/safe/store/actions/utils.ts index 559a5e3376..c4afc92a55 100644 --- a/src/logic/safe/store/actions/utils.ts +++ b/src/logic/safe/store/actions/utils.ts @@ -99,6 +99,7 @@ export const extractRemoteSafeInfo = async (remoteSafeInfo: SafeInfo): Promise

({ modules: [], spendingLimits: [], balances: [], + implementation: { + value: '', + name: null, + logoUri: null, + }, loaded: false, nonce: 0, recurringUser: undefined, diff --git a/src/logic/safe/store/reducer/safe.ts b/src/logic/safe/store/reducer/safe.ts index 7e5b621349..fd60e4eca2 100644 --- a/src/logic/safe/store/reducer/safe.ts +++ b/src/logic/safe/store/reducer/safe.ts @@ -51,6 +51,9 @@ const updateSafeProps = (prevSafe, safe) => { List.isList(safe[key]) ? record.set(key, safe[key]) : record.update(key, (current) => current.merge(safe[key])) + } else { + // TODO: temporary fix if type is AddressEx because it's neither a Map, nor has a size property + record.set(key, safe[key]) } } else { // By default we overwrite the value. This is for strings, numbers and unset values diff --git a/src/logic/safe/utils/__tests__/safeVersion.test.ts b/src/logic/safe/utils/__tests__/safeVersion.test.ts index 10cfad951c..44ce634b55 100644 --- a/src/logic/safe/utils/__tests__/safeVersion.test.ts +++ b/src/logic/safe/utils/__tests__/safeVersion.test.ts @@ -1,5 +1,6 @@ import { FEATURES } from '@gnosis.pm/safe-react-gateway-sdk' -import { checkIfSafeNeedsUpdate, hasFeature } from 'src/logic/safe/utils/safeVersion' +import * as GatewaySDK from '@gnosis.pm/safe-react-gateway-sdk' +import { checkIfSafeNeedsUpdate, hasFeature, isValidMasterCopy } from 'src/logic/safe/utils/safeVersion' describe('Check safe version', () => { it('Calls checkIfSafeNeedUpdate, should return true if the safe version is bellow the target one', async () => { @@ -36,4 +37,50 @@ describe('Check safe version', () => { expect(hasFeature(FEATURES.SAFE_APPS, '1.1.1')).toBe(true) }) }) + + describe('isValidMasterCopy', () => { + it('returns false if address is not contained in result', async () => { + jest.spyOn(GatewaySDK, 'getMasterCopies').mockImplementation(() => + Promise.resolve([ + { + address: '0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552', + version: '1.3.0', + deployer: 'Gnosis', + deployedBlockNumber: 12504268, + lastIndexedBlockNumber: 14927028, + l2: false, + }, + ]), + ) + + const isValid = await isValidMasterCopy('1', '0x0000000000000000000000000000000000000005') + expect(isValid).toBe(false) + }) + + it('returns true if address is contained in list', async () => { + jest.spyOn(GatewaySDK, 'getMasterCopies').mockImplementation(() => + Promise.resolve([ + { + address: '0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552', + version: '1.3.0', + deployer: 'Gnosis', + deployedBlockNumber: 12504268, + lastIndexedBlockNumber: 14927028, + l2: false, + }, + { + address: '0x3E5c63644E683549055b9Be8653de26E0B4CD36E', + version: '1.3.0+L2', + deployer: 'Gnosis', + deployedBlockNumber: 12504423, + lastIndexedBlockNumber: 14927028, + l2: true, + }, + ]), + ) + + const isValid = await isValidMasterCopy('1', '0x3E5c63644E683549055b9Be8653de26E0B4CD36E') + expect(isValid).toBe(true) + }) + }) }) diff --git a/src/logic/safe/utils/__tests__/shouldSafeStoreBeUpdated.test.ts b/src/logic/safe/utils/__tests__/shouldSafeStoreBeUpdated.test.ts index 6b6a60787b..fd5ec47f99 100644 --- a/src/logic/safe/utils/__tests__/shouldSafeStoreBeUpdated.test.ts +++ b/src/logic/safe/utils/__tests__/shouldSafeStoreBeUpdated.test.ts @@ -35,6 +35,11 @@ const getMockedOldSafe = ({ { tokenAddress: mockedActiveTokenAddress1, tokenBalance: '100' }, { tokenAddress: mockedActiveTokenAddress2, tokenBalance: '10' }, ], + implementation: { + value: '', + name: null, + logoUri: null, + }, loaded: true, nonce: nonce || 2, recurringUser: recurringUser || false, diff --git a/src/logic/safe/utils/safeVersion.ts b/src/logic/safe/utils/safeVersion.ts index 0c853357f1..b3821d8864 100644 --- a/src/logic/safe/utils/safeVersion.ts +++ b/src/logic/safe/utils/safeVersion.ts @@ -1,13 +1,14 @@ import semverLessThan from 'semver/functions/lt' import semverSatisfies from 'semver/functions/satisfies' import semverValid from 'semver/functions/valid' -import { FEATURES } from '@gnosis.pm/safe-react-gateway-sdk' +import { FEATURES, getMasterCopies } from '@gnosis.pm/safe-react-gateway-sdk' import { GnosisSafe } from 'src/types/contracts/gnosis_safe.d' import { getSafeMasterContract } from 'src/logic/contracts/safeContracts' import { LATEST_SAFE_VERSION } from 'src/utils/constants' import { Errors, logError } from 'src/logic/exceptions/CodedException' import { getChainInfo } from 'src/config' +import { sameAddress } from 'src/logic/wallets/ethAddresses' const FEATURES_BY_VERSION: Record = { [FEATURES.SAFE_TX_GAS_OPTIONAL]: '>=1.3.0', @@ -86,3 +87,11 @@ export const getSafeVersionInfo = async (safeVersion: string): Promise => { + const supportedMasterCopies = await getMasterCopies(chainId) + + return supportedMasterCopies.some((supportedMasterCopy) => + sameAddress(supportedMasterCopy.address, masterCopyAddress), + ) +}