feat: Goerli Smart Accounts. (#347)

* feat: added Goerli smart accounts

* chore: remove viem version modifier. Split useSmartAccount usage into multiple lines

* feat: fix error handling
This commit is contained in:
tomiir 2023-12-14 11:55:20 -06:00 committed by GitHub
parent deb123f283
commit 61b181b9d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1897 additions and 1443 deletions

View File

@ -39,6 +39,7 @@
"mnemonic-keyring": "1.4.0",
"near-api-js": "^0.45.0",
"next": "12.1.5",
"permissionless": "0.0.0-main.20231120T173047",
"react": "17.0.2",
"react-code-blocks": "0.0.9-0",
"react-dom": "17.0.2",
@ -46,15 +47,16 @@
"react-qr-reader-es6": "2.2.1-2",
"solana-wallet": "^1.0.2",
"tronweb": "^4.4.0",
"valtio": "1.6.0"
"valtio": "1.6.0",
"viem": "1.19.13"
},
"devDependencies": {
"@types/node": "17.0.35",
"@types/react": "18.0.9",
"@types/react": "17.0.2",
"eslint": "8.15.0",
"eslint-config-next": "12.1.6",
"eslint-config-prettier": "8.5.0",
"prettier": "2.6.2",
"typescript": "4.6.4"
"typescript": "5.2.2"
}
}

View File

@ -1,22 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 800 600" style="enable-background:new 0 0 800 600;" xml:space="preserve">
<style type="text/css">
.st0{fill:#2C7DF7;}
.st1{fill:#FFFFFF;}
</style>
<path class="st0" d="M399.8,559.9c143.8,0,260.3-116.6,260.3-260.3S543.6,39.2,399.8,39.2c-143.8,0-260.3,116.6-260.3,260.3
S256,559.9,399.8,559.9z"/>
<path class="st1" d="M424.8,449.1c-21.1,0-36.4-5.1-46.1-15.2c-9-8.6-14.2-20.4-14.5-32.8c-0.1-3.8,0.8-7.5,2.5-10.8
c1.7-2.9,4-5.3,6.9-6.9c3.3-1.7,7-2.5,10.8-2.5c3.7,0,7.4,0.9,10.8,2.5c2.9,1.6,5.2,4,6.9,6.9c1.8,3.3,2.7,7.1,2.6,10.8
c0.2,4.5-1.1,9-3.7,12.7c-2.2,3-5.2,5.3-8.8,6.4c3.5,4.4,8.4,7.5,13.8,8.6c6.1,1.8,12.5,2.7,18.9,2.7c8.4,0.1,16.7-2.4,23.7-7.1
c7.3-5.1,12.7-12.5,15.5-21c3.5-10.1,5.2-20.7,5.1-31.4c0.3-11.1-1.6-22.2-5.5-32.6c-3-8.3-8.6-15.5-16-20.3
c-6.9-4.4-14.9-6.6-23-6.6c-7.1,0.6-14,2.9-20,6.8l-14.8,7.4v-7.4l66.5-89.2h-92.2v92.6c-0.3,6.7,1.5,13.3,5.1,18.9
c1.7,2.5,4.1,4.4,6.8,5.7c2.7,1.3,5.7,1.9,8.7,1.7c5.5-0.1,10.9-2,15.3-5.4c5-3.6,9.3-8.1,12.8-13.2c0.3-0.9,0.9-1.7,1.7-2.2
c0.6-0.5,1.4-0.8,2.2-0.8c1.6,0.1,3.1,0.8,4.3,1.8c1.5,1.7,2.4,4,2.3,6.3c-0.2,1.6-0.5,3.1-0.8,4.6c-3.3,7.9-8.8,14.7-15.8,19.6
c-6.5,4.4-14.3,6.8-22.2,6.7c-20,0-33.8-3.9-41.5-11.8c-4-4.3-7-9.4-9-15c-2-5.5-2.8-11.4-2.5-17.3v-92.4h-46.9v-17.2H332v-39.2
l-10.8-10.8v-8.8h31.3l11.8,6.1v52.7l121.8-0.4l12.1,12.2l-74.7,75c4.5-1.8,9.3-3,14.1-3.4c9.6,0.4,18.9,3.1,27.3,7.8
c9.7,4.6,17.8,11.9,23.5,21c5.1,7.7,8.7,16.3,10.6,25.3c1.6,7.1,2.4,14.4,2.5,21.7c0,13.9-3.1,27.6-9.2,40.1
c-5.8,12.1-15.6,21.9-27.7,27.7C452.3,446,438.6,449.1,424.8,449.1L424.8,449.1z"/>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 800 600" style="enable-background:new 0 0 800 600;" xml:space="preserve">
<style type="text/css">
.st0{fill:#2C7DF7;}
.st1{fill:#FFFFFF;}
</style>
<path class="st0" d="M399.8,559.9c143.8,0,260.3-116.6,260.3-260.3S543.6,39.2,399.8,39.2c-143.8,0-260.3,116.6-260.3,260.3
S256,559.9,399.8,559.9z"/>
<path class="st1" d="M424.8,449.1c-21.1,0-36.4-5.1-46.1-15.2c-9-8.6-14.2-20.4-14.5-32.8c-0.1-3.8,0.8-7.5,2.5-10.8
c1.7-2.9,4-5.3,6.9-6.9c3.3-1.7,7-2.5,10.8-2.5c3.7,0,7.4,0.9,10.8,2.5c2.9,1.6,5.2,4,6.9,6.9c1.8,3.3,2.7,7.1,2.6,10.8
c0.2,4.5-1.1,9-3.7,12.7c-2.2,3-5.2,5.3-8.8,6.4c3.5,4.4,8.4,7.5,13.8,8.6c6.1,1.8,12.5,2.7,18.9,2.7c8.4,0.1,16.7-2.4,23.7-7.1
c7.3-5.1,12.7-12.5,15.5-21c3.5-10.1,5.2-20.7,5.1-31.4c0.3-11.1-1.6-22.2-5.5-32.6c-3-8.3-8.6-15.5-16-20.3
c-6.9-4.4-14.9-6.6-23-6.6c-7.1,0.6-14,2.9-20,6.8l-14.8,7.4v-7.4l66.5-89.2h-92.2v92.6c-0.3,6.7,1.5,13.3,5.1,18.9
c1.7,2.5,4.1,4.4,6.8,5.7c2.7,1.3,5.7,1.9,8.7,1.7c5.5-0.1,10.9-2,15.3-5.4c5-3.6,9.3-8.1,12.8-13.2c0.3-0.9,0.9-1.7,1.7-2.2
c0.6-0.5,1.4-0.8,2.2-0.8c1.6,0.1,3.1,0.8,4.3,1.8c1.5,1.7,2.4,4,2.3,6.3c-0.2,1.6-0.5,3.1-0.8,4.6c-3.3,7.9-8.8,14.7-15.8,19.6
c-6.5,4.4-14.3,6.8-22.2,6.7c-20,0-33.8-3.9-41.5-11.8c-4-4.3-7-9.4-9-15c-2-5.5-2.8-11.4-2.5-17.3v-92.4h-46.9v-17.2H332v-39.2
l-10.8-10.8v-8.8h31.3l11.8,6.1v52.7l121.8-0.4l12.1,12.2l-74.7,75c4.5-1.8,9.3-3,14.1-3.4c9.6,0.4,18.9,3.1,27.3,7.8
c9.7,4.6,17.8,11.9,23.5,21c5.1,7.7,8.7,16.3,10.6,25.3c1.6,7.1,2.4,14.4,2.5,21.7c0,13.9-3.1,27.6-9.2,40.1
c-5.8,12.1-15.6,21.9-27.7,27.7C452.3,446,438.6,449.1,424.8,449.1L424.8,449.1z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -6,9 +6,10 @@ interface Props {
rgb: string
flexDirection: 'row' | 'col'
alignItems: 'center' | 'flex-start'
flexWrap?: 'wrap' | 'nowrap'
}
export default function ChainCard({ rgb, children, flexDirection, alignItems }: Props) {
export default function ChainCard({ rgb, children, flexDirection, alignItems, flexWrap }: Props) {
return (
<Card
bordered
@ -23,6 +24,7 @@ export default function ChainCard({ rgb, children, flexDirection, alignItems }:
>
<Card.Body
css={{
flexWrap,
flexDirection,
alignItems,
justifyContent: 'space-between',

View File

@ -0,0 +1,143 @@
import ChainCard from '@/components/ChainCard'
import SettingsStore from '@/store/SettingsStore'
import { truncate } from '@/utils/HelperUtil'
import { updateSignClientChainId } from '@/utils/WalletConnectUtil'
import { Avatar, Button, Text, Tooltip, Loading } from '@nextui-org/react'
import { eip155Wallets } from '@/utils/EIP155WalletUtil'
import Image from 'next/image'
import { useState, useEffect } from 'react'
import { useSnapshot } from 'valtio'
import useSmartAccount from '@/hooks/useSmartAccount'
interface Props {
name: string
logo: string
rgb: string
address: string
chainId: string
isActiveChain: boolean
}
export default function SmartAccountCard({
name,
logo,
rgb,
address = '',
chainId,
isActiveChain
}: Props) {
const [copied, setCopied] = useState(false)
const { activeChainId } = useSnapshot(SettingsStore.state)
const {
deploy,
isDeployed,
address: smartAccountAddress,
loading,
sendTestTransaction,
} = useSmartAccount(eip155Wallets[address].getPrivateKey() as `0x${string}`)
function onCopy() {
navigator?.clipboard?.writeText(address)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
async function onChainChanged(chainId: string, address: string) {
SettingsStore.setActiveChainId(chainId)
await updateSignClientChainId(chainId.toString(), address)
}
async function onCreateSmartAccount() {
try {
if (!isDeployed) {
await deploy()
}
} catch (error) {
console.error(error)
}
}
const getFaucetUrl = () => `https://${name?.toLowerCase()?.replace('ethereum', '')?.trim()}faucet.com`
return (
<ChainCard rgb={rgb} flexDirection="row" alignItems="center" flexWrap="wrap">
<Avatar src={logo} />
<div style={{ flex: 1 }}>
<Text h5 css={{ marginLeft: '$9' }}>
{name}
</Text>
<Text weight="light" size={13} css={{ marginLeft: '$9' }}>
{address ? truncate(address, 19) : '<no address available>'}
</Text>
</div>
<Tooltip content={copied ? 'Copied!' : 'Copy'} placement="left">
<Button
size="sm"
css={{ minWidth: 'auto', backgroundColor: 'rgba(255, 255, 255, 0.15)' }}
data-testid={'chain-copy-button' + chainId}
onClick={onCopy}
>
<Image
src={copied ? '/icons/checkmark-icon.svg' : '/icons/copy-icon.svg'}
width={15}
height={15}
alt="copy icon"
/>
</Button>
</Tooltip>
<Button
size="sm"
css={{
minWidth: 'auto',
backgroundColor: 'rgba(255, 255, 255, 0.15)',
marginLeft: '$5'
}}
data-testid={'chain-switch-button' + chainId}
onPress={() => {
onChainChanged(chainId, address)
}}
>
{activeChainId === chainId ? `` : `🔄`}
</Button>
{smartAccountAddress ? (
<>
<Text h5 css={{ marginTop: 20 }}>
Smart Account:
</Text>
<Text small css={{ marginTop: 5 }}>
{smartAccountAddress}
</Text>
<Button
size="md"
css={{ marginTop: 20, width: '100%' }}
onClick={sendTestTransaction}
disabled={!isActiveChain || loading}
>
{loading ? <Loading size="sm" /> : 'Send Test TX'}
</Button>
</>
) : (
<>
<Button
disabled={!isActiveChain || loading}
size="sm"
css={{ marginTop: 20, width: '100%' }}
onClick={() => window.open(getFaucetUrl(), '_blank')}
>
{name} Faucet
</Button>
<Button
disabled={!isActiveChain || loading}
size="sm"
css={{ marginTop: 10, width: '100%' }}
onClick={onCreateSmartAccount}
>
{loading ? <Loading size="sm" /> : 'Create Smart Account'}
</Button>
</>
)}
</ChainCard>
)
}

View File

@ -8,6 +8,16 @@
*/
export type TEIP155Chain = keyof typeof EIP155_CHAINS
export type EIP155TestChain = {
chainId: number
name: string
logo: string
rgb: string
rpc: string
namespace: string
smartAccountEnabled?: boolean
}
/**
* Chains
*/
@ -54,14 +64,23 @@ export const EIP155_MAINNET_CHAINS = {
}
}
export const EIP155_TEST_CHAINS = {
export const EIP155_TEST_CHAINS: Record<string,EIP155TestChain> = {
'eip155:5': {
chainId: 5,
name: 'Ethereum Goerli',
logo: '/chain-logos/eip155-1.png',
rgb: '99, 125, 234',
rpc: 'https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161',
namespace: 'eip155'
namespace: 'eip155',
smartAccountEnabled: true,
},
'eip155:11155111': {
chainId: 11155111,
name: 'Ethereum Sepolia',
logo: '/chain-logos/eip155-1.png',
rgb: '99, 125, 234',
rpc: 'https://rpc.sepolia.org',
namespace: 'eip155',
},
'eip155:43113': {
chainId: 43113,

View File

@ -0,0 +1,53 @@
import { SmartAccountLib } from "@/lib/SmartAccountLib";
import { useCallback, useEffect, useState } from "react";
export default function useSmartAccount(signerPrivateKey: `0x${string}`) {
const [loading, setLoading] = useState(false)
const [client, setClient] = useState<SmartAccountLib>();
const [isDeployed, setIsDeployed] = useState(false)
const [address, setAddress] = useState<`0x${string}`>()
const execute = useCallback(async (callback: () => void) => {
try {
setLoading(true)
await callback()
setLoading(false)
}
catch (e) {
console.error(e)
setLoading(false)
}
}, [setLoading])
const deploy = useCallback(async () => {
if (!client) return
execute(client?.deploySmartAccount)
}, [client, execute])
const sendTestTransaction = useCallback(async () => {
if (!client) return
execute(client?.sendTestTransaction)
}, [client, execute])
useEffect(() => {
const smartAccountClient = new SmartAccountLib(signerPrivateKey, 'goerli')
setClient(smartAccountClient)
}, [signerPrivateKey])
useEffect(() => {
client?.checkIfSmartAccountDeployed()
.then((deployed: boolean) => {
setIsDeployed(deployed)
setAddress(client?.address)
})
}, [client])
return {
address,
isDeployed,
deploy,
loading,
sendTestTransaction,
}
}

View File

@ -27,6 +27,10 @@ export default class EIP155Lib {
return this.wallet.mnemonic.phrase
}
getPrivateKey() {
return this.wallet.privateKey
}
getAddress() {
return this.wallet.address
}

View File

@ -0,0 +1,153 @@
import { createSmartAccountClient } from 'permissionless'
import { privateKeyToSafeSmartAccount } from 'permissionless/accounts'
import * as chains from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'
import { type Chain, createWalletClient, formatEther, createPublicClient, http } from 'viem'
import { createPimlicoBundlerClient } from 'permissionless/clients/pimlico'
export type SmartAccountEnabledChains = 'sepolia' | 'goerli'
// -- Helpers -----------------------------------------------------------------
const bundlerApiKey = process.env.NEXT_PUBLIC_PIMLICO_KEY
const projectId = process.env.NEXT_PUBLIC_PROJECT_ID
// -- Sdk ---------------------------------------------------------------------
export class SmartAccountLib {
public chain: Chain
private bundlerApiKey: string
#signerPrivateKey: `0x${string}`;
public isDeployed: boolean = false;
public address?: `0x${string}`;
public constructor(privateKey: `0x${string}`, chain: SmartAccountEnabledChains = 'goerli') {
if (!bundlerApiKey) {
throw new Error('Missing required data in SmartAccountSdk')
}
this.bundlerApiKey = bundlerApiKey
this.chain = chains[chain] as Chain
this.#signerPrivateKey = privateKey
}
// -- Public ------------------------------------------------------------------
// -- Private -----------------------------------------------------------------
private getWalletConnectTransport = () => http(
`https://rpc.walletconnect.com/v1/?chainId=EIP155:${this.chain.id}&projectId=${projectId}`,
{ retryDelay: 1000 }
);
private getBundlerTransport = () => http(
`https://api.pimlico.io/v1/${this.chain.name.toLowerCase()}/rpc?apikey=${this.bundlerApiKey}`,
{ retryDelay: 1000 }
);
private getBundlerClient = () => createPimlicoBundlerClient({
chain: this.chain,
transport: this.getBundlerTransport()
})
private getPublicClient = () => createPublicClient({
chain: this.chain,
transport: this.getWalletConnectTransport()
})
private getSignerClient = () => {
const signerAccount = privateKeyToAccount(this.#signerPrivateKey)
return createWalletClient({
account: signerAccount,
chain: this.chain,
transport: this.getWalletConnectTransport()
})
}
private getSmartAccountClient = async () => {
const smartAccount = await privateKeyToSafeSmartAccount(this.getPublicClient(), {
privateKey: this.#signerPrivateKey,
safeVersion: '1.4.1',
entryPoint: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789'
})
return createSmartAccountClient({
account: smartAccount,
chain: this.chain,
transport: this.getBundlerTransport()
})
}
private prefundSmartAccount = async (address: `0x${string}`) => {
const signerAccountViemClient = this.getSignerClient();
const publicClient = this.getPublicClient();
const bundlerClient = this.getBundlerClient();
const smartAccountBalance = await publicClient.getBalance({ address })
console.log(`Smart Account Balance: ${formatEther(smartAccountBalance)} ETH`)
if (smartAccountBalance < 1n) {
console.log(`Smart Account has no balance. Starting prefund`)
const { fast: fastPrefund } = await bundlerClient.getUserOperationGasPrice()
const prefundHash = await signerAccountViemClient.sendTransaction({
to: address,
value: 10000000000000000n,
maxFeePerGas: fastPrefund.maxFeePerGas,
maxPriorityFeePerGas: fastPrefund.maxPriorityFeePerGas
})
await publicClient.waitForTransactionReceipt({ hash: prefundHash })
console.log(`Prefunding Success`)
const newSmartAccountBalance = await publicClient.getBalance({ address })
console.log(
`Smart Account Balance: ${formatEther(newSmartAccountBalance)} ETH`
)
}
}
// By default first transaction will deploy the smart contract if it hasn't been deployed yet
public sendTestTransaction = async () => {
const publicClient = this.getPublicClient();
const bundlerClient = this.getBundlerClient();
const smartAccountClient = await this.getSmartAccountClient();
const { fast: testGas, } = await bundlerClient.getUserOperationGasPrice()
const testHash = await smartAccountClient.sendTransaction({
to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' as `0x${string}`,
value: 0n,
maxFeePerGas: testGas.maxFeePerGas,
maxPriorityFeePerGas: testGas.maxPriorityFeePerGas,
})
console.log(`Sending Test Transaction With Hash: ${testHash}`)
await publicClient.waitForTransactionReceipt({ hash: testHash })
console.log(`Test Transaction Success`)
}
public checkIfSmartAccountDeployed = async () => {
console.log('checking if deployed')
const smartAccountClient = await this.getSmartAccountClient();
const publicClient = this.getPublicClient();
const bytecode = await publicClient.getBytecode({ address: smartAccountClient.account.address })
this.isDeployed = Boolean(bytecode)
console.log(`Smart Account Deployed: ${this.isDeployed}`)
if (this.isDeployed) {
this.address = smartAccountClient.account.address
}
return this.isDeployed;
}
public deploySmartAccount = async () => {
const smartAccountClient = await this.getSmartAccountClient();
const isDeployed = await this.checkIfSmartAccountDeployed()
if (!isDeployed) {
// If not deployed, prefund smart account from signer
// Step 3: Send prefund transaction from signer to smart account if empty
await this.prefundSmartAccount(smartAccountClient.account.address)
// Step 4: Create account by sending test tx
await this.sendTestTransaction()
await this.checkIfSmartAccountDeployed()
console.log(`Account Created`)
}
}
}

View File

@ -14,11 +14,13 @@ import SettingsStore from '@/store/SettingsStore'
import { Text } from '@nextui-org/react'
import { Fragment } from 'react'
import { useSnapshot } from 'valtio'
import SmartAccountCard from '@/components/SmartAccountCard'
export default function HomePage() {
const {
testNets,
eip155Address,
activeChainId,
cosmosAddress,
solanaAddress,
polkadotAddress,
@ -131,17 +133,34 @@ export default function HomePage() {
<Text h4 css={{ marginBottom: '$5' }}>
Testnets
</Text>
{Object.entries(EIP155_TEST_CHAINS).map(([caip10, { name, logo, rgb }]) => (
<AccountCard
key={name}
name={name}
logo={logo}
rgb={rgb}
address={eip155Address}
chainId={caip10.toString()}
data-testid={'chain-card-' + caip10.toString()}
/>
))}
{Object.entries(EIP155_TEST_CHAINS).map(([caip10, { name, logo, rgb, chainId, smartAccountEnabled }]) => {
if (smartAccountEnabled) {
return (
<SmartAccountCard
key={name}
name={name}
logo={logo}
rgb={rgb}
address={eip155Address}
chainId={caip10.toString()}
data-testid={'chain-card-' + caip10.toString()}
isActiveChain={activeChainId === `eip155:${chainId}`}
/>
)
}
return (
<AccountCard
key={name}
name={name}
logo={logo}
rgb={rgb}
address={eip155Address}
chainId={caip10.toString()}
data-testid={'chain-card-' + caip10.toString()}
/>
)
})}
{Object.entries(SOLANA_TEST_CHAINS).map(([caip10, { name, logo, rgb }]) => (
<AccountCard
key={name}

View File

@ -102,7 +102,7 @@ const SettingsStore = {
} else {
localStorage.removeItem('TEST_NETS')
}
}
},
}
export default SettingsStore

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
@ -21,7 +21,8 @@
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["src/*"],
"react": ["node_modules/@types/react"],
}
},
"include": [

File diff suppressed because it is too large Load Diff