How to build an app with Safe and ERC-7579
The smart account ecosystem needed to be more cohesive. Each provider built its modules, which were often incompatible with other smart account implementations. Developers had to build new modules compatible with their smart accounts or miss out on essential application features.
ERC-7579 (opens in a new tab) aims to ensure interoperability across implementations. It defines the account interface so developers can implement modules for all smart accounts that follow this standard. The Safe7579 Adapter makes your Safe compatible with any ERC-7579 modules. As a developer building with Safe, you can access a rich ecosystem of modules to add features to your application.
In this tutorial, you will build an app that can:
- Enable a 7579 module on a newly deployed Safe (the OwnableExecutor (opens in a new tab) module by Rhinestone)
- Send a transaction via the 7579 module (Send a dummy transaction as the new owner via
executeOnOwnedAccount
) - Interact with the 7579 directly to add a new owner to the module
The full code for this tutorial is in the Safe7579 module tutorial repository (opens in a new tab).
Prerequisites
Prerequisite knowledge: You will need some basic experience with React (opens in a new tab), Next.js (opens in a new tab), ERC-4337 (opens in a new tab) and ERC-7579 (opens in a new tab).
Before progressing with the tutorial, please make sure you have the following:
- Downloaded and installed Node.js (opens in a new tab) and pnpm (opens in a new tab).
- Created an API key from Pimlico (opens in a new tab).
- Metamask installed in your browser and connected to the Sepolia network.
- Two test accounts in Metamask, the second with some Sepolia Eth for gas.
1. Setup a Next.js application
Initialize a new Next.js app using pnpm with the following command:
_10pnpm create next-app
When prompted by the CLI:
- Select
yes
to TypeScript, ESLint, and App router. - Select
no
to all other questions (Tailwind,src
directory, and import aliases).
Install dependencies
For this project, you will use Pimlico's Permissionless.js (opens in a new tab) to set up a Safe and interact with it and viem (opens in a new tab) for some helper functions.
Currently, permissionless.js
can only be used to deploy single-signer Safe accounts. Multi-signature ERC-7579 Safes will be coming soon.
Run the following command to add all these dependencies to the project:
_10pnpm add permissionless@0.2.0 viem@2.21.7 truncate-eth-address@1.0.2
2. Setup project
First, set up the project and add some UI and styles so you can focus on the 7579-related code for the rest of the tutorial.
Add CSS
Replace the content of app/globals.css
with the following:
_106:root {_106 background-color: #121312; _106 font-family: Citerne, 'DM Sans', sans-serif;_106 font-size: 14px;_106 line-height: 1.4;_106 }_106_106* {_106 box-sizing: border-box;_106 margin: 0;_106 padding: 0;_106 color: #fff;_106}_106_106body {_106 align-items: center;_106 display: flex;_106 flex-direction: column;_106 justify-content: space-between;_106 margin: 6rem auto;_106 width: 500px;_106}_106_106.card {_106 background-color: #1c1c1c;_106 border-radius: 6px;_106 margin-bottom: 24px;_106 padding: 24px;_106 text-align: left;_106 width: 100%;_106 display: flex;_106 flex-direction: column;_106 box-shadow: 0 0 100px rgba(18, 255, 128, 0.2);_106}_106_106.title {_106 display: flex;_106 align-items: center;_106 gap: 10px;_106 margin-bottom: 35px;_106 font-size: large;_106}_106_106.actions {_106 display: flex;_106 justify-content: flex-end;_106 gap: 16px;_106 margin-top: 50px;_106}_106_106button {_106 background-color: #12ff80;_106 border: none;_106 border-radius: 6px;_106 color: rgba(0, 0, 0, 0.87);_106 border: 1px solid #12ff80;_106 cursor: pointer;_106 font-weight: bold;_106 padding: 8px 24px;_106 position: relative;_106}_106button.skip {_106 background-color: transparent;_106 border: 1px solid #12ff80;_106 color: #12ff80;_106}_106_106.button--loading {_106 color: transparent;_106 background-color: transparent;_106}_106_106.button--loading::after {_106 content: "";_106 position: absolute;_106 width: 16px;_106 height: 16px;_106 top: 0;_106 left: 0;_106 right: 0;_106 bottom: 0;_106 margin: auto;_106 border: 4px solid transparent;_106 border-top-color: #12ff80;_106 border-radius: 50%;_106 animation: button-loading-spinner 1s ease infinite;_106}_106_106@keyframes button-loading-spinner {_106 from {_106 transform: rotate(0turn);_106 }_106_106 to {_106 transform: rotate(1turn);_106 }_106}_106_106pre {_106 border: 1px solid #303033;_106 border-radius: 8px;_106 color: #a1a3a7;_106 margin: 24px 0;_106 padding: 24px;_106 text-align: center;_106}
Add a scaffold React component
Now, replace the content of app/page.tsx
with the following code. It includes all necessary imports, the React component and the UI, and empty functions you will fill with code in the following steps. From now on, you will only work on this file.
_265'use client'_265_265import { createSmartAccountClient } from 'permissionless'_265import { sepolia } from 'viem/chains'_265import {_265 encodePacked,_265 http,_265 encodeFunctionData,_265 parseAbi,_265 createWalletClient,_265 createPublicClient,_265 custom,_265 encodeAbiParameters,_265 parseAbiParameters,_265 HttpTransport,_265 Client,_265 parseEther_265} from 'viem'_265import { Erc7579Actions, erc7579Actions } from 'permissionless/actions/erc7579'_265import { createPimlicoClient } from 'permissionless/clients/pimlico'_265import {_265 toSafeSmartAccount,_265 ToSafeSmartAccountReturnType_265} from 'permissionless/accounts'_265import { useEffect, useState } from 'react'_265import truncateEthAddress from 'truncate-eth-address'_265import { SendUserOperationParameters } from 'viem/account-abstraction'_265_265export default function Home () {_265 const [safeAccount, setSafeAccount] =_265 useState<ToSafeSmartAccountReturnType<'0.7'> | null>(null)_265 const [smartAccountClient, setSmartAccountClient] = useState<_265 | (Client<HttpTransport, typeof sepolia> &_265 Erc7579Actions<ToSafeSmartAccountReturnType<'0.7'>> & {_265 sendUserOperation: (_265 params: SendUserOperationParameters_265 ) => Promise<string>_265 })_265 | null_265 >(null)_265 const [ownerAddress, setOwnerAddress] = useState<string | null>(null)_265 const [executorAddress, setExecutorAddress] = useState<string | null>(null)_265 const [safeAddress, setSafeAddress] = useState<string | null>(null)_265 const [safeIsDeployed, setSafeIsDeployed] = useState(false)_265 const [moduleIsInstalled, setModuleIsInstalled] = useState(false)_265 const [executorTransactionIsSent, setExecutorTransactionIsSent] =_265 useState(false)_265 const [ownerIsAdded, setOwnerIsAdded] = useState(false)_265 const [moduleIsUninstalled, setModuleIsUninstalled] = useState(false)_265 const [loading, setLoading] = useState(false)_265 const [walletClient, setWalletClient] = useState<ReturnType<_265 typeof createWalletClient_265 > | null>(null)_265_265 // The module we will use is deployed as a smart contract on Sepolia:_265 const ownableExecutorModule = '0xc98B026383885F41d9a995f85FC480E9bb8bB891'_265_265 // TODO: Make sure to add your own API key to the Pimlico URL:_265 const pimlicoUrl =_265 'https://api.pimlico.io/v2/sepolia/rpc?apikey=YOUR_PIMLICO_API_KEY'_265_265 // The Pimlico client is used as a paymaster:_265 const pimlicoClient = createPimlicoClient({_265 transport: http(pimlicoUrl),_265 chain: sepolia_265 })_265_265 useEffect(() => {_265 // We create a wallet client to connect to MetaMask:_265 const walletClient = createWalletClient({_265 chain: sepolia,_265 // @ts-expect-error MetaMask is a requirement for this tutorial_265 transport: custom(typeof window !== 'undefined' ? window.ethereum! : null)_265 })_265 setWalletClient(walletClient)_265 }, [])_265_265 // Check for connected accounts on page load:_265 useEffect(() => {_265 checkAddresses()_265 // eslint-disable-next-line react-hooks/exhaustive-deps_265 }, [walletClient])_265_265 // Check whether the user has connected two accounts, without MetaMask popping up:_265 const checkAddresses = async () => {_265 if (!walletClient) return_265 const addresses = await walletClient!.getAddresses()_265 setOwnerAddress(addresses[0])_265 setExecutorAddress(addresses[1])_265 if (addresses.length >= 2) {_265 init()_265 }_265 }_265_265 const connectWallets = async () => {_265 // Only at the request address call, MetaMask will pop up and ask the user to connect:_265 await walletClient!.requestAddresses()_265 checkAddresses()_265 }_265_265 // The public client is required for the safe account creation:_265 const publicClient = createPublicClient({_265 transport: http('https://rpc.ankr.com/eth_sepolia'),_265 chain: sepolia_265 })_265_265 // The following functions will be filled with code in the following steps:_265_265 const init = async () => {}_265_265 const installModule = async () => {}_265_265 const executeOnOwnedAccount = async () => {}_265_265 const addOwner = async () => {}_265_265 const uninstallModule = async () => {}_265_265 // Depending on the state of the tutorial, different cards are displayed:_265 // Step 1: Connect Wallets_265 if (!ownerAddress || !executorAddress) {_265 return (_265 <div className='card'>_265 <div className='title'>Connect two accounts</div>_265 <div>_265 Please ensure to connect with two accounts to this site. The second_265 account needs to have some Sepolia Eth for gas._265 </div>_265 <div className='actions'>_265 <button onClick={connectWallets}>Connect Wallet</button>_265 </div>_265 </div>_265 )_265 }_265_265 // Step 2: Install Module_265 if (!moduleIsInstalled) {_265 return (_265 <div className='card'>_265 <div className='title'>Install Module</div>_265 <div>_265 Your Safe has the address{' '}_265 {safeAddress && truncateEthAddress(safeAddress)} and is{' '}_265 {safeIsDeployed ? 'deployed' : 'not yet deployed'}._265 {!safeIsDeployed &&_265 'It will be deployed with your first transaction, when you install the module.'}_265 </div>_265 <div>_265 You can now install the module. MetaMask will ask you to sign a_265 message with the first account after clicking the button._265 </div>_265 <div className='actions'>_265 <button_265 onClick={installModule}_265 className={loading ? 'button--loading' : ''}_265 >_265 Install Module_265 </button>_265 </div>_265 </div>_265 )_265 }_265_265 // Step 3: Execute on Owned Account_265 if (!executorTransactionIsSent) {_265 return (_265 <div className='card'>_265 <div className='title'>Execute on owned account</div>_265 <div>_265 You can now execute a transaction on the owned account as the_265 executor. In this case, you will send a dummy transaction. But you_265 could also claim ownership of the account._265 </div>_265 <div>_265 When you click the button, Metamask will request a transaction from_265 the second account._265 </div>_265 <div className='actions'>_265 <button_265 className='skip'_265 onClick={() => {_265 setExecutorTransactionIsSent(true)_265 setLoading(false)_265 }}_265 >_265 Skip_265 </button>_265 <button_265 onClick={executeOnOwnedAccount}_265 className={loading ? 'button--loading' : ''}_265 >_265 Execute on owned account_265 </button>_265 </div>_265 </div>_265 )_265 }_265_265 // Step 4: Add Owner_265 if (!ownerIsAdded) {_265 return (_265 <div className='card'>_265 <div className='title'>Add Owner</div>_265 <div>_265 Now, you will interact with the 7579 module directly. You can add an_265 owner to the Safe. The new owner will be able to execute transactions_265 on the Safe. Metamask will request a signature from the first owner._265 </div>_265 <div>_265 <div className='actions'>_265 <button_265 className='skip'_265 onClick={() => {_265 setOwnerIsAdded(true)_265 setLoading(false)_265 }}_265 >_265 Skip_265 </button>_265 <button_265 onClick={addOwner}_265 className={loading ? 'button--loading' : ''}_265 >_265 Add Owner_265 </button>_265 </div>_265 </div>_265 </div>_265 )_265 }_265_265 // Step 5: Uninstall Module_265 if (!moduleIsUninstalled) {_265 return (_265 <div className='card'>_265 <div className='title'>Uninstall Module</div>_265 <div>_265 To finish the module's lifecycle, you can now uninstall the_265 module. MetaMask will ask you to sign a message after clicking the_265 button._265 </div>_265 <div className='actions'>_265 <button_265 onClick={uninstallModule}_265 className={loading ? 'button--loading' : ''}_265 >_265 Uninstall Module_265 </button>_265 </div>_265 </div>_265 )_265 }_265_265 // Step 6: Finish_265 return (_265 <div className='card'>_265 <div className='title'>Well done</div>_265 <div>_265 Congratulations! You've successfully installed, executed,_265 interacted with, and uninstalled the module. This tutorial is now_265 complete. Great job! Keep exploring!_265 </div>_265 </div>_265 )_265}
Add your Pimlico API key to the pimlicoUrl
variable. You can find your API key in the Pimlico dashboard.
You can now run the development server with pnpm dev
and open the app in your browser at http://localhost:3000
. You should see a card that asks you to connect two wallets. Connect two wallets to proceed with the tutorial.
3. Initialize the clients
In the first step, you create the clients that allow you to interact with the smart account. As permissionless.js is just a tiny wrapper around viem, you will use many of viem's functions in this tutorial.
To add this code, overwrite the init
function with this one:
_50const init = async () => {_50 // The safe account is created using the public client:_50 const safeAccount = await toSafeSmartAccount<_50 '0.7',_50 '0xEBe001b3D534B9B6E2500FB78E67a1A137f561CE'_50 >({_50 client: publicClient,_50 // @ts-expect-error The wallet client is set in the useEffect_50 owners: [walletClient!],_50 version: '1.4.1',_50 // These modules are required for the 7579 functionality:_50 safe4337ModuleAddress: '0x3Fdb5BC686e861480ef99A6E3FaAe03c0b9F32e2', // These are not meant to be used in production as of now._50 erc7579LaunchpadAddress: '0xEBe001b3D534B9B6E2500FB78E67a1A137f561CE' // These are not meant to be used in production as of now._50 })_50_50 const isSafeDeployed = await safeAccount.isDeployed()_50_50 setSafeAddress(safeAccount.address)_50 setSafeIsDeployed(isSafeDeployed)_50_50 // Finally, we create the smart account client, which provides functionality to interact with the smart account:_50 const smartAccountClient = createSmartAccountClient({_50 account: safeAccount,_50 chain: sepolia,_50 bundlerTransport: http(pimlicoUrl),_50 paymaster: pimlicoClient,_50 userOperation: {_50 estimateFeesPerGas: async () => {_50 return (await pimlicoClient.getUserOperationGasPrice()).fast_50 }_50 }_50 }).extend(erc7579Actions())_50_50 // Check whether the module has been installed already:_50 const isModuleInstalled =_50 isSafeDeployed &&_50 (await smartAccountClient.isModuleInstalled({_50 address: ownableExecutorModule,_50 type: 'executor',_50 context: '0x'_50 }))_50_50 setModuleIsInstalled(isModuleInstalled)_50_50 // We store the clients in the state to use them in the following steps:_50 setSafeAccount(safeAccount)_50 setSmartAccountClient(smartAccountClient)_50_50 console.log('setup done')_50}
You must refresh your page after adding this code, as the initial site load will trigger the init
function and set up the Safe account and the Smart account client. You can check the console to see if the setup was successful.
4. Install the 7579 module
Now, add the function to install the OwnableExecutor
module as an executor
to your smart account.
Overwrite the installModule
function with this one.
_27const installModule = async () => {_27 setLoading(true)_27 console.log('Installing module...')_27_27 // The smart accounts client operates on 4337. It does not send transactions directly but instead creates user_27 // operations. The Pimlico bundler takes those user operations and sends them to the blockchain as regular_27 // transactions. We also use the Pimlico paymaster to sponsor the transaction. So, all interactions are free_27 // on Sepolia._27 const userOpHash = await smartAccountClient?.installModule({_27 type: 'executor',_27 address: ownableExecutorModule,_27 context: encodePacked(['address'], [executorAddress as `0x${string}`])_27 })_27_27 console.log('User operation hash:', userOpHash, '\nwaiting for receipt...')_27_27 // After we sent the user operation, we wait for the transaction to be settled:_27 const transactionReceipt = await pimlicoClient.waitForUserOperationReceipt({_27 hash: userOpHash as `0x${string}`_27 })_27_27 console.log('Module installed:', transactionReceipt)_27_27 setModuleIsInstalled(true)_27 setSafeIsDeployed((await safeAccount?.isDeployed()) ?? false)_27 setLoading(false)_27}
When you open the UI now and click the “Install Module” button, the console should log the module installation process. You can use jiffyscan.xyz (opens in a new tab) to inspect the user operation hash. From there, you can copy the transaction hash and inspect the transaction with Etherscan (opens in a new tab), Tenderly (opens in a new tab), or other block explorers.
5. Send a transaction via the 7579 module
In the following function, you will use the OwnableExecutor
module. The module allows owners to execute transactions from the smart account without collecting signatures. For this example, you will send a dummy transaction that sends zero eth to owner1.
In detail:
- Owner2 calls module
- The module calls
executeAsModule
on the smart account - The smart account executes the transaction (and sends zero eth to owner1)
Replace the executeOnOwnedAccount
function with this code:
_32const executeOnOwnedAccount = async () => {_32 setLoading(true)_32 console.log('Executing on owned account...')_32_32 // We encode the transaction we want the smart account to send. The fields are:_32 // - to (address)_32 // - value (uint256)_32 // - data (bytes)_32 // In this example case, it is a dummy transaction with zero data._32 const executeOnOwnedAccountData = encodePacked(_32 ['address', 'uint256', 'bytes'],_32 ['0xa6d3DEBAAB2B8093e69109f23A75501F864F74e2', parseEther('0'), '0x']_32 )_32_32 // Now, we call the `executeOnOwnedAccount` function of the `ownableExecutorModule` with the address of the safe_32 // account and the data we want to execute. This will make our smart account send the transaction that is encoded above._32 const hash = await walletClient!.writeContract({_32 chain: sepolia,_32 account: executorAddress as `0x${string}`,_32 abi: parseAbi(['function executeOnOwnedAccount(address, bytes)']),_32 functionName: 'executeOnOwnedAccount',_32 args: [safeAddress as `0x${string}`, executeOnOwnedAccountData],_32 address: ownableExecutorModule_32 })_32_32 console.log('Executed on owned account, transaction hash:', hash)_32_32 await publicClient?.waitForTransactionReceipt({ hash })_32_32 setExecutorTransactionIsSent(true)_32 setLoading(false)_32}
When you open the UI and click the “Execute on owned account” button, your console should log the transaction. You can inspect the transaction with Tenderly to follow the call stack from the module over the Safe 7579 adapter to your Safe and the transaction's final receiver.
You also learned the required data format to send a 7579 transaction from a module to a Safe. It is precisely the data you packed for the transaction in executeOnOwnedAccountData
. Every other 7579 module uses the same data type to send transactions to a Safe. However, with most other modules, you don’t have to pack the data yourself; you call a function on the module, and the module sends the dedicated transaction to the smart account.
6. Interact with the 7579 module directly
Some modules can be configured directly. The OwnableExecutor
module allows you to add additional owners and remove existing owners. This example outlines how you interact with the module directly to add a new owner.
The call flow is:
- Sign a user operation with your smart account client and send it to the bundler.
- The bundler bundles the user operation into a regular transaction and sends it to the meme pool.
- The transaction executes a call from your smart account to the module with the defined data.
- The module recognizes your smart account as an authorized sender. It stores the new owner of your smart account in its storage.
Replace addOwner
with this function:
_34const addOwner = async () => {_34 setLoading(true)_34 console.log('Adding owner...')_34_34 // The addOwner function is part of the OwnableExecutorModule. We encode the function data using the viem library:_34 const addOwnerData = encodeFunctionData({_34 abi: parseAbi(['function addOwner(address)']),_34 functionName: 'addOwner',_34 args: ['0x0000000000000000000000000000000000000002'] // We add 0x2 as the new owner just as an example._34 })_34_34 // We use the smart account client to send the user operation: In this call, our smart account calls the `addOwner`_34 // function at the `ownableExecutorModule` with the new owner's address._34 const userOp = await smartAccountClient?.sendUserOperation({_34 calls: [_34 {_34 to: ownableExecutorModule,_34 value: parseEther('0'),_34 data: addOwnerData_34 }_34 ]_34 })_34_34 console.log('User operation:', userOp, '\nwaiting for tx receipt...')_34_34 // Again, we wait for the transaction to be settled:_34 const receipt = await pimlicoClient.waitForUserOperationReceipt({_34 hash: userOp as `0x${string}`_34 })_34_34 console.log('Owner added, tx receipt:', receipt)_34 setOwnerIsAdded(true)_34 setLoading(false)_34}
When you open the UI and click the “Add Owner” button, your console should log the user operation that adds a new owner. Make sure to inspect the final transaction (you can get the transaction hash from jiffyscan.xyz) to understand the call stack from the smart account to the module.
7. Uninstall the 7579 module
The last step is to uninstall the module. If the module is no longer needed, you can remove it from the smart account.
Replace the uninstallModule
function with this code:
_30const uninstallModule = async () => {_30 setLoading(true)_30 console.log('Uninstalling module...')_30_30 // To uninstall the module, use the `uninstallModule`._30 // You have to pack the abi parameter yourself:_30 // - previousEntry (address): The address of the previous entry in the module sentinel list._30 // - deInitData (bytes): The data that is passed to the deInit function of the module._30 // As this is the only module, the previous entry is the sentinel address 0x1. The deInitData is empty for the_30 // OwnableExecutor._30 const userOp = await smartAccountClient?.uninstallModule({_30 type: 'executor',_30 address: ownableExecutorModule,_30 context: encodeAbiParameters(_30 parseAbiParameters('address prevEntry, bytes memory deInitData'),_30 ['0x0000000000000000000000000000000000000001', '0x']_30 )_30 })_30_30 console.log('User operation:', userOp, '\nwaiting for tx receipt...')_30_30 // We wait for the transaction to be settled:_30 const receipt = await pimlicoClient.waitForUserOperationReceipt({_30 hash: userOp as `0x${string}`_30 })_30_30 console.log('Module uninstalled, tx receipt:', receipt)_30 setModuleIsUninstalled(true)_30 setLoading(false)_30}
In the last step of the UI, you can now click the “Uninstall Module” button to remove the module from the smart account. Notice that depending on the type of the 7579 module, the method required different deInitData
.
Also, you have to pass the correct previous entry address to the uninstallModule
function. If you have only one module installed, the previous entry is the sentinel address (opens in a new tab) 0x1
.
That’s it! You have successfully built an app that can interact with a Safe Smart Account using the ERC-7579 standard. You can now deploy and test your app with your Safes and modules.
Do more with Safe and ERC-7579
You learned how to deploy an ERC-7579-compatible Safe Smart Account and use an ERC-7579-compatible module, the OwnableExecutor from Rhinestone. We hope you enjoyed this tutorial and that the combination of Safe and 7579 will allow you to tap into new functionalities for your decentralized apps.
As a next step, you can add more functionalities to your app using other ERC-7579-compatible modules (opens in a new tab).
You can also find more inspiration on this list of ERC-7579 modules (opens in a new tab). You can also read more about this ERC in our overview (opens in a new tab) or the official documentation (opens in a new tab).
Did you encounter any difficulties? Let us know by opening an issue (opens in a new tab) or asking a question on Stack Exchange (opens in a new tab) with the safe-core
tag.