Advanced
ERC-7579
Tutorials
Build an app with Safe and ERC-7579

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.

screenshot

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:

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:


_10
pnpm 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:


_10
pnpm 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:


_10
echo "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:


_10
pnpm dev

Go to http://localhost:3000 in your browser to see the default Next.js application.

screenshot-default

2. Initialize permissionless client

Create a lib folder at the project root, and add a file permissionless.ts:


_10
mkdir lib
_10
cd lib
_10
touch permissionless.ts

Add the code necessary to create Pimlico's smartAccountClient by adding this content to permissionless.ts :


_78
import { Hex, createPublicClient, http, Chain, Transport } from 'viem'
_78
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
_78
import { sepolia } from 'viem/chains'
_78
import {
_78
ENTRYPOINT_ADDRESS_V07,
_78
createSmartAccountClient,
_78
SmartAccountClient
_78
} from 'permissionless'
_78
import {
_78
signerToSafeSmartAccount,
_78
SafeSmartAccount
_78
} from 'permissionless/accounts'
_78
import { erc7579Actions, Erc7579Actions } from 'permissionless/actions/erc7579'
_78
import {
_78
createPimlicoBundlerClient,
_78
createPimlicoPaymasterClient
_78
} from 'permissionless/clients/pimlico'
_78
import { EntryPoint } from 'permissionless/types'
_78
_78
export type SafeSmartAccountClient = SmartAccountClient<
_78
EntryPoint,
_78
Transport,
_78
Chain,
_78
SafeSmartAccount<EntryPoint>
_78
> &
_78
Erc7579Actions<EntryPoint, SafeSmartAccount<EntryPoint>>
_78
_78
const pimlicoUrl = `https://api.pimlico.io/v2/sepolia/rpc?apikey=${process.env.NEXT_PUBLIC_PIMLICO_API_KEY}`
_78
const safe4337ModuleAddress = '0x3Fdb5BC686e861480ef99A6E3FaAe03c0b9F32e2'
_78
const erc7569LaunchpadAddress = '0xEBe001b3D534B9B6E2500FB78E67a1A137f561CE'
_78
_78
const 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
_78
const signer = privateKeyToAccount(privateKey)
_78
_78
export const publicClient = createPublicClient({
_78
transport: http('https://rpc.ankr.com/eth_sepolia')
_78
})
_78
_78
const paymasterClient = createPimlicoPaymasterClient({
_78
transport: http(pimlicoUrl),
_78
entryPoint: ENTRYPOINT_ADDRESS_V07
_78
})
_78
_78
const bundlerClient = createPimlicoBundlerClient({
_78
transport: http(pimlicoUrl),
_78
entryPoint: ENTRYPOINT_ADDRESS_V07
_78
})
_78
_78
export 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 and bundlerClient necessary to initialize the permissionless 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:


_10
touch scheduledTransfers.ts

Add the code necessary to create a scheduled transfer using Rhinestone's ScheduledTransfers module:


_103
import {
_103
getScheduledTransferData,
_103
getScheduledTransfersExecutor,
_103
getCreateScheduledTransferAction,
_103
getExecuteScheduledTransferAction
_103
} from '@rhinestone/module-sdk'
_103
_103
import { SafeSmartAccountClient } from './permissionless'
_103
import abi from '../abi/ScheduleTransfersModule.json'
_103
_103
export interface ScheduledTransferDataInput {
_103
startDate: number
_103
repeatEvery: number
_103
numberOfRepeats: number
_103
amount: number
_103
recipient: `0x${string}`
_103
}
_103
_103
export const scheduledTransfersModuleAddress =
_103
'0xF1aE317941efeb1ffB103D959EF58170F1e577E0'
_103
const sepoliaUSDCTokenAddress = '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8'
_103
_103
export 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
_103
export 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
_103
export 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:


_10
cd ..
_10
mkdir components
_10
cd components
_10
touch ScheduledTransferForm.tsx

Add the following code to ScheduledTransferForm.tsx:


_155
import { useState, useEffect } from 'react'
_155
_155
import { SafeSmartAccountClient } from '@/lib/permissionless'
_155
import {
_155
install7579Module,
_155
scheduleTransfer,
_155
scheduledTransfersModuleAddress
_155
} from '@/lib/scheduledTransfers'
_155
_155
const 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
_155
export 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
_71
import { useEffect, useState } from 'react'
_71
_71
import {
_71
getSmartAccountClient,
_71
publicClient,
_71
type SafeSmartAccountClient
_71
} from '../lib/permissionless'
_71
_71
import ScheduledTransferForm from '../components/ScheduledTransferForm'
_71
import abi from '../abi/ScheduleTransfersModule.json'
_71
import { scheduledTransfersModuleAddress } from '@/lib/scheduledTransfers'
_71
import ScheduledTransfers from '@/components/ScheduledTransfers'
_71
import ProcessedTransfers from '@/components/ProcessedTransfers'
_71
_71
export 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:


_87
import type { Metadata } from 'next'
_87
import { Inter } from 'next/font/google'
_87
import Img from 'next/image'
_87
import './globals.css'
_87
_87
const inter = Inter({ subsets: ['latin'] })
_87
_87
export const metadata: Metadata = {
_87
title: 'Safe Tutorial: ERC-7579',
_87
description: 'Generated by create next app'
_87
}
_87
_87
export 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:


_28
h1,
_28
h2,
_28
h3 {
_28
margin-top: 40px;
_28
margin-bottom: 10px;
_28
}
_28
_28
button {
_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
_28
input {
_28
padding: 10px;
_28
border-radius: 5px;
_28
border: 1px solid #ccc;
_28
margin: 10px 0;
_28
}
_28
_28
button: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.

screenshot-finalized

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.

screenshot

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:

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.

Was this page helpful?