diff --git a/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts b/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts index 172c33ebbe..42bb6210bb 100644 --- a/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts +++ b/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts @@ -12,11 +12,22 @@ import { JsonRpcProvider } from 'ethers' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { waitFor } from '@testing-library/react' import { type EIP1193Provider } from '@web3-onboard/core' +import { type ReplayedSafeProps } from '@/store/slices' +import { faker } from '@faker-js/faker' -const mockProps = { - owners: [], - threshold: 1, - saltNonce: 1, +const mockProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: faker.finance.ethereumAddress(), + paymentReceiver: ZERO_ADDRESS, + }, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '0', + safeVersion: '1.3.0', } describe('useEstimateSafeCreationGas', () => { @@ -28,7 +39,7 @@ describe('useEstimateSafeCreationGas', () => { jest .spyOn(safeContracts, 'getReadOnlyProxyFactoryContract') .mockResolvedValue({ getAddress: () => ZERO_ADDRESS } as unknown as SafeProxyFactoryContractImplementationType) - jest.spyOn(sender, 'encodeSafeCreationTx').mockReturnValue(Promise.resolve(EMPTY_DATA)) + jest.spyOn(sender, 'encodeSafeCreationTx').mockReturnValue(EMPTY_DATA) jest.spyOn(wallet, 'default').mockReturnValue({} as ConnectedWallet) }) diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index 4c94a353a6..b8a4545dd0 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -2,10 +2,10 @@ import { JsonRpcProvider } from 'ethers' import * as contracts from '@/services/contracts/safeContracts' import type { SafeProvider } from '@safe-global/protocol-kit' import type { CompatibilityFallbackHandlerContractImplementationType } from '@safe-global/protocol-kit/dist/src/types' -import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +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 { SAFE_TO_L2_SETUP_INTERFACE, relaySafeCreation, getRedirect } from '@/components/new-safe/create/logic/index' +import { relaySafeCreation, getRedirect } from '@/components/new-safe/create/logic/index' import { relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' import { @@ -21,8 +21,8 @@ import * as gateway from '@safe-global/safe-gateway-typescript-sdk' import { FEATURES, getLatestSafeVersion } from '@/utils/chains' import { type FEATURES as GatewayFeatures } from '@safe-global/safe-gateway-typescript-sdk' import { chainBuilder } from '@/tests/builders/chains' -import { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' -import { SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' +import { type ReplayedSafeProps } from '@/store/slices' +import { faker } from '@faker-js/faker' const provider = new JsonRpcProvider(undefined, { name: 'ethereum', chainId: 1 }) @@ -70,13 +70,29 @@ describe('createNewSafeViaRelayer', () => { const safeContractAddress = await ( await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) ).getAddress() - const l2Deployment = getSafeL2SingletonDeployment({ version: latestSafeVersion, network: mockChainInfo.chainId }) + + 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, - SAFE_TO_L2_SETUP_ADDRESS, - SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [l2Deployment?.defaultAddress]), + ZERO_ADDRESS, + EMPTY_DATA, await readOnlyFallbackHandlerContract.getAddress(), ZERO_ADDRESS, 0, @@ -89,7 +105,7 @@ describe('createNewSafeViaRelayer', () => { expectedSaltNonce, ]) - const taskId = await relaySafeCreation(mockChainInfo, [owner1, owner2], expectedThreshold, expectedSaltNonce) + const taskId = await relaySafeCreation(mockChainInfo, undeployedSafeProps) expect(taskId).toEqual('0x123') expect(relayTransaction).toHaveBeenCalledTimes(1) @@ -104,7 +120,24 @@ describe('createNewSafeViaRelayer', () => { const relayFailedError = new Error('Relay failed') jest.spyOn(gateway, 'relayTransaction').mockRejectedValue(relayFailedError) - expect(relaySafeCreation(mockChainInfo, [owner1, owner2], 1, 69)).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', () => { diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index f6ffa70ece..47a8140d68 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -2,25 +2,29 @@ import type { SafeVersion } from '@safe-global/safe-core-sdk-types' import { Interface, type Eip1193Provider, type Provider } from 'ethers' import { getSafeInfo, type SafeInfo, type ChainInfo, relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' -import { - getReadOnlyFallbackHandlerContract, - getReadOnlyGnosisSafeContract, - getReadOnlyProxyFactoryContract, -} from '@/services/contracts/safeContracts' +import { getReadOnlyProxyFactoryContract } from '@/services/contracts/safeContracts' import type { UrlObject } from 'url' import { AppRoutes } from '@/config/routes' import { SAFE_APPS_EVENTS, trackEvent } from '@/services/analytics' import { predictSafeAddress, SafeFactory, SafeProvider } from '@safe-global/protocol-kit' -import type Safe from '@safe-global/protocol-kit' -import type { DeploySafeProps } from '@safe-global/protocol-kit' +import type { DeploySafeProps, PredictedSafeProps } from '@safe-global/protocol-kit' import { isValidSafeVersion } from '@/hooks/coreSDK/safeCoreSDK' import { backOff } from 'exponential-backoff' -import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { getLatestSafeVersion } from '@/utils/chains' -import { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' +import { + getCompatibilityFallbackHandlerDeployment, + getProxyFactoryDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, +} from '@safe-global/safe-deployments' import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' -import { type ReplayedSafeProps } from '@/store/slices' +import type { ReplayedSafeProps, UndeployedSafeProps } from '@/store/slices' +import { activateReplayedSafe, isPredictedSafeProps } from '@/features/counterfactual/utils' +import { getSafeContractDeployment } from '@/services/contracts/deployments' +import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' +import { createWeb3 } from '@/hooks/wallets/web3' export type SafeCreationProps = { owners: string[] @@ -44,12 +48,20 @@ const getSafeFactory = async ( */ export const createNewSafe = async ( provider: Eip1193Provider, - props: DeploySafeProps, + undeployedSafeProps: UndeployedSafeProps, safeVersion: SafeVersion, + chain: ChainInfo, + callback: (txHash: string) => void, isL1SafeSingleton?: boolean, -): Promise => { +): Promise => { const safeFactory = await getSafeFactory(provider, safeVersion, isL1SafeSingleton) - return safeFactory.deploySafe(props) + + if (isPredictedSafeProps(undeployedSafeProps)) { + await safeFactory.deploySafe({ ...undeployedSafeProps, callback }) + } else { + const txResponse = await activateReplayedSafe(chain, undeployedSafeProps, createWeb3(provider)) + callback(txResponse.hash) + } } /** @@ -77,50 +89,30 @@ export const computeNewSafeAddress = async ( export const SAFE_TO_L2_SETUP_INTERFACE = new Interface(['function setupToL2(address l2Singleton)']) +export const encodeSafeSetupCall = (safeAccountConfig: ReplayedSafeProps['safeAccountConfig']) => { + return Safe__factory.createInterface().encodeFunctionData('setup', [ + safeAccountConfig.owners, + safeAccountConfig.threshold, + safeAccountConfig.to, + safeAccountConfig.data, + safeAccountConfig.fallbackHandler, + ZERO_ADDRESS, + 0, + safeAccountConfig.paymentReceiver, + ]) +} + /** * Encode a Safe creation transaction NOT using the Core SDK because it doesn't support that * This is used for gas estimation. */ -export const encodeSafeCreationTx = async ({ - owners, - threshold, - saltNonce, - chain, - safeVersion, -}: SafeCreationProps & { chain: ChainInfo; safeVersion?: SafeVersion; to?: string; data?: string }) => { - const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) - const readOnlyL1SafeContract = await getReadOnlyGnosisSafeContract(chain, usedSafeVersion, true) - const l2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) - const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion) - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(usedSafeVersion) - - const callData = { - owners, - threshold, - to: SAFE_TO_L2_SETUP_ADDRESS, - data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [l2Deployment?.defaultAddress]), - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - paymentToken: ZERO_ADDRESS, - payment: 0, - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - } +export const encodeSafeCreationTx = (undeployedSafe: UndeployedSafeProps, chain: ChainInfo) => { + const replayedSafeProps = assertNewUndeployedSafeProps(undeployedSafe, chain) - // @ts-ignore union type is too complex - const setupData = readOnlySafeContract.encode('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - - return readOnlyProxyContract.encode('createProxyWithNonce', [ - await readOnlyL1SafeContract.getAddress(), // always L1 Mastercopy - setupData, - BigInt(saltNonce), + return Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + replayedSafeProps.masterCopy, + encodeSafeSetupCall(replayedSafeProps.safeAccountConfig), + BigInt(replayedSafeProps.saltNonce), ]) } @@ -128,11 +120,11 @@ export const estimateSafeCreationGas = async ( chain: ChainInfo, provider: Provider, from: string, - safeParams: SafeCreationProps, + undeployedSafe: UndeployedSafeProps, safeVersion?: SafeVersion, ): Promise => { const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(safeVersion ?? getLatestSafeVersion(chain)) - const encodedSafeCreationTx = await encodeSafeCreationTx({ ...safeParams, chain }) + const encodedSafeCreationTx = encodeSafeCreationTx(undeployedSafe, chain) const gas = await provider.estimateGas({ from, @@ -185,85 +177,119 @@ export const getRedirect = ( return redirectUrl + `${appendChar}safe=${address}` } -export const relaySafeCreation = async ( - chain: ChainInfo, - owners: string[], - threshold: number, - saltNonce: number, - version?: SafeVersion, -) => { - const latestSafeVersion = getLatestSafeVersion(chain) - - const safeVersion = version ?? latestSafeVersion - - const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(safeVersion) - const proxyFactoryAddress = await readOnlyProxyFactoryContract.getAddress() - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(safeVersion) - const fallbackHandlerAddress = await readOnlyFallbackHandlerContract.getAddress() - const readOnlyL1SafeContract = await getReadOnlyGnosisSafeContract(chain, safeVersion, true) - const safeContractAddress = await readOnlyL1SafeContract.getAddress() - const l2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) - - const callData = { - owners, - threshold, - to: SAFE_TO_L2_SETUP_ADDRESS, - data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [l2Deployment?.defaultAddress]), - fallbackHandler: fallbackHandlerAddress, - paymentToken: ZERO_ADDRESS, - payment: 0, - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - } +export const relaySafeCreation = async (chain: ChainInfo, undeployedSafeProps: UndeployedSafeProps) => { + const replayedSafeProps = assertNewUndeployedSafeProps(undeployedSafeProps, chain) + const encodedSafeCreationTx = encodeSafeCreationTx(replayedSafeProps, chain) - // @ts-ignore - const initializer = readOnlyL1SafeContract.encode('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) + const relayResponse = await relayTransaction(chain.chainId, { + to: replayedSafeProps.factoryAddress, + data: encodedSafeCreationTx, + version: replayedSafeProps.safeVersion, + }) - const createProxyWithNonceCallData = readOnlyProxyFactoryContract.encode('createProxyWithNonce', [ - safeContractAddress, - initializer, - BigInt(saltNonce), - ]) + return relayResponse.taskId +} - const relayResponse = await relayTransaction(chain.chainId, { - to: proxyFactoryAddress, - data: createProxyWithNonceCallData, +export type UndeployedSafeWithoutSalt = Omit + +/** + * Creates a new undeployed Safe without default config: + * + * Always use the L1 MasterCopy and add a migration to L2 in to the setup. + * Use our ecosystem ID as paymentReceiver. + * + */ +export const createNewUndeployedSafeWithoutSalt = ( + safeVersion: SafeVersion, + safeAccountConfig: Pick, + chainId: string, +): UndeployedSafeWithoutSalt => { + // Create universal deployment Data across chains: + const fallbackHandlerDeployment = getCompatibilityFallbackHandlerDeployment({ version: safeVersion, + network: chainId, }) + const fallbackHandlerAddress = fallbackHandlerDeployment?.defaultAddress + const safeL2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chainId }) + const safeL2Address = safeL2Deployment?.defaultAddress - return relayResponse.taskId + const safeL1Deployment = getSafeSingletonDeployment({ version: safeVersion, network: chainId }) + const safeL1Address = safeL1Deployment?.defaultAddress + + const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chainId }) + const safeFactoryAddress = safeFactoryDeployment?.defaultAddress + + if (!safeL2Address || !safeL1Address || !safeFactoryAddress || !fallbackHandlerAddress) { + throw new Error('No Safe deployment found') + } + + const replayedSafe: Omit = { + factoryAddress: safeFactoryAddress, + masterCopy: safeL1Address, + safeAccountConfig: { + threshold: safeAccountConfig.threshold, + owners: safeAccountConfig.owners, + fallbackHandler: fallbackHandlerAddress, + to: SAFE_TO_L2_SETUP_ADDRESS, + data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]), + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion, + } + + return replayedSafe } -export const relayReplayedSafeCreation = async ( - chain: ChainInfo, - replayedSafe: ReplayedSafeProps, - safeVersion: SafeVersion | undefined, -) => { - const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) - const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion, replayedSafe.factoryAddress) - - if (!replayedSafe.masterCopy || !replayedSafe.setupData) { - throw Error('Cannot replay Safe without deployment info') +/** + * Migrates a counterfactual Safe from the pre multichain era to the new predicted Safe data + * @param predictedSafeProps + * @param chain + * @returns + */ +export const migrateLegacySafeProps = (predictedSafeProps: PredictedSafeProps, chain: ChainInfo): ReplayedSafeProps => { + const safeVersion = predictedSafeProps.safeDeploymentConfig?.safeVersion + const saltNonce = predictedSafeProps.safeDeploymentConfig?.saltNonce + const { chainId } = chain + if (!safeVersion || !saltNonce) { + throw new Error('Undeployed Safe with incomplete data.') } - const createProxyWithNonceCallData = readOnlyProxyContract.encode('createProxyWithNonce', [ - replayedSafe.masterCopy, - replayedSafe.setupData, - BigInt(replayedSafe.saltNonce), - ]) - const relayResponse = await relayTransaction(chain.chainId, { - to: replayedSafe.factoryAddress, - data: createProxyWithNonceCallData, - version: usedSafeVersion, + const fallbackHandlerDeployment = getCompatibilityFallbackHandlerDeployment({ + version: safeVersion, + network: chainId, }) + const fallbackHandlerAddress = fallbackHandlerDeployment?.defaultAddress - return relayResponse.taskId + const masterCopyDeployment = getSafeContractDeployment(chain, safeVersion) + const masterCopyAddress = masterCopyDeployment?.defaultAddress + + const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chainId }) + const safeFactoryAddress = safeFactoryDeployment?.defaultAddress + + if (!masterCopyAddress || !safeFactoryAddress || !fallbackHandlerAddress) { + throw new Error('No Safe deployment found') + } + + return { + factoryAddress: safeFactoryAddress, + masterCopy: masterCopyAddress, + safeAccountConfig: { + threshold: predictedSafeProps.safeAccountConfig.threshold, + owners: predictedSafeProps.safeAccountConfig.owners, + fallbackHandler: predictedSafeProps.safeAccountConfig.fallbackHandler ?? fallbackHandlerAddress, + to: predictedSafeProps.safeAccountConfig.to ?? ZERO_ADDRESS, + data: predictedSafeProps.safeAccountConfig.data ?? EMPTY_DATA, + paymentReceiver: predictedSafeProps.safeAccountConfig.paymentReceiver ?? ZERO_ADDRESS, + }, + safeVersion, + saltNonce, + } +} + +export const assertNewUndeployedSafeProps = (props: UndeployedSafeProps, chain: ChainInfo): ReplayedSafeProps => { + if (isPredictedSafeProps(props)) { + return migrateLegacySafeProps(props, chain) + } + + return props } diff --git a/src/components/new-safe/create/logic/utils.test.ts b/src/components/new-safe/create/logic/utils.test.ts index 3185464f47..700c081b24 100644 --- a/src/components/new-safe/create/logic/utils.test.ts +++ b/src/components/new-safe/create/logic/utils.test.ts @@ -1,14 +1,22 @@ import * as creationUtils from '@/components/new-safe/create/logic/index' import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' -import * as walletUtils from '@/utils/wallets' import { faker } from '@faker-js/faker' -import type { DeploySafeProps } from '@safe-global/protocol-kit' import { chainBuilder } from '@/tests/builders/chains' +import { type ReplayedSafeProps } from '@/store/slices' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import * as web3Hooks from '@/hooks/wallets/web3' +import { type JsonRpcProvider, id } from 'ethers' +import { Safe_proxy_factory__factory } from '@/types/contracts' +import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' + +// Proxy Factory 1.3.0 creation code +const mockProxyCreationCode = + '0x608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea2646970667358221220d1429297349653a4918076d650332de1a1068c5f3e07c5c82360c277770b955264736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f7669646564' describe('getAvailableSaltNonce', () => { jest.spyOn(creationUtils, 'computeNewSafeAddress').mockReturnValue(Promise.resolve(faker.finance.ethereumAddress())) - let mockDeployProps: DeploySafeProps + let mockDeployProps: ReplayedSafeProps beforeAll(() => { mockDeployProps = { @@ -16,7 +24,16 @@ describe('getAvailableSaltNonce', () => { threshold: 1, owners: [faker.finance.ethereumAddress()], fallbackHandler: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ casing: 'lower', length: 64 }), + to: faker.finance.ethereumAddress(), + paymentReceiver: faker.finance.ethereumAddress(), + payment: 0, + paymentToken: ZERO_ADDRESS, }, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + safeVersion: '1.4.1', + saltNonce: '0', } }) @@ -25,9 +42,22 @@ describe('getAvailableSaltNonce', () => { }) it('should return initial nonce if no contract is deployed to the computed address', async () => { - jest.spyOn(walletUtils, 'isSmartContract').mockReturnValue(Promise.resolve(false)) + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockReturnValue({ + getCode: jest.fn().mockReturnValue('0x'), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider) + const initialNonce = faker.string.numeric() - const mockChain = chainBuilder().build() + const mockChain = chainBuilder().with({ chainId: '1' }).build() const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) @@ -35,16 +65,108 @@ describe('getAvailableSaltNonce', () => { }) it('should return an increased nonce if a contract is deployed to the computed address', async () => { - jest.spyOn(walletUtils, 'isSmartContract').mockReturnValueOnce(Promise.resolve(true)) + let requiredTries = 3 + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockReturnValue({ + getCode: jest + .fn() + .mockImplementation(() => (requiredTries-- > 0 ? faker.string.hexadecimal({ length: 64 }) : '0x')), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider) + const initialNonce = faker.string.numeric() + const mockChain = chainBuilder().with({ chainId: '1' }).build() + const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) + + expect(result).toEqual((Number(initialNonce) + 3).toString()) + }) + + it('should skip known addresses without checking getCode', async () => { + const mockProvider = { + getCode: jest.fn().mockImplementation(() => '0x'), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider const initialNonce = faker.string.numeric() + + const replayedProps = { ...mockDeployProps, saltNonce: initialNonce } + const knownAddresses = [await predictAddressBasedOnReplayData(replayedProps, mockProvider)] + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockReturnValue(mockProvider) const mockChain = chainBuilder().build() + const result = await getAvailableSaltNonce({}, replayedProps, [mockChain], knownAddresses) - const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) + // The known address (initialNonce) will be skipped + expect(result).toEqual((Number(initialNonce) + 1).toString()) + expect(mockProvider.getCode).toHaveBeenCalledTimes(1) + }) + + it('should check cross chain', async () => { + const mockMainnet = chainBuilder().with({ chainId: '1' }).build() + const mockGnosis = chainBuilder().with({ chainId: '100' }).build() + + // We mock that on GnosisChain the first nonce is already deployed + const mockGnosisProvider = { + getCode: jest.fn().mockImplementation(() => '0x'), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '100' }), + } as unknown as JsonRpcProvider + + // We Mock that on Mainnet the first two nonces are already deployed + let mainnetTriesRequired = 2 + const mockMainnetProvider = { + getCode: jest + .fn() + .mockImplementation(() => (mainnetTriesRequired-- > 0 ? faker.string.hexadecimal({ length: 64 }) : '0x')), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider + const initialNonce = faker.string.numeric() - jest.spyOn(walletUtils, 'isSmartContract').mockReturnValueOnce(Promise.resolve(false)) + const replayedProps = { ...mockDeployProps, saltNonce: initialNonce } + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockImplementation((chain) => { + if (chain.chainId === '100') { + return mockGnosisProvider + } + if (chain.chainId === '1') { + return mockMainnetProvider + } + throw new Error('Web3Provider not found') + }) - const increasedNonce = (Number(initialNonce) + 1).toString() + const result = await getAvailableSaltNonce({}, replayedProps, [mockMainnet, mockGnosis], []) - expect(result).toEqual(increasedNonce) + // The known address (initialNonce) will be skipped + expect(result).toEqual((Number(initialNonce) + 2).toString()) }) }) diff --git a/src/components/new-safe/create/logic/utils.ts b/src/components/new-safe/create/logic/utils.ts index 4a8f254234..fbc5a69bc3 100644 --- a/src/components/new-safe/create/logic/utils.ts +++ b/src/components/new-safe/create/logic/utils.ts @@ -1,20 +1,18 @@ -import { computeNewSafeAddress } from '@/components/new-safe/create/logic/index' import { isSmartContract } from '@/utils/wallets' -import type { DeploySafeProps } from '@safe-global/protocol-kit' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' import { sameAddress } from '@/utils/addresses' import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' +import { type ReplayedSafeProps } from '@/store/slices' +import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' export const getAvailableSaltNonce = async ( customRpcs: { [chainId: string]: string }, - props: DeploySafeProps, + replayedSafe: ReplayedSafeProps, chains: ChainInfo[], // All addresses from the sidebar disregarding the chain. This is an optimization to reduce RPC calls knownSafeAddresses: string[], - safeVersion?: SafeVersion, ): Promise => { let isAvailableOnAllChains = true const allRPCs = chains.map((chain) => { @@ -31,9 +29,13 @@ export const getAvailableSaltNonce = async ( if (!rpcUrl) { throw new Error(`No RPC available for ${chain.chainName}`) } - const safeAddress = await computeNewSafeAddress(rpcUrl, props, chain, safeVersion) + const web3ReadOnly = createWeb3ReadOnly(chain, rpcUrl) + if (!web3ReadOnly) { + throw new Error('Could not initiate RPC') + } + const safeAddress = await predictAddressBasedOnReplayData(replayedSafe, web3ReadOnly) const isKnown = knownSafeAddresses.some((knownAddress) => sameAddress(knownAddress, safeAddress)) - if (isKnown || (await isSmartContract(safeAddress, createWeb3ReadOnly(chain, rpcUrl)))) { + if (isKnown || (await isSmartContract(safeAddress, web3ReadOnly))) { // We found a chain where the nonce is used up isAvailableOnAllChains = false break @@ -44,13 +46,11 @@ export const getAvailableSaltNonce = async ( if (!isAvailableOnAllChains) { return getAvailableSaltNonce( customRpcs, - { ...props, saltNonce: (Number(props.saltNonce) + 1).toString() }, + { ...replayedSafe, saltNonce: (Number(replayedSafe.saltNonce) + 1).toString() }, chains, knownSafeAddresses, - safeVersion, ) } - // We know that there will be a saltNonce but the type has it as optional - return props.saltNonce! + return replayedSafe.saltNonce } diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index bb15d9380c..4f149f0994 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -5,10 +5,9 @@ import { getTotalFeeFormatted } from '@/hooks/useGasPrice' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' import { - computeNewSafeAddress, createNewSafe, + createNewUndeployedSafeWithoutSalt, relaySafeCreation, - SAFE_TO_L2_SETUP_INTERFACE, } from '@/components/new-safe/create/logic' import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' import css from '@/components/new-safe/create/steps/ReviewStep/styles.module.css' @@ -19,7 +18,7 @@ import ReviewRow from '@/components/new-safe/ReviewRow' import ErrorMessage from '@/components/tx/ErrorMessage' import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' import PayNowPayLater, { PayMethod } from '@/features/counterfactual/PayNowPayLater' -import { CF_TX_GROUP_KEY, createCounterfactualSafe } from '@/features/counterfactual/utils' +import { CF_TX_GROUP_KEY, replayCounterfactualSafeDeployment } from '@/features/counterfactual/utils' import { useCurrentChain, useHasFeature } from '@/hooks/useChains' import useGasPrice from '@/hooks/useGasPrice' import useIsWrongChain from '@/hooks/useIsWrongChain' @@ -28,7 +27,6 @@ import useWalletCanPay from '@/hooks/useWalletCanPay' import useWallet from '@/hooks/wallets/useWallet' import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { gtmSetSafeAddress } from '@/services/analytics/gtm' -import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts' import { asError } from '@/services/exceptions/utils' import { useAppDispatch, useAppSelector } from '@/store' import { FEATURES, hasFeature } from '@/utils/chains' @@ -36,19 +34,20 @@ import { hasRemainingRelays } from '@/utils/relaying' import { isWalletRejection } from '@/utils/wallets' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import { Box, Button, CircularProgress, Divider, Grid, Typography } from '@mui/material' -import { type DeploySafeProps } from '@safe-global/protocol-kit' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import classnames from 'classnames' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' -import { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' -import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' import ChainIndicator from '@/components/common/ChainIndicator' import NetworkWarning from '../../NetworkWarning' import useAllSafes from '@/components/welcome/MyAccounts/useAllSafes' import { uniq } from 'lodash' import { selectRpc } from '@/store/settingsSlice' import { AppRoutes } from '@/config/routes' +import { type ReplayedSafeProps } from '@/store/slices' +import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { createWeb3 } from '@/hooks/wallets/web3' +import { type DeploySafeProps } from '@safe-global/protocol-kit' export const NetworkFee = ({ totalFee, @@ -151,15 +150,26 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { - return { - owners: data.owners.map((owner) => owner.address), - threshold: data.threshold, - saltNonce: Date.now(), // This is not the final saltNonce but easier to use and will only result in a slightly higher gas estimation - } - }, [data.owners, data.threshold]) + const newSafeProps = useMemo( + () => + chain + ? createNewUndeployedSafeWithoutSalt( + data.safeVersion, + { + owners: data.owners.map((owner) => owner.address), + threshold: data.threshold, + }, + chain.chainId, + ) + : undefined, + [chain, data.owners, data.safeVersion, data.threshold], + ) - const { gasLimit } = useEstimateSafeCreationGas(safeParams, data.safeVersion) + // We estimate with a random nonce as we'll just slightly overestimates like this + const { gasLimit } = useEstimateSafeCreationGas( + newSafeProps ? { ...newSafeProps, saltNonce: Date.now().toString() } : undefined, + data.safeVersion, + ) const maxFeePerGas = gasPrice?.maxFeePerGas const maxPriorityFeePerGas = gasPrice?.maxPriorityFeePerGas @@ -179,46 +189,24 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { try { - if (!wallet || !chain) return + if (!wallet || !chain || !newSafeProps) return setIsCreating(true) - // Create universal deployment Data across chains: - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(data.safeVersion) - const safeL2Deployment = getSafeL2SingletonDeployment({ version: data.safeVersion, network: chain.chainId }) - const safeL2Address = safeL2Deployment?.defaultAddress - if (!safeL2Address) { - throw new Error('No Safe deployment found') - } - - const props: DeploySafeProps = { - safeAccountConfig: { - threshold: data.threshold, - owners: data.owners.map((owner) => owner.address), - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - to: SAFE_TO_L2_SETUP_ADDRESS, - data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]), - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - }, - } // Figure out the shared available nonce across chains const nextAvailableNonce = await getAvailableSaltNonce( customRPCs, - { ...props, saltNonce: data.saltNonce.toString() }, + { ...newSafeProps, saltNonce: '0' }, data.networks, knownAddresses, - data.safeVersion, ) - const safeAddress = await computeNewSafeAddress( - wallet.provider, - { ...props, saltNonce: nextAvailableNonce }, - chain, - data.safeVersion, - ) + const replayedSafeWithNonce = { ...newSafeProps, saltNonce: nextAvailableNonce } + + const safeAddress = await predictAddressBasedOnReplayData(replayedSafeWithNonce, createWeb3(wallet.provider)) for (const network of data.networks) { - createSafe(network, props, safeAddress, nextAvailableNonce) + createSafe(network, replayedSafeWithNonce, safeAddress) } if (isCounterfactualEnabled && payMethod === PayMethod.PayLater) { @@ -234,12 +222,13 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { + const createSafe = async (chain: ChainInfo, props: ReplayedSafeProps, safeAddress: string) => { if (!wallet) return try { @@ -247,7 +236,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { // Create a counterfactual Safe - createCounterfactualSafe(chain, safeAddress, saltNonce, data, dispatch, props, PayMethod.PayNow) + replayCounterfactualSafeDeployment(chain.chainId, safeAddress, props, data.name, dispatch) if (taskId) { safeCreationDispatch(SafeCreationEvent.RELAYING, { groupKey: CF_TX_GROUP_KEY, taskId, safeAddress }) @@ -283,26 +272,17 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { - onSubmitCallback(undefined, txHash) - }, - }, + props, data.safeVersion, + chain, + (txHash) => { + onSubmitCallback(undefined, txHash) + }, true, ) } diff --git a/src/components/new-safe/create/useEstimateSafeCreationGas.ts b/src/components/new-safe/create/useEstimateSafeCreationGas.ts index e5870689b6..4adcef1f5b 100644 --- a/src/components/new-safe/create/useEstimateSafeCreationGas.ts +++ b/src/components/new-safe/create/useEstimateSafeCreationGas.ts @@ -2,11 +2,12 @@ import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import useWallet from '@/hooks/wallets/useWallet' import useAsync from '@/hooks/useAsync' import { useCurrentChain } from '@/hooks/useChains' -import { estimateSafeCreationGas, type SafeCreationProps } from '@/components/new-safe/create/logic' +import { estimateSafeCreationGas } from '@/components/new-safe/create/logic' import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import { type UndeployedSafeProps } from '@/store/slices' export const useEstimateSafeCreationGas = ( - safeParams: SafeCreationProps | undefined, + undeployedSafe: UndeployedSafeProps | undefined, safeVersion?: SafeVersion, ): { gasLimit?: bigint @@ -18,10 +19,10 @@ export const useEstimateSafeCreationGas = ( const wallet = useWallet() const [gasLimit, gasLimitError, gasLimitLoading] = useAsync(() => { - if (!wallet?.address || !chain || !web3ReadOnly || !safeParams) return + if (!wallet?.address || !chain || !web3ReadOnly || !undeployedSafe) return - return estimateSafeCreationGas(chain, web3ReadOnly, wallet.address, safeParams, safeVersion) - }, [wallet, chain, web3ReadOnly, safeParams, safeVersion]) + return estimateSafeCreationGas(chain, web3ReadOnly, wallet.address, undeployedSafe, safeVersion) + }, [wallet?.address, chain, web3ReadOnly, undeployedSafe, safeVersion]) return { gasLimit, gasLimitError, gasLimitLoading } } diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index ed5df1e7df..e0afb30ec5 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -43,7 +43,7 @@ import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sd import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' import { ChangeSignerSetupWarning } from '@/features/multichain/components/ChangeOwnerSetupWarning/ChangeOwnerSetupWarning' -import { isChangingSignerSetup } from '@/features/multichain/helpers/utils' +import { isChangingSignerSetup } from '@/features/multichain/utils/utils' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' export type SubmitCallback = (txId: string, isExecuted?: boolean) => void diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index 6b08bfea5b..7c524d6252 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -25,7 +25,7 @@ import type { SafeItem } from './useAllSafes' import FiatValue from '@/components/common/FiatValue' import QueueActions from './QueueActions' import { useGetHref } from './useGetHref' -import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' +import { extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features/counterfactual/utils' type AccountItemProps = { safeItem: SafeItem @@ -59,6 +59,8 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) ? extractCounterfactualSafeSetup(undeployedSafe, chain?.chainId) : undefined + const isReplayable = !safeItem.isWatchlist && (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props)) + return ( - + + safes.some((safeItem) => { + const undeployedSafe = undeployedSafes[safeItem.chainId]?.[safeItem.address] + // We can only replay deployed Safes and new counterfactual Safes. + return !undeployedSafe || !isPredictedSafeProps(undeployedSafe.props) + }), + [safes, undeployedSafes], + ) + const findOverview = (item: SafeItem) => { return safeOverviews?.find( (overview) => item.chainId === overview.chainId && sameAddress(overview.address.value, item.address), @@ -157,7 +168,7 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: /> ))} - {!isWatchlist && ( + {!isWatchlist && hasReplayableSafe && ( <> diff --git a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts index dbbb6d9b84..107d2ad12a 100644 --- a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts +++ b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts @@ -4,8 +4,10 @@ import { type UndeployedSafesState, type UndeployedSafe, type ReplayedSafeProps import { sameAddress } from '@/utils/addresses' import { type MultiChainSafeItem } from '../useAllSafesGrouped' import { Safe_proxy_factory__factory } from '@/types/contracts' -import { keccak256, ethers, solidityPacked, getCreate2Address, type JsonRpcProvider } from 'ethers' +import { keccak256, ethers, solidityPacked, getCreate2Address, type Provider } from 'ethers' import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' +import { encodeSafeSetupCall } from '@/components/new-safe/create/logic' +import { memoize } from 'lodash' export const isMultiChainSafeItem = (safe: SafeItem | MultiChainSafeItem): safe is MultiChainSafeItem => { if ('safes' in safe && 'address' in safe) { @@ -98,16 +100,18 @@ export const getSharedSetup = ( return undefined } -export const predictAddressBasedOnReplayData = async ( - safeCreationData: ReplayedSafeProps, - provider: JsonRpcProvider, -) => { - if (!safeCreationData.setupData) { - throw new Error('Cannot predict address without setupData') - } +const memoizedGetProxyCreationCode = memoize( + async (factoryAddress: string, provider: Provider) => { + return Safe_proxy_factory__factory.connect(factoryAddress, provider).proxyCreationCode() + }, + async (factoryAddress, provider) => `${factoryAddress}${(await provider.getNetwork()).chainId}`, +) + +export const predictAddressBasedOnReplayData = async (safeCreationData: ReplayedSafeProps, provider: Provider) => { + const setupData = encodeSafeSetupCall(safeCreationData.safeAccountConfig) // Step 1: Hash the initializer - const initializerHash = keccak256(safeCreationData.setupData) + const initializerHash = keccak256(setupData) // Step 2: Encode the initializerHash and saltNonce using abi.encodePacked equivalent const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [safeCreationData.saltNonce])]) @@ -116,10 +120,7 @@ export const predictAddressBasedOnReplayData = async ( const salt = keccak256(encoded) // Get Proxy creation code - const proxyCreationCode = await Safe_proxy_factory__factory.connect( - safeCreationData.factoryAddress, - provider, - ).proxyCreationCode() + const proxyCreationCode = await memoizedGetProxyCreationCode(safeCreationData.factoryAddress, provider) const constructorData = safeCreationData.masterCopy const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2) diff --git a/src/features/counterfactual/ActivateAccountFlow.tsx b/src/features/counterfactual/ActivateAccountFlow.tsx index c5467474aa..89a9d1aa09 100644 --- a/src/features/counterfactual/ActivateAccountFlow.tsx +++ b/src/features/counterfactual/ActivateAccountFlow.tsx @@ -1,4 +1,4 @@ -import { createNewSafe, relayReplayedSafeCreation, relaySafeCreation } from '@/components/new-safe/create/logic' +import { createNewSafe, relaySafeCreation } from '@/components/new-safe/create/logic' import { NetworkFee, SafeSetupOverview } from '@/components/new-safe/create/steps/ReviewStep' import ReviewRow from '@/components/new-safe/ReviewRow' import { TxModalContext } from '@/components/tx-flow' @@ -8,12 +8,7 @@ import ErrorMessage from '@/components/tx/ErrorMessage' import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' import { selectUndeployedSafe, type UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' -import { - activateReplayedSafe, - CF_TX_GROUP_KEY, - extractCounterfactualSafeSetup, - isPredictedSafeProps, -} from '@/features/counterfactual/utils' +import { CF_TX_GROUP_KEY, extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features/counterfactual/utils' import useChainId from '@/hooks/useChainId' import { useCurrentChain } from '@/hooks/useChains' import useGasPrice, { getTotalFeeFormatted } from '@/hooks/useGasPrice' @@ -36,30 +31,19 @@ import { sameAddress } from '@/utils/addresses' import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas' import useIsWrongChain from '@/hooks/useIsWrongChain' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' -import { createWeb3 } from '@/hooks/wallets/web3' import { SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' import CheckWallet from '@/components/common/CheckWallet' const useActivateAccount = (undeployedSafe: UndeployedSafe | undefined) => { const chain = useCurrentChain() const [gasPrice] = useGasPrice() - const deploymentProps = useMemo( - () => - undeployedSafe && isPredictedSafeProps(undeployedSafe.props) - ? { - owners: undeployedSafe.props.safeAccountConfig.owners, - saltNonce: Number(undeployedSafe.props.safeDeploymentConfig?.saltNonce ?? 0), - threshold: undeployedSafe.props.safeAccountConfig.threshold, - } - : undefined, - [undeployedSafe], - ) - const safeVersion = - undeployedSafe && isPredictedSafeProps(undeployedSafe?.props) + undeployedSafe && + (isPredictedSafeProps(undeployedSafe?.props) ? undeployedSafe?.props.safeDeploymentConfig?.safeVersion - : undefined - const { gasLimit } = useEstimateSafeCreationGas(deploymentProps, safeVersion) + : undeployedSafe?.props.safeVersion) + + const { gasLimit } = useEstimateSafeCreationGas(undeployedSafe?.props, safeVersion) const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) const maxFeePerGas = gasPrice?.maxFeePerGas @@ -134,38 +118,19 @@ const ActivateAccountFlow = () => { try { if (willRelay) { - let taskId: string - if (isPredictedSafeProps(undeployedSafe.props)) { - taskId = await relaySafeCreation(chain, owners, threshold, Number(saltNonce!), safeVersion) - } else { - taskId = await relayReplayedSafeCreation(chain, undeployedSafe.props, safeVersion) - } + const taskId = await relaySafeCreation(chain, undeployedSafe.props) safeCreationDispatch(SafeCreationEvent.RELAYING, { groupKey: CF_TX_GROUP_KEY, taskId, safeAddress }) onSubmit() } else { - if (isPredictedSafeProps(undeployedSafe.props)) { - await createNewSafe( - wallet.provider, - { - safeAccountConfig: undeployedSafe.props.safeAccountConfig, - saltNonce, - options, - callback: onSubmit, - }, - safeVersion ?? getLatestSafeVersion(chain), - isMultichainSafe ? true : undefined, - ) - } else { - // Deploy replayed Safe Creation - const txResponse = await activateReplayedSafe( - safeVersion ?? getLatestSafeVersion(chain), - chain, - undeployedSafe.props, - createWeb3(wallet.provider), - ) - onSubmit(txResponse.hash) - } + await createNewSafe( + wallet.provider, + undeployedSafe.props, + safeVersion ?? getLatestSafeVersion(chain), + chain, + onSubmit, + isMultichainSafe ? true : undefined, + ) } } catch (_err) { const err = asError(_err) diff --git a/src/features/counterfactual/store/undeployedSafesSlice.ts b/src/features/counterfactual/store/undeployedSafesSlice.ts index 60bea32ef4..d24f9909fe 100644 --- a/src/features/counterfactual/store/undeployedSafesSlice.ts +++ b/src/features/counterfactual/store/undeployedSafesSlice.ts @@ -3,7 +3,7 @@ import { type RootState } from '@/store' import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import type { PredictedSafeProps } from '@safe-global/protocol-kit' import { selectChainIdAndSafeAddress, selectSafeAddress } from '@/store/common' -import { type CreationTransaction } from 'safe-client-gateway-sdk' +import { type SafeVersion } from '@safe-global/safe-core-sdk-types' export enum PendingSafeStatus { AWAITING_EXECUTION = 'AWAITING_EXECUTION', @@ -22,8 +22,21 @@ type UndeployedSafeStatus = { signerNonce?: number | null } -export type ReplayedSafeProps = Pick & { +export type ReplayedSafeProps = { + factoryAddress: string + masterCopy: string + safeAccountConfig: { + threshold: number + owners: string[] + fallbackHandler: string + to: string + data: string + paymentToken?: string + payment?: number + paymentReceiver: string + } saltNonce: string + safeVersion: SafeVersion } export type UndeployedSafeProps = PredictedSafeProps | ReplayedSafeProps diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts index d25905cb85..77a1fff5fa 100644 --- a/src/features/counterfactual/utils.ts +++ b/src/features/counterfactual/utils.ts @@ -33,11 +33,10 @@ import { } from '@safe-global/safe-gateway-typescript-sdk' import type { BrowserProvider, ContractTransactionResponse, Eip1193Provider, Provider } from 'ethers' import type { NextRouter } from 'next/router' -import { Safe__factory } from '@/types/contracts' -import { getCompatibilityFallbackHandlerDeployments } from '@safe-global/safe-deployments' +import { getSafeL2SingletonDeployments, getSafeSingletonDeployments } from '@safe-global/safe-deployments' import { sameAddress } from '@/utils/addresses' -import { getReadOnlyProxyFactoryContract } from '@/services/contracts/safeContracts' +import { encodeSafeCreationTx } from '@/components/new-safe/create/logic' export const getUndeployedSafeInfo = (undeployedSafe: UndeployedSafe, address: string, chain: ChainInfo) => { const safeSetup = extractCounterfactualSafeSetup(undeployedSafe, chain.chainId) @@ -369,29 +368,34 @@ export const checkSafeActionViaRelay = (taskId: string, safeAddress: string, typ }, TIMEOUT_TIME) } -export const isReplayedSafeProps = (props: UndeployedSafeProps): props is ReplayedSafeProps => { - if ('setupData' in props && 'masterCopy' in props && 'factoryAddress' in props && 'saltNonce' in props) { - return true - } - return false -} +export const isReplayedSafeProps = (props: UndeployedSafeProps): props is ReplayedSafeProps => + 'safeAccountConfig' in props && 'masterCopy' in props && 'factoryAddress' in props && 'saltNonce' in props -export const isPredictedSafeProps = (props: UndeployedSafeProps): props is PredictedSafeProps => { - if ('safeAccountConfig' in props) { - return true - } - return false -} +export const isPredictedSafeProps = (props: UndeployedSafeProps): props is PredictedSafeProps => + 'safeAccountConfig' in props && !('masterCopy' in props) -const determineFallbackHandlerVersion = (fallbackHandler: string, chainId: string): SafeVersion | undefined => { +export const determineMasterCopyVersion = (masterCopy: string, chainId: string): SafeVersion | undefined => { const SAFE_VERSIONS: SafeVersion[] = ['1.4.1', '1.3.0', '1.2.0', '1.1.1', '1.0.0'] return SAFE_VERSIONS.find((version) => { - const deployments = getCompatibilityFallbackHandlerDeployments({ version })?.networkAddresses[chainId] + const isL1Singleton = () => { + const deployments = getSafeSingletonDeployments({ version })?.networkAddresses[chainId] + + if (Array.isArray(deployments)) { + return deployments.some((deployment) => sameAddress(masterCopy, deployment)) + } + return sameAddress(masterCopy, deployments) + } + + const isL2Singleton = () => { + const deployments = getSafeL2SingletonDeployments({ version })?.networkAddresses[chainId] - if (Array.isArray(deployments)) { - return deployments.some((deployment) => sameAddress(fallbackHandler, deployment)) + if (Array.isArray(deployments)) { + return deployments.some((deployment) => sameAddress(masterCopy, deployment)) + } + return sameAddress(masterCopy, deployments) } - return sameAddress(fallbackHandler, deployments) + + return isL1Singleton() || isL2Singleton() }) } @@ -419,41 +423,20 @@ export const extractCounterfactualSafeSetup = ( saltNonce: undeployedSafe.props.safeDeploymentConfig?.saltNonce, } } else { - if (!undeployedSafe.props.setupData) { - return undefined - } - const [owners, threshold, to, data, fallbackHandler, ...setupParams] = - Safe__factory.createInterface().decodeFunctionData('setup', undeployedSafe.props.setupData) - - const safeVersion = determineFallbackHandlerVersion(fallbackHandler, chainId) + const { owners, threshold, fallbackHandler } = undeployedSafe.props.safeAccountConfig return { owners, threshold: Number(threshold), fallbackHandler, - safeVersion, + safeVersion: undeployedSafe.props.safeVersion, saltNonce: undeployedSafe.props.saltNonce, } } } -export const activateReplayedSafe = async ( - safeVersion: SafeVersion, - chain: ChainInfo, - props: ReplayedSafeProps, - provider: BrowserProvider, -) => { - const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) - const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion, props.factoryAddress) - - if (!props.masterCopy || !props.setupData) { - throw Error('Cannot replay Safe without deployment info') - } - const data = readOnlyProxyContract.encode('createProxyWithNonce', [ - props.masterCopy, - props.setupData, - BigInt(props.saltNonce), - ]) +export const activateReplayedSafe = async (chain: ChainInfo, props: ReplayedSafeProps, provider: BrowserProvider) => { + const data = await encodeSafeCreationTx(props, chain) return (await provider.getSigner()).sendTransaction({ to: props.factoryAddress, diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index abdf712b5e..beccedec52 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -75,7 +75,7 @@ const ReplaySafeDialog = ({ const onFormSubmit = handleSubmit(async (data) => { const selectedChain = chain ?? replayableChains?.find((config) => config.chainId === data.chainId) - if (!safeCreationData || !safeCreationData.setupData || !selectedChain || !safeCreationData.masterCopy) { + if (!safeCreationData || !selectedChain) { return } diff --git a/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts b/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts index 4875d4cc14..e03a8f392e 100644 --- a/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts +++ b/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts @@ -44,39 +44,6 @@ describe('useCompatibleNetworks', () => { expect(result.current).toHaveLength(0) }) - it('should return empty list for incomplete creation data', () => { - const callData = { - owners: [faker.finance.ethereumAddress()], - threshold: 1, - to: ZERO_ADDRESS, - data: EMPTY_DATA, - fallbackHandler: faker.finance.ethereumAddress(), - paymentToken: ZERO_ADDRESS, - payment: 0, - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - } - - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - - const creationData: ReplayedSafeProps = { - factoryAddress: faker.finance.ethereumAddress(), - masterCopy: null, - saltNonce: '0', - setupData, - } - const { result } = renderHook(() => useCompatibleNetworks(creationData)) - expect(result.current).toHaveLength(0) - }) - it('should set available to false for unknown masterCopies', () => { const callData = { owners: [faker.finance.ethereumAddress()], @@ -89,22 +56,12 @@ describe('useCompatibleNetworks', () => { paymentReceiver: ECOSYSTEM_ID_ADDRESS, } - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - const creationData: ReplayedSafeProps = { factoryAddress: faker.finance.ethereumAddress(), masterCopy: faker.finance.ethereumAddress(), saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.4.1', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current.every((config) => config.available)).toEqual(false) @@ -121,22 +78,13 @@ describe('useCompatibleNetworks', () => { payment: 0, paymentReceiver: ECOSYSTEM_ID_ADDRESS, } - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) { const creationData: ReplayedSafeProps = { factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, masterCopy: L1_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.4.1', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -149,7 +97,8 @@ describe('useCompatibleNetworks', () => { factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, masterCopy: L2_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.4.1', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -170,24 +119,14 @@ describe('useCompatibleNetworks', () => { paymentReceiver: ECOSYSTEM_ID_ADDRESS, } - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - // 1.3.0, L1 and canonical { const creationData: ReplayedSafeProps = { factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.canonical?.address!, masterCopy: L1_130_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.3.0', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -201,7 +140,8 @@ describe('useCompatibleNetworks', () => { factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.canonical?.address!, masterCopy: L2_130_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.3.0', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -215,7 +155,8 @@ describe('useCompatibleNetworks', () => { factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.eip155?.address!, masterCopy: L1_130_MASTERCOPY_DEPLOYMENTS?.eip155?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.3.0', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -229,7 +170,8 @@ describe('useCompatibleNetworks', () => { factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.eip155?.address!, masterCopy: L2_130_MASTERCOPY_DEPLOYMENTS?.eip155?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.3.0', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -250,22 +192,12 @@ describe('useCompatibleNetworks', () => { paymentReceiver: ECOSYSTEM_ID_ADDRESS, } - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - const creationData: ReplayedSafeProps = { factoryAddress: PROXY_FACTORY_111_DEPLOYMENTS?.canonical?.address!, masterCopy: L1_111_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.1.1', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) diff --git a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts index e0f4ab2489..8eec258755 100644 --- a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts +++ b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -1,23 +1,19 @@ -import { renderHook, waitFor } from '@/tests/test-utils' +import { fakerChecksummedAddress, renderHook, waitFor } from '@/tests/test-utils' import { SAFE_CREATION_DATA_ERRORS, useSafeCreationData } from '../useSafeCreationData' import { faker } from '@faker-js/faker' -import { PendingSafeStatus, type ReplayedSafeProps, type UndeployedSafe } from '@/store/slices' +import { PendingSafeStatus, type UndeployedSafe } from '@/store/slices' import { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { chainBuilder } from '@/tests/builders/chains' -import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import * as sdk from '@/services/tx/tx-sender/sdk' import * as cgwSdk from 'safe-client-gateway-sdk' import * as web3 from '@/hooks/wallets/web3' import { encodeMultiSendData, type SafeProvider } from '@safe-global/protocol-kit' -import { - getCompatibilityFallbackHandlerDeployment, - getProxyFactoryDeployment, - getSafeSingletonDeployment, -} from '@safe-global/safe-deployments' import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' import { type JsonRpcProvider } from 'ethers' import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' describe('useSafeCreationData', () => { beforeAll(() => { @@ -45,7 +41,17 @@ describe('useSafeCreationData', () => { factoryAddress: faker.finance.ethereumAddress(), saltNonce: '420', masterCopy: faker.finance.ethereumAddress(), - setupData: faker.string.hexadecimal({ length: 64 }), + safeVersion: '1.3.0', + safeAccountConfig: { + owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + threshold: 1, + data: faker.string.hexadecimal({ length: 64 }), + to: faker.finance.ethereumAddress(), + fallbackHandler: faker.finance.ethereumAddress(), + payment: 0, + paymentToken: ZERO_ADDRESS, + paymentReceiver: ZERO_ADDRESS, + }, }, status: { status: PendingSafeStatus.AWAITING_EXECUTION, @@ -68,7 +74,7 @@ describe('useSafeCreationData', () => { }) }) - it('should extract replayedSafe data from an predictedSafe', async () => { + it('should throw error for legacy counterfactual Safes', async () => { const safeAddress = faker.finance.ethereumAddress() const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] const undeployedSafe = { @@ -98,90 +104,9 @@ describe('useSafeCreationData', () => { }, }) - const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ - undeployedSafe.props.safeAccountConfig.owners, - undeployedSafe.props.safeAccountConfig.threshold, - ZERO_ADDRESS, - EMPTY_DATA, - getCompatibilityFallbackHandlerDeployment({ network: '1', version: '1.3.0' })?.defaultAddress!, - ZERO_ADDRESS, - 0, - ZERO_ADDRESS, - ]) - - // Should return replayedSafeProps - const expectedProps: ReplayedSafeProps = { - factoryAddress: getProxyFactoryDeployment({ network: '1', version: '1.3.0' })?.defaultAddress!, - saltNonce: '69', - masterCopy: getSafeSingletonDeployment({ network: '1', version: '1.3.0' })?.networkAddresses['1'], - setupData, - } await waitFor(async () => { await Promise.resolve() - expect(result.current).toEqual([expectedProps, undefined, false]) - }) - }) - - it('should extract replayedSafe data from an predictedSafe which has a custom Setup', async () => { - const safeAddress = faker.finance.ethereumAddress() - const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] - const undeployedSafe = { - props: { - safeAccountConfig: { - owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], - threshold: 2, - fallbackHandler: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 64 }), - to: faker.finance.ethereumAddress(), - payment: 123, - paymentReceiver: faker.finance.ethereumAddress(), - paymentToken: faker.finance.ethereumAddress(), - }, - safeDeploymentConfig: { - saltNonce: '69', - safeVersion: '1.3.0', - }, - }, - status: { - status: PendingSafeStatus.AWAITING_EXECUTION, - type: PayMethod.PayLater, - }, - } - - const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ - undeployedSafe.props.safeAccountConfig.owners, - undeployedSafe.props.safeAccountConfig.threshold, - undeployedSafe.props.safeAccountConfig.to, - undeployedSafe.props.safeAccountConfig.data, - undeployedSafe.props.safeAccountConfig.fallbackHandler, - undeployedSafe.props.safeAccountConfig.paymentToken, - undeployedSafe.props.safeAccountConfig.payment, - undeployedSafe.props.safeAccountConfig.paymentReceiver, - ]) - - // Should return replayedSafeProps - const expectedProps: ReplayedSafeProps = { - factoryAddress: getProxyFactoryDeployment({ network: '1', version: '1.3.0' })?.defaultAddress!, - saltNonce: '69', - masterCopy: getSafeSingletonDeployment({ network: '1', version: '1.3.0' })?.defaultAddress, - setupData, - } - - // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { - initialReduxState: { - undeployedSafes: { - '1': { - [safeAddress]: undeployedSafe as UndeployedSafe, - }, - }, - }, - }) - - // Expectations - await waitFor(async () => { - await Promise.resolve() - expect(result.current).toEqual([expectedProps, undefined, false]) + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.LEGACY_COUNTERFATUAL), false]) }) }) @@ -429,7 +354,7 @@ describe('useSafeCreationData', () => { [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, faker.finance.ethereumAddress(), - faker.string.hexadecimal({ length: 64 }), + faker.string.hexadecimal({ length: 64, casing: 'lower' }), faker.finance.ethereumAddress(), faker.finance.ethereumAddress(), 0, @@ -479,24 +404,34 @@ describe('useSafeCreationData', () => { }) it('should return transaction data for direct Safe creation txs', async () => { + const safeProps = { + owners: [fakerChecksummedAddress(), fakerChecksummedAddress()], + threshold: 1, + to: fakerChecksummedAddress(), + data: faker.string.hexadecimal({ length: 64, casing: 'lower' }), + fallbackHandler: fakerChecksummedAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: fakerChecksummedAddress(), + } const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ - [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], - 1, - faker.finance.ethereumAddress(), - faker.string.hexadecimal({ length: 64 }), - faker.finance.ethereumAddress(), - faker.finance.ethereumAddress(), - 0, - faker.finance.ethereumAddress(), + safeProps.owners, + safeProps.threshold, + safeProps.to, + safeProps.data, + safeProps.fallbackHandler, + safeProps.paymentToken, + safeProps.payment, + safeProps.paymentReceiver, ]) const mockTxHash = faker.string.hexadecimal({ length: 64 }) - const mockFactoryAddress = faker.finance.ethereumAddress() - const mockMasterCopyAddress = faker.finance.ethereumAddress() + const mockFactoryAddress = fakerChecksummedAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress! jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ data: { created: new Date(Date.now()).toISOString(), - creator: faker.finance.ethereumAddress(), + creator: fakerChecksummedAddress(), factoryAddress: mockFactoryAddress, transactionHash: mockTxHash, masterCopy: mockMasterCopyAddress, @@ -532,8 +467,9 @@ describe('useSafeCreationData', () => { { factoryAddress: mockFactoryAddress, masterCopy: mockMasterCopyAddress, - setupData, + safeAccountConfig: safeProps, saltNonce: '69', + safeVersion: '1.3.0', }, undefined, false, @@ -542,20 +478,31 @@ describe('useSafeCreationData', () => { }) it('should return transaction data for creation bundles', async () => { + const safeProps = { + owners: [fakerChecksummedAddress(), fakerChecksummedAddress()], + threshold: 1, + to: fakerChecksummedAddress(), + data: faker.string.hexadecimal({ length: 64, casing: 'lower' }), + fallbackHandler: fakerChecksummedAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: fakerChecksummedAddress(), + } + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ - [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], - 1, - faker.finance.ethereumAddress(), - faker.string.hexadecimal({ length: 64 }), - faker.finance.ethereumAddress(), - faker.finance.ethereumAddress(), - 0, - faker.finance.ethereumAddress(), + safeProps.owners, + safeProps.threshold, + safeProps.to, + safeProps.data, + safeProps.fallbackHandler, + safeProps.paymentToken, + safeProps.payment, + safeProps.paymentReceiver, ]) const mockTxHash = faker.string.hexadecimal({ length: 64 }) const mockFactoryAddress = faker.finance.ethereumAddress() - const mockMasterCopyAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.4.1' })?.defaultAddress! jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ data: { @@ -610,7 +557,8 @@ describe('useSafeCreationData', () => { { factoryAddress: mockFactoryAddress, masterCopy: mockMasterCopyAddress, - setupData, + safeAccountConfig: safeProps, + safeVersion: '1.4.1', saltNonce: '69', }, undefined, diff --git a/src/features/multichain/hooks/useCompatibleNetworks.ts b/src/features/multichain/hooks/useCompatibleNetworks.ts index 21b662774a..6e59a72fc0 100644 --- a/src/features/multichain/hooks/useCompatibleNetworks.ts +++ b/src/features/multichain/hooks/useCompatibleNetworks.ts @@ -37,10 +37,6 @@ export const useCompatibleNetworks = ( const { masterCopy, factoryAddress } = creation - if (!masterCopy) { - return [] - } - const allL1SingletonDeployments = SUPPORTED_VERSIONS.map((version) => getSafeSingletonDeployments({ version }), ).filter(Boolean) as SingletonDeploymentV2[] diff --git a/src/features/multichain/hooks/useSafeCreationData.ts b/src/features/multichain/hooks/useSafeCreationData.ts index b3286baf2e..a2df2ad8b2 100644 --- a/src/features/multichain/hooks/useSafeCreationData.ts +++ b/src/features/multichain/hooks/useSafeCreationData.ts @@ -1,66 +1,43 @@ import useAsync, { type AsyncResult } from '@/hooks/useAsync' import { createWeb3ReadOnly } from '@/hooks/wallets/web3' import { type UndeployedSafe, selectRpc, type ReplayedSafeProps, selectUndeployedSafes } from '@/store/slices' -import { Safe_proxy_factory__factory } from '@/types/contracts' +import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' import { sameAddress } from '@/utils/addresses' import { getCreationTransaction } from 'safe-client-gateway-sdk' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' -import { isPredictedSafeProps } from '@/features/counterfactual/utils' -import { - getReadOnlyGnosisSafeContract, - getReadOnlyProxyFactoryContract, - getReadOnlyFallbackHandlerContract, -} from '@/services/contracts/safeContracts' -import { getLatestSafeVersion } from '@/utils/chains' -import { ZERO_ADDRESS, EMPTY_DATA } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { determineMasterCopyVersion, isPredictedSafeProps } from '@/features/counterfactual/utils' import { logError } from '@/services/exceptions' import ErrorCodes from '@/services/exceptions/ErrorCodes' import { asError } from '@/services/exceptions/utils' -const getUndeployedSafeCreationData = async ( - undeployedSafe: UndeployedSafe, - chain: ChainInfo, -): Promise => { - if (isPredictedSafeProps(undeployedSafe.props)) { - // Copy predicted safe - // Encode Safe creation and determine the addresses the Safe creation would use - const { owners, threshold, to, data, fallbackHandler, paymentToken, payment, paymentReceiver } = - undeployedSafe.props.safeAccountConfig - const usedSafeVersion = undeployedSafe.props.safeDeploymentConfig?.safeVersion ?? getLatestSafeVersion(chain) - const readOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, usedSafeVersion) - const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(usedSafeVersion) - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(usedSafeVersion) - - const callData = { - owners, - threshold, - to: to ?? ZERO_ADDRESS, - data: data ?? EMPTY_DATA, - fallbackHandler: fallbackHandler ?? (await readOnlyFallbackHandlerContract.getAddress()), - paymentToken: paymentToken ?? ZERO_ADDRESS, - payment: payment ?? 0, - paymentReceiver: paymentReceiver ?? ZERO_ADDRESS, - } +export const SAFE_CREATION_DATA_ERRORS = { + TX_NOT_FOUND: 'The Safe creation transaction could not be found. Please retry later.', + NO_CREATION_DATA: 'The Safe creation information for this Safe could not be found or is incomplete.', + UNSUPPORTED_SAFE_CREATION: 'The method this Safe was created with is not supported.', + NO_PROVIDER: 'The RPC provider for the origin network is not available.', + LEGACY_COUNTERFATUAL: 'This undeployed Safe cannot be replayed. Please activate the Safe first.', +} - // @ts-ignore union type is too complex - const setupData = readOnlySafeContract.encode('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - - return { - factoryAddress: await readOnlyProxyFactoryContract.getAddress(), - masterCopy: await readOnlySafeContract.getAddress(), - saltNonce: undeployedSafe.props.safeDeploymentConfig?.saltNonce ?? '0', - setupData, - } +export const decodeSetupData = (setupData: string): ReplayedSafeProps['safeAccountConfig'] => { + const [owners, threshold, to, data, fallbackHandler, paymentToken, payment, paymentReceiver] = + Safe__factory.createInterface().decodeFunctionData('setup', setupData) + + return { + owners: [...owners], + threshold: Number(threshold), + to, + data, + fallbackHandler, + paymentToken, + payment: Number(payment), + paymentReceiver, + } +} + +const getUndeployedSafeCreationData = async (undeployedSafe: UndeployedSafe): Promise => { + if (isPredictedSafeProps(undeployedSafe.props)) { + throw new Error(SAFE_CREATION_DATA_ERRORS.LEGACY_COUNTERFATUAL) } // We already have a replayed Safe. In this case we can return the identical data @@ -70,13 +47,6 @@ const getUndeployedSafeCreationData = async ( const proxyFactoryInterface = Safe_proxy_factory__factory.createInterface() const createProxySelector = proxyFactoryInterface.getFunction('createProxyWithNonce').selector -export const SAFE_CREATION_DATA_ERRORS = { - TX_NOT_FOUND: 'The Safe creation transaction could not be found. Please retry later.', - NO_CREATION_DATA: 'The Safe creation information for this Safe could be found or is incomplete.', - UNSUPPORTED_SAFE_CREATION: 'The method this Safe was created with is not supported yet.', - NO_PROVIDER: 'The RPC provider for the origin network is not available.', -} - const getCreationDataForChain = async ( chain: ChainInfo, undeployedSafe: UndeployedSafe, @@ -85,7 +55,7 @@ const getCreationDataForChain = async ( ): Promise => { // 1. The safe is counterfactual if (undeployedSafe) { - return getUndeployedSafeCreationData(undeployedSafe, chain) + return getUndeployedSafeCreationData(undeployedSafe) } const { data: creation } = await getCreationTransaction({ @@ -119,7 +89,6 @@ const getCreationDataForChain = async ( } // decode tx - const [masterCopy, initializer, saltNonce] = proxyFactoryInterface.decodeFunctionData( 'createProxyWithNonce', `0x${txData.slice(startOfTx)}`, @@ -133,12 +102,19 @@ const getCreationDataForChain = async ( // We found the wrong tx. This tx seems to deploy multiple Safes at once. This is not supported yet. throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) } + const safeAccountConfig = decodeSetupData(creation.setupData) + const safeVersion = determineMasterCopyVersion(creation.masterCopy, chain.chainId) + + if (!safeVersion) { + throw new Error('Could not determine Safe version of used master copy') + } return { factoryAddress: creation.factoryAddress, masterCopy: creation.masterCopy, - setupData: creation.setupData, + safeAccountConfig, saltNonce: saltNonce.toString(), + safeVersion, } } @@ -156,6 +132,7 @@ export const useSafeCreationData = (safeAddress: string, chains: ChainInfo[]): A try { for (const chain of chains) { const undeployedSafe = undeployedSafes[chain.chainId]?.[safeAddress] + try { const creationData = await getCreationDataForChain(chain, undeployedSafe, safeAddress, customRpc) return creationData diff --git a/src/features/multichain/helpers/utils.ts b/src/features/multichain/utils/utils.ts similarity index 100% rename from src/features/multichain/helpers/utils.ts rename to src/features/multichain/utils/utils.ts diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx index 81a95277fc..95fc419813 100644 --- a/src/tests/test-utils.tsx +++ b/src/tests/test-utils.tsx @@ -10,6 +10,8 @@ import * as web3 from '@/hooks/wallets/web3' import { type JsonRpcProvider, AbiCoder } from 'ethers' import { id } from 'ethers' import { Provider } from 'react-redux' +import { checksumAddress } from '@/utils/addresses' +import { faker } from '@faker-js/faker' const mockRouter = (props: Partial = {}): NextRouter => ({ asPath: '/', @@ -134,6 +136,8 @@ const mockWeb3Provider = ( return mockWeb3ReadOnly } +export const fakerChecksummedAddress = () => checksumAddress(faker.finance.ethereumAddress()) + // re-export everything export * from '@testing-library/react'