Created
August 28, 2024 20:19
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'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> | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'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') | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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