Skip to main content

Multisig Wallet

Learn how to create a multisig wallet 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)
  • Some Sepolia ETH
  • If you need, the full example of this tutorial on Github

Step 1: Get setup

  1. Create a new directory for your project:
mkdir safe-smart-multisig
cd safe-smart-multisig
  1. Install required dependencies
yarn add abstractionkit
  1. 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
  • yarn add dotenv to load environment variables from the .env file
.env
CHAIN_ID=11155111
BUNDLER_URL=https://sepolia.test.voltaire.candidewallet.com/rpc
JSON_RPC_NODE_PROVIDER=NODE_PROVIDER_URL

// highlight-start
#account owner pub & private key
PUBLIC_ADDRESS1=
PRIVATE_KEY1=

PUBLIC_ADDRESS2=
PRIVATE_KEY2=
// highlight-end
info

We host free bundlers endpoints, you do not need to create an account.

  1. Create a empty file and a function to run our script
index.ts
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

index.ts
import * as dotenv from "dotenv";
import { SafeAccountV0_2_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 with npx ts-node index.ts

If everything worked, you will get the address of the account in the console.

Terminal
Account address(sender): 0x32afdcfa1e3bfe70d03ecb55b5c8045c26515c9d
tip

Now that you have generated the smart account, go ahead and fund it with some Sepolia ETH here.

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.

index.ts
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.

index.ts
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
{ numberOfSigners: 2 } // set the number of signers for accurate gas estimation
)

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")
info

You can override any of the returned values using the overrides parameter.

Step 6: Sign and Submit

  1. Call signUserOperation, which will create a signature for the private keys provided of the owners of the Safe.
index.ts
const chainId = BigInt(process.env.CHAIN_ID as string);
const privateKey = process.env.PRIVATE_KEY as string;

userOperation.signature = smartAccount.signUserOperation(
userOperation,
[privateKey1, privateKey2],
chainId,
)
  1. Use the Bundler RPC to send the userop to the bundler with sendUserOperation, and await the return SendUseroperationResponse object to confirm the on-chain inclusion of the user operation.
index.ts
const sendUserOperationResponse = await smartAccount.sendUserOperation(userOperation, bundlerUrl)

console.log("UserOperation sent. Waiting to be included ......")
  1. Track the userOperation and wait 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 with npx ts-node index.ts

You've now sent your first user operation with a multisig wallet! If everything went well, you should see the bundler returning a user operation 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 user operation hash on a dedicated ERC-4337 explorer like JiffyScan.

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_2_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.
{
numberOfSigners:2 // set the number of signers for accurate gas estimation
}
)

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()