Skip to content

Commit

Permalink
[Counterfactual] Add deploy safe flow (#3199)
Browse files Browse the repository at this point in the history
* feat: Create counterfactual 1/1 safes

* fix: Add feature flag

* fix: Lint issues

* fix: Use incremental saltNonce for all safe creations

* fix: Replace useCounterfactualBalance hook with get function and write tests

* refactor: Move creation logic out of Review component

* fix: useLoadBalance check for undefined value

* fix: Extract saltNonce, safeAddress calculation into a hook

* refactor: Rename redux slice

* fix: Show error message in case saltNonce can't be retrieved

* fix: Disable create button if deploy props are loading

* fix: Revert hook change and update comment

* feat: Deploy counterfactual safe with first transaction

* fix: Get gas limit estimations in parallel

* fix: Add getBalances fallback for safe app calls

* fix: Hide nonce in tx flow for counterfactual safes

* fix: Show txEvents for first tx and close the flow when user submits tx

* feat: Add deploy safe flow

* refactor: Extract getTotalFeeFormatted and move logic into a local hook

* fix: Allow relaying when deploying a safe

* refactor: Extract safe setup overview component

* fix: Wait for relayed deployment before clearing counterfactual state
  • Loading branch information
usame-algan authored Feb 8, 2024
1 parent 8737cb8 commit 0ff4793
Show file tree
Hide file tree
Showing 7 changed files with 300 additions and 44 deletions.
87 changes: 50 additions & 37 deletions src/components/new-safe/create/steps/ReviewStep/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils'
import type { NamedAddress } from '@/components/new-safe/create/types'
import ErrorMessage from '@/components/tx/ErrorMessage'
import { createCounterfactualSafe } from '@/features/counterfactual/utils'
import useWalletCanPay from '@/hooks/useWalletCanPay'
Expand All @@ -11,9 +12,8 @@ import lightPalette from '@/components/theme/lightPalette'
import ChainIndicator from '@/components/common/ChainIndicator'
import EthHashInfo from '@/components/common/EthHashInfo'
import { useCurrentChain, useHasFeature } from '@/hooks/useChains'
import useGasPrice, { getTotalFee } from '@/hooks/useGasPrice'
import useGasPrice, { getTotalFeeFormatted } from '@/hooks/useGasPrice'
import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas'
import { formatVisualAmount } from '@/utils/formatters'
import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper'
import type { NewSafeFormData } from '@/components/new-safe/create'
import css from '@/components/new-safe/create/steps/ReviewStep/styles.module.css'
Expand Down Expand Up @@ -100,6 +100,52 @@ export const NetworkFee = ({
)
}

export const SafeSetupOverview = ({
name,
owners,
threshold,
}: {
name?: string
owners: NamedAddress[]
threshold: number
}) => {
const chain = useCurrentChain()

return (
<Grid container spacing={3}>
<ReviewRow name="Network" value={<ChainIndicator chainId={chain?.chainId} inline />} />
{name && <ReviewRow name="Name" value={<Typography>{name}</Typography>} />}
<ReviewRow
name="Owners"
value={
<Box data-testid="review-step-owner-info" className={css.ownersArray}>
{owners.map((owner, index) => (
<EthHashInfo
address={owner.address}
name={owner.name || owner.ens}
shortAddress={false}
showPrefix={false}
showName
hasExplorer
showCopyButton
key={index}
/>
))}
</Box>
}
/>
<ReviewRow
name="Threshold"
value={
<Typography>
{threshold} out of {owners.length} owner(s)
</Typography>
}
/>
</Grid>
)
}

const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps<NewSafeFormData>) => {
const isWrongChain = useIsWrongChain()
useSyncSafeCreationStep(setStep)
Expand Down Expand Up @@ -136,10 +182,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps<NewSafe

const walletCanPay = useWalletCanPay({ gasLimit, maxFeePerGas, maxPriorityFeePerGas })

const totalFee =
gasLimit && maxFeePerGas
? formatVisualAmount(getTotalFee(maxFeePerGas, maxPriorityFeePerGas, gasLimit), chain?.nativeCurrency.decimals)
: '> 0.001'
const totalFee = getTotalFeeFormatted(maxFeePerGas, maxPriorityFeePerGas, gasLimit, chain)

// Only 1 out of 1 safe setups are supported for now
const isCounterfactual = data.threshold === 1 && data.owners.length === 1 && isCounterfactualEnabled
Expand Down Expand Up @@ -193,37 +236,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps<NewSafe
return (
<>
<Box className={layoutCss.row}>
<Grid container spacing={3}>
<ReviewRow name="Network" value={<ChainIndicator chainId={chain?.chainId} inline />} />
{data.name && <ReviewRow name="Name" value={<Typography>{data.name}</Typography>} />}
<ReviewRow
name="Owners"
value={
<Box data-testid="review-step-owner-info" className={css.ownersArray}>
{data.owners.map((owner, index) => (
<EthHashInfo
address={owner.address}
name={owner.name || owner.ens}
shortAddress={false}
showPrefix={false}
showName
hasExplorer
showCopyButton
key={index}
/>
))}
</Box>
}
/>
<ReviewRow
name="Threshold"
value={
<Typography>
{data.threshold} out of {data.owners.length} owner(s)
</Typography>
}
/>
</Grid>
<SafeSetupOverview name={data.name} owners={data.owners} threshold={data.threshold} />
</Box>

{!isCounterfactual && (
Expand Down
226 changes: 226 additions & 0 deletions src/features/counterfactual/ActivateAccount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { createNewSafe, relaySafeCreation } from '@/components/new-safe/create/logic'
import NetworkWarning from '@/components/new-safe/create/NetworkWarning'
import { NetworkFee, SafeSetupOverview } from '@/components/new-safe/create/steps/ReviewStep'
import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation'
import ReviewRow from '@/components/new-safe/ReviewRow'
import { TxModalContext } from '@/components/tx-flow'
import TxCard from '@/components/tx-flow/common/TxCard'

import TxLayout from '@/components/tx-flow/common/TxLayout'
import ErrorMessage from '@/components/tx/ErrorMessage'
import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector'
import useDeployGasLimit from '@/features/counterfactual/hooks/useDeployGasLimit'
import { removeUndeployedSafe, selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
import useChainId from '@/hooks/useChainId'
import { useCurrentChain } from '@/hooks/useChains'
import useGasPrice, { getTotalFeeFormatted } from '@/hooks/useGasPrice'
import useIsWrongChain from '@/hooks/useIsWrongChain'
import { useLeastRemainingRelays } from '@/hooks/useRemainingRelays'
import useSafeInfo from '@/hooks/useSafeInfo'
import useWalletCanPay from '@/hooks/useWalletCanPay'
import useWallet from '@/hooks/wallets/useWallet'
import { useWeb3 } from '@/hooks/wallets/web3'
import { asError } from '@/services/exceptions/utils'
import { isSocialLoginWallet } from '@/services/mpc/SocialLoginModule'
import { waitForCreateSafeTx } from '@/services/tx/txMonitor'
import { useAppDispatch, useAppSelector } from '@/store'
import { hasFeature } from '@/utils/chains'
import { hasRemainingRelays } from '@/utils/relaying'
import { Alert, Box, Button, CircularProgress, Divider, Grid, Typography } from '@mui/material'
import type { DeploySafeProps } from '@safe-global/protocol-kit'
import { FEATURES } from '@safe-global/safe-gateway-typescript-sdk'
import React, { useContext, useState } from 'react'

const useActivateAccount = () => {
const chain = useCurrentChain()
const [gasPrice] = useGasPrice()
const { gasLimit } = useDeployGasLimit()

const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559)
const maxFeePerGas = gasPrice?.maxFeePerGas
const maxPriorityFeePerGas = gasPrice?.maxPriorityFeePerGas

const options: DeploySafeProps['options'] = isEIP1559
? {
maxFeePerGas: maxFeePerGas?.toString(),
maxPriorityFeePerGas: maxPriorityFeePerGas?.toString(),
gasLimit: gasLimit?.toString(),
}
: { gasPrice: maxFeePerGas?.toString(), gasLimit: gasLimit?.toString() }

const totalFee = getTotalFeeFormatted(maxFeePerGas, maxPriorityFeePerGas, gasLimit, chain)
const walletCanPay = useWalletCanPay({ gasLimit, maxFeePerGas, maxPriorityFeePerGas })

return { options, totalFee, walletCanPay }
}

const ActivateAccountFlow = () => {
const [isSubmittable, setIsSubmittable] = useState<boolean>(true)
const [submitError, setSubmitError] = useState<Error | undefined>()
const [executionMethod, setExecutionMethod] = useState(ExecutionMethod.RELAY)

const isWrongChain = useIsWrongChain()
const chain = useCurrentChain()
const chainId = useChainId()
const { safeAddress } = useSafeInfo()
const provider = useWeb3()
const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, safeAddress))
const { setTxFlow } = useContext(TxModalContext)
const dispatch = useAppDispatch()
const wallet = useWallet()
const { options, totalFee, walletCanPay } = useActivateAccount()

const ownerAddresses = undeployedSafe?.safeAccountConfig.owners || []
const [minRelays] = useLeastRemainingRelays(ownerAddresses)

// Every owner has remaining relays and relay method is selected
const canRelay = hasRemainingRelays(minRelays)
const willRelay = canRelay && executionMethod === ExecutionMethod.RELAY

if (!undeployedSafe) return null

const onSuccess = () => {
dispatch(removeUndeployedSafe({ chainId, address: safeAddress }))
}

const createSafe = async () => {
if (!provider || !chain) return

setIsSubmittable(false)
setSubmitError(undefined)

try {
if (willRelay) {
const taskId = await relaySafeCreation(
chain,
undeployedSafe.safeAccountConfig.owners,
undeployedSafe.safeAccountConfig.threshold,
Number(undeployedSafe.safeDeploymentConfig?.saltNonce!),
)

waitForCreateSafeTx(taskId, (status) => {
if (status === SafeCreationStatus.SUCCESS) {
onSuccess()
}
})
} else {
await createNewSafe(provider, {
safeAccountConfig: undeployedSafe.safeAccountConfig,
saltNonce: undeployedSafe.safeDeploymentConfig?.saltNonce,
options,
})
onSuccess()
}
} catch (_err) {
const err = asError(_err)
setIsSubmittable(true)
setSubmitError(err)
return
}

setTxFlow(undefined)
}

const submitDisabled = !isSubmittable
const isSocialLogin = isSocialLoginWallet(wallet?.label)

return (
<TxLayout title="Activate account" hideNonce>
<TxCard>
<Typography>
You&apos;re about to deploy this Safe Account and will have to confirm the transaction with your connected
wallet.
</Typography>

<Divider sx={{ mx: -3, my: 2 }} />

<SafeSetupOverview
owners={undeployedSafe.safeAccountConfig.owners.map((owner) => ({ name: '', address: owner }))}
threshold={undeployedSafe.safeAccountConfig.threshold}
/>

<Divider sx={{ mx: -3, mt: 2, mb: 1 }} />
<Box display="flex" flexDirection="column" gap={3}>
{canRelay && !isSocialLogin && (
<Grid container spacing={3}>
<ReviewRow
name="Execution method"
value={
<ExecutionMethodSelector
executionMethod={executionMethod}
setExecutionMethod={setExecutionMethod}
relays={minRelays}
/>
}
/>
</Grid>
)}

<Grid data-testid="network-fee-section" container spacing={3}>
<ReviewRow
name="Est. network fee"
value={
<>
<NetworkFee totalFee={totalFee} willRelay={willRelay} chain={chain} />

{!willRelay && !isSocialLogin && (
<Typography variant="body2" color="text.secondary" mt={1}>
You will have to confirm a transaction with your connected wallet.
</Typography>
)}
</>
}
/>
</Grid>

{submitError && (
<Box mt={1}>
<ErrorMessage error={submitError}>Error submitting the transaction. Please try again.</ErrorMessage>
</Box>
)}

{isWrongChain && <NetworkWarning />}

{!walletCanPay && !willRelay && (
<ErrorMessage>
Your connected wallet doesn&apos;t have enough funds to execute this transaction
</ErrorMessage>
)}
</Box>

<Divider sx={{ mx: -3, mt: 2, mb: 1 }} />

<Box display="flex" flexDirection="row" justifyContent="flex-end" gap={3}>
<Button onClick={createSafe} variant="contained" size="stretched" disabled={submitDisabled}>
{!isSubmittable ? <CircularProgress size={20} /> : 'Activate'}
</Button>
</Box>
</TxCard>
</TxLayout>
)
}

const ActivateAccount = () => {
const { safe } = useSafeInfo()
const { setTxFlow } = useContext(TxModalContext)

if (safe.deployed) return null

const activateAccount = () => {
setTxFlow(<ActivateAccountFlow />)
}

return (
<Alert severity="info" sx={{ mb: 3 }}>
<Typography fontWeight="bold">Activate your account?</Typography>
<Typography variant="body2" mb={3}>
Activate your account now by deploying it and paying a network fee.
</Typography>
<Button variant="contained" size="small" onClick={activateAccount}>
Activate now
</Button>
</Alert>
)
}

export default ActivateAccount
6 changes: 3 additions & 3 deletions src/features/counterfactual/hooks/useDeployGasLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ const useDeployGasLimit = (safeTx?: SafeTransaction) => {
const chainId = useChainId()

const [gasLimit, gasLimitError, gasLimitLoading] = useAsync<bigint | undefined>(async () => {
if (!safeTx || !onboard) return
if (!onboard) return

const sdk = await getSafeSDKWithSigner(onboard, chainId)

const [gas, safeTxGas, safeDeploymentGas] = await Promise.all([
estimateTxBaseGas(sdk, safeTx),
estimateSafeTxGas(sdk, safeTx),
safeTx ? estimateTxBaseGas(sdk, safeTx) : '0',
safeTx ? estimateSafeTxGas(sdk, safeTx) : '0',
estimateSafeDeploymentGas(sdk),
])

Expand Down
6 changes: 3 additions & 3 deletions src/features/counterfactual/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { NewSafeFormData } from '@/components/new-safe/create'
import { LATEST_SAFE_VERSION } from '@/config/constants'
import { AppRoutes } from '@/config/routes'
import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
import type { NewSafeFormData } from '@/components/new-safe/create'
import { type ConnectedWallet } from '@/hooks/wallets/useOnboard'
import { asError } from '@/services/exceptions/utils'
import { assertWalletChain, getUncheckedSafeSDK } from '@/services/tx/tx-sender/sdk'
import { AppRoutes } from '@/config/routes'
import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
import { txDispatch, TxEvent } from '@/services/tx/txEvents'
import type { AppDispatch } from '@/store'
import { addOrUpdateSafe } from '@/store/addedSafesSlice'
Expand Down
Loading

0 comments on commit 0ff4793

Please sign in to comment.