Skip to content

Instantly share code, notes, and snippets.

@elmariachi111
Created August 28, 2024 20:19
Show Gist options
  • Save elmariachi111/2c11aeac0c01308236d03a478e682226 to your computer and use it in GitHub Desktop.
Save elmariachi111/2c11aeac0c01308236d03a478e682226 to your computer and use it in GitHub Desktop.
smart contract wallet transaction preparation for Catalyst V2 spawns (Alchemy Account Kit & Gas Policiues / Privy Signer)
import { Button, Flex, Text, useToast } from '@chakra-ui/react'
import { useOnChange } from '@moleculexyz/common'
import { usePrivy } from '@privy-io/react-auth'
import { useRouter } from 'next/router'
import { useCallback, useMemo, useState } from 'react'
import { Address, Chain } from 'viem'
import { Alert } from '@/components/atoms/Alert'
import { BackButton } from '@/components/atoms/BackButton'
import { ContinueButton } from '@/components/atoms/ContinueButton'
import { ConnectButton } from '@/components/molecules/ConnectButton'
import { MintLayout } from '@/components/templates/MintLayout'
import { useOrbis } from '@/context/OrbisContext'
import { useSmartAccountContext } from '@/context/SmartAccountContext'
import { ipSeedAddress } from '@/generated/wagmi'
import { useActiveWallet } from '@/hooks/useActiveWallet'
import { useBeneficiary } from '@/hooks/useBeneficiary'
import { useSpawn } from '@/hooks/useIPSeed'
import { computeTokenId } from '@/hooks/useIPSeedProject'
import { LaunchParameters, useLaunchProject } from '@/hooks/useLaunchProject'
import { useMintStore } from '@/hooks/useMintStore'
import { createProject } from '@/lib/orbis'
const LaunchButton = (props: { sourcer: Address; chain: Chain }) => {
const { sourcer, chain } = props
const smartAccount = useSmartAccountContext()
const { files, projectInformation, reset } = useMintStore((s) => ({
files: s.files,
projectInformation: s.projectInformation,
reset: s.reset
}))
const router = useRouter()
const toast = useToast()
const [isWorking, setIsWorking] = useState(false)
const [launchResult, setLaunchResult] = useState<{
hash: `0x${string}`
projectId: string
tokenId: bigint
}>()
const {
spawn,
subsidizedSpawn,
isPending: isLoading,
isFetching
} = useSpawn({
notifications: true
})
const { launchOrbisProject } = useLaunchProject()
const {
deploySafe,
safeAccountConfig,
predictBeneficiaryAddress,
safeDeployTransactionRequest
} = useBeneficiary(sourcer)
const isDisabled = useMemo(() => {
return isLoading || isFetching || isWorking
}, [isLoading, isFetching, isWorking])
useOnChange(
launchResult,
useCallback(
(prev, next) => {
if (!prev?.hash && next?.hash) {
router
.replace(`/projects/${next.tokenId.toString()}`)
.then(() => reset())
}
},
[reset, router]
)
)
const launch = useCallback(async () => {
if (!projectInformation?.name || files.length === 0) {
console.error('no project data')
return
}
//@ts-ignore
const contractAddress = ipSeedAddress[chain.id]
if (!contractAddress) {
console.error('no contract address')
return
}
try {
setIsWorking(true)
const projectId = await createProject({
name: projectInformation.name,
description: projectInformation.description
})
const tokenId = computeTokenId(
smartAccount.smartAccountReady
? (smartAccount.smartAccountAddress as Address)
: sourcer,
projectId
)
const { predictedAddress, isSafeDeployed } =
await predictBeneficiaryAddress(tokenId)
const launchParameters: LaunchParameters = {
projectId,
tokenId,
chain,
contractAddress,
projectInformation,
files,
sourcer,
beneficiary: predictedAddress
}
console.debug('launching with', launchParameters)
const ctxResult = await launchOrbisProject(launchParameters)
let txResult
if (smartAccount.smartAccountReady && safeAccountConfig) {
txResult = await subsidizedSpawn(
launchParameters,
isSafeDeployed
? undefined
: await safeDeployTransactionRequest(
sourcer,
safeAccountConfig,
tokenId
)
)
} else {
if (!isSafeDeployed && safeAccountConfig) {
// --> 2 transactions, could also be multicalled
await deploySafe(safeAccountConfig, tokenId)
}
txResult = await spawn(launchParameters)
}
console.log('everything set up.', txResult, ctxResult)
setLaunchResult({ ...txResult, tokenId, projectId })
} catch (e: any) {
console.error(e)
toast({
title: 'failed launching project',
// Alchemy's short error message is in e.details
description: e.details || e.message || e.toString(),
status: 'error'
})
} finally {
setIsWorking(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
projectInformation,
files,
chain,
smartAccount,
sourcer,
predictBeneficiaryAddress,
launchOrbisProject,
safeAccountConfig,
subsidizedSpawn,
safeDeployTransactionRequest,
spawn,
toast
])
return (
<ContinueButton
onClick={launch}
loadingText="Launching..."
isLoading={isDisabled}
isDisabled={isDisabled}
data-track-id="projectLaunch"
data-address={sourcer}
>
Launch Project
</ContinueButton>
)
}
export const LaunchPage = () => {
const { activeWallet, activeChain: chain } = useActiveWallet()
const smartAccount = useSmartAccountContext()
const { connectedDid, connect } = useOrbis()
const { ready, authenticated } = usePrivy()
return (
<MintLayout prevSlot={<BackButton href="/mint/marketparams" />}>
{!activeWallet || !connectedDid || !authenticated || !ready || !chain ? (
<Alert backgroundColor="moleculeGrey.100" rounded="lg" mt={4}>
<Flex
p={10}
h="200px"
w="full"
direction="column"
align="center"
justify="space-between"
>
<Text>You will need to sign one message with Ceramic.</Text>
{activeWallet ? (
<Button w={80} onClick={() => connect(activeWallet)}>
Sign
</Button>
) : (
<ConnectButton />
)}
</Flex>
</Alert>
) : (
<Flex direction="column" align="center" gap={10}>
<Flex direction="column" align="center" gap={6}>
<Text fontSize="xx-large" fontWeight={700}>
You&apos;re all set!
</Text>
<Text fontSize="large">
Click below to launch your project and start getting feedback.
</Text>
</Flex>
<LaunchButton
sourcer={activeWallet.address as Address}
chain={chain}
/>
<Text fontSize="small" textColor="gray.400">
{smartAccount.smartAccountReady
? "You will be asked to sign one message. We'll pay the fees."
: 'You will be asked to confirm two transactions in your wallet'}
</Text>
</Flex>
)}
</MintLayout>
)
}
import { createLightAccountAlchemyClient } from '@alchemy/aa-alchemy'
import {
base,
baseSepolia,
BatchUserOperationCallData,
SendUserOperationResult,
SmartAccountClient,
type SmartAccountSigner,
UserOperationCallData,
WalletClientSigner
} from '@alchemy/aa-core'
import { ConnectedWallet, EIP1193Provider } from '@privy-io/react-auth'
import React, { useContext, useEffect, useState } from 'react'
import {
Address,
createWalletClient,
custom,
hexToBigInt,
RpcTransactionRequest,
WalletClient
} from 'viem'
import { useActiveWallet } from '@/hooks/useActiveWallet'
interface ISmartAccountContext {
eoa?: ConnectedWallet
eip1193Provider?: EIP1193Provider
walletClient?: WalletClient
smartAccountSigner?: SmartAccountSigner
smartAccountClient?: SmartAccountClient
smartAccountAddress?: Address
smartAccountReady: boolean
sendSponsoredUserOperation?: (
transactionRequest: RpcTransactionRequest | RpcTransactionRequest[]
) => Promise<SendUserOperationResult>
}
//built using input from
//https://github.com/privy-io/base-paymaster-example/blob/main/hooks/SmartAccountContext.tsx
//https://accountkit.alchemy.com/smart-accounts/accounts/guides/light-account.html#using-light-account
//https://github.com/alchemyplatform/aa-sdk/blob/main/examples/aa-simple-dapp/src/context/wallet/index.tsx#L39
const defaultContext: ISmartAccountContext = {
smartAccountReady: false
}
const SmartAccountContext =
React.createContext<ISmartAccountContext>(defaultContext)
const useSmartAccountContext = () => useContext(SmartAccountContext)
const SmartAccountProvider = ({ children }: { children: React.ReactNode }) => {
const [context, setContext] = useState<ISmartAccountContext>(defaultContext)
const { activeChain: chain, activeWallet } = useActiveWallet()
useEffect(() => {
;(async () => {
if (
!process.env.NEXT_PUBLIC_ALCHEMY_API_KEY ||
!process.env.NEXT_PUBLIC_ALCHEMY_GAS_MANAGER_POLICY ||
!activeWallet ||
activeWallet.walletClientType !== 'privy' ||
!chain
) {
setContext(defaultContext)
return
}
const eip1193Provider = await activeWallet.getEthereumProvider()
const walletClient = createWalletClient({
account: activeWallet.address as Address,
chain,
transport: custom(eip1193Provider)
})
const smartAccountSigner: SmartAccountSigner = new WalletClientSigner(
walletClient,
'privy' //arbitrary, was 'json-rpc'
)
//https://accountkit.alchemy.com/smart-accounts/accounts/guides/light-account.html#using-light-account
//https://accountkit.alchemy.com/smart-accounts/accounts/deployment-addresses.html#light-account
const smartAccountClient = await createLightAccountAlchemyClient({
apiKey: process.env.NEXT_PUBLIC_ALCHEMY_API_KEY,
chain: chain.id === base.id ? base : baseSepolia,
signer: smartAccountSigner,
gasManagerConfig: {
policyId: process.env.NEXT_PUBLIC_ALCHEMY_GAS_MANAGER_POLICY
}
})
const sendSponsoredUserOperation = async (
transactionRequest: RpcTransactionRequest | RpcTransactionRequest[]
): Promise<SendUserOperationResult> => {
let userOp: { uo: UserOperationCallData | BatchUserOperationCallData }
if (Array.isArray(transactionRequest)) {
//todo: this appears different on the latest api -> https://accountkit.alchemy.com/using-smart-accounts/batch-user-operations.html#batching-using-senduseroperation
userOp = {
uo: transactionRequest.map((rpcRequest) => ({
target: rpcRequest.to as Address,
value: rpcRequest.value ? hexToBigInt(rpcRequest.value) : 0n,
data: rpcRequest.data as any
}))
}
} else {
userOp = {
uo: {
target: transactionRequest.to as Address,
value: transactionRequest.value
? hexToBigInt(transactionRequest.value)
: 0n,
data: transactionRequest.data as any
}
}
}
// todo: deactivated for now because this triggers a signature request
// const eligibility =
// await smartAccountClient.checkGasSponsorshipEligibility(userOp)
// console.debug('eligibility result: ', eligibility)
return smartAccountClient.sendUserOperation(userOp)
}
const smartAccountAddress = smartAccountClient.getAddress()
console.debug('setup SCW provider with ', smartAccountAddress, chain)
setContext({
eoa: activeWallet,
eip1193Provider,
walletClient,
smartAccountSigner,
smartAccountClient,
smartAccountAddress,
sendSponsoredUserOperation,
smartAccountReady: true
})
})()
}, [chain, activeWallet])
return (
<SmartAccountContext.Provider value={{ ...context }}>
{children}
</SmartAccountContext.Provider>
)
}
export { SmartAccountProvider, useSmartAccountContext }
import { SmartAccountClient } from '@alchemy/aa-core'
import { Link, Text, ToastId, ToastProps, useToast } from '@chakra-ui/react'
import { useOnChange } from '@moleculexyz/common'
import { DecodedLog, Ethereum, Strings } from '@moleculexyz/core'
import React, { useCallback, useMemo, useState } from 'react'
import {
Abi,
GetTransactionReturnType,
Hex,
WriteContractReturnType
} from 'viem'
import { useAccount, useWaitForTransactionReceipt } from 'wagmi'
export const useBundlerInteraction = (props: {
client?: SmartAccountClient
notifications?: boolean
}) => {
const { client } = props
const { chain } = useAccount()
const toast = useToast()
const toastIdRef = React.useRef<ToastId>()
const [bundlerState, setBundlerState] = useState<{
relayedTxResult?: WriteContractReturnType
bundleHash?: Hex
isBundling: boolean
}>({ isBundling: false })
useOnChange(bundlerState, (prev, next) => {
//if (!notifications) return
if (!prev.relayedTxResult && !!next.relayedTxResult) {
const hash = next.relayedTxResult
const explorerLink = `${chain?.blockExplorers?.default?.url}/tx/${hash}`
const toastData: ToastProps = {
title: 'user operation was relayed.',
status: 'success',
description: (
<Text>
it&apos;s now becoming a transaction:{' '}
<Link href={explorerLink} isExternal>
{Strings.trimAddress(next.relayedTxResult)}
</Link>
</Text>
)
}
if (toastIdRef.current) {
toast.update(toastIdRef.current, toastData)
} else {
toastIdRef.current = toast(toastData)
}
}
})
useOnChange(bundlerState, (prev, next) => {
//if (!notifications) return
if (!prev.isBundling && next.isBundling) {
const hash = next.bundleHash || '0x'
toast({
title: 'user operation submitted.',
status: 'info',
description: (
<Text>
The bundler will relay your user operation (
{Strings.trimAddress(hash)}) to the network.
</Text>
)
})
}
})
const waitForBundle = useCallback(
async (bundleHash: `0x${string}`) => {
if (!client) {
throw new Error('no client')
}
setBundlerState({ isBundling: true, bundleHash })
const relayedTxHash = await client.waitForUserOperationTransaction({
hash: bundleHash
})
setBundlerState((o) => ({
...o,
isBundling: false,
relayedTxResult: relayedTxHash
}))
console.debug('Relayed transaction hash', relayedTxHash)
return { hash: relayedTxHash }
},
[client]
)
return { waitForBundle, ...bundlerState }
}
/**
* @param props.data - the result of a writeAsync call, naming this "data" allows callers to simply reuse their tx "data" responses like { data }
* @param props.logPredicate - how to filter logs from the tx
* @param props.notifications - whether to show toast notifications
*/
export const useContractInteraction = (props: {
data?: WriteContractReturnType
abi?: Abi
logPredicate?: (log: DecodedLog) => boolean
notifications?: boolean
client?: {
getTransaction: (hash: `0x${string}`) => Promise<GetTransactionReturnType>
}
}) => {
const toast = useToast()
const toastIdRef = React.useRef<ToastId>()
const { abi, logPredicate, data: writeContractResult } = props
const { chain } = useAccount()
const {
data: receipt,
isFetching,
fetchStatus,
isSuccess
} = useWaitForTransactionReceipt({
hash: writeContractResult,
timeout: 1000 * 60 * 5
})
const logs = useMemo(() => {
if (!receipt || !abi) return []
console.debug(`tx [${receipt.transactionHash}] receipt`, receipt)
return Ethereum.safeDecodeLogs(receipt, abi).filter(
logPredicate || (() => true)
)
}, [abi, logPredicate, receipt])
useOnChange(fetchStatus, (prev, next) => {
//if (!notifications) return
const hash = writeContractResult!
const explorerLink = `${chain?.blockExplorers?.default?.url}/tx/${hash}`
if (prev === 'idle' && next === 'fetching') {
const toastData: ToastProps = {
title: 'transaction is on its way.',
description: (
<Text>
check its status here:{' '}
<Link href={explorerLink} isExternal>
{Strings.trimAddress(hash)}
</Link>
</Text>
),
status: 'loading'
}
if (toastIdRef.current) {
toast.update(toastIdRef.current, toastData)
} else {
toastIdRef.current = toast(toastData)
}
}
})
useOnChange(isSuccess, (prev, next) => {
//if (!notifications) return
if (!writeContractResult) return
const explorerLink = `${chain?.blockExplorers?.default?.url}/tx/${writeContractResult}`
if (prev === false && next === true) {
const toastData: ToastProps = {
title: 'Transaction is finalized.',
description: (
<Text>
check its status here:{' '}
<Link href={explorerLink} isExternal>
{Strings.trimAddress(writeContractResult)}
</Link>
</Text>
),
status: 'success',
duration: 10000,
isClosable: true
}
if (toastIdRef.current) {
toast.update(toastIdRef.current, toastData)
} else {
toastIdRef.current = toast(toastData)
}
}
})
return { receipt, logs, isFetching }
}
export const tryTx = (
abi: Abi,
preparedCall?: () => Promise<WriteContractReturnType>,
toast?: ReturnType<typeof useToast>
): Promise<WriteContractReturnType> => {
try {
if (!preparedCall) {
throw new Error('no prepared call')
}
return preparedCall()
} catch (e: any) {
const dError = Ethereum.safeDecodeErrors({ abi, data: e })
const description = dError ? dError.errorName : e.message || e.toString()
console.error('transaction failed', e, description)
toast &&
toast({
isClosable: true,
status: 'error',
title: 'Transaction failed',
description
})
return Promise.resolve('0x')
}
}
export const useSpawn = ({ notifications }: { notifications: boolean }) => {
const {
data: txResult,
writeContractAsync: spawn,
...rest
} = useWriteIpSeedSpawn()
const smartAccount = useSmartAccountContext()
const publicClient = usePublicClient()
const { waitForBundle, isBundling, relayedTxResult } = useBundlerInteraction({
notifications,
client: smartAccount.smartAccountClient
})
const { receipt, logs, isFetching } = useContractInteraction({
data: txResult || relayedTxResult,
abi: ipSeedAbi,
notifications,
logPredicate: (log) => log.eventName === 'Spawned'
})
const toast = useToast()
const doSpawn = useCallback(
async (launchParameters: LaunchParameters) => {
if (!publicClient) throw new Error('Public client not ready')
const hash = await tryTx(
ipSeedAbi,
() =>
spawn({
args: [makeMarketParameters(launchParameters)]
}),
toast
)
const receipt = await publicClient.waitForTransactionReceipt({ hash })
return { hash: receipt.transactionHash }
},
[publicClient, spawn, toast]
)
const doSubsidizedSpawn = useCallback(
async (
launchParameters: LaunchParameters,
safeCreationRequest?: RpcTransactionRequest
) => {
if (
!(
smartAccount.smartAccountReady &&
smartAccount.smartAccountAddress &&
smartAccount.sendSponsoredUserOperation
)
)
throw new Error('Smart Account not ready')
const spawnOp = spawnTransactionRequest(
smartAccount.smartAccountAddress,
launchParameters
)
const bundleHash = await smartAccount.sendSponsoredUserOperation(
safeCreationRequest ? [safeCreationRequest, spawnOp] : spawnOp
)
console.debug('bundleHash on L2', bundleHash)
return waitForBundle(bundleHash.hash)
},
[smartAccount, waitForBundle]
)
return {
spawn: doSpawn,
subsidizedSpawn: doSubsidizedSpawn,
...rest,
receipt,
logs,
isFetching: isFetching || isBundling
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment