diff --git a/packages/safe-core-sdk-types/src/contracts/GnosisSafeProxyFactoryContract.ts b/packages/safe-core-sdk-types/src/contracts/GnosisSafeProxyFactoryContract.ts index 4514ba9de..973124e64 100644 --- a/packages/safe-core-sdk-types/src/contracts/GnosisSafeProxyFactoryContract.ts +++ b/packages/safe-core-sdk-types/src/contracts/GnosisSafeProxyFactoryContract.ts @@ -3,13 +3,14 @@ import { TransactionOptions } from '../types' export interface CreateProxyProps { safeMasterCopyAddress: string initializer: string - saltNonce: number + saltNonce: string options?: TransactionOptions callback?: (txHash: string) => void } export interface GnosisSafeProxyFactoryContract { getAddress(): string + proxyCreationCode(): Promise createProxy(options: CreateProxyProps): Promise encode(methodName: string, params: any[]): string estimateGas(methodName: string, params: any[], options: TransactionOptions): Promise diff --git a/packages/safe-core-sdk-types/src/ethereumLibs/EthAdapter.ts b/packages/safe-core-sdk-types/src/ethereumLibs/EthAdapter.ts index 099da7497..b275c0e95 100644 --- a/packages/safe-core-sdk-types/src/ethereumLibs/EthAdapter.ts +++ b/packages/safe-core-sdk-types/src/ethereumLibs/EthAdapter.ts @@ -28,6 +28,7 @@ export interface EthAdapter { getEip3770Address(fullAddress: string): Promise getBalance(address: string): Promise getChainId(): Promise + getChecksummedAddress(address: string): string getSafeContract({ safeVersion, chainId, @@ -63,4 +64,5 @@ export interface EthAdapter { callback?: (error: Error, gas: number) => void ): Promise call(transaction: EthAdapterTransaction): Promise + encodeParameters(types: string[], values: any[]): string } diff --git a/packages/safe-core-sdk/src/index.ts b/packages/safe-core-sdk/src/index.ts index 6cee1ea11..3643c4630 100644 --- a/packages/safe-core-sdk/src/index.ts +++ b/packages/safe-core-sdk/src/index.ts @@ -8,6 +8,7 @@ import Safe, { } from './Safe' import SafeFactory, { DeploySafeProps, + PredictSafeProps, SafeAccountConfig, SafeDeploymentConfig, SafeFactoryConfig @@ -24,6 +25,7 @@ export { SafeFactoryConfig, SafeAccountConfig, SafeDeploymentConfig, + PredictSafeProps, DeploySafeProps, SafeConfig, ConnectSafeConfig, diff --git a/packages/safe-core-sdk/src/safeFactory/index.ts b/packages/safe-core-sdk/src/safeFactory/index.ts index 9b154f2ae..7a2e1e3c8 100644 --- a/packages/safe-core-sdk/src/safeFactory/index.ts +++ b/packages/safe-core-sdk/src/safeFactory/index.ts @@ -5,12 +5,13 @@ import { SafeVersion, TransactionOptions } from '@gnosis.pm/safe-core-sdk-types' +import { generateAddress2, keccak256, toBuffer } from 'ethereumjs-util' import { SAFE_LAST_VERSION } from '../contracts/config' import { getProxyFactoryContract, getSafeContract } from '../contracts/safeDeploymentContracts' import Safe from '../Safe' import { ContractNetworksConfig } from '../types' import { EMPTY_DATA, ZERO_ADDRESS } from '../utils/constants' -import { validateSafeAccountConfig } from './utils' +import { validateSafeAccountConfig, validateSafeDeploymentConfig } from './utils' export interface SafeAccountConfig { owners: string[] @@ -24,7 +25,12 @@ export interface SafeAccountConfig { } export interface SafeDeploymentConfig { - saltNonce: number + saltNonce: string +} + +export interface PredictSafeProps { + safeAccountConfig: SafeAccountConfig + safeDeploymentConfig: SafeDeploymentConfig } export interface DeploySafeProps { @@ -140,6 +146,36 @@ class SafeFactory { ]) } + async predictSafeAddress({ + safeAccountConfig, + safeDeploymentConfig + }: PredictSafeProps): Promise { + validateSafeAccountConfig(safeAccountConfig) + validateSafeDeploymentConfig(safeDeploymentConfig) + + const from = this.#safeProxyFactoryContract.getAddress() + + const initializer = await this.encodeSetupCallData(safeAccountConfig) + const saltNonce = safeDeploymentConfig.saltNonce + const encodedNonce = toBuffer(this.#ethAdapter.encodeParameters(['uint256'], [saltNonce])).toString( + 'hex' + ) + + const salt = keccak256( + toBuffer('0x' + keccak256(toBuffer(initializer)).toString('hex') + encodedNonce) + ) + + const proxyCreationCode = await this.#safeProxyFactoryContract.proxyCreationCode() + const constructorData = toBuffer( + this.#ethAdapter.encodeParameters(['address'], [this.#gnosisSafeContract.getAddress()]) + ).toString('hex') + const initCode = proxyCreationCode + constructorData + + const proxyAddress = + '0x' + generateAddress2(toBuffer(from), toBuffer(salt), toBuffer(initCode)).toString('hex') + return this.#ethAdapter.getChecksummedAddress(proxyAddress) + } + async deploySafe({ safeAccountConfig, safeDeploymentConfig, @@ -147,10 +183,14 @@ class SafeFactory { callback }: DeploySafeProps): Promise { validateSafeAccountConfig(safeAccountConfig) + if (safeDeploymentConfig) { + validateSafeDeploymentConfig(safeDeploymentConfig) + } const signerAddress = await this.#ethAdapter.getSignerAddress() const initializer = await this.encodeSetupCallData(safeAccountConfig) const saltNonce = - safeDeploymentConfig?.saltNonce ?? Date.now() * 1000 + Math.floor(Math.random() * 1000) + safeDeploymentConfig?.saltNonce ?? + (Date.now() * 1000 + Math.floor(Math.random() * 1000)).toString() if (options?.gas && options?.gasLimit) { throw new Error('Cannot specify gas and gasLimit together in transaction options') diff --git a/packages/safe-core-sdk/src/safeFactory/utils.ts b/packages/safe-core-sdk/src/safeFactory/utils.ts index 3d4fdda17..f0d612f23 100644 --- a/packages/safe-core-sdk/src/safeFactory/utils.ts +++ b/packages/safe-core-sdk/src/safeFactory/utils.ts @@ -1,4 +1,5 @@ -import { SafeAccountConfig } from './' +import { BigNumber } from '@ethersproject/bignumber' +import { SafeAccountConfig, SafeDeploymentConfig } from './' export const validateSafeAccountConfig = ({ owners, threshold }: SafeAccountConfig): void => { if (owners.length <= 0) throw new Error('Owner list must have at least one owner') @@ -6,3 +7,8 @@ export const validateSafeAccountConfig = ({ owners, threshold }: SafeAccountConf if (threshold > owners.length) throw new Error('Threshold must be lower than or equal to owners length') } + +export const validateSafeDeploymentConfig = ({ saltNonce }: SafeDeploymentConfig): void => { + if (BigNumber.from(saltNonce).lt(0)) + throw new Error('saltNonce must be greater than or equal to 0') +} diff --git a/packages/safe-core-sdk/tests/safeFactory.test.ts b/packages/safe-core-sdk/tests/safeFactory.test.ts index 7b8189301..cc14300b2 100644 --- a/packages/safe-core-sdk/tests/safeFactory.test.ts +++ b/packages/safe-core-sdk/tests/safeFactory.test.ts @@ -5,6 +5,7 @@ import { safeVersionDeployed } from '../hardhat/deploy/deploy-contracts' import { ContractNetworksConfig, DeploySafeProps, + PredictSafeProps, SafeAccountConfig, SafeDeploymentConfig, SafeFactory @@ -95,6 +96,93 @@ describe('Safe Proxy Factory', () => { }) }) + describe('predictSafeAddress', async () => { + it('should fail if there are no owners', async () => { + const { accounts, contractNetworks } = await setupTests() + const [account1] = accounts + const ethAdapter = await getEthAdapter(account1.signer) + const safeFactory = await SafeFactory.create({ ethAdapter, contractNetworks }) + const owners: string[] = [] + const threshold = 2 + const safeAccountConfig: SafeAccountConfig = { owners, threshold } + const safeDeploymentConfig: SafeDeploymentConfig = { saltNonce: '1' } + const predictSafeProps: PredictSafeProps = { safeAccountConfig, safeDeploymentConfig } + await chai + .expect(safeFactory.predictSafeAddress(predictSafeProps)) + .rejectedWith('Owner list must have at least one owner') + }) + + it('should fail if the threshold is lower than 0', async () => { + const { accounts, contractNetworks } = await setupTests() + const [account1, account2] = accounts + const ethAdapter = await getEthAdapter(account1.signer) + const safeFactory = await SafeFactory.create({ ethAdapter, contractNetworks }) + const owners = [account1.address, account2.address] + const threshold = 0 + const safeAccountConfig: SafeAccountConfig = { owners, threshold } + const safeDeploymentConfig: SafeDeploymentConfig = { saltNonce: '1' } + const predictSafeProps: PredictSafeProps = { safeAccountConfig, safeDeploymentConfig } + await chai + .expect(safeFactory.predictSafeAddress(predictSafeProps)) + .rejectedWith('Threshold must be greater than or equal to 1') + }) + + it('should fail if the threshold is higher than the threshold', async () => { + const { accounts, contractNetworks } = await setupTests() + const [account1, account2] = accounts + const ethAdapter = await getEthAdapter(account1.signer) + const safeFactory = await SafeFactory.create({ ethAdapter, contractNetworks }) + const owners = [account1.address, account2.address] + const threshold = 3 + const safeAccountConfig: SafeAccountConfig = { owners, threshold } + const safeDeploymentConfig: SafeDeploymentConfig = { saltNonce: '1' } + const predictSafeProps: PredictSafeProps = { safeAccountConfig, safeDeploymentConfig } + await chai + .expect(safeFactory.predictSafeAddress(predictSafeProps)) + .rejectedWith('Threshold must be lower than or equal to owners length') + }) + + it('should fail if the saltNonce is lower than 0', async () => { + const { accounts, contractNetworks } = await setupTests() + const [account1, account2] = accounts + const ethAdapter = await getEthAdapter(account1.signer) + const safeFactory = await SafeFactory.create({ + ethAdapter, + safeVersion: safeVersionDeployed, + contractNetworks + }) + const owners = [account1.address, account2.address] + const threshold = 2 + const safeAccountConfig: SafeAccountConfig = { owners, threshold } + const safeDeploymentConfig: SafeDeploymentConfig = { saltNonce: '-1' } + const predictSafeProps: PredictSafeProps = { safeAccountConfig, safeDeploymentConfig } + await chai + .expect(safeFactory.predictSafeAddress(predictSafeProps)) + .rejectedWith('saltNonce must be greater than or equal to 0') + }) + + it('should predict a new Safe with saltNonce', async () => { + const { accounts, contractNetworks } = await setupTests() + const [account1, account2] = accounts + const ethAdapter = await getEthAdapter(account1.signer) + const safeFactory = await SafeFactory.create({ + ethAdapter, + safeVersion: safeVersionDeployed, + contractNetworks + }) + const owners = [account1.address, account2.address] + const threshold = 2 + const safeAccountConfig: SafeAccountConfig = { owners, threshold } + const safeDeploymentConfig: SafeDeploymentConfig = { saltNonce: '12345' } + const predictSafeProps: PredictSafeProps = { safeAccountConfig, safeDeploymentConfig } + const counterfactualSafeAddress = await safeFactory.predictSafeAddress(predictSafeProps) + const deploySafeProps: DeploySafeProps = { safeAccountConfig, safeDeploymentConfig } + const safe = await safeFactory.deploySafe(deploySafeProps) + const safeAddress = await safe.getAddress() + chai.expect(counterfactualSafeAddress).to.be.eq(safeAddress) + }) + }) + describe('deploySafe', async () => { it('should fail if there are no owners', async () => { const { accounts, contractNetworks } = await setupTests() @@ -105,7 +193,7 @@ describe('Safe Proxy Factory', () => { const threshold = 2 const safeAccountConfig: SafeAccountConfig = { owners, threshold } const safeDeployProps: DeploySafeProps = { safeAccountConfig } - chai + await chai .expect(safeFactory.deploySafe(safeDeployProps)) .rejectedWith('Owner list must have at least one owner') }) @@ -119,7 +207,7 @@ describe('Safe Proxy Factory', () => { const threshold = 0 const safeAccountConfig: SafeAccountConfig = { owners, threshold } const safeDeployProps: DeploySafeProps = { safeAccountConfig } - chai + await chai .expect(safeFactory.deploySafe(safeDeployProps)) .rejectedWith('Threshold must be greater than or equal to 1') }) @@ -133,7 +221,7 @@ describe('Safe Proxy Factory', () => { const threshold = 3 const safeAccountConfig: SafeAccountConfig = { owners, threshold } const deploySafeProps: DeploySafeProps = { safeAccountConfig } - chai + await chai .expect(safeFactory.deploySafe(deploySafeProps)) .rejectedWith('Threshold must be lower than or equal to owners length') }) @@ -150,11 +238,11 @@ describe('Safe Proxy Factory', () => { const owners = [account1.address, account2.address] const threshold = 2 const safeAccountConfig: SafeAccountConfig = { owners, threshold } - const safeDeploymentConfig: SafeDeploymentConfig = { saltNonce: -1 } + const safeDeploymentConfig: SafeDeploymentConfig = { saltNonce: '-1' } const safeDeployProps: DeploySafeProps = { safeAccountConfig, safeDeploymentConfig } - chai + await chai .expect(safeFactory.deploySafe(safeDeployProps)) - .rejectedWith('saltNonce must be greater than 0') + .rejectedWith('saltNonce must be greater than or equal to 0') }) it('should deploy a new Safe without saltNonce', async () => { @@ -189,7 +277,7 @@ describe('Safe Proxy Factory', () => { const owners = [account1.address, account2.address] const threshold = 2 const safeAccountConfig: SafeAccountConfig = { owners, threshold } - const safeDeploymentConfig: SafeDeploymentConfig = { saltNonce: 1 } + const safeDeploymentConfig: SafeDeploymentConfig = { saltNonce: '1' } const deploySafeProps: DeploySafeProps = { safeAccountConfig, safeDeploymentConfig } const safe = await safeFactory.deploySafe(deploySafeProps) const deployedSafeOwners = await safe.getOwners() diff --git a/packages/safe-ethers-lib/src/EthersAdapter.ts b/packages/safe-ethers-lib/src/EthersAdapter.ts index c86d0160b..707e8a179 100644 --- a/packages/safe-ethers-lib/src/EthersAdapter.ts +++ b/packages/safe-ethers-lib/src/EthersAdapter.ts @@ -72,6 +72,10 @@ class EthersAdapter implements EthAdapter { return (await this.#provider.getNetwork()).chainId } + getChecksummedAddress(address: string): string { + return this.#ethers.utils.getAddress(address) + } + getSafeContract({ safeVersion, chainId, @@ -159,6 +163,10 @@ class EthersAdapter implements EthAdapter { call(transaction: EthAdapterTransaction): Promise { return this.#provider.call(transaction) } + + encodeParameters(types: string[], values: any[]) { + return new this.#ethers.utils.AbiCoder().encode(types, values) + } } export default EthersAdapter diff --git a/packages/safe-ethers-lib/src/contracts/GnosisSafeProxyFactory/GnosisSafeProxyFactoryEthersContract.ts b/packages/safe-ethers-lib/src/contracts/GnosisSafeProxyFactory/GnosisSafeProxyFactoryEthersContract.ts index 4db19c4e2..7c515ab4f 100644 --- a/packages/safe-ethers-lib/src/contracts/GnosisSafeProxyFactory/GnosisSafeProxyFactoryEthersContract.ts +++ b/packages/safe-ethers-lib/src/contracts/GnosisSafeProxyFactory/GnosisSafeProxyFactoryEthersContract.ts @@ -1,3 +1,4 @@ +import { BigNumber } from '@ethersproject/bignumber' import { Event } from '@ethersproject/contracts' import { GnosisSafeProxyFactoryContract } from '@gnosis.pm/safe-core-sdk-types' import { ProxyFactory as ProxyFactory_V1_1_1 } from '../../../typechain/src/ethers-v5/v1.1.1/ProxyFactory' @@ -7,7 +8,7 @@ import { EthersTransactionOptions } from '../../types' export interface CreateProxyProps { safeMasterCopyAddress: string initializer: string - saltNonce: number + saltNonce: string options?: EthersTransactionOptions callback?: (txHash: string) => void } @@ -19,6 +20,10 @@ class GnosisSafeProxyFactoryEthersContract implements GnosisSafeProxyFactoryCont return this.contract.address } + async proxyCreationCode(): Promise { + return this.contract.proxyCreationCode() + } + async createProxy({ safeMasterCopyAddress, initializer, @@ -26,9 +31,8 @@ class GnosisSafeProxyFactoryEthersContract implements GnosisSafeProxyFactoryCont options, callback }: CreateProxyProps): Promise { - if (saltNonce < 0) { - throw new Error('saltNonce must be greater than 0') - } + if (BigNumber.from(saltNonce).lt(0)) + throw new Error('saltNonce must be greater than or equal to 0') if (options && !options.gasLimit) { options.gasLimit = await this.estimateGas( 'createProxyWithNonce', diff --git a/packages/safe-web3-lib/src/Web3Adapter.ts b/packages/safe-web3-lib/src/Web3Adapter.ts index cadae2285..dd35c887c 100644 --- a/packages/safe-web3-lib/src/Web3Adapter.ts +++ b/packages/safe-web3-lib/src/Web3Adapter.ts @@ -57,6 +57,10 @@ class Web3Adapter implements EthAdapter { return this.#web3.eth.getChainId() } + getChecksummedAddress(address: string): string { + return this.#web3.utils.toChecksumAddress(address) + } + getSafeContract({ safeVersion, chainId, @@ -187,6 +191,10 @@ class Web3Adapter implements EthAdapter { call(transaction: EthAdapterTransaction): Promise { return this.#web3.eth.call(transaction) } + + encodeParameters(types: string[], values: any[]): string { + return this.#web3.eth.abi.encodeParameters(types, values) + } } export default Web3Adapter diff --git a/packages/safe-web3-lib/src/contracts/GnosisSafeProxyFactory/GnosisSafeProxyFactoryWeb3Contract.ts b/packages/safe-web3-lib/src/contracts/GnosisSafeProxyFactory/GnosisSafeProxyFactoryWeb3Contract.ts index c2d492a5f..2d7491424 100644 --- a/packages/safe-web3-lib/src/contracts/GnosisSafeProxyFactory/GnosisSafeProxyFactoryWeb3Contract.ts +++ b/packages/safe-web3-lib/src/contracts/GnosisSafeProxyFactory/GnosisSafeProxyFactoryWeb3Contract.ts @@ -1,3 +1,4 @@ +import { BigNumber } from '@ethersproject/bignumber' import { GnosisSafeProxyFactoryContract } from '@gnosis.pm/safe-core-sdk-types' import { TransactionReceipt } from 'web3-core/types' import { ProxyFactory as ProxyFactory_V1_1_1 } from '../../../typechain/src/web3-v1/v1.1.1/proxy_factory' @@ -8,7 +9,7 @@ import { toTxResult } from '../../utils' export interface CreateProxyProps { safeMasterCopyAddress: string initializer: string - saltNonce: number + saltNonce: string options?: Web3TransactionOptions callback?: (txHash: string) => void } @@ -20,6 +21,10 @@ class GnosisSafeProxyFactoryWeb3Contract implements GnosisSafeProxyFactoryContra return this.contract.options.address } + async proxyCreationCode(): Promise { + return this.contract.methods.proxyCreationCode().call() + } + async createProxy({ safeMasterCopyAddress, initializer, @@ -27,9 +32,8 @@ class GnosisSafeProxyFactoryWeb3Contract implements GnosisSafeProxyFactoryContra options, callback }: CreateProxyProps): Promise { - if (saltNonce < 0) { - throw new Error('saltNonce must be greater than 0') - } + if (BigNumber.from(saltNonce).lt(0)) + throw new Error('saltNonce must be greater than or equal to 0') if (options && !options.gas) { options.gas = await this.estimateGas( 'createProxyWithNonce',