diff --git a/src/components/balances/AssetsTable/index.tsx b/src/components/balances/AssetsTable/index.tsx index 224983b867..db035340b0 100644 --- a/src/components/balances/AssetsTable/index.tsx +++ b/src/components/balances/AssetsTable/index.tsx @@ -1,3 +1,4 @@ +import CheckBalance from '@/features/counterfactual/CheckBalance' import { type ReactElement, useMemo, useContext } from 'react' import { Button, Tooltip, Typography, SvgIcon, IconButton, Box, Checkbox, Skeleton } from '@mui/material' import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' @@ -138,7 +139,7 @@ const AssetsTable = ({ [hiddenAssets, balances.items, showHiddenAssets], ) - const hasNoAssets = balances.items.length === 1 && balances.items[0].balance === '0' + const hasNoAssets = !loading && balances.items.length === 1 && balances.items[0].balance === '0' const selectedAssetCount = visibleAssets?.filter((item) => isAssetSelected(item.tokenInfo.address)).length || 0 @@ -257,6 +258,8 @@ const AssetsTable = ({ )} + + ) } diff --git a/src/components/common/CooldownButton/index.test.tsx b/src/components/common/CooldownButton/index.test.tsx new file mode 100644 index 0000000000..7de9dc9eed --- /dev/null +++ b/src/components/common/CooldownButton/index.test.tsx @@ -0,0 +1,77 @@ +import { render, waitFor } from '@/tests/test-utils' +import CooldownButton from './index' + +describe('CooldownButton', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + it('should be disabled initially if startDisabled is set and become enabled after seconds', async () => { + const onClickEvent = jest.fn() + const result = render( + + Try again + , + ) + + expect(result.getByRole('button')).toBeDisabled() + expect(result.getByText('Try again in 30s')).toBeVisible() + + jest.advanceTimersByTime(10_000) + + await waitFor(() => { + expect(result.getByRole('button')).toBeDisabled() + expect(result.getByText('Try again in 20s')).toBeVisible() + }) + + jest.advanceTimersByTime(5_000) + + await waitFor(() => { + expect(result.getByRole('button')).toBeDisabled() + expect(result.getByText('Try again in 15s')).toBeVisible() + }) + + jest.advanceTimersByTime(15_000) + + await waitFor(() => { + expect(result.getByRole('button')).toBeEnabled() + }) + result.getByRole('button').click() + + expect(onClickEvent).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(result.getByRole('button')).toBeDisabled() + }) + }) + + it('should be enabled initially if startDisabled is not set and become disabled after click', async () => { + const onClickEvent = jest.fn() + const result = render( + + Try again + , + ) + + expect(result.getByRole('button')).toBeEnabled() + result.getByRole('button').click() + + expect(onClickEvent).toHaveBeenCalledTimes(1) + + await waitFor(() => { + expect(result.getByRole('button')).toBeDisabled() + expect(result.getByText('Try again in 30s')).toBeVisible() + }) + + jest.advanceTimersByTime(30_000) + + await waitFor(() => { + expect(result.getByRole('button')).toBeEnabled() + }) + result.getByRole('button').click() + + expect(onClickEvent).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/components/common/CooldownButton/index.tsx b/src/components/common/CooldownButton/index.tsx new file mode 100644 index 0000000000..aee9af0714 --- /dev/null +++ b/src/components/common/CooldownButton/index.tsx @@ -0,0 +1,48 @@ +import { Button } from '@mui/material' +import { useState, useCallback, useEffect, type ReactNode } from 'react' + +// TODO: Extract into a hook so it can be reused for links and not just buttons +const CooldownButton = ({ + onClick, + cooldown, + startDisabled = false, + children, +}: { + onClick: () => void + startDisabled?: boolean + cooldown: number // Cooldown in seconds + children: ReactNode +}) => { + const [remainingSeconds, setRemainingSeconds] = useState(startDisabled ? cooldown : 0) + const [lastSendTime, setLastSendTime] = useState(startDisabled ? Date.now() : 0) + + const adjustSeconds = useCallback(() => { + const remainingCoolDownSeconds = Math.max(0, cooldown * 1000 - (Date.now() - lastSendTime)) / 1000 + setRemainingSeconds(remainingCoolDownSeconds) + }, [cooldown, lastSendTime]) + + useEffect(() => { + // Counter for progress + const interval = setInterval(adjustSeconds, 1000) + return () => clearInterval(interval) + }, [adjustSeconds]) + + const handleClick = () => { + setLastSendTime(Date.now()) + setRemainingSeconds(cooldown) + onClick() + } + + const isDisabled = remainingSeconds > 0 + + return ( + + ) +} + +export default CooldownButton diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 75d8d96613..faebec1afa 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -1,3 +1,4 @@ +import useOnboard from '@/hooks/wallets/useOnboard' import type { ReactElement } from 'react' import dynamic from 'next/dynamic' import { Grid } from '@mui/material' @@ -17,8 +18,11 @@ const RecoveryWidget = dynamic(() => import('@/features/recovery/components/Reco const Dashboard = (): ReactElement => { const router = useRouter() + const onboard = useOnboard() const { [CREATION_MODAL_QUERY_PARM]: showCreationModal = '' } = router.query + console.log(onboard) + const supportsRecovery = useIsRecoverySupported() const [recovery] = useRecovery() const showRecoveryWidget = supportsRecovery && !recovery diff --git a/src/components/new-safe/ReviewRow/index.tsx b/src/components/new-safe/ReviewRow/index.tsx index 7478c054c4..6f434c108c 100644 --- a/src/components/new-safe/ReviewRow/index.tsx +++ b/src/components/new-safe/ReviewRow/index.tsx @@ -1,13 +1,15 @@ import React, { type ReactElement } from 'react' import { Grid, Typography } from '@mui/material' -const ReviewRow = ({ name, value }: { name: string; value: ReactElement }) => { +const ReviewRow = ({ name, value }: { name?: string; value: ReactElement }) => { return ( <> - - {name} - - + {name && ( + + {name} + + )} + {value} diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index 6273208db0..cff9bf4e14 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -1,3 +1,4 @@ +import type { SafeVersion } from '@safe-global/safe-core-sdk-types' import { type BrowserProvider, type Provider } from 'ethers' import { getSafeInfo, type SafeInfo, type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' @@ -59,10 +60,14 @@ export const getSafeDeployProps = async ( /** * Create a Safe creation transaction via Core SDK and submits it to the wallet */ -export const createNewSafe = async (ethersProvider: BrowserProvider, props: DeploySafeProps): Promise => { +export const createNewSafe = async ( + ethersProvider: BrowserProvider, + props: DeploySafeProps, + safeVersion?: SafeVersion, +): Promise => { const ethAdapter = await createEthersAdapter(ethersProvider) - const safeFactory = await SafeFactory.create({ ethAdapter }) + const safeFactory = await SafeFactory.create({ ethAdapter, safeVersion }) return safeFactory.deploySafe(props) } @@ -280,10 +285,22 @@ export const getRedirect = ( return redirectUrl + `${appendChar}safe=${address}` } -export const relaySafeCreation = async (chain: ChainInfo, owners: string[], threshold: number, saltNonce: number) => { - const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(chain.chainId, LATEST_SAFE_VERSION) +export const relaySafeCreation = async ( + chain: ChainInfo, + owners: string[], + threshold: number, + saltNonce: number, + safeVersion?: SafeVersion, +) => { + const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract( + chain.chainId, + safeVersion ?? LATEST_SAFE_VERSION, + ) const proxyFactoryAddress = await readOnlyProxyFactoryContract.getAddress() - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(chain.chainId, LATEST_SAFE_VERSION) + const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract( + chain.chainId, + safeVersion ?? LATEST_SAFE_VERSION, + ) const fallbackHandlerAddress = await readOnlyFallbackHandlerContract.getAddress() const readOnlySafeContract = await getReadOnlyGnosisSafeContract(chain) const safeContractAddress = await readOnlySafeContract.getAddress() diff --git a/src/components/new-safe/create/steps/ReviewStep/index.test.tsx b/src/components/new-safe/create/steps/ReviewStep/index.test.tsx index 397e4bc18a..8ebfc9bdaa 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.test.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.test.tsx @@ -1,10 +1,14 @@ +import type { NewSafeFormData } from '@/components/new-safe/create' +import * as useChains from '@/hooks/useChains' +import * as relay from '@/utils/relaying' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { render } from '@/tests/test-utils' -import { NetworkFee } from '@/components/new-safe/create/steps/ReviewStep/index' +import ReviewStep, { NetworkFee } from '@/components/new-safe/create/steps/ReviewStep/index' import * as useWallet from '@/hooks/wallets/useWallet' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' -import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/SocialLoginModule' +import * as socialLogin from '@/services/mpc/SocialLoginModule' +import { act, fireEvent } from '@testing-library/react' const mockChainInfo = { chainId: '100', @@ -25,14 +29,14 @@ describe('NetworkFee', () => { }) it('displays a sponsored by message for social login', () => { - jest.spyOn(useWallet, 'default').mockReturnValue({ label: ONBOARD_MPC_MODULE_LABEL } as unknown as ConnectedWallet) + jest.spyOn(useWallet, 'default').mockReturnValue({ label: 'Social Login' } as unknown as ConnectedWallet) const result = render() expect(result.getByText(/Your account is sponsored by Gnosis/)).toBeInTheDocument() }) it('displays an error message for social login if there are no relays left', () => { - jest.spyOn(useWallet, 'default').mockReturnValue({ label: ONBOARD_MPC_MODULE_LABEL } as unknown as ConnectedWallet) + jest.spyOn(useWallet, 'default').mockReturnValue({ label: 'Social Login' } as unknown as ConnectedWallet) const result = render() expect( @@ -40,3 +44,103 @@ describe('NetworkFee', () => { ).toBeInTheDocument() }) }) + +describe('ReviewStep', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should display a pay now pay later option for counterfactual safe setups', () => { + const mockData: NewSafeFormData = { + name: 'Test', + threshold: 1, + owners: [{ name: '', address: '0x1' }], + saltNonce: 0, + } + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + + const { getByText } = render( + , + ) + + expect(getByText('Pay now')).toBeInTheDocument() + }) + + it('should not display the network fee for counterfactual safes', () => { + const mockData: NewSafeFormData = { + name: 'Test', + threshold: 1, + owners: [{ name: '', address: '0x1' }], + saltNonce: 0, + } + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + + const { queryByText } = render( + , + ) + + expect(queryByText('You will have to confirm a transaction and pay an estimated fee')).not.toBeInTheDocument() + }) + + it('should not display the execution method for counterfactual safes', () => { + const mockData: NewSafeFormData = { + name: 'Test', + threshold: 1, + owners: [{ name: '', address: '0x1' }], + saltNonce: 0, + } + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + + const { queryByText } = render( + , + ) + + expect(queryByText('Who will pay gas fees:')).not.toBeInTheDocument() + }) + + it('should display the network fee for counterfactual safes if the user selects pay now', async () => { + const mockData: NewSafeFormData = { + name: 'Test', + threshold: 1, + owners: [{ name: '', address: '0x1' }], + saltNonce: 0, + } + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + + const { getByText } = render( + , + ) + + const payNow = getByText('Pay now') + + act(() => { + fireEvent.click(payNow) + }) + + expect(getByText(/You will have to confirm a transaction and pay an estimated fee/)).toBeInTheDocument() + }) + + it('should display the execution method for counterfactual safes if the user selects pay now and there is relaying', async () => { + const mockData: NewSafeFormData = { + name: 'Test', + threshold: 1, + owners: [{ name: '', address: '0x1' }], + saltNonce: 0, + } + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + jest.spyOn(relay, 'hasRemainingRelays').mockReturnValue(true) + jest.spyOn(socialLogin, 'isSocialLoginWallet').mockReturnValue(false) + + const { getByText } = render( + , + ) + + const payNow = getByText('Pay now') + + act(() => { + fireEvent.click(payNow) + }) + + expect(getByText(/Who will pay gas fees:/)).toBeInTheDocument() + }) +}) diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 585939ed23..dee132db0c 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -1,52 +1,55 @@ -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' -import { useAppDispatch } from '@/store' -import { FEATURES } from '@/utils/chains' -import { useRouter } from 'next/router' -import { useMemo, useState } from 'react' -import { Button, Grid, Typography, Divider, Box, Alert } from '@mui/material' -import lightPalette from '@/components/theme/lightPalette' import ChainIndicator from '@/components/common/ChainIndicator' +import type { NamedAddress } from '@/components/new-safe/create/types' import EthHashInfo from '@/components/common/EthHashInfo' -import { useCurrentChain, useHasFeature } from '@/hooks/useChains' -import useGasPrice, { getTotalFeeFormatted } from '@/hooks/useGasPrice' -import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas' +import { getTotalFeeFormatted } from '@/hooks/useGasPrice' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' +import { computeNewSafeAddress } from '@/components/new-safe/create/logic' +import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' +import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import css from '@/components/new-safe/create/steps/ReviewStep/styles.module.css' import layoutCss from '@/components/new-safe/create/styles.module.css' -import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts' -import { computeNewSafeAddress } from '@/components/new-safe/create/logic' -import useWallet from '@/hooks/wallets/useWallet' -import { useWeb3 } from '@/hooks/wallets/web3' +import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas' import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCreationStep' -import ArrowBackIcon from '@mui/icons-material/ArrowBack' -import NetworkWarning from '@/components/new-safe/create/NetworkWarning' -import useIsWrongChain from '@/hooks/useIsWrongChain' import ReviewRow from '@/components/new-safe/ReviewRow' -import { ExecutionMethodSelector, ExecutionMethod } from '@/components/tx/ExecutionMethodSelector' -import { MAX_HOUR_RELAYS, useLeastRemainingRelays } from '@/hooks/useRemainingRelays' -import classnames from 'classnames' -import { hasRemainingRelays } from '@/utils/relaying' -import { usePendingSafe } from '../StatusStep/usePendingSafe' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' +import { RELAY_SPONSORS } from '@/components/tx/SponsoredBy' import { LATEST_SAFE_VERSION } from '@/config/constants' +import PayNowPayLater, { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { createCounterfactualSafe } from '@/features/counterfactual/utils' +import { useCurrentChain, useHasFeature } from '@/hooks/useChains' +import useGasPrice from '@/hooks/useGasPrice' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import { MAX_HOUR_RELAYS, useLeastRemainingRelays } from '@/hooks/useRemainingRelays' +import useWalletCanPay from '@/hooks/useWalletCanPay' +import useWallet from '@/hooks/wallets/useWallet' +import { useWeb3 } from '@/hooks/wallets/web3' +import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts' import { isSocialLoginWallet } from '@/services/mpc/SocialLoginModule' -import { RELAY_SPONSORS } from '@/components/tx/SponsoredBy' -import Image from 'next/image' -import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useAppDispatch } from '@/store' +import { FEATURES } from '@/utils/chains' +import { hasRemainingRelays } from '@/utils/relaying' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import { Alert, Box, Button, Divider, Grid, Typography } from '@mui/material' import { type DeploySafeProps } from '@safe-global/protocol-kit' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import classnames from 'classnames' +import Image from 'next/image' +import { useRouter } from 'next/router' +import { useMemo, useState } from 'react' +import { usePendingSafe } from '../StatusStep/usePendingSafe' export const NetworkFee = ({ totalFee, chain, willRelay, + inline = false, }: { totalFee: string chain: ChainInfo | undefined willRelay: boolean + inline?: boolean }) => { const wallet = useWallet() @@ -54,16 +57,8 @@ export const NetworkFee = ({ if (!isSocialLogin) { return ( - - + + ≈ {totalFee} {chain?.nativeCurrency.symbol} @@ -156,6 +151,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps() const isCounterfactualEnabled = useHasFeature(FEATURES.COUNTERFACTUAL) @@ -211,7 +207,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps + {isCounterfactual && ( + <> + + + + + {canRelay && !isSocialLogin && payMethod === PayMethod.PayNow && ( + + + } + /> + + )} + + {payMethod === PayMethod.PayNow && ( + + + You will have to confirm a transaction and pay an estimated fee of{' '} + with your connected + wallet + + + )} + + + )} + {!isCounterfactual && ( <> diff --git a/src/components/new-safe/create/steps/ReviewStep/styles.module.css b/src/components/new-safe/create/steps/ReviewStep/styles.module.css index 2d4e1be420..9b87174229 100644 --- a/src/components/new-safe/create/steps/ReviewStep/styles.module.css +++ b/src/components/new-safe/create/steps/ReviewStep/styles.module.css @@ -13,3 +13,16 @@ .errorMessage { margin-top: 0; } + +.networkFee { + padding: var(--space-1); + background-color: var(--color-secondary-background); + color: var(--color-static-main); + width: fit-content; + border-radius: 6px; +} + +.networkFeeInline { + padding: 2px 4px; + display: inline-flex; +} diff --git a/src/components/sidebar/Sidebar/index.tsx b/src/components/sidebar/Sidebar/index.tsx index e6d0128553..d34dfa39c7 100644 --- a/src/components/sidebar/Sidebar/index.tsx +++ b/src/components/sidebar/Sidebar/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useState, type ReactElement } from 'react' -import { Box, Divider, Drawer, IconButton } from '@mui/material' +import { Box, Divider, Drawer } from '@mui/material' import ChevronRight from '@mui/icons-material/ChevronRight' import ChainIndicator from '@/components/common/ChainIndicator' @@ -31,9 +31,9 @@ const Sidebar = (): ReactElement => { {/* Open the safes list */} - + {/* Address, balance, copy button, etc */} diff --git a/src/components/sidebar/Sidebar/styles.module.css b/src/components/sidebar/Sidebar/styles.module.css index fc200c3691..f5f3013f33 100644 --- a/src/components/sidebar/Sidebar/styles.module.css +++ b/src/components/sidebar/Sidebar/styles.module.css @@ -46,6 +46,8 @@ .drawerButton { position: absolute !important; z-index: 2; + color: var(--color-text-primary); + padding: 8px 0; right: 0; transform: translateX(50%); margin-top: 54px; diff --git a/src/features/counterfactual/ActivateAccount.tsx b/src/features/counterfactual/ActivateAccount.tsx index 860316236f..27101f6fa9 100644 --- a/src/features/counterfactual/ActivateAccount.tsx +++ b/src/features/counterfactual/ActivateAccount.tsx @@ -11,6 +11,7 @@ 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 { CF_TX_GROUP_KEY, showSubmitNotification } from '@/features/counterfactual/utils' import useChainId from '@/hooks/useChainId' import { useCurrentChain } from '@/hooks/useChains' import useGasPrice, { getTotalFeeFormatted } from '@/hooks/useGasPrice' @@ -22,6 +23,7 @@ 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 { txDispatch, TxEvent } from '@/services/tx/txEvents' import { waitForCreateSafeTx } from '@/services/tx/txMonitor' import { useAppDispatch, useAppSelector } from '@/store' import { hasFeature } from '@/utils/chains' @@ -44,12 +46,12 @@ const useActivateAccount = () => { ? { maxFeePerGas: maxFeePerGas?.toString(), maxPriorityFeePerGas: maxPriorityFeePerGas?.toString(), - gasLimit: gasLimit?.toString(), + gasLimit: gasLimit?.totalGas.toString(), } - : { gasPrice: maxFeePerGas?.toString(), gasLimit: gasLimit?.toString() } + : { gasPrice: maxFeePerGas?.toString(), gasLimit: gasLimit?.totalGas.toString() } - const totalFee = getTotalFeeFormatted(maxFeePerGas, maxPriorityFeePerGas, gasLimit, chain) - const walletCanPay = useWalletCanPay({ gasLimit, maxFeePerGas, maxPriorityFeePerGas }) + const totalFee = getTotalFeeFormatted(maxFeePerGas, maxPriorityFeePerGas, gasLimit?.totalGas, chain) + const walletCanPay = useWalletCanPay({ gasLimit: gasLimit?.totalGas, maxFeePerGas, maxPriorityFeePerGas }) return { options, totalFee, walletCanPay } } @@ -81,6 +83,12 @@ const ActivateAccountFlow = () => { const onSuccess = () => { dispatch(removeUndeployedSafe({ chainId, address: safeAddress })) + txDispatch(TxEvent.SUCCESS, { groupKey: CF_TX_GROUP_KEY }) + } + + const onSubmit = (txHash?: string) => { + showSubmitNotification(dispatch, chain, txHash) + setTxFlow(undefined) } const createSafe = async () => { @@ -96,19 +104,28 @@ const ActivateAccountFlow = () => { undeployedSafe.safeAccountConfig.owners, undeployedSafe.safeAccountConfig.threshold, Number(undeployedSafe.safeDeploymentConfig?.saltNonce!), + undeployedSafe.safeDeploymentConfig?.safeVersion, ) + onSubmit() + waitForCreateSafeTx(taskId, (status) => { if (status === SafeCreationStatus.SUCCESS) { onSuccess() } }) } else { - await createNewSafe(provider, { - safeAccountConfig: undeployedSafe.safeAccountConfig, - saltNonce: undeployedSafe.safeDeploymentConfig?.saltNonce, - options, - }) + await createNewSafe( + provider, + { + safeAccountConfig: undeployedSafe.safeAccountConfig, + saltNonce: undeployedSafe.safeDeploymentConfig?.saltNonce, + options, + + callback: onSubmit, + }, + undeployedSafe.safeDeploymentConfig?.safeVersion, + ) onSuccess() } } catch (_err) { @@ -117,8 +134,6 @@ const ActivateAccountFlow = () => { setSubmitError(err) return } - - setTxFlow(undefined) } const submitDisabled = !isSubmittable diff --git a/src/features/counterfactual/CheckBalance.tsx b/src/features/counterfactual/CheckBalance.tsx new file mode 100644 index 0000000000..b8e2c97229 --- /dev/null +++ b/src/features/counterfactual/CheckBalance.tsx @@ -0,0 +1,43 @@ +import CooldownButton from '@/components/common/CooldownButton' +import ExternalLink from '@/components/common/ExternalLink' +import { getCounterfactualBalance } from '@/features/counterfactual/utils' +import { useCurrentChain } from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useWeb3 } from '@/hooks/wallets/web3' +import { getBlockExplorerLink } from '@/utils/chains' +import { Box, Typography } from '@mui/material' + +const CheckBalance = () => { + const { safe, safeAddress } = useSafeInfo() + const chain = useCurrentChain() + const provider = useWeb3() + + if (safe.deployed) return null + + const handleBalanceRefresh = async () => { + void getCounterfactualBalance(safeAddress, provider, chain, true) + } + + const blockExplorerLink = chain ? getBlockExplorerLink(chain, safeAddress) : undefined + + return ( + + + Don't see your tokens? + + + + Refresh balance + + or + {blockExplorerLink && ( + + check on Block Explorer + + )} + + + ) +} + +export default CheckBalance diff --git a/src/features/counterfactual/CounterfactualForm.tsx b/src/features/counterfactual/CounterfactualForm.tsx index f2423be182..a4b3753086 100644 --- a/src/features/counterfactual/CounterfactualForm.tsx +++ b/src/features/counterfactual/CounterfactualForm.tsx @@ -3,6 +3,7 @@ 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 { getTotalFeeFormatted } from '@/hooks/useGasPrice' import useSafeInfo from '@/hooks/useSafeInfo' import useWalletCanPay from '@/hooks/useWalletCanPay' import useOnboard from '@/hooks/wallets/useOnboard' @@ -10,7 +11,7 @@ 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 { CircularProgress, Box, Button, CardActions, Divider, Alert } from '@mui/material' import classNames from 'classnames' import ErrorMessage from '@/components/tx/ErrorMessage' @@ -46,6 +47,7 @@ export const CounterfactualForm = ({ }): ReactElement => { const wallet = useWallet() const onboard = useOnboard() + const chain = useCurrentChain() const chainId = useChainId() const dispatch = useAppDispatch() const { safeAddress } = useSafeInfo() @@ -61,7 +63,7 @@ export const CounterfactualForm = ({ // Estimate gas limit const { gasLimit, gasLimitError } = useDeployGasLimit(safeTx) - const [advancedParams, setAdvancedParams] = useAdvancedParams(gasLimit) + const [advancedParams, setAdvancedParams] = useAdvancedParams(gasLimit?.totalGas) // On modal submit const handleSubmit = async (e: SyntheticEvent) => { @@ -91,12 +93,11 @@ export const CounterfactualForm = ({ return } - // TODO: Show a success or status screen setTxFlow(undefined) } const walletCanPay = useWalletCanPay({ - gasLimit, + gasLimit: gasLimit?.totalGas, maxFeePerGas: advancedParams.maxFeePerGas, maxPriorityFeePerGas: advancedParams.maxPriorityFeePerGas, }) @@ -113,6 +114,30 @@ export const CounterfactualForm = ({ return ( <>
+ + Executing this transaction will also activate your account. Additional network fees will apply. Base fee is{' '} + + {getTotalFeeFormatted( + advancedParams.maxFeePerGas, + advancedParams.maxPriorityFeePerGas, + BigInt(gasLimit?.baseGas || '0') + BigInt(gasLimit?.safeTxGas || '0'), + chain, + )}{' '} + {chain?.nativeCurrency.symbol} + + , one time activation fee is{' '} + + {getTotalFeeFormatted( + advancedParams.maxFeePerGas, + advancedParams.maxPriorityFeePerGas, + BigInt(gasLimit?.safeDeploymentGas || '0'), + chain, + )}{' '} + {chain?.nativeCurrency.symbol} + + . This is an estimation and the final cost can be higher. + +
> +}) => { + const chain = useCurrentChain() + + const onChoosePayMethod = (_: ChangeEvent, newPayMethod: string) => { + setPayMethod(newPayMethod as PayMethod) + } + + return ( + <> + + Before you continue + + + + + + + + There will be a one-time network fee to activate your smart account wallet. + + + + + + + + If you choose to pay later, the fee will be included with the first transaction you make. + + + + + + + Safe doesn't profit from the fees. + + + + + + Pay now + + {canRelay ? ( + 'Sponsored free transaction' + ) : ( + <> + ≈ {totalFee} {chain?.nativeCurrency.symbol} + + )} + + + } + control={} + /> + + + Pay later + + with the first transaction + + + } + control={} + /> + + + + ) +} + +export default PayNowPayLater diff --git a/src/features/counterfactual/__tests__/useDeployGasLimit.test.ts b/src/features/counterfactual/__tests__/useDeployGasLimit.test.ts new file mode 100644 index 0000000000..27764a7344 --- /dev/null +++ b/src/features/counterfactual/__tests__/useDeployGasLimit.test.ts @@ -0,0 +1,65 @@ +import useDeployGasLimit from '@/features/counterfactual/hooks/useDeployGasLimit' +import * as onboard from '@/hooks/wallets/useOnboard' +import * as sdk from '@/services/tx/tx-sender/sdk' +import { safeTxBuilder } from '@/tests/builders/safeTx' +import * as protocolKit from '@safe-global/protocol-kit' +import { renderHook } from '@/tests/test-utils' +import { waitFor } from '@testing-library/react' +import type { OnboardAPI } from '@web3-onboard/core' + +describe('useDeployGasLimit hook', () => { + it('returns undefined in onboard is not initialized', () => { + jest.spyOn(onboard, 'default').mockReturnValue(undefined) + const { result } = renderHook(() => useDeployGasLimit()) + + expect(result.current.gasLimit).toBeUndefined() + }) + + it('returns safe deployment gas estimation', async () => { + const mockGas = '100' + jest.spyOn(onboard, 'default').mockImplementation(jest.fn(() => ({} as OnboardAPI))) + jest.spyOn(sdk, 'getSafeSDKWithSigner').mockImplementation(jest.fn()) + const mockEstimateSafeDeploymentGas = jest + .spyOn(protocolKit, 'estimateSafeDeploymentGas') + .mockReturnValue(Promise.resolve(mockGas)) + + const { result } = renderHook(() => useDeployGasLimit()) + + await waitFor(() => { + expect(mockEstimateSafeDeploymentGas).toHaveBeenCalled() + expect(result.current.gasLimit?.safeDeploymentGas).toEqual(mockGas) + }) + }) + + it('does not estimate baseGas and safeTxGas if there is no safeTx and returns 0 for them instead', async () => { + jest.spyOn(onboard, 'default').mockImplementation(jest.fn(() => ({} as OnboardAPI))) + jest.spyOn(sdk, 'getSafeSDKWithSigner').mockImplementation(jest.fn()) + jest.spyOn(protocolKit, 'estimateSafeDeploymentGas').mockReturnValue(Promise.resolve('100')) + + const mockEstimateTxBaseGas = jest.spyOn(protocolKit, 'estimateTxBaseGas') + const mockEstimateSafeTxGas = jest.spyOn(protocolKit, 'estimateSafeTxGas') + + const { result } = renderHook(() => useDeployGasLimit()) + + await waitFor(() => { + expect(mockEstimateTxBaseGas).not.toHaveBeenCalled() + expect(mockEstimateSafeTxGas).not.toHaveBeenCalled() + expect(result.current.gasLimit?.baseGas).toEqual('0') + expect(result.current.gasLimit?.safeTxGas).toEqual('0') + }) + }) + + it('returns the totalFee', async () => { + jest.spyOn(onboard, 'default').mockImplementation(jest.fn(() => ({} as OnboardAPI))) + jest.spyOn(sdk, 'getSafeSDKWithSigner').mockImplementation(jest.fn()) + jest.spyOn(protocolKit, 'estimateSafeDeploymentGas').mockReturnValue(Promise.resolve('100')) + jest.spyOn(protocolKit, 'estimateTxBaseGas').mockReturnValue(Promise.resolve('100')) + jest.spyOn(protocolKit, 'estimateSafeTxGas').mockReturnValue(Promise.resolve('100')) + + const { result } = renderHook(() => useDeployGasLimit(safeTxBuilder().build())) + + await waitFor(() => { + expect(result.current.gasLimit?.totalGas).toEqual(300n) + }) + }) +}) diff --git a/src/features/counterfactual/__tests__/utils.test.ts b/src/features/counterfactual/__tests__/utils.test.ts index 589372a73e..b13a4ef13b 100644 --- a/src/features/counterfactual/__tests__/utils.test.ts +++ b/src/features/counterfactual/__tests__/utils.test.ts @@ -1,4 +1,9 @@ -import { getCounterfactualBalance, getUndeployedSafeInfo } from '@/features/counterfactual/utils' +import { + getCounterfactualBalance, + getNativeBalance, + getUndeployedSafeInfo, + setNativeBalance, +} from '@/features/counterfactual/utils' import * as web3 from '@/hooks/wallets/web3' import { chainBuilder } from '@/tests/builders/chains' import { faker } from '@faker-js/faker' @@ -32,6 +37,9 @@ describe('Counterfactual utils', () => { }) describe('getCounterfactualBalance', () => { + const mockSafeAddress = faker.finance.ethereumAddress() + const mockChain = chainBuilder().build() + beforeEach(() => { jest.clearAllMocks() }) @@ -41,13 +49,13 @@ describe('Counterfactual utils', () => { const mockReadOnlyProvider = { getBalance: jest.fn(() => Promise.resolve(mockBalance)), } as unknown as JsonRpcProvider - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => mockReadOnlyProvider) + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementationOnce(() => mockReadOnlyProvider) - const mockSafeAddress = faker.finance.ethereumAddress() - const mockChain = chainBuilder().build() const result = await getCounterfactualBalance(mockSafeAddress, undefined, mockChain) + const nativeBalanceCache = getNativeBalance() - expect(mockReadOnlyProvider.getBalance).toHaveBeenCalled() + expect(mockReadOnlyProvider.getBalance).toHaveBeenCalledTimes(1) + expect(nativeBalanceCache).toEqual(mockBalance) expect(result).toEqual({ fiatTotal: '0', items: [ @@ -63,10 +71,14 @@ describe('Counterfactual utils', () => { }, ], }) + + // Should use the cache now + const newResult = await getCounterfactualBalance(mockSafeAddress, undefined, mockChain) + expect(mockReadOnlyProvider.getBalance).toHaveBeenCalledTimes(1) + expect(newResult?.items[0].balance).toEqual('123') }) it('should return undefined if there is no chain info', async () => { - const mockSafeAddress = faker.finance.ethereumAddress() const mockProvider = { getBalance: jest.fn(() => Promise.resolve(1n)) } as unknown as BrowserProvider const result = await getCounterfactualBalance(mockSafeAddress, mockProvider, undefined) @@ -75,10 +87,8 @@ describe('Counterfactual utils', () => { }) it('should return the native balance', async () => { - const mockSafeAddress = faker.finance.ethereumAddress() const mockBalance = 1000000n const mockProvider = { getBalance: jest.fn(() => Promise.resolve(mockBalance)) } as unknown as BrowserProvider - const mockChain = chainBuilder().build() const result = await getCounterfactualBalance(mockSafeAddress, mockProvider, mockChain) @@ -99,5 +109,40 @@ describe('Counterfactual utils', () => { ], }) }) + + it('should not use the cache if the ignoreCache flag is passed', async () => { + const mockBalance = 123n + const mockReadOnlyProvider = { + getBalance: jest.fn(() => Promise.resolve(mockBalance)), + } as unknown as JsonRpcProvider + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementationOnce(() => mockReadOnlyProvider) + + // Set local cache + const mockCacheBalance = 10n + setNativeBalance(mockCacheBalance) + const nativeBalanceCache = getNativeBalance() + expect(nativeBalanceCache).toEqual(mockCacheBalance) + + // Call function and ignore cache + const result = await getCounterfactualBalance(mockSafeAddress, undefined, mockChain, true) + + expect(mockReadOnlyProvider.getBalance).toHaveBeenCalled() + expect(result?.items[0].balance).not.toEqual(mockCacheBalance) + expect(result).toEqual({ + fiatTotal: '0', + items: [ + { + tokenInfo: { + type: TokenType.NATIVE_TOKEN, + address: ZERO_ADDRESS, + ...mockChain.nativeCurrency, + }, + balance: mockBalance.toString(), + fiatBalance: '0', + fiatConversion: '0', + }, + ], + }) + }) }) }) diff --git a/src/features/counterfactual/hooks/useDeployGasLimit.ts b/src/features/counterfactual/hooks/useDeployGasLimit.ts index bee4b964bb..0807869423 100644 --- a/src/features/counterfactual/hooks/useDeployGasLimit.ts +++ b/src/features/counterfactual/hooks/useDeployGasLimit.ts @@ -21,25 +21,38 @@ const getExtraGasForSafety = (safeTx?: SafeTransaction): bigint => { : 0n } +type DeployGasLimitProps = { + baseGas: string + safeTxGas: string + safeDeploymentGas: string + totalGas: bigint +} + const useDeployGasLimit = (safeTx?: SafeTransaction) => { const onboard = useOnboard() const chainId = useChainId() - const [gasLimit, gasLimitError, gasLimitLoading] = useAsync(async () => { - if (!onboard) return + const [gasLimit, gasLimitError, gasLimitLoading] = useAsync( + async () => { + if (!onboard) return + + const sdk = await getSafeSDKWithSigner(onboard, chainId) - const sdk = await getSafeSDKWithSigner(onboard, chainId) + const extraGasForSafety = getExtraGasForSafety(safeTx) - const extraGasForSafety = getExtraGasForSafety(safeTx) + const [baseGas, safeTxGas, safeDeploymentGas] = await Promise.all([ + safeTx ? estimateTxBaseGas(sdk, safeTx) : '0', + safeTx ? estimateSafeTxGas(sdk, safeTx) : '0', + estimateSafeDeploymentGas(sdk), + ]) - const [gas, safeTxGas, safeDeploymentGas] = await Promise.all([ - safeTx ? estimateTxBaseGas(sdk, safeTx) : '0', - safeTx ? estimateSafeTxGas(sdk, safeTx) : '0', - estimateSafeDeploymentGas(sdk), - ]) + const totalGas = BigInt(baseGas) + BigInt(safeTxGas) + BigInt(safeDeploymentGas) + extraGasForSafety - return BigInt(gas) + BigInt(safeTxGas) + BigInt(safeDeploymentGas) + extraGasForSafety - }, [onboard, chainId, safeTx]) + return { baseGas, safeTxGas, safeDeploymentGas, totalGas } + }, + [onboard, chainId, safeTx], + false, + ) return { gasLimit, gasLimitError, gasLimitLoading } } diff --git a/src/features/counterfactual/styles.module.css b/src/features/counterfactual/styles.module.css new file mode 100644 index 0000000000..a487c0b9fe --- /dev/null +++ b/src/features/counterfactual/styles.module.css @@ -0,0 +1,32 @@ +.radioContainer { + border: 1px solid var(--color-border-light); + margin: 0; + border-radius: 6px; + height: 72px; + flex-basis: 72px; + padding: 0 var(--space-1); +} + +.radioGroup { + gap: var(--space-2); + flex-wrap: wrap; +} + +.active { + outline: 1px solid var(--color-primary-main); + border-color: var(--color-primary-main); +} + +.active .radioTitle { + font-weight: bold; +} + +.active .radioSubtitle { + color: var(--color-text-primary); +} + +@media (max-width: 400px) { + .radioGroup { + flex-direction: column; + } +} diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts index 152e04da71..23953d0654 100644 --- a/src/features/counterfactual/utils.ts +++ b/src/features/counterfactual/utils.ts @@ -12,7 +12,9 @@ import { txDispatch, TxEvent } from '@/services/tx/txEvents' import type { AppDispatch } from '@/store' import { addOrUpdateSafe } from '@/store/addedSafesSlice' import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { showNotification } from '@/store/notificationsSlice' import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { getBlockExplorerLink } from '@/utils/chains' 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' @@ -47,6 +49,8 @@ export const getUndeployedSafeInfo = (undeployedSafe: PredictedSafeProps, addres }) } +export const CF_TX_GROUP_KEY = 'cf-tx' + export const dispatchTxExecutionAndDeploySafe = async ( safeTx: SafeTransaction, txOptions: TransactionOptions, @@ -55,8 +59,7 @@ export const dispatchTxExecutionAndDeploySafe = async ( onSuccess?: () => void, ) => { const sdkUnchecked = await getUncheckedSafeSDK(onboard, chainId) - const eventParams = { groupKey: 'cf-tx' } - const safeAddress = await sdkUnchecked.getAddress() + const eventParams = { groupKey: CF_TX_GROUP_KEY } let result: ContractTransactionResponse | undefined try { @@ -68,8 +71,11 @@ export const dispatchTxExecutionAndDeploySafe = async ( const deploymentTx = await sdkUnchecked.wrapSafeTransactionIntoDeploymentBatch(signedTx, txOptions) + // We need to estimate the actual gasLimit after the user has signed since it is more accurate than what useDeployGasLimit returns + const gas = await signer.estimateGas({ data: deploymentTx.data, value: deploymentTx.value, to: deploymentTx.to }) + // @ts-ignore TODO: Check why TransactionResponse type doesn't work - result = await signer.sendTransaction(deploymentTx) + result = await signer.sendTransaction({ ...deploymentTx, gasLimit: gas }) txDispatch(TxEvent.EXECUTING, eventParams) } catch (error) { txDispatch(TxEvent.FAILED, { ...eventParams, error: asError(error) }) @@ -86,21 +92,20 @@ export const dispatchTxExecutionAndDeploySafe = async ( } else if (didRevert(receipt)) { txDispatch(TxEvent.REVERTED, { ...eventParams, error: new Error('Transaction reverted by EVM') }) } else { - txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress }) + txDispatch(TxEvent.SUCCESS, eventParams) + onSuccess?.() } }) .catch((err) => { const error = err as EthersError if (didReprice(error)) { - txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress }) + txDispatch(TxEvent.SUCCESS, eventParams) + onSuccess?.() } else { txDispatch(TxEvent.FAILED, { ...eventParams, error: asError(error) }) } }) - .finally(() => { - onSuccess?.() - }) return result!.hash } @@ -120,9 +125,14 @@ export const deploySafeAndExecuteTx = async ( return dispatchTxExecutionAndDeploySafe(safeTx, txOptions, onboard, chainId, onSuccess) } -const { getStore: getNativeBalance, setStore: setNativeBalance } = new ExternalStore() +export const { getStore: getNativeBalance, setStore: setNativeBalance } = new ExternalStore(0n) -export const getCounterfactualBalance = async (safeAddress: string, provider?: BrowserProvider, chain?: ChainInfo) => { +export const getCounterfactualBalance = async ( + safeAddress: string, + provider?: BrowserProvider, + chain?: ChainInfo, + ignoreCache?: boolean, +) => { let balance: bigint | undefined if (!chain) return undefined @@ -133,7 +143,8 @@ export const getCounterfactualBalance = async (safeAddress: string, provider?: B balance = await provider.getBalance(safeAddress) } else { const cachedBalance = getNativeBalance() - balance = cachedBalance !== undefined ? cachedBalance : await getWeb3ReadOnly()?.getBalance(safeAddress) + const useCache = cachedBalance && cachedBalance > 0n && !ignoreCache + balance = useCache ? cachedBalance : (await getWeb3ReadOnly()?.getBalance(safeAddress)) || 0n setNativeBalance(balance) } @@ -196,3 +207,16 @@ export const createCounterfactualSafe = ( query: { safe: `${chain.shortName}:${safeAddress}`, [CREATION_MODAL_QUERY_PARM]: true }, }) } + +export const showSubmitNotification = (dispatch: AppDispatch, chain?: ChainInfo, txHash?: string) => { + const link = chain && txHash ? getBlockExplorerLink(chain, txHash) : undefined + dispatch( + showNotification({ + variant: 'info', + groupKey: CF_TX_GROUP_KEY, + message: 'Safe Account activation in progress', + detailedMessage: 'Your Safe Account will be deployed onchain after the transaction is executed.', + link: link ? { href: link.href, title: link.title } : undefined, + }), + ) +} diff --git a/src/services/analytics/__tests__/tx-tracking.test.ts b/src/services/analytics/__tests__/tx-tracking.test.ts index 7c0dac6150..dc2abb999e 100644 --- a/src/services/analytics/__tests__/tx-tracking.test.ts +++ b/src/services/analytics/__tests__/tx-tracking.test.ts @@ -161,7 +161,7 @@ describe('getTransactionTrackingType', () => { }, } as unknown) - expect(txType).toEqual(TX_TYPES.safeapps) + expect(txType).toEqual('https://gnosis-safe.io/app') }) it('should return batch for multisend transactions', async () => { diff --git a/src/services/analytics/events/transactions.ts b/src/services/analytics/events/transactions.ts index 1ba910a40e..cbb3c1d1c9 100644 --- a/src/services/analytics/events/transactions.ts +++ b/src/services/analytics/events/transactions.ts @@ -19,7 +19,6 @@ export enum TX_TYPES { batch = 'batch', rejection = 'rejection', typed_message = 'typed_message', - safeapps = 'safeapps', walletconnect = 'walletconnect', custom = 'custom', } diff --git a/src/services/analytics/tx-tracking.ts b/src/services/analytics/tx-tracking.ts index 05b7354b57..6ba2d7462a 100644 --- a/src/services/analytics/tx-tracking.ts +++ b/src/services/analytics/tx-tracking.ts @@ -56,7 +56,7 @@ export const getTransactionTrackingType = async (chainId: string, txId: string): } if (details.safeAppInfo) { - return isWalletConnectSafeApp(details.safeAppInfo.url) ? TX_TYPES.walletconnect : TX_TYPES.safeapps + return isWalletConnectSafeApp(details.safeAppInfo.url) ? TX_TYPES.walletconnect : details.safeAppInfo.url } if (isMultiSendTxInfo(txInfo)) {