Execute transactions
In this quickstart guide, you will create a 2 of 3 multi-sig Safe and propose and execute a transaction to send some ETH out of this Safe.
To find more details and configuration options for available methods, see the Protocol Kit reference.
Prerequisites
- Node.js and npm (opens in a new tab)
- Three externally-owned accounts with Testnet ETH in at least one account
Install dependencies
First, we need to install some dependencies.
_10yarn add @safe-global/protocol-kit \_10 @safe-global/api-kit \_10 @safe-global/safe-core-sdk-types
Initialize Signers and Providers
The signers trigger transactions to the Ethereum blockchain or off-chain transactions. The provider connects to the Ethereum blockchain.
You can get a public RPC URL from Chainlist (opens in a new tab), however, public RPC URLs can be unreliable so you can also try a dedicated provider like Infura or Alchemy.
For this guide, we will be creating a Safe on the Sepolia Testnet.
_14// https://chainlist.org/?search=sepolia&testnets=true_14const RPC_URL = 'https://eth-sepolia.public.blastapi.io'_14_14// Initialize signers_14const OWNER_1_ADDRESS = // ..._14const OWNER_1_PRIVATE_KEY = // ..._14_14const OWNER_2_ADDRESS = // ..._14const OWNER_2_PRIVATE_KEY = // ..._14_14const OWNER_3_ADDRESS = // ..._14_14const provider = new ethers.JsonRpcProvider(RPC_URL)_14const owner1Signer = new ethers.Wallet(OWNER_1_PRIVATE_KEY, provider)
Initialize the API Kit
The API Kit (opens in a new tab) consumes the Safe Transaction Service API (opens in a new tab). To use this library, create a new instance of the SafeApiKit
class, imported from @safe-global/api-kit
. In chains where Safe provides a Transaction Service, it's enough to specify the chainId.
You can specify your own service using the optional txServiceUrl
parameter.
You will be using Sepolia for this tutorial, however, you can also get service URLs for different networks.
_11import SafeApiKit from '@safe-global/api-kit'_11_11const apiKit = new SafeApiKit({_11 chainId: 1n_11})_11_11// Or using a custom service_11const apiKit = new SafeApiKit({_11 chainId: 1n, // set the correct chainId_11 txServiceUrl: 'https://url-to-your-custom-service'_11})
Initialize the Protocol Kit
The SafeFactory
class allows the deployment of new Safe accounts while the Safe
class represents an instance of a specific one.
_10import { SafeFactory } from '@safe-global/protocol-kit'_10_10const safeFactory = await SafeFactory.init({_10 provider: RPC_URL,_10 signer: OWNER_1_PRIVATE_KEY_10})
Deploy a Safe
Calling the deploySafe
method will deploy the desired Safe and return a Protocol Kit initialized instance ready to be used. Check the method reference for more details on additional configuration parameters and callbacks.
_20import { SafeAccountConfig } from '@safe-global/protocol-kit'_20_20const safeAccountConfig: SafeAccountConfig = {_20 owners: [_20 await OWNER_1_ADDRESS,_20 await OWNER_2_ADDRESS,_20 await OWNER_3_ADDRESS_20 ],_20 threshold: 2,_20 // Optional params_20}_20_20/* This Safe is tied to owner 1 because the factory was initialized with the owner 1 as the signer. */_20const protocolKitOwner1 = await safeFactory.deploySafe({ safeAccountConfig })_20_20const safeAddress = await protocolKitOwner1.getAddress()_20_20console.log('Your Safe has been deployed:')_20console.log(`https://sepolia.etherscan.io/address/${safeAddress}`)_20console.log(`https://app.safe.global/sep:${safeAddress}`)
Send ETH to the Safe
You will send some ETH to this Safe.
_13const safeAddress = protocolKit.getAddress()_13_13const safeAmount = ethers.parseUnits('0.01', 'ether').toHexString()_13_13const transactionParameters = {_13 to: safeAddress,_13 value: safeAmount_13}_13_13const tx = await owner1Signer.sendTransaction(transactionParameters)_13_13console.log('Fundraising.')_13console.log(`Deposit Transaction: https://sepolia.etherscan.io/tx/${tx.hash}`)
Making a transaction from a Safe
The first signer will sign and propose a transaction to send 0.005 ETH out of the Safe. Then, the second signer will add their own proposal and execute the transaction since it meets the 2 of 3 thresholds.
At a high level, making a transaction from the Safe requires the following steps:
Overview
The high-level overview of a multi-sig transaction is PCE: Propose. Confirm. Execute.
- First signer proposes a transaction
- Create transaction: define the amount, destination, and any additional data
- Perform an off-chain signature of the transaction before proposing
- Submit the transaction and signature to the Safe Transaction Service
- Second signer confirms the transaction
- Get pending transactions from the Safe service
- Perform an off-chain signature of the transaction
- Submit the signature to the service
- Anyone executes the transaction
- In this example, the first signer executes the transaction
- Anyone can get the pending transaction from the Safe service
- Account executing the transaction pays the gas fee
Create a transaction
For more details on what to include in a transaction see createTransaction
method.
_13import { MetaTransactionData } from '@safe-global/safe-core-sdk-types'_13_13// Any address can be used. In this example you will use vitalik.eth_13const destination = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'_13const amount = ethers.parseUnits('0.005', 'ether').toString()_13_13const safeTransactionData: MetaTransactionData = {_13 to: destination,_13 data: '0x',_13 value: amount_13}_13// Create a Safe transaction with the provided parameters_13const safeTransaction = await protocolKitOwner1.createTransaction({ transactions: [safeTransactionData] })
Track the Safe transaction
Optionally, you can track all your Safe transactions on-chain by attaching an on-chain identifier to the data
property.
This identifier must be unique for every project and has a length of 16 bytes. You can create a random one or derive it from a text string, maybe from your project name:
_10const onchainIdentifier = toHex(_10 'TEXT_TO_DERIVE_THE_IDENTIFIER', // It could be your project name_10 { size: 16 }_10)
Once generated, fill the Ecosystem On-chain Tracking Form (opens in a new tab) and provide the value of your onchainIdentifier
.
Add the onchainIdentifier
at the end of the Safe transaction data
.
_10safeTransaction.data.data = concat([_10 safeOperation.data.data as `0x{string}`,_10 onchainIdentifier_10]).toString()
Propose the transaction
To propose a transaction to the Safe Transaction Service we need to call the proposeTransaction
method from the API Kit instance.
For a full list and description of the properties see proposeTransaction
in the API Kit reference.
_13// Deterministic hash based on transaction parameters_13const safeTxHash = await protocolKitOwner1.getTransactionHash(safeTransaction)_13_13// Sign transaction to verify that the transaction is coming from owner 1_13const senderSignature = await protocolKitOwner1.signHash(safeTxHash)_13_13await apiKit.proposeTransaction({_13 safeAddress,_13 safeTransactionData: safeTransaction.data,_13 safeTxHash,_13 senderAddress: OWNER_1_ADDRESS,_13 senderSignature: senderSignature.data_13})
Get pending transactions
_10const pendingTransactions = (await apiKit.getPendingTransactions(safeAddress)).results
Confirm the transaction: Second confirmation
When owner 2 is connected to the application, the Protocol Kit should be initialized again with the existing Safe address the address of the owner 2 instead of the owner 1.
_12// Assumes that the first pending transaction is the transaction you want to confirm_12const transaction = pendingTransactions[0]_12const safeTxHash = transaction.safeTxHash_12_12const protocolKitOwner2 = await Safe.init({_12 provider: RPC_URL,_12 signer: OWNER_2_PRIVATE_KEY,_12 safeAddress_12})_12_12const signature = await protocolKitOwner2.signHash(safeTxHash)_12const response = await apiKit.confirmTransaction(safeTxHash, signature.data)
Execute the transaction
Anyone can execute the Safe transaction once it has the required number of signatures. In this example, owner 1 will execute the transaction and pay for the gas fees.
_10const safeTransaction = await apiKit.getTransaction(safeTxHash)_10const executeTxResponse = await protocolKit.executeTransaction(safeTransaction)_10const receipt = await executeTxResponse.transactionResponse?.wait()_10_10console.log('Transaction executed:')_10console.log(`https://sepolia.etherscan.io/tx/${receipt.transactionHash}`)
Confirm that the transaction was executed
You know that the transaction was executed if the balance in your Safe changes.
_10const afterBalance = await protocolKit.getBalance()_10_10console.log(`The final balance of the Safe: ${ethers.formatUnits(afterBalance, 'ether')} ETH`)
_10$ node index.js_10_10Fundraising._10_10Initial balance of Safe: 0.01 ETH_10Buying a car._10The final balance of the Safe: 0.005 ETH
Conclusion
In this quickstart, you learned how to create and deploy a new Safe account and to propose and then execute a transaction from it.