From 91f05660c8b33d769e52a9e7aa933c18e9a20130 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Thu, 19 Sep 2024 15:47:22 +0200 Subject: [PATCH] [Multichain] Feat: multichain feature toggle (#4209) - add multichain feature toggle - do not offer multichain creation with toggle off - do not include migration if feature toggle is off --- .../NetworkSelector/NetworkMultiSelector.tsx | 13 +- .../new-safe/create/logic/index.test.ts | 322 ++++++++++++------ src/components/new-safe/create/logic/index.ts | 22 +- .../create/steps/ReviewStep/index.tsx | 3 +- .../MyAccounts/utils/multiChainSafe.ts | 11 +- src/utils/chains.ts | 1 + 6 files changed, 260 insertions(+), 112 deletions(-) diff --git a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx index 0eae24e1e8..6cd39c69f3 100644 --- a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx +++ b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx @@ -12,6 +12,7 @@ import { SetNameStepFields } from '@/components/new-safe/create/steps/SetNameSte 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, @@ -55,12 +56,22 @@ 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 + // Check required feature toggles + if (!hasMultiChainCreationFeatures(optionNetwork) || !hasMultiChainCreationFeatures(firstSelectedNetwork)) { + return true + } + + // Check if required deployments are available const optionHasCanonicalSingletonDeployment = hasCanonicalDeployment( getSafeSingletonDeployments({ network: optionNetwork.chainId, diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index b8a4545dd0..eb0f5d3036 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -5,7 +5,12 @@ import type { CompatibilityFallbackHandlerContractImplementationType } from '@sa import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import * as web3 from '@/hooks/wallets/web3' import * as sdkHelpers from '@/services/tx/tx-sender/sdk' -import { relaySafeCreation, getRedirect } from '@/components/new-safe/create/logic/index' +import { + relaySafeCreation, + getRedirect, + createNewUndeployedSafeWithoutSalt, + SAFE_TO_L2_SETUP_INTERFACE, +} from '@/components/new-safe/create/logic/index' import { relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' import { @@ -23,6 +28,13 @@ import { type FEATURES as GatewayFeatures } from '@safe-global/safe-gateway-type import { chainBuilder } from '@/tests/builders/chains' import { type ReplayedSafeProps } from '@/store/slices' import { faker } from '@faker-js/faker' +import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' +import { + getFallbackHandlerDeployment, + getProxyFactoryDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, +} from '@safe-global/safe-deployments' const provider = new JsonRpcProvider(undefined, { name: 'ethereum', chainId: 1 }) @@ -32,114 +44,115 @@ const latestSafeVersion = getLatestSafeVersion( .build(), ) -describe('createNewSafeViaRelayer', () => { - const owner1 = toBeHex('0x1', 20) - const owner2 = toBeHex('0x2', 20) +describe('create/logic', () => { + describe('createNewSafeViaRelayer', () => { + const owner1 = toBeHex('0x1', 20) + const owner2 = toBeHex('0x2', 20) + + const mockChainInfo = chainBuilder() + .with({ + chainId: '1', + l2: false, + features: [FEATURES.SAFE_141 as unknown as GatewayFeatures], + }) + .build() - const mockChainInfo = chainBuilder() - .with({ - chainId: '1', - l2: false, - features: [FEATURES.SAFE_141 as unknown as GatewayFeatures], + beforeAll(() => { + jest.resetAllMocks() + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => provider) }) - .build() - beforeAll(() => { - jest.resetAllMocks() - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => provider) - }) + it('returns taskId if create Safe successfully relayed', async () => { + const mockSafeProvider = { + getExternalProvider: jest.fn(), + getExternalSigner: jest.fn(), + getChainId: jest.fn().mockReturnValue(BigInt(1)), + } as unknown as SafeProvider - it('returns taskId if create Safe successfully relayed', async () => { - const mockSafeProvider = { - getExternalProvider: jest.fn(), - getExternalSigner: jest.fn(), - getChainId: jest.fn().mockReturnValue(BigInt(1)), - } as unknown as SafeProvider - - jest.spyOn(gateway, 'relayTransaction').mockResolvedValue({ taskId: '0x123' }) - jest.spyOn(sdkHelpers, 'getSafeProvider').mockImplementation(() => mockSafeProvider) - - jest.spyOn(contracts, 'getReadOnlyFallbackHandlerContract').mockResolvedValue({ - getAddress: () => '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4', - } as unknown as CompatibilityFallbackHandlerContractImplementationType) - - const expectedSaltNonce = 69 - const expectedThreshold = 1 - const proxyFactoryAddress = await (await getReadOnlyProxyFactoryContract(latestSafeVersion)).getAddress() - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(latestSafeVersion) - const safeContractAddress = await ( - await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) - ).getAddress() - - const undeployedSafeProps: ReplayedSafeProps = { - safeAccountConfig: { - owners: [owner1, owner2], - threshold: 1, - data: EMPTY_DATA, - to: ZERO_ADDRESS, - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - paymentReceiver: ZERO_ADDRESS, - payment: 0, - paymentToken: ZERO_ADDRESS, - }, - safeVersion: latestSafeVersion, - factoryAddress: proxyFactoryAddress, - masterCopy: safeContractAddress, - saltNonce: '69', - } - - const expectedInitializer = Gnosis_safe__factory.createInterface().encodeFunctionData('setup', [ - [owner1, owner2], - expectedThreshold, - ZERO_ADDRESS, - EMPTY_DATA, - await readOnlyFallbackHandlerContract.getAddress(), - ZERO_ADDRESS, - 0, - ZERO_ADDRESS, - ]) - - const expectedCallData = Proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ - safeContractAddress, - expectedInitializer, - expectedSaltNonce, - ]) - - const taskId = await relaySafeCreation(mockChainInfo, undeployedSafeProps) - - expect(taskId).toEqual('0x123') - expect(relayTransaction).toHaveBeenCalledTimes(1) - expect(relayTransaction).toHaveBeenCalledWith('1', { - to: proxyFactoryAddress, - data: expectedCallData, - version: latestSafeVersion, + jest.spyOn(gateway, 'relayTransaction').mockResolvedValue({ taskId: '0x123' }) + jest.spyOn(sdkHelpers, 'getSafeProvider').mockImplementation(() => mockSafeProvider) + + jest.spyOn(contracts, 'getReadOnlyFallbackHandlerContract').mockResolvedValue({ + getAddress: () => '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4', + } as unknown as CompatibilityFallbackHandlerContractImplementationType) + + const expectedSaltNonce = 69 + const expectedThreshold = 1 + const proxyFactoryAddress = await (await getReadOnlyProxyFactoryContract(latestSafeVersion)).getAddress() + const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(latestSafeVersion) + const safeContractAddress = await ( + await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) + ).getAddress() + + const undeployedSafeProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [owner1, owner2], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), + paymentReceiver: ZERO_ADDRESS, + payment: 0, + paymentToken: ZERO_ADDRESS, + }, + safeVersion: latestSafeVersion, + factoryAddress: proxyFactoryAddress, + masterCopy: safeContractAddress, + saltNonce: '69', + } + + const expectedInitializer = Gnosis_safe__factory.createInterface().encodeFunctionData('setup', [ + [owner1, owner2], + expectedThreshold, + ZERO_ADDRESS, + EMPTY_DATA, + await readOnlyFallbackHandlerContract.getAddress(), + ZERO_ADDRESS, + 0, + ZERO_ADDRESS, + ]) + + const expectedCallData = Proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + safeContractAddress, + expectedInitializer, + expectedSaltNonce, + ]) + + const taskId = await relaySafeCreation(mockChainInfo, undeployedSafeProps) + + expect(taskId).toEqual('0x123') + expect(relayTransaction).toHaveBeenCalledTimes(1) + expect(relayTransaction).toHaveBeenCalledWith('1', { + to: proxyFactoryAddress, + data: expectedCallData, + version: latestSafeVersion, + }) }) - }) - it('should throw an error if relaying fails', () => { - const relayFailedError = new Error('Relay failed') - jest.spyOn(gateway, 'relayTransaction').mockRejectedValue(relayFailedError) + it('should throw an error if relaying fails', () => { + const relayFailedError = new Error('Relay failed') + jest.spyOn(gateway, 'relayTransaction').mockRejectedValue(relayFailedError) - const undeployedSafeProps: ReplayedSafeProps = { - safeAccountConfig: { - owners: [owner1, owner2], - threshold: 1, - data: EMPTY_DATA, - to: ZERO_ADDRESS, - fallbackHandler: faker.finance.ethereumAddress(), - paymentReceiver: ZERO_ADDRESS, - payment: 0, - paymentToken: ZERO_ADDRESS, - }, - safeVersion: latestSafeVersion, - factoryAddress: faker.finance.ethereumAddress(), - masterCopy: faker.finance.ethereumAddress(), - saltNonce: '69', - } - - expect(relaySafeCreation(mockChainInfo, undeployedSafeProps)).rejects.toEqual(relayFailedError) - }) + const undeployedSafeProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [owner1, owner2], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: faker.finance.ethereumAddress(), + paymentReceiver: ZERO_ADDRESS, + payment: 0, + paymentToken: ZERO_ADDRESS, + }, + safeVersion: latestSafeVersion, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '69', + } + expect(relaySafeCreation(mockChainInfo, undeployedSafeProps)).rejects.toEqual(relayFailedError) + }) + }) describe('getRedirect', () => { it("should redirect to home for any redirect that doesn't start with /apps", () => { const expected = { @@ -162,4 +175,111 @@ describe('createNewSafeViaRelayer', () => { ) }) }) + + describe('createNewUndeployedSafeWithoutSalt', () => { + it('should throw errors if no deployments are found', () => { + expect(() => + createNewUndeployedSafeWithoutSalt( + '1.4.1', + { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + }, + chainBuilder().with({ chainId: 'NON_EXISTING' }).build(), + ), + ).toThrowError(new Error('No Safe deployment found')) + }) + + it('should use l1 masterCopy and no migration on l1s without multichain feature', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.4.1', + safeSetup, + chainBuilder() + .with({ chainId: '1' }) + // Multichain creation is toggled off + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL] as any }) + .with({ l2: false }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '1' })?.defaultAddress, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.4.1', + masterCopy: getSafeSingletonDeployment({ version: '1.4.1', network: '1' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '1' })?.defaultAddress, + }) + }) + + it('should use l2 masterCopy and no migration on l2s without multichain feature', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.4.1', + safeSetup, + chainBuilder() + .with({ chainId: '137' }) + // Multichain creation is toggled off + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL] as any }) + .with({ l2: true }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.4.1', + masterCopy: getSafeL2SingletonDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + }) + }) + + it('should use l1 masterCopy and migration on l2s with multichain feature', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.4.1', + safeSetup, + chainBuilder() + .with({ chainId: '137' }) + // Multichain creation is toggled off + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ l2: true }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + to: SAFE_TO_L2_SETUP_ADDRESS, + data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [ + getSafeL2SingletonDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + ]), + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.4.1', + masterCopy: getSafeSingletonDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + }) + }) + }) }) diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index 47a8140d68..8b03859cd6 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -25,6 +25,7 @@ import { activateReplayedSafe, isPredictedSafeProps } from '@/features/counterfa import { getSafeContractDeployment } from '@/services/contracts/deployments' import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' import { createWeb3 } from '@/hooks/wallets/web3' +import { hasMultiChainCreationFeatures } from '@/components/welcome/MyAccounts/utils/multiChainSafe' export type SafeCreationProps = { owners: string[] @@ -202,36 +203,41 @@ export type UndeployedSafeWithoutSalt = Omit export const createNewUndeployedSafeWithoutSalt = ( safeVersion: SafeVersion, safeAccountConfig: Pick, - chainId: string, + chain: ChainInfo, ): UndeployedSafeWithoutSalt => { // Create universal deployment Data across chains: const fallbackHandlerDeployment = getCompatibilityFallbackHandlerDeployment({ version: safeVersion, - network: chainId, + network: chain.chainId, }) const fallbackHandlerAddress = fallbackHandlerDeployment?.defaultAddress - const safeL2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chainId }) + const safeL2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) const safeL2Address = safeL2Deployment?.defaultAddress - const safeL1Deployment = getSafeSingletonDeployment({ version: safeVersion, network: chainId }) + const safeL1Deployment = getSafeSingletonDeployment({ version: safeVersion, network: chain.chainId }) const safeL1Address = safeL1Deployment?.defaultAddress - const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chainId }) + const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chain.chainId }) const safeFactoryAddress = safeFactoryDeployment?.defaultAddress if (!safeL2Address || !safeL1Address || !safeFactoryAddress || !fallbackHandlerAddress) { throw new Error('No Safe deployment found') } + // Only do migration if the chain supports multiChain deployments. + const includeMigration = hasMultiChainCreationFeatures(chain) + + const masterCopy = includeMigration ? safeL1Address : chain.l2 ? safeL2Address : safeL1Address + const replayedSafe: Omit = { factoryAddress: safeFactoryAddress, - masterCopy: safeL1Address, + masterCopy, safeAccountConfig: { threshold: safeAccountConfig.threshold, owners: safeAccountConfig.owners, fallbackHandler: fallbackHandlerAddress, - to: SAFE_TO_L2_SETUP_ADDRESS, - data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]), + to: includeMigration ? SAFE_TO_L2_SETUP_ADDRESS : ZERO_ADDRESS, + data: includeMigration ? SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]) : EMPTY_DATA, paymentReceiver: ECOSYSTEM_ID_ADDRESS, }, safeVersion, diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 4f149f0994..e3d87b916e 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -139,6 +139,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps(false) const [submitError, setSubmitError] = useState() const isCounterfactualEnabled = useHasFeature(FEATURES.COUNTERFACTUAL) + const isMultiChainDeploymentEnabled = useHasFeature(FEATURES.MULTI_CHAIN_SAFE_CREATION) const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) const ownerAddresses = useMemo(() => data.owners.map((owner) => owner.address), [data.owners]) @@ -159,7 +160,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps owner.address), threshold: data.threshold, }, - chain.chainId, + chain, ) : undefined, [chain, data.owners, data.safeVersion, data.threshold], diff --git a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts index 107d2ad12a..5d8d12ab1a 100644 --- a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts +++ b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts @@ -1,4 +1,4 @@ -import { type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { type ChainInfo, type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' import { type SafeItem } from '../useAllSafes' import { type UndeployedSafesState, type UndeployedSafe, type ReplayedSafeProps } from '@/store/slices' import { sameAddress } from '@/utils/addresses' @@ -8,6 +8,7 @@ import { keccak256, ethers, solidityPacked, getCreate2Address, type Provider } f import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' import { encodeSafeSetupCall } from '@/components/new-safe/create/logic' import { memoize } from 'lodash' +import { FEATURES, hasFeature } from '@/utils/chains' export const isMultiChainSafeItem = (safe: SafeItem | MultiChainSafeItem): safe is MultiChainSafeItem => { if ('safes' in safe && 'address' in safe) { @@ -126,3 +127,11 @@ export const predictAddressBasedOnReplayData = async (safeCreationData: Replayed const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2) return getCreate2Address(safeCreationData.factoryAddress, salt, keccak256(initCode)) } + +export const hasMultiChainCreationFeatures = (chain: ChainInfo): boolean => { + return ( + hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_CREATION) && + hasFeature(chain, FEATURES.COUNTERFACTUAL) && + hasFeature(chain, FEATURES.SAFE_141) + ) +} diff --git a/src/utils/chains.ts b/src/utils/chains.ts index 0e5179df62..74355ac115 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -35,6 +35,7 @@ export enum FEATURES { ZODIAC_ROLES = 'ZODIAC_ROLES', SAFE_141 = 'SAFE_141', STAKING = 'STAKING', + MULTI_CHAIN_SAFE_CREATION = 'MULTI_CHAIN_SAFE_CREATION', } export const FeatureRoutes = {