diff --git a/src/components/safe-apps/AppFrame/index.tsx b/src/components/safe-apps/AppFrame/index.tsx index 8b59b5b613..3b07088f25 100644 --- a/src/components/safe-apps/AppFrame/index.tsx +++ b/src/components/safe-apps/AppFrame/index.tsx @@ -1,3 +1,4 @@ +import useBalances from '@/hooks/useBalances' import { useContext, useState } from 'react' import type { ReactElement } from 'react' import { useMemo } from 'react' @@ -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, @@ -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 diff --git a/src/components/tx-flow/common/TxLayout/index.tsx b/src/components/tx-flow/common/TxLayout/index.tsx index eda536aa11..f51a98fdd1 100644 --- a/src/components/tx-flow/common/TxLayout/index.tsx +++ b/src/components/tx-flow/common/TxLayout/index.tsx @@ -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' @@ -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 @@ -41,7 +43,7 @@ const TxLayoutHeader = ({ - {!hideNonce && nonceNeeded && } + {!hideNonce && safe.deployed && nonceNeeded && } ) } diff --git a/src/components/tx/GasParams/GasParams.test.tsx b/src/components/tx/GasParams/GasParams.test.tsx index 9e4ca9b461..3d6178ef0d 100644 --- a/src/components/tx/GasParams/GasParams.test.tsx +++ b/src/components/tx/GasParams/GasParams.test.tsx @@ -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", () => { diff --git a/src/components/tx/GasParams/index.tsx b/src/components/tx/GasParams/index.tsx index d223e30376..ce527316ea 100644 --- a/src/components/tx/GasParams/index.tsx +++ b/src/components/tx/GasParams/index.tsx @@ -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' @@ -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() || '' diff --git a/src/components/tx/SignOrExecuteForm/hooks.ts b/src/components/tx/SignOrExecuteForm/hooks.ts index 8628c33745..27b2279d1b 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.ts @@ -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' @@ -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' @@ -32,16 +31,6 @@ type TxActions = { ) => Promise } -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() @@ -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 => { @@ -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) diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index 87ee435a0e..eb5b0712aa 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -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' @@ -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 @@ -122,7 +127,7 @@ export const SignOrExecuteForm = ({ )} - {canExecute && !props.onlyExecute && } + {canExecute && !props.onlyExecute && !isCounterfactualSafe && } @@ -130,7 +135,9 @@ export const SignOrExecuteForm = ({ - {willExecute ? ( + {isCounterfactualSafe ? ( + + ) : willExecute ? ( ) : ( + isExecutionLoop: ReturnType + txSecurity: ReturnType + safeTx?: SafeTransaction +}): ReactElement => { + const wallet = useWallet() + const onboard = useOnboard() + const chainId = useChainId() + const dispatch = useAppDispatch() + const { safeAddress } = useSafeInfo() + + // Form state + const [isSubmittable, setIsSubmittable] = useState(true) + const [submitError, setSubmitError] = useState() + + // 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 ( + <> +
+
+ +
+ + {/* Error messages */} + {cannotPropose ? ( + + ) : isExecutionLoop ? ( + + Cannot execute a transaction from the Safe Account itself, please connect a different account. + + ) : !walletCanPay ? ( + Your connected wallet doesn't have enough funds to execute this transaction. + ) : ( + gasLimitError && ( + + This transaction will most likely fail. + {` To save gas costs, ${isCreation ? 'avoid creating' : 'reject'} this transaction.`} + + ) + )} + + {submitError && ( + + Error submitting the transaction. Please try again. + + )} + + + + + {/* Submit button */} + + {(isOk) => ( + + )} + + + + + ) +} + +const useTxSecurityContext = () => useContext(TxSecurityContext) + +export default madProps(CounterfactualForm, { + isOwner: useIsSafeOwner, + isExecutionLoop: useIsExecutionLoop, + txSecurity: useTxSecurityContext, +}) diff --git a/src/features/counterfactual/hooks/useDeployGasLimit.ts b/src/features/counterfactual/hooks/useDeployGasLimit.ts new file mode 100644 index 0000000000..a2a7a35e7b --- /dev/null +++ b/src/features/counterfactual/hooks/useDeployGasLimit.ts @@ -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(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 diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts index b58954338e..671b2e4689 100644 --- a/src/features/counterfactual/utils.ts +++ b/src/features/counterfactual/utils.ts @@ -2,11 +2,20 @@ 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 ConnectedWallet } from '@/hooks/wallets/useOnboard' +import { asError } from '@/services/exceptions/utils' +import { assertWalletChain, getUncheckedSafeSDK } from '@/services/tx/tx-sender/sdk' +import { txDispatch, TxEvent } from '@/services/tx/txEvents' import type { AppDispatch } from '@/store' import { addOrUpdateSafe } from '@/store/addedSafesSlice' import { upsertAddressBookEntry } from '@/store/addressBookSlice' import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { didReprice, didRevert, type EthersError } from '@/utils/ethers-utils' +import { assertOnboard, assertTx, assertWallet } from '@/utils/helpers' import type { DeploySafeProps, PredictedSafeProps } from '@safe-global/protocol-kit' +import type { SafeTransaction, TransactionOptions } from '@safe-global/safe-core-sdk-types' +import type { OnboardAPI } from '@web3-onboard/core' +import type { ContractTransactionResponse } from 'ethers' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import type { SafeVersion } from '@safe-global/safe-core-sdk-types' import { @@ -14,8 +23,10 @@ import { ImplementationVersionState, type SafeBalanceResponse, TokenType, + type SafeInfo, } from '@safe-global/safe-gateway-typescript-sdk' import type { BrowserProvider } from 'ethers' +import { createWeb3 } from '@/hooks/wallets/web3' import type { NextRouter } from 'next/router' export const getUndeployedSafeInfo = (undeployedSafe: PredictedSafeProps, address: string, chainId: string) => { @@ -33,6 +44,79 @@ export const getUndeployedSafeInfo = (undeployedSafe: PredictedSafeProps, addres }) } +export const dispatchTxExecutionAndDeploySafe = async ( + safeTx: SafeTransaction, + txOptions: TransactionOptions, + onboard: OnboardAPI, + chainId: SafeInfo['chainId'], + onSuccess?: () => void, +) => { + const sdkUnchecked = await getUncheckedSafeSDK(onboard, chainId) + const eventParams = { groupKey: 'cf-tx' } + const safeAddress = await sdkUnchecked.getAddress() + + let result: ContractTransactionResponse | undefined + try { + const signedTx = await sdkUnchecked.signTransaction(safeTx) + + const wallet = await assertWalletChain(onboard, chainId) + const provider = createWeb3(wallet.provider) + const signer = await provider.getSigner() + + const deploymentTx = await sdkUnchecked.wrapSafeTransactionIntoDeploymentBatch(signedTx, txOptions) + + // @ts-ignore TODO: Check why TransactionResponse type doesn't work + result = await signer.sendTransaction(deploymentTx) + txDispatch(TxEvent.EXECUTING, eventParams) + } catch (error) { + txDispatch(TxEvent.FAILED, { ...eventParams, error: asError(error) }) + throw error + } + + txDispatch(TxEvent.PROCESSING, { ...eventParams, txHash: result!.hash }) + + result + ?.wait() + .then((receipt) => { + if (receipt === null) { + txDispatch(TxEvent.FAILED, { ...eventParams, error: new Error('No transaction receipt found') }) + } else if (didRevert(receipt)) { + txDispatch(TxEvent.REVERTED, { ...eventParams, error: new Error('Transaction reverted by EVM') }) + } else { + txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress }) + } + }) + .catch((err) => { + const error = err as EthersError + + if (didReprice(error)) { + txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress }) + } else { + txDispatch(TxEvent.FAILED, { ...eventParams, error: asError(error) }) + } + }) + .finally(() => { + onSuccess?.() + }) + + return result!.hash +} + +export const deploySafeAndExecuteTx = async ( + txOptions: TransactionOptions, + chainId: string, + wallet: ConnectedWallet | null, + safeTx?: SafeTransaction, + onboard?: OnboardAPI, + onSuccess?: () => void, +) => { + assertTx(safeTx) + assertWallet(wallet) + assertOnboard(onboard) + + return dispatchTxExecutionAndDeploySafe(safeTx, txOptions, onboard, chainId, onSuccess) +} + export const getCounterfactualBalance = async (safeAddress: string, provider?: BrowserProvider, chain?: ChainInfo) => { const balance = await provider?.getBalance(safeAddress) diff --git a/src/features/walletconnect/services/__tests__/WalletConnectWallet.test.ts b/src/features/walletconnect/services/__tests__/WalletConnectWallet.test.ts index c963bfbbcf..ce86f0dbbb 100644 --- a/src/features/walletconnect/services/__tests__/WalletConnectWallet.test.ts +++ b/src/features/walletconnect/services/__tests__/WalletConnectWallet.test.ts @@ -274,6 +274,7 @@ describe('WalletConnectWallet', () => { }, requiredNamespaces: {} as ProposalTypes.RequiredNamespaces, optionalNamespaces: {} as ProposalTypes.OptionalNamespaces, + expiryTimestamp: 2, }, verifyContext: {} as Verify.Context, }, @@ -311,6 +312,7 @@ describe('WalletConnectWallet', () => { }, requiredNamespaces: {} as ProposalTypes.RequiredNamespaces, optionalNamespaces: {} as ProposalTypes.OptionalNamespaces, + expiryTimestamp: 2, }, verifyContext: {} as Verify.Context, }, diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 6592417b31..0c90822e51 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,4 +1,8 @@ // `assert` does not work with arrow functions +import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { OnboardAPI } from '@web3-onboard/core' + export function invariant(condition: T, error: string): asserts condition { if (condition) { return @@ -6,3 +10,15 @@ export function invariant(condition: T, error: string): asser throw new Error(error) } + +export function assertTx(safeTx: SafeTransaction | undefined): asserts safeTx { + return invariant(safeTx, 'Transaction not provided') +} + +export function assertWallet(wallet: ConnectedWallet | null): asserts wallet { + return invariant(wallet, 'Wallet not connected') +} + +export function assertOnboard(onboard: OnboardAPI | undefined): asserts onboard { + return invariant(onboard, 'Onboard not connected') +}