Skip to content

Commit

Permalink
[Counterfactual] Deploy safe with first transaction (safe-global#3185)
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

* 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
  • Loading branch information
usame-algan authored Feb 8, 2024
1 parent ec8c01d commit 8737cb8
Show file tree
Hide file tree
Showing 11 changed files with 334 additions and 23 deletions.
13 changes: 9 additions & 4 deletions src/components/safe-apps/AppFrame/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import useBalances from '@/hooks/useBalances'
import { useContext, useState } from 'react'
import type { ReactElement } from 'react'
import { useMemo } from 'react'
Expand Down Expand Up @@ -72,6 +73,7 @@ const AppFrame = ({ appUrl, allowedFeaturesList, safeAppFromManifest }: AppFrame

const addressBook = useAddressBook()
const chain = useCurrentChain()
const { balances } = useBalances()
const router = useRouter()
const {
expanded: queueBarExpanded,
Expand Down Expand Up @@ -164,10 +166,13 @@ const AppFrame = ({ appUrl, allowedFeaturesList, safeAppFromManifest }: AppFrame
onGetSafeInfo: useGetSafeInfo(),
onGetSafeBalances: (currency) => {
const isDefaultTokenlistSupported = chain && hasFeature(chain, FEATURES.DEFAULT_TOKENLIST)
return getBalances(chainId, safeAddress, currency, {
exclude_spam: true,
trusted: isDefaultTokenlistSupported && TOKEN_LISTS.TRUSTED === tokenlist,
})

return safe.deployed
? getBalances(chainId, safeAddress, currency, {
exclude_spam: true,
trusted: isDefaultTokenlistSupported && TOKEN_LISTS.TRUSTED === tokenlist,
})
: Promise.resolve(balances)
},
onGetChainInfo: () => {
if (!chain) return
Expand Down
4 changes: 3 additions & 1 deletion src/components/tx-flow/common/TxLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import useSafeInfo from '@/hooks/useSafeInfo'
import { type ComponentType, type ReactElement, type ReactNode, useContext, useEffect, useState } from 'react'
import { Box, Container, Grid, Typography, Button, Paper, SvgIcon, IconButton, useMediaQuery } from '@mui/material'
import { useTheme } from '@mui/material/styles'
Expand All @@ -23,6 +24,7 @@ const TxLayoutHeader = ({
icon: TxLayoutProps['icon']
subtitle: TxLayoutProps['subtitle']
}) => {
const { safe } = useSafeInfo()
const { nonceNeeded } = useContext(SafeTxContext)

if (hideNonce && !icon && !subtitle) return null
Expand All @@ -41,7 +43,7 @@ const TxLayoutHeader = ({
</Typography>
</Box>

{!hideNonce && nonceNeeded && <TxNonce />}
{!hideNonce && safe.deployed && nonceNeeded && <TxNonce />}
</Box>
)
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/tx/GasParams/GasParams.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('GasParams', () => {
)

expect(getByText('Estimated fee')).toBeInTheDocument()
expect(getByText('0.21 SepoliaETH')).toBeInTheDocument()
expect(getByText('0.42 SepoliaETH')).toBeInTheDocument()
})

it("Doesn't show an estimated fee if there is no gasLimit", () => {
Expand Down
6 changes: 4 additions & 2 deletions src/components/tx/GasParams/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getTotalFee } from '@/hooks/useGasPrice'
import type { ReactElement, SyntheticEvent } from 'react'
import { Accordion, AccordionDetails, AccordionSummary, Skeleton, Typography, Link, Grid } from '@mui/material'
import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
Expand Down Expand Up @@ -51,8 +52,9 @@ export const _GasParams = ({
const isError = gasLimitError && !gasLimit

// Total gas cost
// TODO: Check how to use getTotalFee here
const totalFee = !isLoading ? formatVisualAmount(maxFeePerGas * gasLimit, chain?.nativeCurrency.decimals) : '> 0.001'
const totalFee = !isLoading
? formatVisualAmount(getTotalFee(maxFeePerGas, maxPriorityFeePerGas, gasLimit), chain?.nativeCurrency.decimals)
: '> 0.001'

// Individual gas params
const gasLimitString = gasLimit?.toString() || ''
Expand Down
16 changes: 3 additions & 13 deletions src/components/tx/SignOrExecuteForm/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { assertTx, assertWallet, assertOnboard } from '@/utils/helpers'
import { useMemo } from 'react'
import { type TransactionOptions, type SafeTransaction } from '@safe-global/safe-core-sdk-types'
import { sameString } from '@safe-global/protocol-kit/dist/src/utils'
Expand All @@ -13,8 +14,6 @@ import {
dispatchTxSigning,
} from '@/services/tx/tx-sender'
import { useHasPendingTxs } from '@/hooks/usePendingTxs'
import type { ConnectedWallet } from '@/hooks/wallets/useOnboard'
import type { OnboardAPI } from '@web3-onboard/core'
import { getSafeTxGas, getNonces } from '@/services/tx/tx-sender/recommendedNonce'
import useAsync from '@/hooks/useAsync'
import { useUpdateBatch } from '@/hooks/useDraftBatch'
Expand All @@ -32,16 +31,6 @@ type TxActions = {
) => Promise<string>
}

function assertTx(safeTx: SafeTransaction | undefined): asserts safeTx {
if (!safeTx) throw new Error('Transaction not provided')
}
function assertWallet(wallet: ConnectedWallet | null): asserts wallet {
if (!wallet) throw new Error('Wallet not connected')
}
function assertOnboard(onboard: OnboardAPI | undefined): asserts onboard {
if (!onboard) throw new Error('Onboard not connected')
}

export const useTxActions = (): TxActions => {
const { safe } = useSafeInfo()
const onboard = useOnboard()
Expand Down Expand Up @@ -135,7 +124,7 @@ export const useTxActions = (): TxActions => {
}

return { addToBatch, signTx, executeTx }
}, [safe, onboard, wallet, addTxToBatch])
}, [safe, wallet, addTxToBatch, onboard])
}

export const useValidateNonce = (safeTx: SafeTransaction | undefined): boolean => {
Expand All @@ -162,6 +151,7 @@ export const useRecommendedNonce = (): number | undefined => {
const [recommendedNonce] = useAsync(
async () => {
if (!safe.chainId || !safeAddress) return
if (!safe.deployed) return 0

const nonces = await getNonces(safe.chainId, safeAddress)

Expand Down
11 changes: 9 additions & 2 deletions src/components/tx/SignOrExecuteForm/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import CounterfactualForm from '@/features/counterfactual/CounterfactualForm'
import useSafeInfo from '@/hooks/useSafeInfo'
import { type ReactElement, type ReactNode, useState, useContext, useCallback } from 'react'
import madProps from '@/utils/mad-props'
import DecodedTx from '../DecodedTx'
Expand Down Expand Up @@ -71,6 +73,9 @@ export const SignOrExecuteForm = ({
const [decodedData, decodedDataError, decodedDataLoading] = useDecodeTx(safeTx)
const isBatchable = props.isBatchable !== false && safeTx && !isDelegateCall(safeTx)

const { safe } = useSafeInfo()
const isCounterfactualSafe = !safe.deployed

// If checkbox is checked and the transaction is executable, execute it, otherwise sign it
const canExecute = isCorrectNonce && (props.isExecutable || isNewExecutableTx)
const willExecute = (props.onlyExecute || shouldExecute) && canExecute
Expand Down Expand Up @@ -122,15 +127,17 @@ export const SignOrExecuteForm = ({
</ErrorMessage>
)}

{canExecute && !props.onlyExecute && <ExecuteCheckbox onChange={setShouldExecute} />}
{canExecute && !props.onlyExecute && !isCounterfactualSafe && <ExecuteCheckbox onChange={setShouldExecute} />}

<WrongChainWarning />

<UnknownContractError />

<RiskConfirmationError />

{willExecute ? (
{isCounterfactualSafe ? (
<CounterfactualForm {...props} safeTx={safeTx} isCreation={isCreation} onSubmit={onFormSubmit} onlyExecute />
) : willExecute ? (
<ExecuteForm {...props} safeTx={safeTx} isCreation={isCreation} onSubmit={onFormSubmit} />
) : (
<SignForm
Expand Down
174 changes: 174 additions & 0 deletions src/features/counterfactual/CounterfactualForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { TxModalContext } from '@/components/tx-flow'
import useDeployGasLimit from '@/features/counterfactual/hooks/useDeployGasLimit'
import { removeUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
import { deploySafeAndExecuteTx } from '@/features/counterfactual/utils'
import useChainId from '@/hooks/useChainId'
import useSafeInfo from '@/hooks/useSafeInfo'
import useWalletCanPay from '@/hooks/useWalletCanPay'
import useOnboard from '@/hooks/wallets/useOnboard'
import useWallet from '@/hooks/wallets/useWallet'
import { useAppDispatch } from '@/store'
import madProps from '@/utils/mad-props'
import React, { type ReactElement, type SyntheticEvent, useContext, useState } from 'react'
import { CircularProgress, Box, Button, CardActions, Divider } from '@mui/material'
import classNames from 'classnames'

import ErrorMessage from '@/components/tx/ErrorMessage'
import { trackError, Errors } from '@/services/exceptions'
import { useCurrentChain } from '@/hooks/useChains'
import { getTxOptions } from '@/utils/transactions'
import CheckWallet from '@/components/common/CheckWallet'
import { useIsExecutionLoop } from '@/components/tx/SignOrExecuteForm/hooks'
import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm'
import type { SafeTransaction } from '@safe-global/safe-core-sdk-types'
import AdvancedParams, { useAdvancedParams } from '@/components/tx/AdvancedParams'
import { asError } from '@/services/exceptions/utils'

import css from '@/components/tx/SignOrExecuteForm/styles.module.css'
import commonCss from '@/components/tx-flow/common/styles.module.css'
import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext'
import useIsSafeOwner from '@/hooks/useIsSafeOwner'
import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError'

export const CounterfactualForm = ({
safeTx,
disableSubmit = false,
onlyExecute,
isCreation,
isOwner,
isExecutionLoop,
txSecurity,
}: SignOrExecuteProps & {
isOwner: ReturnType<typeof useIsSafeOwner>
isExecutionLoop: ReturnType<typeof useIsExecutionLoop>
txSecurity: ReturnType<typeof useTxSecurityContext>
safeTx?: SafeTransaction
}): ReactElement => {
const wallet = useWallet()
const onboard = useOnboard()
const chainId = useChainId()
const dispatch = useAppDispatch()
const { safeAddress } = useSafeInfo()

// Form state
const [isSubmittable, setIsSubmittable] = useState<boolean>(true)
const [submitError, setSubmitError] = useState<Error | undefined>()

// Hooks
const currentChain = useCurrentChain()
const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = txSecurity
const { setTxFlow } = useContext(TxModalContext)

// Estimate gas limit
const { gasLimit, gasLimitError } = useDeployGasLimit(safeTx)
const [advancedParams, setAdvancedParams] = useAdvancedParams(gasLimit)

// On modal submit
const handleSubmit = async (e: SyntheticEvent) => {
e.preventDefault()

if (needsRiskConfirmation && !isRiskConfirmed) {
setIsRiskIgnored(true)
return
}

setIsSubmittable(false)
setSubmitError(undefined)

const txOptions = getTxOptions(advancedParams, currentChain)

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

try {
await deploySafeAndExecuteTx(txOptions, chainId, wallet, safeTx, onboard, onSuccess)
} catch (_err) {
const err = asError(_err)
trackError(Errors._804, err)
setIsSubmittable(true)
setSubmitError(err)
return
}

// TODO: Show a success or status screen
setTxFlow(undefined)
}

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

const cannotPropose = !isOwner && !onlyExecute
const submitDisabled =
!safeTx ||
!isSubmittable ||
disableSubmit ||
isExecutionLoop ||
cannotPropose ||
(needsRiskConfirmation && !isRiskConfirmed)

return (
<>
<form onSubmit={handleSubmit}>
<div className={classNames(css.params)}>
<AdvancedParams
willExecute
params={advancedParams}
recommendedGasLimit={gasLimit}
onFormSubmit={setAdvancedParams}
gasLimitError={gasLimitError}
willRelay={false}
/>
</div>

{/* Error messages */}
{cannotPropose ? (
<NonOwnerError />
) : isExecutionLoop ? (
<ErrorMessage>
Cannot execute a transaction from the Safe Account itself, please connect a different account.
</ErrorMessage>
) : !walletCanPay ? (
<ErrorMessage>Your connected wallet doesn&apos;t have enough funds to execute this transaction.</ErrorMessage>
) : (
gasLimitError && (
<ErrorMessage error={gasLimitError}>
This transaction will most likely fail.
{` To save gas costs, ${isCreation ? 'avoid creating' : 'reject'} this transaction.`}
</ErrorMessage>
)
)}

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

<Divider className={commonCss.nestedDivider} sx={{ pt: 3 }} />

<CardActions>
{/* Submit button */}
<CheckWallet allowNonOwner={onlyExecute}>
{(isOk) => (
<Button variant="contained" type="submit" disabled={!isOk || submitDisabled} sx={{ minWidth: '112px' }}>
{!isSubmittable ? <CircularProgress size={20} /> : 'Execute'}
</Button>
)}
</CheckWallet>
</CardActions>
</form>
</>
)
}

const useTxSecurityContext = () => useContext(TxSecurityContext)

export default madProps(CounterfactualForm, {
isOwner: useIsSafeOwner,
isExecutionLoop: useIsExecutionLoop,
txSecurity: useTxSecurityContext,
})
29 changes: 29 additions & 0 deletions src/features/counterfactual/hooks/useDeployGasLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import useAsync from '@/hooks/useAsync'
import useChainId from '@/hooks/useChainId'
import useOnboard from '@/hooks/wallets/useOnboard'
import { getSafeSDKWithSigner } from '@/services/tx/tx-sender/sdk'
import { estimateSafeDeploymentGas, estimateSafeTxGas, estimateTxBaseGas } from '@safe-global/protocol-kit'
import type { SafeTransaction } from '@safe-global/safe-core-sdk-types'

const useDeployGasLimit = (safeTx?: SafeTransaction) => {
const onboard = useOnboard()
const chainId = useChainId()

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

const sdk = await getSafeSDKWithSigner(onboard, chainId)

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

return BigInt(gas) + BigInt(safeTxGas) + BigInt(safeDeploymentGas)
}, [onboard, chainId, safeTx])

return { gasLimit, gasLimitError, gasLimitLoading }
}

export default useDeployGasLimit
Loading

0 comments on commit 8737cb8

Please sign in to comment.