Skip to content

Commit

Permalink
Add 7579 tutorial (#514)
Browse files Browse the repository at this point in the history
* Add 7579 tutorial

* Move example code in its own top-level folder

* Ignore type validation and linter for example files

* Fix vale errors

* Fix typo

* Implement requested changes

* Edit screenshots

* Update pages/advanced/erc-7579/tutorials/7579-tutorial.mdx

Co-authored-by: Konrad <[email protected]>

* Implement requested changes

* Implement requested changes

* Implement requested changes

* Fix deploy workflows

* Fix typo

---------

Co-authored-by: Konrad <[email protected]>
  • Loading branch information
louis-md and kopy-kat authored Jul 5, 2024
1 parent 3ffe1d6 commit 510c32b
Show file tree
Hide file tree
Showing 17 changed files with 650 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ module.exports = {
parserOptions: {
project: './tsconfig.json'
},
ignorePatterns: ['.eslintrc.js', 'next.config.js', 'next-env.d.ts', 'out'],
ignorePatterns: ['.eslintrc.js', 'next.config.js', 'next-env.d.ts', 'out', '**/examples/**'],
rules: {
'@typescript-eslint/key-spacing': 0,
'multiline-ternary': 0,
Expand Down
1 change: 1 addition & 0 deletions .github/styles/config/vocabularies/default/accept.txt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ EURe
EVM
EdgeEVM
Edgeware
ESLint
EtherLite
Etherscan
Eurus
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:

- uses: actions/checkout@v3

- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v4
with:
version: 8

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:

- uses: actions/checkout@v3

- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v4
with:
version: 8

Expand Down
Binary file added assets/7579-tutorial-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/7579-tutorial-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/7579-tutorial.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/next.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 87 additions & 0 deletions examples/erc-7579/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Img from 'next/image'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
title: 'Safe Tutorial: ERC-7579',
description: 'Generated by create next app'
}

export default function RootLayout ({
children
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang='en'>
<body className={inter.className}>
<nav
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '1rem'
}}
>
<a href='https://safe.global'>
<Img width={95} height={36} alt='safe-logo' src='/safe.svg' />
</a>
<div style={{ display: 'flex' }}>
<a
href='https://docs.safe.global/advanced/erc-7579/tutorials/7579-tutorial'
style={{
display: 'flex',
alignItems: 'center',
marginRight: '1rem'
}}
>
Read tutorial{' '}
<Img
width={20}
height={20}
alt='link-icon'
src='/external-link.svg'
style={{ marginLeft: '0.5rem' }}
/>
</a>
<a
href='https://github.com/5afe/safe-tutorial-7579'
style={{ display: 'flex', alignItems: 'center' }}
>
View on GitHub{' '}
<Img
width={24}
height={24}
alt='github-icon'
src='/github.svg'
style={{ marginLeft: '0.5rem' }}
/>
</a>
</div>
</nav>
<div style={{ width: '100%', textAlign: 'center' }}>
<h1>Schedule Transfers</h1>

<div>
Create a new 7579 compatible Safe Account and use it to schedule
transactions.
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginLeft: '40px',
marginRight: '40px',
flexDirection: 'column'
}}
>
{children}
</div>
</body>
</html>
)
}
32 changes: 32 additions & 0 deletions examples/erc-7579/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client'

import { useState } from 'react'

import {
getSmartAccountClient,
type SafeSmartAccountClient
} from '../lib/permissionless'
import ScheduledTransferForm from '../components/ScheduledTransferForm'

export default function Home () {
const [safe, setSafe] = useState<SafeSmartAccountClient | undefined>()

const handleLoadSafe = async () => {
const safe = await getSmartAccountClient()
setSafe(safe)
}

return (
<>
{safe == null ? (
<>
<button onClick={handleLoadSafe} style={{ marginTop: '40px' }}>
Create Safe
</button>
</>
) : (
<ScheduledTransferForm safe={safe} />
)}
</>
)
}
154 changes: 154 additions & 0 deletions examples/erc-7579/components/ScheduledTransferForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { useState, useEffect } from 'react'

import { SafeSmartAccountClient } from '@/lib/permissionless'
import {
install7579Module,
scheduleTransfer,
scheduledTransfersModuleAddress
} from '@/lib/scheduledTransfers'

const ScheduledTransferForm: React.FC<{ safe: SafeSmartAccountClient }> = ({
safe
}) => {
const [recipient, setRecipient] = useState('')
const [amount, setAmount] = useState(0)
const [date, setDate] = useState('')
const [txHash, setTxHash] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const [is7579Installed, setIs7579Installed] = useState(false)

useEffect(() => {
const init7579Module = async () => {
const isModuleInstalled = await safe
.isModuleInstalled({
type: 'executor',
address: scheduledTransfersModuleAddress,
context: '0x'
})
.catch(() => false)
if (isModuleInstalled) {
setIs7579Installed(true)
}
}
void init7579Module()
}, [safe])

return (
<>
<div style={{ marginTop: '40px' }}>Your Safe: {safe.account.address}</div>{' '}
<div style={{ marginTop: '10px' }}>
ERC-7579 module installed:{' '}
{is7579Installed
? 'Yes ✅'
: 'No, schedule a transfer below to install it!'}{' '}
</div>
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '40px',
marginBottom: '40px'
}}
>
<div>
<label htmlFor='address'>Address:</label>
<input
style={{ marginLeft: '20px' }}
id='address'
placeholder='0x...'
onChange={e => setRecipient(e.target.value)}
value={recipient}
/>
</div>
<div>
<label htmlFor='amount'>Amount:</label>
<input
style={{ marginLeft: '20px' }}
id='amount'
type='number'
placeholder='1'
onChange={e => setAmount(Number(e.target.value))}
value={amount}
/>
</div>
<div>
<label htmlFor='date'>Date/Time:</label>
<input
style={{ marginLeft: '20px' }}
id='date'
type='datetime-local'
onChange={e => setDate(e.target.value)}
value={date}
/>
</div>

<button
disabled={!recipient || !amount || !date || loading}
onClick={async () => {
setLoading(true)
setError(false)
const startDate = new Date(date).getTime() / 1000
const transferInputData = {
startDate: 1710759572,
repeatEvery: 60 * 60 * 24,
numberOfRepeats: 1,
amount,
recipient: recipient as `0x${string}`
}

await (!is7579Installed ? install7579Module : scheduleTransfer)(
safe,
transferInputData
)
.then(txHash => {
setTxHash(txHash)
setLoading(false)
setRecipient('')
setAmount(0)
setDate('')
setIs7579Installed(true)
})
.catch(err => {
console.error(err)
setLoading(false)
setError(true)
})
}}
>
Schedule Transfer
</button>
</div>
<div>
{loading ? <p>Processing, please wait...</p> : null}
{error ? (
<p>
There was an error processing the transaction. Please try again.
</p>
) : null}
{txHash ? (
<>
<p>
Success!{' '}
<a
href={`https://sepolia.etherscan.io/tx/${txHash}`}
target='_blank'
rel='noreferrer'
style={{
textDecoration: 'underline',
fontSize: '14px'
}}
>
View on Etherscan
</a>
</p>
</>
) : null}
</div>
</>
)
}

export default ScheduledTransferForm
78 changes: 78 additions & 0 deletions examples/erc-7579/lib/permissionless.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Hex, createPublicClient, http, Chain, Transport } from 'viem'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import {
ENTRYPOINT_ADDRESS_V07,
createSmartAccountClient,
SmartAccountClient
} from 'permissionless'
import {
signerToSafeSmartAccount,
SafeSmartAccount
} from 'permissionless/accounts'
import { erc7579Actions, Erc7579Actions } from 'permissionless/actions/erc7579'
import {
createPimlicoBundlerClient,
createPimlicoPaymasterClient
} from 'permissionless/clients/pimlico'
import { EntryPoint } from 'permissionless/types'

export type SafeSmartAccountClient = SmartAccountClient<
EntryPoint,
Transport,
Chain,
SafeSmartAccount<EntryPoint>
> &
Erc7579Actions<EntryPoint, SafeSmartAccount<EntryPoint>>

const pimlicoUrl = `https://api.pimlico.io/v2/sepolia/rpc?apikey=${process.env.NEXT_PUBLIC_PIMLICO_API_KEY}`
const safe4337ModuleAddress = '0x3Fdb5BC686e861480ef99A6E3FaAe03c0b9F32e2'
const erc7569LaunchpadAddress = '0xEBe001b3D534B9B6E2500FB78E67a1A137f561CE'

const privateKey =
(process.env.NEXT_PUBLIC_PRIVATE_KEY as Hex) ??
(() => {
const pk = generatePrivateKey()
console.log('Private key to add to .env.local:', `PRIVATE_KEY=${pk}`)
return pk
})()

const signer = privateKeyToAccount(privateKey)

const publicClient = createPublicClient({
transport: http('https://rpc.ankr.com/eth_sepolia')
})

const paymasterClient = createPimlicoPaymasterClient({
transport: http(pimlicoUrl),
entryPoint: ENTRYPOINT_ADDRESS_V07
})

const bundlerClient = createPimlicoBundlerClient({
transport: http(pimlicoUrl),
entryPoint: ENTRYPOINT_ADDRESS_V07
})

export const getSmartAccountClient = async () => {
const account = await signerToSafeSmartAccount(publicClient, {
entryPoint: ENTRYPOINT_ADDRESS_V07,
signer,
safeVersion: '1.4.1',
saltNonce: 120n,
safe4337ModuleAddress,
erc7569LaunchpadAddress
})

const smartAccountClient = createSmartAccountClient({
chain: sepolia,
account,
bundlerTransport: http(pimlicoUrl),
middleware: {
gasPrice: async () =>
(await bundlerClient.getUserOperationGasPrice()).fast,
sponsorUserOperation: paymasterClient.sponsorUserOperation
}
}).extend(erc7579Actions({ entryPoint: ENTRYPOINT_ADDRESS_V07 }))

return smartAccountClient as SafeSmartAccountClient
}
Loading

0 comments on commit 510c32b

Please sign in to comment.