Skip to content

Commit

Permalink
feat(protocol-kit): Add deployment functionality to Safe class (#980)
Browse files Browse the repository at this point in the history
* feat(protocol-kit): remove SafeFactory

* add getSafeAddressFromDeploymentTx util fn

* remove throw in getAddress

* Add getProxyCreationEvent util fn

* fix: addresses and non-named event parameters

* fix: transaction type conversion

* fix: restore predictSafe tests

* docs: update Safe deployment readme and playground

---------

Co-authored-by: Daniel <[email protected]>
Co-authored-by: Yago Pérez Vázquez <[email protected]>
  • Loading branch information
3 people authored Sep 27, 2024
1 parent dc602ed commit a6c49eb
Show file tree
Hide file tree
Showing 28 changed files with 1,282 additions and 1,088 deletions.
75 changes: 55 additions & 20 deletions guides/integrating-the-safe-core-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ const apiKit = new SafeApiKit({
### Initialize the Protocol Kit

```js
import Safe, { SafeFactory } from '@safe-global/protocol-kit'

const safeFactory = await SafeFactory.init({ provider, signer })
import Safe from '@safe-global/protocol-kit'

const protocolKit = await Safe.init({ provider, signer, safeAddress })
```
Expand All @@ -67,7 +65,6 @@ There are two versions of the Safe contracts: [Safe.sol](https://github.com/safe
By default `Safe.sol` will be only used on Ethereum Mainnet. For the rest of the networks where the Safe contracts are already deployed, the `SafeL2.sol` contract will be used unless you add the property `isL1SafeSingleton` to force the use of the `Safe.sol` contract.

```js
const safeFactory = await SafeFactory.init({ provider, signer, isL1SafeSingleton: true })

const protocolKit = await Safe.init({ provider, signer, safeAddress, isL1SafeSingleton: true })
```
Expand Down Expand Up @@ -102,36 +99,74 @@ const contractNetworks: ContractNetworksConfig = {
}
}

const safeFactory = await SafeFactory.init({ provider, signer, contractNetworks })

const protocolKit = await Safe.init({ provider, signer, safeAddress, contractNetworks })
```

The `SafeFactory` constructor also accepts the property `safeVersion` to specify the Safe contract version that will be deployed. This string can take the values `1.0.0`, `1.1.1`, `1.2.0`, `1.3.0` or `1.4.1`. If not specified, the `DEFAULT_SAFE_VERSION` value will be used.

```js
const safeVersion = 'X.Y.Z'
const safeFactory = await SafeFactory.init({ provider, signer, safeVersion })
```

## <a name="deploy-safe">3. Deploy a new Safe</a>

The Protocol Kit library allows the deployment of new Safes using the `safeFactory` instance we just created.
The Protocol Kit library now simplifies the creation of new Safes by providing the `createSafeDeploymentTransaction` method. This method returns an Ethereum transaction object ready for execution, which includes the deployment of a Safe.

Here, for example, we can create a new Safe account with 3 owners and 2 required signatures.
Here is an example of how to create a new Safe account with 3 owners and 2 required signatures:

```js
import { SafeAccountConfig } from '@safe-global/protocol-kit'

const safeAccountConfig: SafeAccountConfig = {
owners: ['0x...', '0x...', '0x...']
threshold: 2,
// ... (optional params)
owners: ['0x...', '0x...', '0x...'],
threshold: 2
// Additional optional parameters can be included here
}
const protocolKit = await safeFactory.deploySafe({ safeAccountConfig })

const predictSafe = {
safeAccountConfig,
safeDeploymentConfig: {
saltNonce, // optional parameter
safeVersion // optional parameter
}
}

const protocolKit = await Safe.init({ provider, signer, predictSafe })

const deploymentTransaction = await protocolKit.createSafeDeploymentTransaction()

// Execute this transaction using the Ethereum client of your choice
const txHash = await client.sendTransaction({
to: deploymentTransaction.to,
value: BigInt(deploymentTransaction.value),
data: `0x${deploymentTransaction.data}`
})

```

Calling the method `deploySafe` will deploy the desired Safe and return a Protocol Kit initialized instance ready to be used. Check the [API Reference](https://github.com/safe-global/safe-core-sdk/tree/main/packages/protocol-kit#deploysafe) for more details on additional configuration parameters and callbacks.
Once you obtain the `deploymentTransaction` object, you will have an Ethereum transaction object containing the `to`, `value`, and `data` fields. You can execute this transaction using the Ethereum client of your choice. Check the [API Reference](https://github.com/safe-global/safe-core-sdk/tree/main/packages/protocol-kit#deploysafe) for more details on additional configuration parameters.

After successfully executing the transaction and confirming that the Safe has been deployed, you will need to reconnect to the new Safe address. Use the `connect` method to reinitialize the protocol-kit instance with the deployed Safe address:

```js
// Execute this transaction using the Ethereum client of your choice
const txHash = await client.sendTransaction({
to: deploymentTransaction.to,
value: BigInt(deploymentTransaction.value),
data: `0x${deploymentTransaction.data}`
})

console.log('Transaction hash:', txHash)

const txReceipt = await waitForTransactionReceipt(client, { hash: txHash })

// Extract the Safe address from the deployment transaction receipt
const safeAddress = getSafeAddressFromDeploymentTx(txReceipt, safeVersion)

console.log('safeAddress:', safeAddress)

// Reinitialize the instance of protocol-kit using the obtained Safe address
protocolKit.connect({ safeAddress })

console.log('is Safe deployed:', await protocolKit.isSafeDeployed())
console.log('Safe Address:', await protocolKit.getAddress())
console.log('Safe Owners:', await protocolKit.getOwners())
console.log('Safe Threshold:', await protocolKit.getThreshold())
```

## <a name="create-transaction">4. Create a transaction</a>

Expand Down
150 changes: 97 additions & 53 deletions packages/protocol-kit/src/Safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import {
encodeSetupCallData,
getChainSpecificDefaultSaltNonce,
getPredictedSafeAddressInitCode,
predictSafeAddress
predictSafeAddress,
validateSafeAccountConfig,
validateSafeDeploymentConfig
} from './contracts/utils'
import { ContractInfo, DEFAULT_SAFE_VERSION, getContractInfo } from './contracts/config'
import ContractManager from './managers/contractManager'
Expand Down Expand Up @@ -877,7 +879,7 @@ class Safe {
*/
async getOwnersWhoApprovedTx(txHash: string): Promise<string[]> {
if (!this.#contractManager.safeContract) {
throw new Error('Safe is not deployed')
return []
}

const owners = await this.getOwners()
Expand Down Expand Up @@ -908,6 +910,13 @@ class Safe {
fallbackHandlerAddress: string,
options?: SafeTransactionOptionalProps
): Promise<SafeTransaction> {
const safeVersion = await this.getContractVersion()
if (this.#predictedSafe && !hasSafeFeature(SAFE_FEATURES.ACCOUNT_ABSTRACTION, safeVersion)) {
throw new Error(
'Account Abstraction functionality is not available for Safes with version lower than v1.3.0'
)
}

const safeTransactionData = {
to: await this.getAddress(),
value: '0',
Expand All @@ -933,6 +942,13 @@ class Safe {
async createDisableFallbackHandlerTx(
options?: SafeTransactionOptionalProps
): Promise<SafeTransaction> {
const safeVersion = await this.getContractVersion()
if (this.#predictedSafe && !hasSafeFeature(SAFE_FEATURES.ACCOUNT_ABSTRACTION, safeVersion)) {
throw new Error(
'Account Abstraction functionality is not available for Safes with version lower than v1.3.0'
)
}

const safeTransactionData = {
to: await this.getAddress(),
value: '0',
Expand Down Expand Up @@ -1307,31 +1323,9 @@ class Safe {
? await this.toSafeTransactionType(safeTransaction)
: safeTransaction

const signedSafeTransaction = await this.copyTransaction(transaction)
const signedSafeTransaction = await this.#addPreValidatedSignature(transaction)

const txHash = await this.getTransactionHash(signedSafeTransaction)
const ownersWhoApprovedTx = await this.getOwnersWhoApprovedTx(txHash)
for (const owner of ownersWhoApprovedTx) {
signedSafeTransaction.addSignature(generatePreValidatedSignature(owner))
}
const threshold = await this.getThreshold()
const signerAddress = await this.#safeProvider.getSignerAddress()
if (!signerAddress) {
throw new Error('The protocol-kit requires a signer to use this method')
}
const addressIsOwner = await this.isOwner(signerAddress)
if (threshold > signedSafeTransaction.signatures.size && addressIsOwner) {
signedSafeTransaction.addSignature(generatePreValidatedSignature(signerAddress))
}

if (threshold > signedSafeTransaction.signatures.size) {
const signaturesMissing = threshold - signedSafeTransaction.signatures.size
throw new Error(
`There ${signaturesMissing > 1 ? 'are' : 'is'} ${signaturesMissing} signature${
signaturesMissing > 1 ? 's' : ''
} missing`
)
}
await this.#isReadyToExecute(signedSafeTransaction)

const value = BigInt(signedSafeTransaction.data.value)
if (value !== 0n) {
Expand All @@ -1341,6 +1335,8 @@ class Safe {
}
}

const signerAddress = await this.#safeProvider.getSignerAddress()

const txResponse = await this.#contractManager.safeContract.execTransaction(
signedSafeTransaction,
{
Expand All @@ -1351,6 +1347,58 @@ class Safe {
return txResponse
}

/**
* Adds a PreValidatedSignature to the transaction if the threshold is not reached.
*
* @async
* @param {SafeTransaction} transaction - The transaction to add a signature to.
* @returns {Promise<SafeTransaction>} A promise that resolves to the signed transaction.
*/
async #addPreValidatedSignature(transaction: SafeTransaction): Promise<SafeTransaction> {
const signedSafeTransaction = await this.copyTransaction(transaction)

const txHash = await this.getTransactionHash(signedSafeTransaction)
const ownersWhoApprovedTx = await this.getOwnersWhoApprovedTx(txHash)

for (const owner of ownersWhoApprovedTx) {
signedSafeTransaction.addSignature(generatePreValidatedSignature(owner))
}

const owners = await this.getOwners()
const threshold = await this.getThreshold()
const signerAddress = await this.#safeProvider.getSignerAddress()

if (
threshold > signedSafeTransaction.signatures.size &&
signerAddress &&
owners.includes(signerAddress)
) {
signedSafeTransaction.addSignature(generatePreValidatedSignature(signerAddress))
}

return signedSafeTransaction
}

/**
* Checks if the transaction has enough signatures to be executed.
*
* @async
* @param {SafeTransaction} transaction - The Safe transaction to check.
* @throws Will throw an error if the required number of signatures is not met.
*/
async #isReadyToExecute(transaction: SafeTransaction) {
const threshold = await this.getThreshold()

if (threshold > transaction.signatures.size) {
const signaturesMissing = threshold - transaction.signatures.size
throw new Error(
`There ${signaturesMissing > 1 ? 'are' : 'is'} ${signaturesMissing} signature${
signaturesMissing > 1 ? 's' : ''
} missing`
)
}
}

/**
* Returns the Safe Transaction encoded
*
Expand Down Expand Up @@ -1397,15 +1445,13 @@ class Safe {
* @async
* @param {SafeTransaction} safeTransaction - The Safe transaction to be wrapped into the deployment batch.
* @param {TransactionOptions} [transactionOptions] - Optional. Options for the transaction, such as from, gas price, gas limit, etc.
* @param {string} [customSaltNonce] - Optional. a Custom salt nonce to be used for the deployment of the Safe. If not provided, a default value is used.
* @returns {Promise<Transaction>} A promise that resolves to a Transaction object representing the prepared batch of transactions.
* @throws Will throw an error if the safe is already deployed.
*
*/
async wrapSafeTransactionIntoDeploymentBatch(
safeTransaction: SafeTransaction,
transactionOptions?: TransactionOptions,
customSaltNonce?: string
transactionOptions?: TransactionOptions
): Promise<Transaction> {
const isSafeDeployed = await this.isSafeDeployed()

Expand All @@ -1415,7 +1461,7 @@ class Safe {
}

// we create the deployment transaction
const safeDeploymentTransaction = await this.createSafeDeploymentTransaction(customSaltNonce)
const safeDeploymentTransaction = await this.createSafeDeploymentTransaction()

// First transaction of the batch: The Safe deployment Transaction
const safeDeploymentBatchTransaction = {
Expand Down Expand Up @@ -1443,31 +1489,35 @@ class Safe {
}

/**
* Creates a Safe deployment transaction.
*
* This function prepares a transaction for the deployment of a Safe.
* Both the saltNonce and options parameters are optional, and if not
* provided, default values will be used.
*
* @async
* @param {string} [customSaltNonce] - Optional. a Custom salt nonce to be used for the deployment of the Safe. If not provided, a default value is used.
* @param {TransactionOptions} [options] - Optional. Options for the transaction, such as gas price, gas limit, etc.
* @returns {Promise<Transaction>} A promise that resolves to a Transaction object representing the prepared Safe deployment transaction.
* Creates a transaction to deploy a Safe Account.
*
* @returns {Promise<Transaction>} Returns a promise that resolves to an Ethereum transaction with the fields `to`, `value`, and `data`, which can be used to deploy the Safe Account.
*/
async createSafeDeploymentTransaction(
customSaltNonce?: string,
transactionOptions?: TransactionOptions
): Promise<Transaction> {
async createSafeDeploymentTransaction(): Promise<Transaction> {
if (!this.#predictedSafe) {
throw new Error('Predict Safe should be present')
throw new Error('Predict Safe should be present to build the Safe deployement transaction')
}

const { safeAccountConfig, safeDeploymentConfig } = this.#predictedSafe
const { safeAccountConfig, safeDeploymentConfig = {} } = this.#predictedSafe

validateSafeAccountConfig(safeAccountConfig)
validateSafeDeploymentConfig(safeDeploymentConfig)

const safeVersion = this.getContractVersion()
const safeProvider = this.#safeProvider
const chainId = await safeProvider.getChainId()
const safeVersion = safeDeploymentConfig?.safeVersion || DEFAULT_SAFE_VERSION
const saltNonce = safeDeploymentConfig?.saltNonce || getChainSpecificDefaultSaltNonce(chainId)

// we only check if the safe is deployed if safeVersion >= 1.3.0
if (hasSafeFeature(SAFE_FEATURES.ACCOUNT_ABSTRACTION, safeVersion)) {
const isSafeDeployed = await this.isSafeDeployed()

// if the safe is already deployed throws an error
if (isSafeDeployed) {
throw new Error('Safe already deployed')
}
}

const isL1SafeSingleton = this.#contractManager.isL1SafeSingleton
const customContracts = this.#contractManager.contractNetworks?.[chainId.toString()]

Expand All @@ -1493,13 +1543,7 @@ class Safe {
customContracts
})

const saltNonce =
customSaltNonce ||
safeDeploymentConfig?.saltNonce ||
getChainSpecificDefaultSaltNonce(chainId)

const safeDeployTransactionData = {
...transactionOptions, // optional transaction options like from, gasLimit, gasPrice...
to: safeProxyFactoryContract.getAddress(),
value: '0',
// we use the createProxyWithNonce method to create the Safe in a deterministic address, see: https://github.com/safe-global/safe-contracts/blob/main/contracts/proxies/SafeProxyFactory.sol#L52
Expand Down
Loading

0 comments on commit a6c49eb

Please sign in to comment.