How to build an app with Safe and ERC-7579
The smart account ecosystem was fragmented, with each provider building its own modules 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 get access to a rich ecosystem of modules to make your application feature-rich.
Let's say you want to build an app to enable scheduling transfers for monthly salaries to a team of contributors. However, Safe does not offer a native module for scheduling transfers. With the ERC-7579 compatibility, you can use Rhinestone's Scheduled Transfer module (opens in a new tab) with Safe to build an app to schedule transfers ahead of time.
This tutorial will teach you to build an app that can:
- Deploy an ERC-7579-compatible Safe Smart Account.
- Create a scheduled transaction.
- Execute it at the requested date and time.
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:
- 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).
Note: If you wish to follow along using the completed project, you can check out the GitHub repository (opens in a new tab) for this tutorial.
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, we'll use Pimlico's Permissionless.js (opens in a new tab) to set up a Safe and interact with it, Rhinestone's Module SDK (opens in a new tab) to install and use core modules, and viem (opens in a new tab) for some helper functions.
As of now, 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 viem @rhinestone/module-sdk@0.1.4
Now, create a file named .env.local
at the root of your project, and add your Pimlico API key to it:
_10echo "NEXT_PUBLIC_PIMLICO_API_KEY='your_pimlico_api_key_goes_here'" > .env.local
Run the development server
Run the local development server with the following command:
_10pnpm dev
Go to http://localhost:3000
in your browser to see the default Next.js application.
2. Initialize permissionless
client
Create a lib
folder at the project root, and add a file permissionless.ts
:
_10mkdir lib_10cd lib_10touch permissionless.ts
Add the code necessary to create Pimlico's smartAccountClient
by adding this content to permissionless.ts
:
_78import { Hex, createPublicClient, http, Chain, Transport } from 'viem'_78import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'_78import { sepolia } from 'viem/chains'_78import {_78 ENTRYPOINT_ADDRESS_V07,_78 createSmartAccountClient,_78 SmartAccountClient_78} from 'permissionless'_78import {_78 signerToSafeSmartAccount,_78 SafeSmartAccount_78} from 'permissionless/accounts'_78import { erc7579Actions, Erc7579Actions } from 'permissionless/actions/erc7579'_78import {_78 createPimlicoBundlerClient,_78 createPimlicoPaymasterClient_78} from 'permissionless/clients/pimlico'_78import { EntryPoint } from 'permissionless/types'_78_78export type SafeSmartAccountClient = SmartAccountClient<_78 EntryPoint,_78 Transport,_78 Chain,_78 SafeSmartAccount<EntryPoint>_78> &_78 Erc7579Actions<EntryPoint, SafeSmartAccount<EntryPoint>>_78_78const pimlicoUrl = `https://api.pimlico.io/v2/sepolia/rpc?apikey=${process.env.NEXT_PUBLIC_PIMLICO_API_KEY}`_78const safe4337ModuleAddress = '0x3Fdb5BC686e861480ef99A6E3FaAe03c0b9F32e2'_78const erc7569LaunchpadAddress = '0xEBe001b3D534B9B6E2500FB78E67a1A137f561CE'_78_78const privateKey =_78 (process.env.NEXT_PUBLIC_PRIVATE_KEY as Hex) ??_78 (() => {_78 const pk = generatePrivateKey()_78 console.log('Private key to add to .env.local:', `PRIVATE_KEY=${pk}`)_78 return pk_78 })()_78_78const signer = privateKeyToAccount(privateKey)_78_78export const publicClient = createPublicClient({_78 transport: http('https://rpc.ankr.com/eth_sepolia')_78})_78_78const paymasterClient = createPimlicoPaymasterClient({_78 transport: http(pimlicoUrl),_78 entryPoint: ENTRYPOINT_ADDRESS_V07_78})_78_78const bundlerClient = createPimlicoBundlerClient({_78 transport: http(pimlicoUrl),_78 entryPoint: ENTRYPOINT_ADDRESS_V07_78})_78_78export const getSmartAccountClient = async () => {_78 const account = await signerToSafeSmartAccount(publicClient, {_78 entryPoint: ENTRYPOINT_ADDRESS_V07,_78 signer,_78 safeVersion: '1.4.1',_78 saltNonce: 120n,_78 safe4337ModuleAddress,_78 erc7569LaunchpadAddress_78 })_78_78 const smartAccountClient = createSmartAccountClient({_78 chain: sepolia,_78 account,_78 bundlerTransport: http(pimlicoUrl),_78 middleware: {_78 gasPrice: async () =>_78 (await bundlerClient.getUserOperationGasPrice()).fast,_78 sponsorUserOperation: paymasterClient.sponsorUserOperation_78 }_78 }).extend(erc7579Actions({ entryPoint: ENTRYPOINT_ADDRESS_V07 }))_78_78 return smartAccountClient as SafeSmartAccountClient_78}
It will:
- Load a
PRIVATE_KEY
from.env.local
(or generate one if it doesn't exist); - Create the
publicClient
,paymasterClient
andbundlerClient
necessary to initialize thepermissionless
library; - Create an ERC-7579-compatible Safe Smart Account from the generated private key
- Pass the Safe Smart Account object to
createSmartAccountClient
to generate a Permissionless client.
We can then call getSmartAccountClient()
wherever we need to interact with the Safe via Pimlico.
3. Add Rhinestone module functionality
Create a new file scheduledTransfers.ts
in the lib
folder:
_10touch scheduledTransfers.ts
Add the code necessary to create a scheduled transfer using Rhinestone's ScheduledTransfers
module:
_103import {_103 getScheduledTransferData,_103 getScheduledTransfersExecutor,_103 getCreateScheduledTransferAction,_103 getExecuteScheduledTransferAction_103} from '@rhinestone/module-sdk'_103_103import { SafeSmartAccountClient } from './permissionless'_103import abi from '../abi/ScheduleTransfersModule.json'_103_103export interface ScheduledTransferDataInput {_103 startDate: number_103 repeatEvery: number_103 numberOfRepeats: number_103 amount: number_103 recipient: `0x${string}`_103}_103_103export const scheduledTransfersModuleAddress =_103 '0xF1aE317941efeb1ffB103D959EF58170F1e577E0'_103const sepoliaUSDCTokenAddress = '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8'_103_103export const install7579Module = async (_103 safe: SafeSmartAccountClient,_103 scheduledTransferInput: ScheduledTransferDataInput_103) => {_103 const { startDate, repeatEvery, numberOfRepeats, amount, recipient } =_103 scheduledTransferInput_103 const scheduledTransfer = {_103 startDate,_103 repeatEvery,_103 numberOfRepeats,_103 token: {_103 token_address: sepoliaUSDCTokenAddress as `0x${string}`,_103 decimals: 6_103 },_103 amount,_103 recipient_103 }_103_103 const executionData = getScheduledTransferData({_103 scheduledTransfer_103 })_103_103 const scheduledTransfersModule = getScheduledTransfersExecutor({_103 executeInterval: repeatEvery,_103 numberOfExecutions: numberOfRepeats,_103 startDate,_103 executionData_103 })_103_103 const txHash = await safe.installModule({_103 type: 'executor',_103 address: scheduledTransfersModuleAddress,_103 context: scheduledTransfersModule.initData as `0x${string}`_103 })_103_103 console.log(_103 'Scheduled transfers module is being installed: https://sepolia.etherscan.io/tx/' +_103 txHash_103 )_103_103 return txHash_103}_103_103export const scheduleTransfer = async (_103 safe: SafeSmartAccountClient,_103 scheduledTransferInput: ScheduledTransferDataInput_103) => {_103 const { startDate, repeatEvery, numberOfRepeats, amount, recipient } =_103 scheduledTransferInput_103 const scheduledTransfer = {_103 startDate,_103 repeatEvery,_103 numberOfRepeats,_103 token: {_103 token_address: sepoliaUSDCTokenAddress as `0x${string}`,_103 decimals: 6_103 },_103 amount,_103 recipient_103 }_103_103 const scheduledTransferData = getCreateScheduledTransferAction({_103 scheduledTransfer_103 })_103 const txHash = await safe.sendTransaction({_103 to: scheduledTransferData.target,_103 value: scheduledTransferData.value as bigint,_103 data: scheduledTransferData.callData_103 })_103_103 console.log(_103 'Transfer is being scheduled: https://sepolia.etherscan.io/tx/' + txHash_103 )_103 return txHash_103}_103_103export const executeOrder = async (jobId: number) => {_103 const executeTransfer = getExecuteScheduledTransferAction({ jobId })_103 console.log(executeTransfer)_103 return executeTransfer_103}
This file contains two functions:
install7579Module
will install the module to a Safe and schedule its first transfer;scheduleTransfer
to schedule subsequent transfers in a Safe where the module has been previously installed.
In the UI, we can then detect whether the Safe has the module installed when a user tries to schedule a transfer. If not, it will run install7579Module
; and if it does, it will run scheduleTransfer
.
For brevity, we are only covering a simple use case of the ScheduledTransfers
module. You can find more information about the module's functionalities in the Rhinestone documentation (opens in a new tab), such as the capacity to schedule recurring transfers, with a pre-determined number of repeats.
4. Add UI components
Now that we have the logic necessary to set up a safe and schedule a transfer, let's create a simple UI to interact with it. Create a new file ScheduledTransferForm.tsx
in the components
folder:
_10cd .._10mkdir components_10cd components_10touch ScheduledTransferForm.tsx
Add the following code to ScheduledTransferForm.tsx
:
_155import { useState, useEffect } from 'react'_155_155import { SafeSmartAccountClient } from '@/lib/permissionless'_155import {_155 install7579Module,_155 scheduleTransfer,_155 scheduledTransfersModuleAddress_155} from '@/lib/scheduledTransfers'_155_155const ScheduledTransferForm: React.FC<{ safe: SafeSmartAccountClient }> = ({_155 safe_155}) => {_155 const [recipient, setRecipient] = useState('')_155 const [amount, setAmount] = useState(0)_155 const [date, setDate] = useState('')_155 const [txHash, setTxHash] = useState('')_155 const [loading, setLoading] = useState(false)_155 const [error, setError] = useState(false)_155 const [is7579Installed, setIs7579Installed] = useState(false)_155_155 useEffect(() => {_155 const init7579Module = async () => {_155 const isModuleInstalled = await safe_155 .isModuleInstalled({_155 type: 'executor',_155 address: scheduledTransfersModuleAddress,_155 context: '0x'_155 })_155 .catch(() => false)_155 if (isModuleInstalled) {_155 setIs7579Installed(true)_155 }_155 }_155 void init7579Module()_155 }, [safe])_155_155 return (_155 <>_155 <div style={{ marginTop: '40px' }}>Your Safe: {safe.account.address}</div>{' '}_155 <div style={{ marginTop: '10px' }}>_155 ERC-7579 module installed:{' '}_155 {is7579Installed_155 ? 'Yes ✅'_155 : 'No, schedule a transfer below to install it!'}{' '}_155 </div>_155 <div_155 style={{_155 width: '100%',_155 display: 'flex',_155 justifyContent: 'space-between',_155 alignItems: 'center',_155 marginTop: '40px',_155 marginBottom: '40px'_155 }}_155 >_155 <div>_155 <label htmlFor='address'>Address:</label>_155 <input_155 style={{ marginLeft: '20px' }}_155 id='address'_155 placeholder='0x...'_155 onChange={e => setRecipient(e.target.value)}_155 value={recipient}_155 />_155 </div>_155 <div>_155 <label htmlFor='amount'>Amount (integer):</label>_155 <input_155 style={{ marginLeft: '20px' }}_155 id='amount'_155 type='number'_155 placeholder='1'_155 min='0'_155 onChange={e => setAmount(Number(e.target.value))}_155 value={amount}_155 />_155 </div>_155 <div>_155 <label htmlFor='date'>Date/Time:</label>_155 <input_155 style={{ marginLeft: '20px' }}_155 id='date'_155 type='datetime-local'_155 onChange={e => setDate(e.target.value)}_155 value={date}_155 />_155 </div>_155_155 <button_155 disabled={!recipient || !amount || !date || loading}_155 onClick={async () => {_155 setLoading(true)_155 setError(false)_155 const startDate = new Date(date).getTime() / 1000_155 const transferInputData = {_155 startDate: 1710759572,_155 repeatEvery: 60 * 60 * 24,_155 numberOfRepeats: 1,_155 amount,_155 recipient: recipient as `0x${string}`_155 }_155_155 await (!is7579Installed ? install7579Module : scheduleTransfer)(_155 safe,_155 transferInputData_155 )_155 .then(txHash => {_155 setTxHash(txHash)_155 setLoading(false)_155 setRecipient('')_155 setAmount(0)_155 setDate('')_155 setIs7579Installed(true)_155 })_155 .catch(err => {_155 console.error(err)_155 setLoading(false)_155 setError(true)_155 })_155 }}_155 >_155 Schedule Transfer_155 </button>_155 </div>_155 <div>_155 {loading ? <p>Processing, please wait...</p> : null}_155 {error ? (_155 <p>_155 There was an error processing the transaction. Please try again._155 </p>_155 ) : null}_155 {txHash ? (_155 <>_155 <p>_155 Success!{' '}_155 <a_155 href={`https://sepolia.etherscan.io/tx/${txHash}`}_155 target='_blank'_155 rel='noreferrer'_155 style={{_155 textDecoration: 'underline',_155 fontSize: '14px'_155 }}_155 >_155 View on Etherscan_155 </a>_155 </p>_155 </>_155 ) : null}_155 </div>_155 </>_155 )_155}_155_155export default ScheduledTransferForm
This component will provide a form to allow the user to input the amount, receiver address, and date and time for the scheduled transfer. It will detect whether the Safe has the module installed, and then call either install7579Module
or scheduleTransfer
from the scheduledTransfers.ts
file.
Now, edit app/page.tsx
to include the ScheduledTransferForm
component:
_71'use client'_71_71import { useEffect, useState } from 'react'_71_71import {_71 getSmartAccountClient,_71 publicClient,_71 type SafeSmartAccountClient_71} from '../lib/permissionless'_71_71import ScheduledTransferForm from '../components/ScheduledTransferForm'_71import abi from '../abi/ScheduleTransfersModule.json'_71import { scheduledTransfersModuleAddress } from '@/lib/scheduledTransfers'_71import ScheduledTransfers from '@/components/ScheduledTransfers'_71import ProcessedTransfers from '@/components/ProcessedTransfers'_71_71export default function Home () {_71 const [safe, setSafe] = useState<SafeSmartAccountClient | undefined>()_71 const [logs, setLogs] = useState<any[]>([])_71_71 const handleLoadSafe = async () => {_71 const safe = await getSmartAccountClient()_71 setSafe(safe)_71 }_71_71 useEffect(() => {_71 const unwatch = publicClient.watchContractEvent({_71 address: scheduledTransfersModuleAddress,_71 abi,_71 // eventName: 'ExecutionAdded', // Optional_71 // args: { smartAccount: safe?.account.address }, // Optional_71 onLogs: logs => {_71 setLogs(_logs => [_71 ..._logs,_71 ...logs.filter(_71 log =>_71 !_logs.map(l => l.transactionHash).includes(log.transactionHash)_71 )_71 ])_71 }_71 })_71 return () => unwatch()_71 // }, [safe]) // Optional_71 }, [])_71_71 return (_71 <>_71 {safe == null ? (_71 <>_71 <button onClick={handleLoadSafe} style={{ marginTop: '40px' }}>_71 Create Safe_71 </button>_71 </>_71 ) : (_71 <>_71 <ScheduledTransferForm safe={safe} />_71 <div_71 style={{_71 width: '100%',_71 display: 'flex',_71 justifyContent: 'center'_71 }}_71 >_71 <ScheduledTransfers transfers={logs} />_71 <ProcessedTransfers transfers={logs} />_71 </div>_71 </>_71 )}_71 </>_71 )_71}
5. Add styling (optional)
We can add some styling to our app by editing the contents of layout.tsx
in app
folder:
_87import type { Metadata } from 'next'_87import { Inter } from 'next/font/google'_87import Img from 'next/image'_87import './globals.css'_87_87const inter = Inter({ subsets: ['latin'] })_87_87export const metadata: Metadata = {_87 title: 'Safe Tutorial: ERC-7579',_87 description: 'Generated by create next app'_87}_87_87export default function RootLayout ({_87 children_87}: Readonly<{_87 children: React.ReactNode_87}>) {_87 return (_87 <html lang='en'>_87 <body className={inter.className}>_87 <nav_87 style={{_87 display: 'flex',_87 justifyContent: 'space-between',_87 padding: '1rem'_87 }}_87 >_87 <a href='https://safe.global'>_87 <Img width={95} height={36} alt='safe-logo' src='/safe.svg' />_87 </a>_87 <div style={{ display: 'flex' }}>_87 <a_87 href='https://docs.safe.global/advanced/erc-7579/tutorials/7579-tutorial'_87 style={{_87 display: 'flex',_87 alignItems: 'center',_87 marginRight: '1rem'_87 }}_87 >_87 Read tutorial{' '}_87 <Img_87 width={20}_87 height={20}_87 alt='link-icon'_87 src='/external-link.svg'_87 style={{ marginLeft: '0.5rem' }}_87 />_87 </a>_87 <a_87 href='https://github.com/5afe/safe-tutorial-7579'_87 style={{ display: 'flex', alignItems: 'center' }}_87 >_87 View on GitHub{' '}_87 <Img_87 width={24}_87 height={24}_87 alt='github-icon'_87 src='/github.svg'_87 style={{ marginLeft: '0.5rem' }}_87 />_87 </a>_87 </div>_87 </nav>_87 <div style={{ width: '100%', textAlign: 'center' }}>_87 <h1>Schedule Transfers</h1>_87_87 <div>_87 Create a new ERC-7579-compatible Safe Smart Account and use it to schedule_87 transactions._87 </div>_87 </div>_87 <div_87 style={{_87 display: 'flex',_87 alignItems: 'center',_87 justifyContent: 'space-between',_87 marginLeft: '40px',_87 marginRight: '40px',_87 flexDirection: 'column'_87 }}_87 >_87 {children}_87 </div>_87 </body>_87 </html>_87 )_87}
This will add some basic styling to the app, including a header. You can also add some custom CSS to globals.css
in the same folder:
_28h1,_28h2,_28h3 {_28 margin-top: 40px;_28 margin-bottom: 10px;_28}_28_28button {_28 cursor: pointer;_28 border: none;_28 background: #00e673;_28 color: black;_28 padding: 10px 20px;_28 border-radius: 5px;_28 margin: 10px 0;_28}_28_28input {_28 padding: 10px;_28 border-radius: 5px;_28 border: 1px solid #ccc;_28 margin: 10px 0;_28}_28_28button:disabled {_28 background: #ccc;_28 color: #666;_28}
Testing your app
That's it! You can find the source code for the example created in this tutorial on GitHub (opens in a new tab). You can now return to your browser and see the app displayed.
Click the Create Safe button to initialize the Permissionless client with the private key you stored on .env.local
. It will deploy an ERC-7579-compatible Safe Smart Account on its first transaction.
Once loaded, you will be able to choose an amount to send, a receiver address and select a date and time for your scheduled payment. Click Schedule Transfer to send the transaction. The first time you do this, it will deploy the Safe to Sepolia test network and install the ScheduledTransfers module.
Do more with Safe and ERC-7579
We learned how to deploy an ERC-7579-compatible Safe Smart Account and use an ERC-7579-compatible module, the Scheduled Transfer 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). Here are some ideas:
- Create a dead man switch (opens in a new tab) in case your account becomes inactive after a certain amount of time.
- Add multi-factor validation (opens in a new tab) to your Smart Account.
- Designate an owner (opens in a new tab) that can act on your behalf for executing transactions and paying for gas.
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 in 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.