How to Build a Multisig Setup
Learn how to create a multisig account and sign with all parties a userOperation.
If you are coming from the getting-started guide, you only a few changes to the Safe Ownership. Follow the highlighted code in this tutorial to see the diff.
Quickstart
Prerequisites
- Node and a package manager (yarn or npm)
- Bundler API key from the Candide Dashboard
- Some Sepolia ETH. Find Faucets here
- If you need, fork the full example of this tutorial on Github
Step 1: Get setup
- Create a new directory for your project:
mkdir safe-smart-multisig
cd safe-smart-multisig
yarn init
- Install required dependencies
yarn add abstractionkit dotenv
- Configure Environment Variables
- Create a .env file in ./src and add the following environment variables with your own values
- We will be using Sepolia Testnet for this example
CHAIN_ID=11155111
BUNDLER_URL=https://api.candide.dev/bundler/version/network/YOUR_API_KEY
JSON_RPC_NODE_PROVIDER=NODE_PROVIDER_URL
#account owner pub & private key
PUBLIC_ADDRESS1=
PRIVATE_KEY1=
PUBLIC_ADDRESS2=
PRIVATE_KEY2=
- Create a empty file and a function to run our script
async function main(): Promise<void> {
// Rest of the code will go here...
}
main();
Step 2: Generate Account Address
Let's open up index.ts
To generate an account address, we will be using the Safe Module/Fallback as our base smart account. An extension to the Safe contract that implements the ERC4337 interface. We will control the Account with a two EOA owners.
Since this is our first transaction, we will need to deploy the account in the same transaction
import * as dotenv from "dotenv";
import { SafeAccountV0_3_0 as SafeAccount } from "abstractionkit";
async function main(): Promise<void> {
//get values from .env
dotenv.config();
const ownerPublicAddress1 = process.env.PUBLIC_ADDRESS1 as string;
const ownerPublicAddress2 = process.env.PUBLIC_ADDRESS2 as string;
const smartAccount = SafeAccount.initializeNewAccount(
[ownerPublicAddress1, ownerPublicAddress2],
{ threshold: 2 }
)
console.log("Account address(sender) : " + smartAccount.accountAddress);
}
main();
Let's run this code
npx ts-node index.ts
If everything worked, you will get the calculate address of the account in the console.
Result example
Now that you have generated the smart account, go ahead and fund it with some Sepolia ETH here.
Too lazy to go through a faucet? Add a sponsor paymaster step.
Step 3: Generate the callData
We will be creating two transactions to mint 2 NFTs. You can use your fav library (like ethers or viem) to construct the calldata.
import {
...
MetaTransaction,
getFunctionSelector,
createCallData,
} from "abstractionkit";
const nftContractAddress = "0x9a7af758aE5d7B6aAE84fe4C5Ba67c041dFE5336";
const mintFunctionSignature = 'mint(address)';
const mintFunctionSelector = getFunctionSelector(mintFunctionSignature);
const mintTransactionCallData = createCallData(
mintFunctionSelector,
["address"],
[smartAccount.accountAddress]
);
const transaction1 :MetaTransaction ={
to: nftContractAddress,
value: 0n,
data: mintTransactionCallData,
}
const transaction2 :MetaTransaction ={
to: nftContractAddress,
value: 0n,
data: mintTransactionCallData,
}
Step 4: Create UserOperation
Now the fun part. Call createUserOperation
, which will determine the nonce, fetch the gas prices, estimate gas limits and return a userop to be signed. Use calculateUserOperationMaxGasCost
to calculate its cost.
import {
...
calculateUserOperationMaxGasCost
} from "abstractionkit";
const jsonRpcNodeProvider = process.env.JSON_RPC_NODE_PROVIDER as string
const bundlerUrl = process.env.BUNDLER_URL as string
let userOperation = await smartAccount.createUserOperation(
[transaction1, transaction2], // you can batch multiple transactions to be executed in one userop
jsonRpcNodeProvider, // the node rpc is used to fetch the current nonce and fetch gas prices.
bundlerUrl, // the bundler rpc is used to estimate the gas limits
)
const cost = calculateUserOperationMaxGasCost(userOperation)
console.log("This useroperation may cost upto : " + cost + " wei")
console.log("Please fund the sender account : " + userOperation.sender +" with more than " + cost + " wei")
Step 5: Sign and Submit
- Call
signUserOperation
, which will create a signature for the private keys provided of the owners of the Safe.
const chainId = BigInt(process.env.CHAIN_ID as string);
const privateKey1 = process.env.PRIVATE_KEY1 as string;
const privateKey2 = process.env.PRIVATE_KEY2 as string;
userOperation.signature = smartAccount.signUserOperation(
userOperation,
[privateKey1, privateKey2],
chainId,
)
- Use the Bundler RPC to send the userop to the bundler with
sendUserOperation
, and await the returnSendUseroperationResponse
object to confirm the on-chain inclusion of the user operation.
const sendUserOperationResponse = await smartAccount.sendUserOperation(userOperation, bundlerUrl)
console.log("UserOperation sent. Waiting to be included ......")
- Track the userOperation and wait for its inclusion onchain
let userOperationReceiptResult = await sendUserOperationResponse.included()
console.log("Useroperation receipt received.")
console.log(userOperationReceiptResult)
if (userOperationReceiptResult.success) {
console.log("Two Nfts were minted. The transaction hash is : " + userOperationReceiptResult.receipt.transactionHash)
} else {
console.log("Useroperation execution failed")
}
Now let's run this code again
npx ts-node index.ts
You've now sent your first userOperation with a multisig wallet! If everything went well, you should see the bundler returning a receipt
Result
Useroperation sent. Waiting to be included ......
Useroperation receipt received.
{
userOpHash: '0x1acede61123ab7116eb29c797aeaec3c03615c37732ba66428524aebdb4b4514',
entryPoint: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789',
sender: '0xb8741a449d50ed0dcfe395287f85be152884c8d9',
nonce: 0n,
paymaster: '0x3fe285dcd76bcce4ac92d38a6f2f8e964041e020',
actualGasCost: 8078496n,
actualGasUsed: 504906n,
success: true,
logs: '[{"address":"0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000007b44a0000000000000000000000000000000000000000000000000000000000007b44a","logIndex":"0x9a","removed":false,"topics":["0x49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f","0x1acede61123ab7116eb29c797aeaec3c03615c37732ba66428524aebdb4b4514","0x000000000000000000000000b8741a449d50ed0dcfe395287f85be152884c8d9","0x0000000000000000000000003fe285dcd76bcce4ac92d38a6f2f8e964041e020"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"}]',
receipt: {
blockHash: '0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c',
blockNumber: 10419807n,
from: '0x3cfdc212769c890907bce93d3d8c2c53de6a7a89',
cumulativeGasUsed: 6978990n,
gasUsed: 507053n,
logs: '[{"address":"0xb8741a449d50ed0dcfe395287f85be152884c8d9","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x","logIndex":"0x90","removed":false,"topics":["0xecdf3a3effea5783a3c4c2140e677577666428d44ed9d474a0b3a4c9943f8440","0x000000000000000000000000a581c4a4db7175302464ff3c06380bc3270b4037"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0xb8741a449d50ed0dcfe395287f85be152884c8d9","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000008ecd4ec46d4d2a6b64fe960b3d64e8b94b2234eb000000000000000000000000a581c4a4db7175302464ff3c06380bc3270b40370000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bdbc5fbc9ca8c3f514d073ec3de840ac84fc6d31","logIndex":"0x91","removed":false,"topics":["0x141df868a6331af528e38c83b7aa03edc19be66e37ae67f9285bf4f8e3c6a1a8","0x0000000000000000000000004e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec67"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0x4e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec67","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x00000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c762","logIndex":"0x92","removed":false,"topics":["0x4f51faf6c4561ff95f067657e43439f0f856d97c04d9ec9070a6199ad418e235","0x000000000000000000000000b8741a449d50ed0dcfe395287f85be152884c8d9"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x0000000000000000000000004e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec670000000000000000000000003fe285dcd76bcce4ac92d38a6f2f8e964041e020","logIndex":"0x93","removed":false,"topics":["0xd51a9c61267aa6196961883ecf5ff2da6619c37dac0fa92122513fb32c032d2d","0x1acede61123ab7116eb29c797aeaec3c03615c37732ba66428524aebdb4b4514","0x000000000000000000000000b8741a449d50ed0dcfe395287f85be152884c8d9"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x","logIndex":"0x94","removed":false,"topics":["0xbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f972"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0xb8741a449d50ed0dcfe395287f85be152884c8d9","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x000000000000000000000000a581c4a4db7175302464ff3c06380bc3270b403700000000000000000000000038869bf66a61cf6bdb996a6ae40d5853fd43b526000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001048d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000b200d9de104e3386d9a45a61bce269c43e48b534e4e7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041249c58b00d9de104e3386d9a45a61bce269c43e48b534e4e7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041249c58b000000000000000000000000000000000000000000000000000000000000000000000000000000000000","logIndex":"0x95","removed":false,"topics":["0xb648d3644f584ed1c2232d53c46d87e693586486ad0d1175f8656013110b714e"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0xd9de104e3386d9a45a61bce269c43e48b534e4e7","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x","logIndex":"0x96","removed":false,"topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000b8741a449d50ed0dcfe395287f85be152884c8d9","0x0000000000000000000000000000000000000000000000000000000000000056"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0xd9de104e3386d9a45a61bce269c43e48b534e4e7","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x","logIndex":"0x97","removed":false,"topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000b8741a449d50ed0dcfe395287f85be152884c8d9","0x0000000000000000000000000000000000000000000000000000000000000057"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0xb8741a449d50ed0dcfe395287f85be152884c8d9","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x","logIndex":"0x98","removed":false,"topics":["0x6895c13664aa4f67288b25d7a21d7aaa34916e355fb9b6fae0a139a9085becb8","0x000000000000000000000000a581c4a4db7175302464ff3c06380bc3270b4037"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0x3fe285dcd76bcce4ac92d38a6f2f8e964041e020","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x0000000000000000000000000000000000000000000000000000000000000000","logIndex":"0x99","removed":false,"topics":["0xa050a122b4c0e369e3385eb6b7cccd8019638b2764de67bec0af99130ddf8471","0x1acede61123ab7116eb29c797aeaec3c03615c37732ba66428524aebdb4b4514","0x000000000000000000000000b8741a449d50ed0dcfe395287f85be152884c8d9","0x0000000000000000000000000000000000000000000000000000000000000000"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"},{"address":"0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789","blockHash":"0xe19e52b4c222c1cdbc765f1a4e196ff4bf40c5550926e02974570e1845e88e2c","blockNumber":"0x9efe5f","data":"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000007b44a0000000000000000000000000000000000000000000000000000000000007b44a","logIndex":"0x9a","removed":false,"topics":["0x49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f","0x1acede61123ab7116eb29c797aeaec3c03615c37732ba66428524aebdb4b4514","0x000000000000000000000000b8741a449d50ed0dcfe395287f85be152884c8d9","0x0000000000000000000000003fe285dcd76bcce4ac92d38a6f2f8e964041e020"],"transactionHash":"0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa","transactionIndex":"0x1d"}]',
logsBloom: '0x0000040000009000000000000000010080000000000000000000000000000000000800000000000000020001000004040010000000000000800002000000000000001000000000000000000c0002000000000000010000080040000000000000020000000a0000000500002000000800008000000100000000000014000000000800010020000200008000000040000000000200000400000000000000000000000004000000000000500000000004000210000000000000000002001000000020200082000000000001000008000000000000002060000000100000000026000000082000010000000000000008100220000000000000000000000010000200',
transactionHash: '0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa',
transactionIndex: 29n,
effectiveGasPrice: 16n
}
}
Two Nfts were minted. The transaction hash is : 0xf43576a07f39660737a342c99a187eb70ac59d89bc0df92ff3c1bb8a8da370aa
You can lookup the hash on explorers that supports user operations like Blockscout.
Full Example
Below is the complete example code that you can copy directly to implement the functionality described in the tutorial.
index.js
import * as dotenv from 'dotenv'
import {
SafeAccountV0_3_0 as SafeAccount,
MetaTransaction,
calculateUserOperationMaxGasCost,
getFunctionSelector,
createCallData,
} from "abstractionkit";
async function main(): Promise<void> {
//get values from .env
dotenv.config()
const chainId = BigInt(process.env.CHAIN_ID as string)
const bundlerUrl = process.env.BUNDLER_URL as string
const jsonRpcNodeProvider = process.env.JSON_RPC_NODE_PROVIDER as string
const ownerPublicAddress1 = process.env.PUBLIC_ADDRESS1 as string
const ownerPrivateKey1 = process.env.PRIVATE_KEY1 as string
const ownerPublicAddress2 = process.env.PUBLIC_ADDRESS2 as string
const ownerPrivateKey2 = process.env.PRIVATE_KEY2 as string
//initializeNewAccount only needed when the smart account
//have not been deployed yet for its first useroperation.
//You can store the accountAddress to use it to initialize
//the SafeAccount object for the following useroperations
let smartAccount = SafeAccount.initializeNewAccount(
[ownerPublicAddress1, ownerPublicAddress2],
{
threshold:2
}
)
//After the account contract is deployed, no need to call initializeNewAccount
//let smartAccount = new SafeAccount(accountAddress)
console.log("Account address(sender) : " + smartAccount.accountAddress)
//create two meta transaction to mint two NFTs
//you can use favorite method (like ethers.js) to construct the call data
const nftContractAddress = "0x9a7af758aE5d7B6aAE84fe4C5Ba67c041dFE5336";
const mintFunctionSignature = 'mint(address)';
const mintFunctionSelector = getFunctionSelector(mintFunctionSignature);
const mintTransactionCallData = createCallData(
mintFunctionSelector,
["address"],
[smartAccount.accountAddress]
);
const transaction1 :MetaTransaction ={
to: nftContractAddress,
value: 0n,
data: mintTransactionCallData,
}
const transaction2 :MetaTransaction ={
to: nftContractAddress,
value: 0n,
data: mintTransactionCallData,
}
//createUserOperation will determine the nonce, fetch the gas prices,
//estimate gas limits and return a useroperation to be signed.
//you can override all these values using the overrides parameter.
let userOperation = await smartAccount.createUserOperation(
[
//You can batch multiple transactions to be executed in one useroperation.
transaction1, transaction2,
],
jsonRpcNodeProvider, //the node rpc is used to fetch the current nonce and fetch gas prices.
bundlerUrl, //the bundler rpc is used to estimate the gas limits.
)
const cost = calculateUserOperationMaxGasCost(userOperation)
console.log("This useroperation may cost upto : " + cost + " wei")
console.log(
"Please fund the sender account : " +
userOperation.sender +
" with more than " + cost + " wei"
)
//Safe is a multisig that can have multiple owners/signers
//signUserOperation will create a signature for the provided
//privateKeys
userOperation.signature = smartAccount.signUserOperation(
userOperation,
[ownerPrivateKey1, ownerPrivateKey2],
chainId
)
console.log(userOperation)
//use the bundler rpc to send a useroperation
//sendUserOperation will return a SendUseroperationResponse object
//that can be awaited for the useroperation to be included onchain
const sendUserOperationResponse = await smartAccount.sendUserOperation(
userOperation, bundlerUrl
)
console.log("Useroperation sent. Waiting to be included ......")
//included will return a UserOperationReceiptResult when
//useroperation is included onchain
let userOperationReceiptResult = await sendUserOperationResponse.included()
console.log("Useroperation receipt received.")
console.log(userOperationReceiptResult)
if(userOperationReceiptResult.success){
console.log("Two Nfts were minted. The transaction hash is : " + userOperationReceiptResult.receipt.transactionHash)
}else{
console.log("Useroperation execution failed")
}
}
main()