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

View File

@ -6,9 +6,10 @@ interface Props {
rgb: string rgb: string
flexDirection: 'row' | 'col' flexDirection: 'row' | 'col'
alignItems: 'center' | 'flex-start' 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 ( return (
<Card <Card
bordered bordered
@ -23,6 +24,7 @@ export default function ChainCard({ rgb, children, flexDirection, alignItems }:
> >
<Card.Body <Card.Body
css={{ css={{
flexWrap,
flexDirection, flexDirection,
alignItems, alignItems,
justifyContent: 'space-between', 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 TEIP155Chain = keyof typeof EIP155_CHAINS
export type EIP155TestChain = {
chainId: number
name: string
logo: string
rgb: string
rpc: string
namespace: string
smartAccountEnabled?: boolean
}
/** /**
* Chains * 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': { 'eip155:5': {
chainId: 5, chainId: 5,
name: 'Ethereum Goerli', name: 'Ethereum Goerli',
logo: '/chain-logos/eip155-1.png', logo: '/chain-logos/eip155-1.png',
rgb: '99, 125, 234', rgb: '99, 125, 234',
rpc: 'https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161', 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': { 'eip155:43113': {
chainId: 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 return this.wallet.mnemonic.phrase
} }
getPrivateKey() {
return this.wallet.privateKey
}
getAddress() { getAddress() {
return this.wallet.address 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 { Text } from '@nextui-org/react'
import { Fragment } from 'react' import { Fragment } from 'react'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import SmartAccountCard from '@/components/SmartAccountCard'
export default function HomePage() { export default function HomePage() {
const { const {
testNets, testNets,
eip155Address, eip155Address,
activeChainId,
cosmosAddress, cosmosAddress,
solanaAddress, solanaAddress,
polkadotAddress, polkadotAddress,
@ -131,7 +133,23 @@ export default function HomePage() {
<Text h4 css={{ marginBottom: '$5' }}> <Text h4 css={{ marginBottom: '$5' }}>
Testnets Testnets
</Text> </Text>
{Object.entries(EIP155_TEST_CHAINS).map(([caip10, { name, logo, rgb }]) => ( {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 <AccountCard
key={name} key={name}
name={name} name={name}
@ -141,7 +159,8 @@ export default function HomePage() {
chainId={caip10.toString()} chainId={caip10.toString()}
data-testid={'chain-card-' + caip10.toString()} data-testid={'chain-card-' + caip10.toString()}
/> />
))} )
})}
{Object.entries(SOLANA_TEST_CHAINS).map(([caip10, { name, logo, rgb }]) => ( {Object.entries(SOLANA_TEST_CHAINS).map(([caip10, { name, logo, rgb }]) => (
<AccountCard <AccountCard
key={name} key={name}

View File

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

View File

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

File diff suppressed because it is too large Load Diff