From 93bb38f44fe323774b27304c61cfbd8e871ef201 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Tue, 25 Jun 2024 16:29:39 +0200 Subject: [PATCH] feat: Improve safe creation status screen (#3778) * feat: Improve safe creation status screen * fix: Failing tests * fix: Extract useUndeployedSafe * fix: Add safeViewRedirectURL and remove old creation modal * fix: Show address in success modal * fix: Adjust undeployedSafeSlice to contain pay method * fix: Add safe to added safes and address book * chore: Remove usePendingSafe hook * fix: Show reverted error and adjust rejected error * test: Adjust create_safe_cf smoke test * fix: Adjust success screen wording * fix: Reset status fields on fail * fix: Add missing events, remove GET_STARTED event --------- Co-authored-by: James Mealy --- cypress/e2e/pages/create_wallet.pages.js | 10 - cypress/e2e/smoke/create_safe_cf.cy.js | 2 - public/images/common/tx-failed.svg | 8 + .../dashboard/CreationDialog/index.tsx | 88 ----- src/components/dashboard/index.tsx | 7 - src/components/new-safe/CardStepper/index.tsx | 4 +- .../new-safe/CardStepper/useCardStepper.ts | 3 + .../__tests__/useSyncSafeCreationStep.test.ts | 28 +- src/components/new-safe/create/index.tsx | 3 +- .../new-safe/create/logic/index.test.ts | 185 +--------- src/components/new-safe/create/logic/index.ts | 140 +------ .../create/steps/ReviewStep/index.tsx | 89 ++++- .../create/steps/StatusStep/StatusMessage.tsx | 106 +++--- .../create/steps/StatusStep/StatusStepper.tsx | 68 ---- .../steps/StatusStep/__tests__/index.test.tsx | 22 -- .../__tests__/usePendingSafe.test.ts | 71 ---- .../__tests__/useSafeCreation.test.ts | 348 ------------------ .../__tests__/useSafeCreationEffects.test.ts | 85 ----- .../create/steps/StatusStep/index.tsx | 204 +++++----- .../create/steps/StatusStep/usePendingSafe.ts | 29 -- .../steps/StatusStep/useSafeCreation.ts | 188 ---------- .../StatusStep/useSafeCreationEffects.ts | 90 ----- .../steps/StatusStep/useUndeployedSafe.ts | 19 + .../create/useSyncSafeCreationStep.ts | 7 +- .../counterfactual/ActivateAccountFlow.tsx | 4 +- .../counterfactual/CounterfactualHooks.tsx | 5 +- .../CounterfactualStatusButton.tsx | 14 +- .../CounterfactualSuccessScreen.tsx | 29 +- .../hooks/usePendingSafeStatuses.ts | 93 +++-- .../services/safeCreationEvents.ts | 4 + .../store/undeployedSafesSlice.ts | 17 +- src/features/counterfactual/utils.ts | 29 +- src/hooks/coreSDK/safeCoreSDK.ts | 3 +- .../analytics/events/createLoadSafe.ts | 4 - src/services/tx/__tests__/txMonitor.test.ts | 131 +------ src/services/tx/txMonitor.ts | 39 -- 36 files changed, 397 insertions(+), 1779 deletions(-) create mode 100644 public/images/common/tx-failed.svg delete mode 100644 src/components/dashboard/CreationDialog/index.tsx delete mode 100644 src/components/new-safe/create/steps/StatusStep/StatusStepper.tsx delete mode 100644 src/components/new-safe/create/steps/StatusStep/__tests__/index.test.tsx delete mode 100644 src/components/new-safe/create/steps/StatusStep/__tests__/usePendingSafe.test.ts delete mode 100644 src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreation.test.ts delete mode 100644 src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreationEffects.test.ts delete mode 100644 src/components/new-safe/create/steps/StatusStep/usePendingSafe.ts delete mode 100644 src/components/new-safe/create/steps/StatusStep/useSafeCreation.ts delete mode 100644 src/components/new-safe/create/steps/StatusStep/useSafeCreationEffects.ts create mode 100644 src/components/new-safe/create/steps/StatusStep/useUndeployedSafe.ts diff --git a/cypress/e2e/pages/create_wallet.pages.js b/cypress/e2e/pages/create_wallet.pages.js index a6f8a50c2b..6dfe94971a 100644 --- a/cypress/e2e/pages/create_wallet.pages.js +++ b/cypress/e2e/pages/create_wallet.pages.js @@ -26,7 +26,6 @@ const networkFeeSection = '[data-tetid="network-fee-section"]' const nextBtn = '[data-testid="next-btn"]' const backBtn = '[data-testid="back-btn"]' const cancelBtn = '[data-testid="cancel-btn"]' -const dialogConfirmBtn = '[data-testid="dialog-confirm-btn"]' const safeActivationSection = '[data-testid="activation-section"]' const addressAutocompleteOptions = '[data-testid="address-item"]' export const qrCode = '[data-testid="qr-code"]' @@ -90,19 +89,10 @@ export function clickOnTxType(tx) { cy.get(choiceBtn).contains(tx).click() } -export function verifyNewSafeDialogModal() { - main.verifyElementsIsVisible([dialogConfirmBtn]) -} - export function verifyCFSafeCreated() { main.verifyElementsIsVisible([sidebar.pendingActivationIcon, safeActivationSection]) } -export function clickOnGotitBtn() { - cy.get(dialogConfirmBtn).click() - main.verifyElementsCount(connectedWalletExecMethod, 0) -} - export function selectPayLaterOption() { cy.get(connectedWalletExecMethod).click() } diff --git a/cypress/e2e/smoke/create_safe_cf.cy.js b/cypress/e2e/smoke/create_safe_cf.cy.js index 4d4d775159..a882074c23 100644 --- a/cypress/e2e/smoke/create_safe_cf.cy.js +++ b/cypress/e2e/smoke/create_safe_cf.cy.js @@ -17,8 +17,6 @@ describe('[SMOKE] CF Safe creation tests', () => { createwallet.clickOnNextBtn() createwallet.selectPayLaterOption() createwallet.clickOnReviewStepNextBtn() - createwallet.verifyNewSafeDialogModal() - createwallet.clickOnGotitBtn() createwallet.verifyCFSafeCreated() }) }) diff --git a/public/images/common/tx-failed.svg b/public/images/common/tx-failed.svg new file mode 100644 index 0000000000..c78cf169da --- /dev/null +++ b/public/images/common/tx-failed.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/dashboard/CreationDialog/index.tsx b/src/components/dashboard/CreationDialog/index.tsx deleted file mode 100644 index 9a2ef19e11..0000000000 --- a/src/components/dashboard/CreationDialog/index.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { type ElementType } from 'react' -import { Box, Button, Dialog, DialogContent, Grid, SvgIcon, Typography } from '@mui/material' -import { useRouter } from 'next/router' - -import HomeIcon from '@/public/images/sidebar/home.svg' -import TransactionIcon from '@/public/images/sidebar/transactions.svg' -import AppsIcon from '@/public/images/sidebar/apps.svg' -import SettingsIcon from '@/public/images/sidebar/settings.svg' -import BeamerIcon from '@/public/images/sidebar/whats-new.svg' -import HelpCenterIcon from '@/public/images/sidebar/help-center.svg' -import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' -import { useCurrentChain } from '@/hooks/useChains' -import { CREATION_MODAL_QUERY_PARM } from '@/components/new-safe/create/logic' - -const HintItem = ({ Icon, title, description }: { Icon: ElementType; title: string; description: string }) => { - return ( - - - - - {title} - - - - {description} - - ) -} - -const CreationDialog = () => { - const router = useRouter() - const [open, setOpen] = React.useState(true) - const [remoteSafeApps = []] = useRemoteSafeApps() - const chain = useCurrentChain() - - const onClose = () => { - const { [CREATION_MODAL_QUERY_PARM]: _, ...query } = router.query - router.replace({ pathname: router.pathname, query }) - - setOpen(false) - } - - return ( - - - - Welcome to {'Safe{Wallet}'}! - - - Congratulations on your first step to truly unlock ownership. Enjoy the experience and discover our app. - - - - - - - - - - - - - - - - - ) -} - -export default CreationDialog diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 6bf7442871..fa480c931f 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -9,9 +9,6 @@ import Overview from '@/components/dashboard/Overview/Overview' import { FeaturedApps } from '@/components/dashboard/FeaturedApps/FeaturedApps' import SafeAppsDashboardSection from '@/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection' import GovernanceSection from '@/components/dashboard/GovernanceSection/GovernanceSection' -import CreationDialog from '@/components/dashboard/CreationDialog' -import { useRouter } from 'next/router' -import { CREATION_MODAL_QUERY_PARM } from '../new-safe/create/logic' import useRecovery from '@/features/recovery/hooks/useRecovery' import { useIsRecoverySupported } from '@/features/recovery/hooks/useIsRecoverySupported' import ActivityRewardsSection from '@/components/dashboard/ActivityRewardsSection' @@ -23,9 +20,7 @@ import SwapWidget from '@/features/swap/components/SwapWidget' const RecoveryHeader = dynamic(() => import('@/features/recovery/components/RecoveryHeader')) const Dashboard = (): ReactElement => { - const router = useRouter() const { safe } = useSafeInfo() - const { [CREATION_MODAL_QUERY_PARM]: showCreationModal = '' } = router.query const showSafeApps = useHasFeature(FEATURES.SAFE_APPS) const isSAPBannerEnabled = useHasFeature(FEATURES.SAP_BANNER) const supportsRecovery = useIsRecoverySupported() @@ -83,8 +78,6 @@ const Dashboard = (): ReactElement => { )} - - {showCreationModal ? : null} ) } diff --git a/src/components/new-safe/CardStepper/index.tsx b/src/components/new-safe/CardStepper/index.tsx index 8a7f0768ab..c58a781c00 100644 --- a/src/components/new-safe/CardStepper/index.tsx +++ b/src/components/new-safe/CardStepper/index.tsx @@ -8,7 +8,7 @@ import { useCardStepper } from './useCardStepper' export function CardStepper(props: TxStepperProps) { const [progressColor, setProgressColor] = useState(lightPalette.secondary.main) - const { activeStep, onSubmit, onBack, stepData, setStep } = useCardStepper(props) + const { activeStep, onSubmit, onBack, stepData, setStep, setStepData } = useCardStepper(props) const { steps } = props const currentStep = steps[activeStep] const progress = ((activeStep + 1) / steps.length) * 100 @@ -33,7 +33,7 @@ export function CardStepper(props: TxStepperProps) { /> )} - {currentStep.render(stepData, onSubmit, onBack, setStep, setProgressColor)} + {currentStep.render(stepData, onSubmit, onBack, setStep, setProgressColor, setStepData)} ) diff --git a/src/components/new-safe/CardStepper/useCardStepper.ts b/src/components/new-safe/CardStepper/useCardStepper.ts index c8abd82092..4598325ada 100644 --- a/src/components/new-safe/CardStepper/useCardStepper.ts +++ b/src/components/new-safe/CardStepper/useCardStepper.ts @@ -8,6 +8,7 @@ export type StepRenderProps = { onBack: (data?: Partial) => void setStep: (step: number) => void setProgressColor?: Dispatch> + setStepData?: Dispatch> } type Step = { @@ -19,6 +20,7 @@ type Step = { onBack: StepRenderProps['onBack'], setStep: StepRenderProps['setStep'], setProgressColor: StepRenderProps['setProgressColor'], + setStepData: StepRenderProps['setStepData'], ) => ReactElement } @@ -84,5 +86,6 @@ export const useCardStepper = ({ activeStep, stepData, firstStep, + setStepData, } } diff --git a/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts b/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts index 7a5cd855dd..1c0d1cb8c7 100644 --- a/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts +++ b/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts @@ -1,24 +1,17 @@ +import { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { PendingSafeStatus } from '@/features/counterfactual/store/undeployedSafesSlice' import { renderHook } from '@/tests/test-utils' import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCreationStep' import * as wallet from '@/hooks/wallets/useWallet' import * as localStorage from '@/services/local-storage/useLocalStorage' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' -import * as usePendingSafe from '../steps/StatusStep/usePendingSafe' +import * as useChainId from '@/hooks/useChainId' import * as useIsWrongChain from '@/hooks/useIsWrongChain' import * as useRouter from 'next/router' import { type NextRouter } from 'next/router' import { AppRoutes } from '@/config/routes' describe('useSyncSafeCreationStep', () => { - const mockPendingSafe = { - name: 'joyful-rinkeby-safe', - threshold: 1, - owners: [], - saltNonce: 123, - address: '0x10', - } - const setPendingSafeSpy = jest.fn() - beforeEach(() => { jest.clearAllMocks() }) @@ -26,7 +19,6 @@ describe('useSyncSafeCreationStep', () => { it('should go to the first step if no wallet is connected and there is no pending safe', async () => { const mockPushRoute = jest.fn() jest.spyOn(wallet, 'default').mockReturnValue(null) - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([undefined, setPendingSafeSpy]) jest.spyOn(useRouter, 'useRouter').mockReturnValue({ push: mockPushRoute, } as unknown as NextRouter) @@ -42,14 +34,22 @@ describe('useSyncSafeCreationStep', () => { const mockPushRoute = jest.fn() jest.spyOn(localStorage, 'default').mockReturnValue([{}, jest.fn()]) jest.spyOn(wallet, 'default').mockReturnValue({ address: '0x1' } as ConnectedWallet) - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([mockPendingSafe, setPendingSafeSpy]) + jest.spyOn(useChainId, 'default').mockReturnValue('11155111') jest.spyOn(useRouter, 'useRouter').mockReturnValue({ push: mockPushRoute, } as unknown as NextRouter) const mockSetStep = jest.fn() - renderHook(() => useSyncSafeCreationStep(mockSetStep)) + renderHook(() => useSyncSafeCreationStep(mockSetStep), { + initialReduxState: { + undeployedSafes: { + '11155111': { + '0x123': { status: { status: PendingSafeStatus.PROCESSING, type: PayMethod.PayNow }, props: {} as any }, + }, + }, + }, + }) expect(mockSetStep).toHaveBeenCalledWith(3) @@ -59,7 +59,6 @@ describe('useSyncSafeCreationStep', () => { it('should go to the second step if the wrong chain is connected', async () => { jest.spyOn(localStorage, 'default').mockReturnValue([{}, jest.fn()]) jest.spyOn(wallet, 'default').mockReturnValue({ address: '0x1' } as ConnectedWallet) - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([undefined, setPendingSafeSpy]) jest.spyOn(useIsWrongChain, 'default').mockReturnValue(true) const mockSetStep = jest.fn() @@ -72,7 +71,6 @@ describe('useSyncSafeCreationStep', () => { it('should not do anything if wallet is connected and there is no pending safe', async () => { jest.spyOn(localStorage, 'default').mockReturnValue([undefined, jest.fn()]) jest.spyOn(wallet, 'default').mockReturnValue({ address: '0x1' } as ConnectedWallet) - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([undefined, setPendingSafeSpy]) jest.spyOn(useIsWrongChain, 'default').mockReturnValue(false) const mockSetStep = jest.fn() diff --git a/src/components/new-safe/create/index.tsx b/src/components/new-safe/create/index.tsx index 9a14bad22d..8f738c082a 100644 --- a/src/components/new-safe/create/index.tsx +++ b/src/components/new-safe/create/index.tsx @@ -135,13 +135,14 @@ const CreateSafe = () => { { title: '', subtitle: '', - render: (data, onSubmit, onBack, setStep, setProgressColor) => ( + render: (data, onSubmit, onBack, setStep, setProgressColor, setStepData) => ( ), }, diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index 155e952ffb..6457eca09c 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -1,15 +1,7 @@ -import { JsonRpcProvider, type TransactionResponse } from 'ethers' +import { JsonRpcProvider } from 'ethers' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import * as web3 from '@/hooks/wallets/web3' -import type { TransactionReceipt } from 'ethers' -import { - checkSafeCreationTx, - relaySafeCreation, - handleSafeCreationError, -} from '@/components/new-safe/create/logic/index' -import { type ErrorCode } from 'ethers' -import { EthersTxReplacedReason } from '@/utils/ethers-utils' -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' +import { relaySafeCreation } from '@/components/new-safe/create/logic/index' import { relayTransaction, type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' import { @@ -57,179 +49,6 @@ jest.mock('@safe-global/protocol-kit', () => { } }) -describe('checkSafeCreationTx', () => { - let waitForTxSpy = jest.spyOn(provider, 'waitForTransaction') - - beforeEach(() => { - jest.resetAllMocks() - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => provider) - - waitForTxSpy = jest.spyOn(provider, 'waitForTransaction') - jest.spyOn(provider, 'getBlockNumber').mockReturnValue(Promise.resolve(4)) - jest.spyOn(provider, 'getTransaction').mockReturnValue(Promise.resolve(mockTransaction as TransactionResponse)) - }) - - it('returns SUCCESS if promise was resolved', async () => { - const receipt = { - status: 1, - } as TransactionReceipt - - waitForTxSpy.mockImplementationOnce(() => Promise.resolve(receipt)) - - const result = await checkSafeCreationTx(provider, mockPendingTx, '0x0', jest.fn()) - - expect(result).toBe(SafeCreationStatus.SUCCESS) - }) - - it('returns REVERTED if transaction was reverted', async () => { - const receipt = { - status: 0, - } as TransactionReceipt - - waitForTxSpy.mockImplementationOnce(() => Promise.resolve(receipt)) - - const result = await checkSafeCreationTx(provider, mockPendingTx, '0x0', jest.fn()) - - expect(result).toBe(SafeCreationStatus.REVERTED) - }) - - it('returns TIMEOUT if transaction could not be found within the timeout limit', async () => { - const mockEthersError = { - ...new Error(), - code: 'TIMEOUT' as ErrorCode, - } - - waitForTxSpy.mockImplementationOnce(() => Promise.reject(mockEthersError)) - - const result = await checkSafeCreationTx(provider, mockPendingTx, '0x0', jest.fn()) - - expect(result).toBe(SafeCreationStatus.TIMEOUT) - }) - - it('returns SUCCESS if transaction was replaced', async () => { - const mockEthersError = { - ...new Error(), - code: 'TRANSACTION_REPLACED', - reason: 'repriced', - } - waitForTxSpy.mockImplementationOnce(() => Promise.reject(mockEthersError)) - - const result = await checkSafeCreationTx(provider, mockPendingTx, '0x0', jest.fn()) - - expect(result).toBe(SafeCreationStatus.SUCCESS) - }) - - it('returns ERROR if transaction was cancelled', async () => { - const mockEthersError = { - ...new Error(), - code: 'TRANSACTION_REPLACED', - reason: 'cancelled', - } - waitForTxSpy.mockImplementationOnce(() => Promise.reject(mockEthersError)) - - const result = await checkSafeCreationTx(provider, mockPendingTx, '0x0', jest.fn()) - - expect(result).toBe(SafeCreationStatus.ERROR) - }) -}) - -describe('handleSafeCreationError', () => { - it('returns WALLET_REJECTED if the tx was rejected in the wallet', () => { - const mockEthersError = { - ...new Error(), - code: 'ACTION_REJECTED' as ErrorCode, - reason: '' as EthersTxReplacedReason, - receipt: {} as TransactionReceipt, - } - - const result = handleSafeCreationError(mockEthersError) - - expect(result).toEqual(SafeCreationStatus.WALLET_REJECTED) - }) - - it('returns WALLET_REJECTED if the tx was rejected via WC', () => { - const mockEthersError = { - ...new Error(), - code: 'UNKNOWN_ERROR' as ErrorCode, - reason: '' as EthersTxReplacedReason, - receipt: {} as TransactionReceipt, - message: 'rejected', - } - - const result = handleSafeCreationError(mockEthersError) - - expect(result).toEqual(SafeCreationStatus.WALLET_REJECTED) - }) - - it('returns ERROR if the tx was cancelled', () => { - const mockEthersError = { - ...new Error(), - code: 'TRANSACTION_REPLACED' as ErrorCode, - reason: EthersTxReplacedReason.cancelled, - receipt: {} as TransactionReceipt, - } - - const result = handleSafeCreationError(mockEthersError) - - expect(result).toEqual(SafeCreationStatus.ERROR) - }) - - it('returns SUCCESS if the tx was replaced', () => { - const mockEthersError = { - ...new Error(), - code: 'TRANSACTION_REPLACED' as ErrorCode, - reason: EthersTxReplacedReason.replaced, - receipt: {} as TransactionReceipt, - } - - const result = handleSafeCreationError(mockEthersError) - - expect(result).toEqual(SafeCreationStatus.SUCCESS) - }) - - it('returns SUCCESS if the tx was repriced', () => { - const mockEthersError = { - ...new Error(), - code: 'TRANSACTION_REPLACED' as ErrorCode, - reason: EthersTxReplacedReason.repriced, - receipt: {} as TransactionReceipt, - } - - const result = handleSafeCreationError(mockEthersError) - - expect(result).toEqual(SafeCreationStatus.SUCCESS) - }) - - it('returns ERROR if the tx was not rejected, cancelled or replaced', () => { - const mockEthersError = { - ...new Error(), - code: 'UNKNOWN_ERROR' as ErrorCode, - reason: '' as EthersTxReplacedReason, - receipt: {} as TransactionReceipt, - } - - const result = handleSafeCreationError(mockEthersError) - - expect(result).toEqual(SafeCreationStatus.ERROR) - }) - - it('returns REVERTED if the tx failed', () => { - const mockEthersError = { - ...new Error(), - code: 'UNKNOWN_ERROR' as ErrorCode, - reason: '' as EthersTxReplacedReason, - receipt: { - status: 0, - } as TransactionReceipt, - } - - const result = handleSafeCreationError(mockEthersError) - - expect(result).toEqual(SafeCreationStatus.REVERTED) - }) -}) - describe('createNewSafeViaRelayer', () => { const owner1 = toBeHex('0x1', 20) const owner2 = toBeHex('0x2', 20) diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index a0c504da03..ff50492ebd 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -7,18 +7,9 @@ import { getReadOnlyGnosisSafeContract, getReadOnlyProxyFactoryContract, } from '@/services/contracts/safeContracts' -import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' -import { didRevert, type EthersError } from '@/utils/ethers-utils' -import { Errors, trackError } from '@/services/exceptions' -import { isWalletRejection } from '@/utils/wallets' -import type { PendingSafeTx } from '@/components/new-safe/create/types' -import type { NewSafeFormData } from '@/components/new-safe/create' import type { UrlObject } from 'url' import { AppRoutes } from '@/config/routes' import { SAFE_APPS_EVENTS, trackEvent } from '@/services/analytics' -import type { AppDispatch, AppThunk } from '@/store' -import { showNotification } from '@/store/notificationsSlice' import { predictSafeAddress, SafeFactory } from '@safe-global/protocol-kit' import type Safe from '@safe-global/protocol-kit' import type { DeploySafeProps } from '@safe-global/protocol-kit' @@ -27,7 +18,6 @@ import { createEthersAdapter, isValidSafeVersion } from '@/hooks/coreSDK/safeCor import { backOff } from 'exponential-backoff' import { LATEST_SAFE_VERSION } from '@/config/constants' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import { formatError } from '@/utils/formatters' export type SafeCreationProps = { owners: string[] @@ -35,27 +25,6 @@ export type SafeCreationProps = { saltNonce: number } -/** - * Prepare data for creating a Safe for the Core SDK - */ -export const getSafeDeployProps = async ( - safeParams: SafeCreationProps, - callback: (txHash: string) => void, - chainId: string, -): Promise => { - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(chainId, LATEST_SAFE_VERSION) - - return { - safeAccountConfig: { - threshold: safeParams.threshold, - owners: safeParams.owners, - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - }, - saltNonce: safeParams.saltNonce.toString(), - callback, - } -} - const getSafeFactory = async ( ethersProvider: BrowserProvider, safeVersion = LATEST_SAFE_VERSION, @@ -133,36 +102,6 @@ export const encodeSafeCreationTx = async ({ ]) } -/** - * Encode a Safe creation tx in a way that we can store locally and monitor using _waitForTransaction - */ -export const getSafeCreationTxInfo = async ( - provider: Provider, - owners: NewSafeFormData['owners'], - threshold: NewSafeFormData['threshold'], - saltNonce: NewSafeFormData['saltNonce'], - chain: ChainInfo, - wallet: ConnectedWallet, -): Promise => { - const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(chain.chainId, LATEST_SAFE_VERSION) - - const data = await encodeSafeCreationTx({ - owners: owners.map((owner) => owner.address), - threshold, - saltNonce, - chain, - }) - - return { - data, - from: wallet.address, - nonce: await provider.getTransactionCount(wallet.address), - to: await readOnlyProxyContract.getAddress(), - value: BigInt(0), - startBlock: await provider.getBlockNumber(), - } -} - export const estimateSafeCreationGas = async ( chain: ChainInfo, provider: Provider, @@ -194,83 +133,6 @@ export const pollSafeInfo = async (chainId: string, safeAddress: string): Promis }) } -export const handleSafeCreationError = (error: EthersError) => { - trackError(Errors._800, error.message) - - if (isWalletRejection(error)) { - return SafeCreationStatus.WALLET_REJECTED - } - - if (error.code === 'TRANSACTION_REPLACED') { - if (error.reason === 'cancelled') { - return SafeCreationStatus.ERROR - } else { - return SafeCreationStatus.SUCCESS - } - } - - if (error.receipt && didRevert(error.receipt)) { - return SafeCreationStatus.REVERTED - } - - if (error.code === 'TIMEOUT') { - return SafeCreationStatus.TIMEOUT - } - - return SafeCreationStatus.ERROR -} - -export const SAFE_CREATION_ERROR_KEY = 'create-safe-error' -export const showSafeCreationError = (error: EthersError | Error): AppThunk => { - return (dispatch) => { - dispatch( - showNotification({ - message: `Your transaction was unsuccessful. Reason: ${formatError(error)}`, - detailedMessage: error.message, - groupKey: SAFE_CREATION_ERROR_KEY, - variant: 'error', - }), - ) - } -} - -export const checkSafeCreationTx = async ( - provider: Provider, - pendingTx: PendingSafeTx, - txHash: string, - dispatch: AppDispatch, -): Promise => { - const TIMEOUT_TIME = 60 * 1000 // 1 minute - - try { - // TODO: Use the fix from checkSafeActivation to detect cancellation and speed-up txs again - const receipt = await provider.waitForTransaction(txHash, 1, TIMEOUT_TIME) - - /** The receipt should always be non-null as we require 1 confirmation */ - if (receipt === null) { - throw new Error('Transaction should have a receipt, but got null instead.') - } - - if (didRevert(receipt)) { - return SafeCreationStatus.REVERTED - } - - return SafeCreationStatus.SUCCESS - } catch (err) { - const _err = err as EthersError - - const status = handleSafeCreationError(_err) - - if (status !== SafeCreationStatus.SUCCESS) { - dispatch(showSafeCreationError(_err)) - } - - return status - } -} - -export const CREATION_MODAL_QUERY_PARM = 'showCreationModal' - export const getRedirect = ( chainPrefix: string, safeAddress: string, @@ -284,7 +146,7 @@ export const getRedirect = ( // Go to the dashboard if no specific redirect is provided if (!redirectUrl) { - return { pathname: AppRoutes.home, query: { safe: address, [CREATION_MODAL_QUERY_PARM]: true } } + return { pathname: AppRoutes.home, query: { safe: address } } } // Otherwise, redirect to the provided URL (e.g. from a Safe App) diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 3cc24712e7..5ed1f11c39 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -1,10 +1,12 @@ import ChainIndicator from '@/components/common/ChainIndicator' import type { NamedAddress } from '@/components/new-safe/create/types' import EthHashInfo from '@/components/common/EthHashInfo' +import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' +import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' 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 { computeNewSafeAddress, createNewSafe, relaySafeCreation } 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' @@ -16,7 +18,7 @@ import ErrorMessage from '@/components/tx/ErrorMessage' import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' import { LATEST_SAFE_VERSION } from '@/config/constants' import PayNowPayLater, { PayMethod } from '@/features/counterfactual/PayNowPayLater' -import { createCounterfactualSafe } from '@/features/counterfactual/utils' +import { CF_TX_GROUP_KEY, createCounterfactualSafe } from '@/features/counterfactual/utils' import { useCurrentChain, useHasFeature } from '@/hooks/useChains' import useGasPrice from '@/hooks/useGasPrice' import useIsWrongChain from '@/hooks/useIsWrongChain' @@ -27,17 +29,19 @@ import { useWeb3 } from '@/hooks/wallets/web3' import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { gtmSetSafeAddress } from '@/services/analytics/gtm' import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts' +import { asError } from '@/services/exceptions/utils' import { useAppDispatch } from '@/store' -import { FEATURES } from '@/utils/chains' +import { FEATURES, hasFeature } from '@/utils/chains' import { hasRemainingRelays } from '@/utils/relaying' +import { isWalletRejection } from '@/utils/wallets' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import { Box, Button, CircularProgress, Divider, Grid, Typography } from '@mui/material' import { type DeploySafeProps } from '@safe-global/protocol-kit' +import type { SafeVersion } from '@safe-global/safe-core-sdk-types' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import classnames from 'classnames' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' -import { usePendingSafe } from '../StatusStep/usePendingSafe' export const NetworkFee = ({ totalFee, @@ -118,12 +122,12 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps(false) const [submitError, setSubmitError] = useState() const isCounterfactualEnabled = useHasFeature(FEATURES.COUNTERFACTUAL) + const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) const ownerAddresses = useMemo(() => data.owners.map((owner) => owner.address), [data.owners]) const [minRelays] = useLeastRemainingRelays(ownerAddresses) @@ -187,19 +191,76 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { + dispatch(addUndeployedSafe(undeployedSafe)) + + if (taskId) { + safeCreationDispatch(SafeCreationEvent.RELAYING, { groupKey: CF_TX_GROUP_KEY, taskId, safeAddress }) + } + + if (txHash) { + safeCreationDispatch(SafeCreationEvent.PROCESSING, { + groupKey: CF_TX_GROUP_KEY, + txHash, + safeAddress, + }) + } + + trackEvent(CREATE_SAFE_EVENTS.SUBMIT_CREATE_SAFE) + trackEvent({ ...OVERVIEW_EVENTS.PROCEED_WITH_TX, label: 'deployment', category: CREATE_SAFE_CATEGORY }) + + onSubmit(data) + } - setPendingSafe(pendingSafe) - onSubmit(pendingSafe) + if (willRelay) { + const taskId = await relaySafeCreation( + chain, + props.safeAccountConfig.owners, + props.safeAccountConfig.threshold, + Number(saltNonce), + ) + onSubmitCallback(taskId) + } else { + await createNewSafe(provider, { + safeAccountConfig: props.safeAccountConfig, + saltNonce, + options, + callback: (txHash) => { + onSubmitCallback(undefined, txHash) + }, + }) + } } catch (_err) { - setSubmitError('Error creating the Safe Account. Please try again later.') + const error = asError(_err) + const submitError = isWalletRejection(error) + ? 'User rejected signing.' + : 'Error creating the Safe Account. Please try again later.' + setSubmitError(submitError) + + if (isWalletRejection(error)) { + trackEvent(CREATE_SAFE_EVENTS.REJECT_CREATE_SAFE) + } } setIsCreating(false) diff --git a/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx b/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx index 90300a14bc..3776d953f7 100644 --- a/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx +++ b/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx @@ -1,91 +1,79 @@ -import { Box, Typography } from '@mui/material' -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' +import ExternalLink from '@/components/common/ExternalLink' import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' +import { SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' +import type { UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import { useCurrentChain } from '@/hooks/useChains' +import { getBlockExplorerLink } from '@/utils/chains' +import { Box, Typography } from '@mui/material' +import FailedIcon from '@/public/images/common/tx-failed.svg' -const getStep = (status: SafeCreationStatus) => { - const ERROR_TEXT = 'Please cancel the process or retry the transaction.' - +const getStep = (status: SafeCreationEvent) => { switch (status) { - case SafeCreationStatus.AWAITING: - return { - description: 'Waiting for transaction confirmation.', - instruction: 'Please confirm the transaction with your connected wallet.', - } - case SafeCreationStatus.WALLET_REJECTED: - return { - description: 'Transaction was rejected.', - instruction: ERROR_TEXT, - } - case SafeCreationStatus.PROCESSING: - return { - description: 'Transaction is being executed.', - instruction: 'Please do not leave this page.', - } - case SafeCreationStatus.ERROR: + case SafeCreationEvent.PROCESSING: + case SafeCreationEvent.RELAYING: return { - description: 'There was an error.', - instruction: ERROR_TEXT, + description: 'We are activating your account', + instruction: 'It can take some minutes to create your account, but you can check the progress below.', } - case SafeCreationStatus.REVERTED: + case SafeCreationEvent.FAILED: return { - description: 'Transaction was reverted.', - instruction: ERROR_TEXT, + description: "Your account couldn't be created", + instruction: + 'The creation transaction was rejected by the connected wallet. You can retry or create an account from scratch.', } - case SafeCreationStatus.TIMEOUT: + case SafeCreationEvent.REVERTED: return { - description: 'Transaction was not found. Be aware that it might still be processed.', - instruction: ERROR_TEXT, + description: "Your account couldn't be created", + instruction: 'The creation transaction reverted. You can retry or create an account from scratch.', } - case SafeCreationStatus.SUCCESS: + case SafeCreationEvent.SUCCESS: return { description: 'Your Safe Account is being indexed..', instruction: 'The account will be ready for use shortly. Please do not leave this page.', } - case SafeCreationStatus.INDEXED: + case SafeCreationEvent.INDEXED: return { description: 'Your Safe Account was successfully created!', instruction: '', } - case SafeCreationStatus.INDEX_FAILED: - return { - description: 'Your Safe Account is successfully created!', - instruction: - 'You can already open Safe{Wallet}. It might take a moment until it becomes fully usable in the interface.', - } } } -const StatusMessage = ({ status, isError }: { status: SafeCreationStatus; isError: boolean }) => { +const StatusMessage = ({ + status, + isError, + pendingSafe, +}: { + status: SafeCreationEvent + isError: boolean + pendingSafe: UndeployedSafe | undefined +}) => { const stepInfo = getStep(status) + const chain = useCurrentChain() - const color = isError ? 'error' : 'info' - const isSuccess = status >= SafeCreationStatus.SUCCESS - const spinnerStatus = isError ? SpinnerStatus.ERROR : isSuccess ? SpinnerStatus.SUCCESS : SpinnerStatus.PROCESSING + const isSuccess = status === SafeCreationEvent.SUCCESS + const spinnerStatus = isSuccess ? SpinnerStatus.SUCCESS : SpinnerStatus.PROCESSING + const explorerLink = + chain && pendingSafe?.status.txHash ? getBlockExplorerLink(chain, pendingSafe.status.txHash) : undefined return ( <> - - + + {isError ? : } + + {stepInfo.description} - {stepInfo.instruction && ( - ({ - backgroundColor: palette[color].background, - borderColor: palette[color].light, - borderWidth: 1, - borderStyle: 'solid', - borderRadius: '6px', - })} - padding={3} - mt={4} - mb={0} - > - {stepInfo.instruction} - - )} + + {stepInfo.instruction && ( + + {stepInfo.instruction} + + )} + {!isError && explorerLink && Check Status} + ) } diff --git a/src/components/new-safe/create/steps/StatusStep/StatusStepper.tsx b/src/components/new-safe/create/steps/StatusStep/StatusStepper.tsx deleted file mode 100644 index de73ce73b6..0000000000 --- a/src/components/new-safe/create/steps/StatusStep/StatusStepper.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Box, Step, StepConnector, Stepper, Typography } from '@mui/material' -import css from '@/components/new-safe/create/steps/StatusStep/styles.module.css' -import EthHashInfo from '@/components/common/EthHashInfo' -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' -import StatusStep from '@/components/new-safe/create/steps/StatusStep/StatusStep' -import { usePendingSafe } from './usePendingSafe' - -const StatusStepper = ({ status }: { status: SafeCreationStatus }) => { - const [pendingSafe] = usePendingSafe() - if (!pendingSafe?.safeAddress) return null - - return ( - }> - - - - - Your Safe Account address - - - - - - - - - - Validating transaction - - {pendingSafe.txHash && ( - - )} - - - - - - - Indexing - - - - - - - Safe Account is ready - - - - - ) -} - -export default StatusStepper diff --git a/src/components/new-safe/create/steps/StatusStep/__tests__/index.test.tsx b/src/components/new-safe/create/steps/StatusStep/__tests__/index.test.tsx deleted file mode 100644 index 31b5aae5ec..0000000000 --- a/src/components/new-safe/create/steps/StatusStep/__tests__/index.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' -import { render } from '@/tests/test-utils' -import { CreateSafeStatus } from '@/components/new-safe/create/steps/StatusStep' -import { type NewSafeFormData } from '@/components/new-safe/create' -import * as useSafeCreation from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' - -describe('StatusStep', () => { - it('should call useSafeCreation with PROCESSING status if relaying', () => { - const useSafeCreationSpy = jest.spyOn(useSafeCreation, 'useSafeCreation') - - render( - {}} - onBack={() => {}} - setStep={() => {}} - />, - ) - - expect(useSafeCreationSpy).toHaveBeenCalledWith(SafeCreationStatus.PROCESSING, expect.anything(), true) - }) -}) diff --git a/src/components/new-safe/create/steps/StatusStep/__tests__/usePendingSafe.test.ts b/src/components/new-safe/create/steps/StatusStep/__tests__/usePendingSafe.test.ts deleted file mode 100644 index 03cad0f74c..0000000000 --- a/src/components/new-safe/create/steps/StatusStep/__tests__/usePendingSafe.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { renderHook } from '@/tests/test-utils' -import { usePendingSafe } from '../usePendingSafe' - -import { toBeHex } from 'ethers' -import { useCurrentChain } from '@/hooks/useChains' - -// mock useCurrentChain -jest.mock('@/hooks/useChains', () => ({ - useCurrentChain: jest.fn(() => ({ - shortName: 'gor', - chainId: '5', - chainName: 'Goerli', - features: [], - })), -})) - -describe('usePendingSafe()', () => { - const mockPendingSafe1 = { - name: 'joyful-rinkeby-safe', - threshold: 1, - owners: [], - saltNonce: 123, - address: toBeHex('0x10', 20), - } - const mockPendingSafe2 = { - name: 'joyful-rinkeby-safe', - threshold: 1, - owners: [], - saltNonce: 123, - address: toBeHex('0x10', 20), - } - - beforeEach(() => { - window.localStorage.clear() - }) - it('Should initially be undefined', () => { - const { result } = renderHook(() => usePendingSafe()) - expect(result.current[0]).toBeUndefined() - }) - - it('Should set the pendingSafe per ChainId', async () => { - const { result, rerender } = renderHook(() => usePendingSafe()) - - result.current[1](mockPendingSafe1) - - rerender() - - expect(result.current[0]).toEqual(mockPendingSafe1) - ;(useCurrentChain as jest.Mock).mockImplementation(() => ({ - shortName: 'eth', - chainId: '1', - chainName: 'Ethereum', - features: [], - })) - - rerender() - expect(result.current[0]).toEqual(undefined) - - result.current[1](mockPendingSafe2) - rerender() - expect(result.current[0]).toEqual(mockPendingSafe2) - ;(useCurrentChain as jest.Mock).mockImplementation(() => ({ - shortName: 'gor', - chainId: '5', - chainName: 'Goerli', - features: [], - })) - rerender() - expect(result.current[0]).toEqual(mockPendingSafe1) - }) -}) diff --git a/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreation.test.ts b/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreation.test.ts deleted file mode 100644 index 2fc29f51b4..0000000000 --- a/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreation.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { renderHook } from '@/tests/test-utils' -import { SafeCreationStatus, useSafeCreation } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' -import * as web3 from '@/hooks/wallets/web3' -import * as chain from '@/hooks/useChains' -import * as wallet from '@/hooks/wallets/useWallet' -import * as logic from '@/components/new-safe/create/logic' -import * as contracts from '@/services/contracts/safeContracts' -import * as txMonitor from '@/services/tx/txMonitor' -import * as usePendingSafe from '@/components/new-safe/create/steps/StatusStep/usePendingSafe' -import { BrowserProvider, zeroPadValue, type JsonRpcProvider } from 'ethers' -import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' -import { chainBuilder } from '@/tests/builders/chains' -import { waitFor } from '@testing-library/react' -import type Safe from '@safe-global/protocol-kit' -import type CompatibilityFallbackHandlerEthersContract from '@safe-global/protocol-kit/dist/src/adapters/ethers/contracts/CompatibilityFallbackHandler/CompatibilityFallbackHandlerEthersContract' -import { FEATURES } from '@safe-global/safe-gateway-typescript-sdk' -import * as gasPrice from '@/hooks/useGasPrice' -import { MockEip1193Provider } from '@/tests/mocks/providers' - -const mockSafeInfo = { - data: '0x', - from: '0x1', - to: '0x2', - nonce: 1, - value: BigInt(0), - startBlock: 1, -} - -jest.mock('@safe-global/protocol-kit', () => { - const originalModule = jest.requireActual('@safe-global/protocol-kit') - - // Mock class - class MockEthersAdapter extends originalModule.EthersAdapter { - getChainId = jest.fn().mockImplementation(() => Promise.resolve(BigInt(4))) - } - - return { - ...originalModule, - EthersAdapter: MockEthersAdapter, - } -}) - -describe('useSafeCreation', () => { - const mockPendingSafe = { - name: 'joyful-rinkeby-safe', - threshold: 1, - owners: [], - saltNonce: 123, - address: '0x10', - } - const mockSetPendingSafe = jest.fn() - const mockStatus = SafeCreationStatus.AWAITING - const mockSetStatus = jest.fn() - const mockProvider: BrowserProvider = new BrowserProvider(MockEip1193Provider) - const mockReadOnlyProvider = { - getCode: jest.fn(), - } as unknown as JsonRpcProvider - - beforeEach(() => { - jest.resetAllMocks() - jest.restoreAllMocks() - - const mockChain = chainBuilder().with({ features: [] }).build() - jest.spyOn(web3, 'useWeb3').mockImplementation(() => mockProvider) - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => mockReadOnlyProvider) - jest.spyOn(web3, 'useWeb3ReadOnly').mockImplementation(() => mockReadOnlyProvider) - jest.spyOn(chain, 'useCurrentChain').mockImplementation(() => mockChain) - jest.spyOn(wallet, 'default').mockReturnValue({} as ConnectedWallet) - jest.spyOn(logic, 'getSafeCreationTxInfo').mockReturnValue(Promise.resolve(mockSafeInfo)) - jest.spyOn(logic, 'estimateSafeCreationGas').mockReturnValue(Promise.resolve(BigInt(200000))) - jest.spyOn(contracts, 'getReadOnlyFallbackHandlerContract').mockResolvedValue({ - getAddress: () => zeroPadValue('0x0123', 20), - } as unknown as CompatibilityFallbackHandlerEthersContract) - jest - .spyOn(gasPrice, 'default') - .mockReturnValue([{ maxFeePerGas: BigInt(123), maxPriorityFeePerGas: undefined }, undefined, false]) - }) - - it('should create a safe with gas params if there is no txHash and status is AWAITING', async () => { - const createSafeSpy = jest.spyOn(logic, 'createNewSafe').mockReturnValue(Promise.resolve({} as Safe)) - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([mockPendingSafe, mockSetPendingSafe]) - renderHook(() => useSafeCreation(mockStatus, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).toHaveBeenCalled() - - const { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = createSafeSpy.mock.calls[0][1].options || {} - - expect(gasPrice).toBe('123') - - expect(maxFeePerGas).toBeUndefined() - expect(maxPriorityFeePerGas).toBeUndefined() - }) - }) - - it('should create a safe with EIP-1559 gas params if there is no txHash and status is AWAITING', async () => { - jest - .spyOn(gasPrice, 'default') - .mockReturnValue([{ maxFeePerGas: BigInt(123), maxPriorityFeePerGas: BigInt(456) }, undefined, false]) - - jest.spyOn(chain, 'useCurrentChain').mockImplementation(() => - chainBuilder() - .with({ features: [FEATURES.EIP1559] }) - .build(), - ) - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([mockPendingSafe, mockSetPendingSafe]) - - const createSafeSpy = jest.spyOn(logic, 'createNewSafe').mockReturnValue(Promise.resolve({} as Safe)) - - renderHook(() => useSafeCreation(mockStatus, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).toHaveBeenCalled() - - const { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = createSafeSpy.mock.calls[0][1].options || {} - - expect(maxFeePerGas).toBe('123') - expect(maxPriorityFeePerGas).toBe('456') - - expect(gasPrice).toBeUndefined() - }) - }) - - it('should create a safe with no gas params if the gas estimation threw, there is no txHash and status is AWAITING', async () => { - jest.spyOn(gasPrice, 'default').mockReturnValue([undefined, Error('Error for testing'), false]) - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([mockPendingSafe, mockSetPendingSafe]) - - const createSafeSpy = jest.spyOn(logic, 'createNewSafe').mockReturnValue(Promise.resolve({} as Safe)) - - renderHook(() => useSafeCreation(mockStatus, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).toHaveBeenCalled() - - const { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = createSafeSpy.mock.calls[0][1].options || {} - - expect(gasPrice).toBeUndefined() - expect(maxFeePerGas).toBeUndefined() - expect(maxPriorityFeePerGas).toBeUndefined() - }) - }) - - it('should not create a safe if there is no txHash, status is AWAITING but gas is loading', async () => { - jest.spyOn(gasPrice, 'default').mockReturnValue([undefined, undefined, true]) - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([mockPendingSafe, mockSetPendingSafe]) - - const createSafeSpy = jest.spyOn(logic, 'createNewSafe').mockReturnValue(Promise.resolve({} as Safe)) - - renderHook(() => useSafeCreation(mockStatus, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - }) - - it('should not create a safe if the status is not AWAITING', async () => { - const createSafeSpy = jest.spyOn(logic, 'createNewSafe') - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([mockPendingSafe, mockSetPendingSafe]) - - renderHook(() => useSafeCreation(SafeCreationStatus.WALLET_REJECTED, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - - renderHook(() => useSafeCreation(SafeCreationStatus.PROCESSING, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - - renderHook(() => useSafeCreation(SafeCreationStatus.ERROR, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - - renderHook(() => useSafeCreation(SafeCreationStatus.REVERTED, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - - renderHook(() => useSafeCreation(SafeCreationStatus.TIMEOUT, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - - renderHook(() => useSafeCreation(SafeCreationStatus.SUCCESS, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - - renderHook(() => useSafeCreation(SafeCreationStatus.INDEXED, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - - renderHook(() => useSafeCreation(SafeCreationStatus.INDEX_FAILED, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - }) - - it('should not create a safe if there is a txHash', async () => { - const createSafeSpy = jest.spyOn(logic, 'createNewSafe') - jest - .spyOn(usePendingSafe, 'usePendingSafe') - .mockReturnValue([{ ...mockPendingSafe, txHash: '0x123' }, mockSetPendingSafe]) - - renderHook(() => useSafeCreation(SafeCreationStatus.AWAITING, mockSetStatus, false)) - - await waitFor(() => { - expect(createSafeSpy).not.toHaveBeenCalled() - }) - }) - - it('should watch a tx if there is a txHash and a tx object', async () => { - const watchSafeTxSpy = jest.spyOn(logic, 'checkSafeCreationTx') - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([ - { - ...mockPendingSafe, - txHash: '0x123', - tx: { - data: '0x', - from: '0x1234', - nonce: 0, - startBlock: 0, - to: '0x456', - value: BigInt(0), - }, - }, - mockSetPendingSafe, - ]) - renderHook(() => useSafeCreation(mockStatus, mockSetStatus, false)) - - await waitFor(() => { - expect(watchSafeTxSpy).toHaveBeenCalledTimes(1) - }) - }) - - it('should watch a tx even if no wallet is connected', async () => { - jest.spyOn(wallet, 'default').mockReturnValue(null) - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([ - { - ...mockPendingSafe, - txHash: '0x123', - tx: { - data: '0x', - from: '0x1234', - nonce: 0, - startBlock: 0, - to: '0x456', - value: BigInt(0), - }, - }, - mockSetPendingSafe, - ]) - const watchSafeTxSpy = jest.spyOn(logic, 'checkSafeCreationTx') - - renderHook(() => useSafeCreation(mockStatus, mockSetStatus, false)) - - await waitFor(() => { - expect(watchSafeTxSpy).toHaveBeenCalledTimes(1) - }) - }) - - it('should not watch a tx if there is no txHash', async () => { - const watchSafeTxSpy = jest.spyOn(logic, 'checkSafeCreationTx') - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([mockPendingSafe, mockSetPendingSafe]) - renderHook(() => useSafeCreation(mockStatus, mockSetStatus, false)) - - await waitFor(() => { - expect(watchSafeTxSpy).not.toHaveBeenCalled() - }) - }) - - it('should not watch a tx if there is no tx object', async () => { - const watchSafeTxSpy = jest.spyOn(logic, 'checkSafeCreationTx') - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([ - { - ...mockPendingSafe, - tx: { - data: '0x', - from: '0x1234', - nonce: 0, - startBlock: 0, - to: '0x456', - value: BigInt(0), - }, - }, - mockSetPendingSafe, - ]) - renderHook(() => useSafeCreation(mockStatus, mockSetStatus, false)) - - await waitFor(() => { - expect(watchSafeTxSpy).not.toHaveBeenCalled() - }) - }) - - it('should set a PROCESSING state when watching a tx', async () => { - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([ - { - ...mockPendingSafe, - txHash: '0x123', - tx: { - data: '0x', - from: '0x1234', - nonce: 0, - startBlock: 0, - to: '0x456', - value: BigInt(0), - }, - }, - mockSetPendingSafe, - ]) - - renderHook(() => useSafeCreation(mockStatus, mockSetStatus, false)) - - await waitFor(() => { - expect(mockSetStatus).toHaveBeenCalledWith(SafeCreationStatus.PROCESSING) - }) - }) - - it('should set a PROCESSING state and monitor relay taskId after successfully tx relay', async () => { - jest.spyOn(logic, 'relaySafeCreation').mockResolvedValue('0x456') - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([ - { - ...mockPendingSafe, - }, - mockSetPendingSafe, - ]) - const txMonitorSpy = jest.spyOn(txMonitor, 'waitForCreateSafeTx').mockImplementation(jest.fn()) - - const initialStatus = SafeCreationStatus.PROCESSING - - renderHook(() => useSafeCreation(initialStatus, mockSetStatus, true)) - - await waitFor(() => { - expect(mockSetStatus).toHaveBeenCalledWith(SafeCreationStatus.PROCESSING) - expect(txMonitorSpy).toHaveBeenCalledWith('0x456', expect.anything()) - }) - }) -}) diff --git a/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreationEffects.test.ts b/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreationEffects.test.ts deleted file mode 100644 index 2c878565a0..0000000000 --- a/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreationEffects.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { renderHook } from '@/tests/test-utils' -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' -import { type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import * as web3 from '@/hooks/wallets/web3' -import * as pendingSafe from '@/components/new-safe/create/logic' -import * as usePendingSafe from '@/components/new-safe/create/steps/StatusStep/usePendingSafe' -import * as addressbook from '@/components/new-safe/create/logic/address-book' -import useSafeCreationEffects from '@/components/new-safe/create/steps/StatusStep/useSafeCreationEffects' -import type { PendingSafeData } from '@/components/new-safe/create/types' -import { toBeHex, BrowserProvider } from 'ethers' -import { MockEip1193Provider } from '@/tests/mocks/providers' - -describe('useSafeCreationEffects', () => { - beforeEach(() => { - jest.resetAllMocks() - jest.spyOn(pendingSafe, 'pollSafeInfo').mockImplementation(jest.fn(() => Promise.resolve({} as SafeInfo))) - jest.spyOn(addressbook, 'updateAddressBook').mockReturnValue(() => {}) - - const mockProvider: BrowserProvider = new BrowserProvider(MockEip1193Provider) - jest.spyOn(web3, 'useWeb3').mockImplementation(() => mockProvider) - }) - - it('should clear the tx hash if it exists on ERROR or REVERTED', () => { - const setStatusSpy = jest.fn() - const setPendingSafeSpy = jest.fn() - jest - .spyOn(usePendingSafe, 'usePendingSafe') - .mockReturnValue([{ txHash: '0x123' } as PendingSafeData, setPendingSafeSpy]) - - renderHook(() => - useSafeCreationEffects({ - status: SafeCreationStatus.ERROR, - setStatus: setStatusSpy, - }), - ) - - expect(setPendingSafeSpy).toHaveBeenCalled() - }) - - it('should not clear the tx hash if it doesnt exist on ERROR or REVERTED', () => { - const setStatusSpy = jest.fn() - const setPendingSafeSpy = jest.fn() - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([{} as PendingSafeData, setPendingSafeSpy]) - renderHook(() => - useSafeCreationEffects({ - status: SafeCreationStatus.ERROR, - setStatus: setStatusSpy, - }), - ) - - expect(setPendingSafeSpy).not.toHaveBeenCalled() - }) - - it('should poll safe info on SUCCESS', () => { - const pollSafeInfoSpy = jest.spyOn(pendingSafe, 'pollSafeInfo') - const setStatusSpy = jest.fn() - const setPendingSafeSpy = jest.fn() - jest - .spyOn(usePendingSafe, 'usePendingSafe') - .mockReturnValue([{ safeAddress: toBeHex('0x123', 20) } as PendingSafeData, setPendingSafeSpy]) - renderHook(() => - useSafeCreationEffects({ - status: SafeCreationStatus.SUCCESS, - setStatus: setStatusSpy, - }), - ) - - expect(pollSafeInfoSpy).toHaveBeenCalled() - }) - - it('should not poll safe info on SUCCESS if there is no safe address', () => { - const pollSafeInfoSpy = jest.spyOn(pendingSafe, 'pollSafeInfo') - const setStatusSpy = jest.fn() - const setPendingSafeSpy = jest.fn() - jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([{} as PendingSafeData, setPendingSafeSpy]) - renderHook(() => - useSafeCreationEffects({ - status: SafeCreationStatus.SUCCESS, - setStatus: setStatusSpy, - }), - ) - - expect(pollSafeInfoSpy).not.toHaveBeenCalled() - }) -}) diff --git a/src/components/new-safe/create/steps/StatusStep/index.tsx b/src/components/new-safe/create/steps/StatusStep/index.tsx index 59920ed04d..2a6b01ac0d 100644 --- a/src/components/new-safe/create/steps/StatusStep/index.tsx +++ b/src/components/new-safe/create/steps/StatusStep/index.tsx @@ -1,75 +1,64 @@ -import { useCallback, useEffect, useState } from 'react' -import { Box, Button, Divider, Paper, Tooltip, Typography } from '@mui/material' -import { useRouter } from 'next/router' - -import Track from '@/components/common/Track' -import { CREATE_SAFE_EVENTS } from '@/services/analytics/events/createLoadSafe' -import StatusMessage from '@/components/new-safe/create/steps/StatusStep/StatusMessage' -import useWallet from '@/hooks/wallets/useWallet' -import useIsWrongChain from '@/hooks/useIsWrongChain' -import type { NewSafeFormData } from '@/components/new-safe/create' +import { useCounter } from '@/components/common/Notifications/useCounter' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' -import useSafeCreationEffects from '@/components/new-safe/create/steps/StatusStep/useSafeCreationEffects' -import { SafeCreationStatus, useSafeCreation } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' -import StatusStepper from '@/components/new-safe/create/steps/StatusStep/StatusStepper' -import { OPEN_SAFE_LABELS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' +import type { NewSafeFormData } from '@/components/new-safe/create' import { getRedirect } from '@/components/new-safe/create/logic' -import layoutCss from '@/components/new-safe/create/styles.module.css' -import { AppRoutes } from '@/config/routes' +import { updateAddressBook } from '@/components/new-safe/create/logic/address-book' +import StatusMessage from '@/components/new-safe/create/steps/StatusStep/StatusMessage' +import useUndeployedSafe from '@/components/new-safe/create/steps/StatusStep/useUndeployedSafe' import lightPalette from '@/components/theme/lightPalette' +import { AppRoutes } from '@/config/routes' +import { safeCreationPendingStatuses } from '@/features/counterfactual/hooks/usePendingSafeStatuses' +import { SafeCreationEvent, safeCreationSubscribe } from '@/features/counterfactual/services/safeCreationEvents' import { useCurrentChain } from '@/hooks/useChains' -import { usePendingSafe } from './usePendingSafe' +import Rocket from '@/public/images/common/rocket.svg' +import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' +import { useAppDispatch } from '@/store' +import { Alert, AlertTitle, Box, Button, Paper, Stack, SvgIcon, Typography } from '@mui/material' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' import useSyncSafeCreationStep from '../../useSyncSafeCreationStep' -export const getInitialCreationStatus = (willRelay: boolean): SafeCreationStatus => - willRelay ? SafeCreationStatus.PROCESSING : SafeCreationStatus.AWAITING +const SPEED_UP_THRESHOLD_IN_SECONDS = 15 -export const CreateSafeStatus = ({ data, setProgressColor, setStep }: StepRenderProps) => { +export const CreateSafeStatus = ({ + data, + setProgressColor, + setStep, + setStepData, +}: StepRenderProps) => { + const [status, setStatus] = useState(SafeCreationEvent.PROCESSING) + const [safeAddress, pendingSafe] = useUndeployedSafe() const router = useRouter() - const chainInfo = useCurrentChain() - const chainPrefix = chainInfo?.shortName || '' - const wallet = useWallet() - const isWrongChain = useIsWrongChain() - const isConnected = wallet && !isWrongChain - const [pendingSafe, setPendingSafe] = usePendingSafe() - useSyncSafeCreationStep(setStep) - - // The willRelay flag can come from the previous step or from local storage - const willRelay = !!(data.willRelay || pendingSafe?.willRelay) - const initialStatus = getInitialCreationStatus(willRelay) - const [status, setStatus] = useState(initialStatus) - - const { handleCreateSafe } = useSafeCreation(status, setStatus, willRelay) - - useSafeCreationEffects({ - status, - setStatus, - }) + const chain = useCurrentChain() + const dispatch = useAppDispatch() - const onClose = useCallback(() => { - setPendingSafe(undefined) + const counter = useCounter(pendingSafe?.status.submittedAt) - router.push(AppRoutes.welcome.index) - }, [router, setPendingSafe]) + const isError = status === SafeCreationEvent.FAILED || status === SafeCreationEvent.REVERTED - const handleRetry = useCallback(() => { - setStatus(initialStatus) - void handleCreateSafe() - }, [handleCreateSafe, initialStatus]) + useSyncSafeCreationStep(setStep) - const onFinish = useCallback(() => { - trackEvent(CREATE_SAFE_EVENTS.GET_STARTED) + useEffect(() => { + const unsubFns = Object.entries(safeCreationPendingStatuses).map(([event]) => + safeCreationSubscribe(event as SafeCreationEvent, async () => { + setStatus(event as SafeCreationEvent) + }), + ) + + return () => { + unsubFns.forEach((unsub) => unsub()) + } + }, []) - const { safeAddress } = pendingSafe || {} + useEffect(() => { + if (!chain || !safeAddress) return - if (safeAddress) { - setPendingSafe(undefined) - router.push(getRedirect(chainPrefix, safeAddress, router.query?.safeViewRedirectURL)) + if (status === SafeCreationEvent.SUCCESS) { + dispatch(updateAddressBook(chain.chainId, safeAddress, data.name, data.owners, data.threshold)) + router.push(getRedirect(chain.shortName, safeAddress, router.query?.safeViewRedirectURL)) } - }, [chainPrefix, pendingSafe, router, setPendingSafe]) - - const displaySafeLink = status >= SafeCreationStatus.INDEXED - const isError = status >= SafeCreationStatus.WALLET_REJECTED && status <= SafeCreationStatus.TIMEOUT + }, [dispatch, chain, data.name, data.owners, data.threshold, router, safeAddress, status]) useEffect(() => { if (!setProgressColor) return @@ -81,63 +70,64 @@ export const CreateSafeStatus = ({ data, setProgressColor, setStep }: StepRender } }, [isError, setProgressColor]) + const tryAgain = () => { + trackEvent(CREATE_SAFE_EVENTS.RETRY_CREATE_SAFE) + + if (!pendingSafe) { + setStep(0) + return + } + + setProgressColor?.(lightPalette.secondary.main) + setStep(2) + setStepData?.({ + owners: pendingSafe.props.safeAccountConfig.owners.map((owner) => ({ name: '', address: owner })), + name: '', + threshold: pendingSafe.props.safeAccountConfig.threshold, + saltNonce: Number(pendingSafe.props.safeDeploymentConfig?.saltNonce), + safeAddress, + }) + } + + const onCancel = () => { + trackEvent(CREATE_SAFE_EVENTS.CANCEL_CREATE_SAFE) + } + return ( - - - - - {!isError && pendingSafe && ( - <> - - - - - - )} - - {displaySafeLink && ( - <> - - - - - - - - )} - - {isError && ( - <> - - - - - - - - - - - - - - - - - )} + + + + )} + ) } diff --git a/src/components/new-safe/create/steps/StatusStep/usePendingSafe.ts b/src/components/new-safe/create/steps/StatusStep/usePendingSafe.ts deleted file mode 100644 index 08c3ac543e..0000000000 --- a/src/components/new-safe/create/steps/StatusStep/usePendingSafe.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useCurrentChain } from '@/hooks/useChains' -import useLocalStorage from '@/services/local-storage/useLocalStorage' -import { useCallback } from 'react' -import type { PendingSafeByChain, PendingSafeData } from '../../types' - -const SAFE_PENDING_CREATION_STORAGE_KEY = 'pendingSafe_v2' - -export const usePendingSafe = (): [PendingSafeData | undefined, (safe: PendingSafeData | undefined) => void] => { - const [pendingSafes, setPendingSafes] = useLocalStorage(SAFE_PENDING_CREATION_STORAGE_KEY) - - const chainInfo = useCurrentChain() - - const pendingSafe = chainInfo && pendingSafes?.[chainInfo.chainId] - const setPendingSafe = useCallback( - (safe: PendingSafeData | undefined) => { - if (!chainInfo?.chainId) { - return - } - - // Always copy the object because useLocalStorage does not check for deep equality when writing back to ls - const newPendingSafes = pendingSafes ? { ...pendingSafes } : {} - newPendingSafes[chainInfo.chainId] = safe - setPendingSafes(newPendingSafes) - }, - [chainInfo?.chainId, pendingSafes, setPendingSafes], - ) - - return [pendingSafe, setPendingSafe] -} diff --git a/src/components/new-safe/create/steps/StatusStep/useSafeCreation.ts b/src/components/new-safe/create/steps/StatusStep/useSafeCreation.ts deleted file mode 100644 index b0fae21862..0000000000 --- a/src/components/new-safe/create/steps/StatusStep/useSafeCreation.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { Dispatch, SetStateAction } from 'react' -import { useCallback, useEffect, useState } from 'react' -import { useWeb3, useWeb3ReadOnly } from '@/hooks/wallets/web3' -import { useCurrentChain } from '@/hooks/useChains' -import useWallet from '@/hooks/wallets/useWallet' -import type { EthersError } from '@/utils/ethers-utils' -import { getInitialCreationStatus } from '@/components/new-safe/create/steps/StatusStep/index' -import type { PendingSafeTx } from '@/components/new-safe/create/types' -import { - createNewSafe, - getSafeDeployProps, - checkSafeCreationTx, - getSafeCreationTxInfo, - handleSafeCreationError, - SAFE_CREATION_ERROR_KEY, - showSafeCreationError, - relaySafeCreation, - estimateSafeCreationGas, -} from '@/components/new-safe/create/logic' -import { useAppDispatch } from '@/store' -import { closeByGroupKey } from '@/store/notificationsSlice' -import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' -import { waitForCreateSafeTx } from '@/services/tx/txMonitor' -import useGasPrice from '@/hooks/useGasPrice' -import { hasFeature } from '@/utils/chains' -import { FEATURES } from '@/utils/chains' -import type { DeploySafeProps } from '@safe-global/protocol-kit' -import { usePendingSafe } from './usePendingSafe' - -export enum SafeCreationStatus { - AWAITING, - PROCESSING, - WALLET_REJECTED, - ERROR, - REVERTED, - TIMEOUT, - SUCCESS, - INDEXED, - INDEX_FAILED, -} - -export const useSafeCreation = ( - status: SafeCreationStatus, - setStatus: Dispatch>, - willRelay: boolean, -) => { - const [isCreating, setIsCreating] = useState(false) - const [isWatching, setIsWatching] = useState(false) - const dispatch = useAppDispatch() - const [pendingSafe, setPendingSafe] = usePendingSafe() - - const wallet = useWallet() - const provider = useWeb3() - const web3ReadOnly = useWeb3ReadOnly() - const chain = useCurrentChain() - const [gasPrice, , gasPriceLoading] = useGasPrice() - - const maxFeePerGas = gasPrice?.maxFeePerGas - const maxPriorityFeePerGas = gasPrice?.maxPriorityFeePerGas - - const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) - - const createSafeCallback = useCallback( - async (txHash: string, tx: PendingSafeTx) => { - setStatus(SafeCreationStatus.PROCESSING) - trackEvent(CREATE_SAFE_EVENTS.SUBMIT_CREATE_SAFE) - setPendingSafe(pendingSafe ? { ...pendingSafe, txHash, tx } : undefined) - }, - [setStatus, setPendingSafe, pendingSafe], - ) - - const handleCreateSafe = useCallback(async () => { - if (!pendingSafe || !provider || !chain || !wallet || isCreating || gasPriceLoading) return - - setIsCreating(true) - dispatch(closeByGroupKey({ groupKey: SAFE_CREATION_ERROR_KEY })) - - const { owners, threshold, saltNonce } = pendingSafe - const ownersAddresses = owners.map((owner) => owner.address) - - try { - if (willRelay) { - const taskId = await relaySafeCreation(chain, ownersAddresses, threshold, saltNonce) - - setPendingSafe(pendingSafe ? { ...pendingSafe, taskId } : undefined) - setStatus(SafeCreationStatus.PROCESSING) - waitForCreateSafeTx(taskId, setStatus) - } else { - const tx = await getSafeCreationTxInfo(provider, owners, threshold, saltNonce, chain, wallet) - - const safeParams = { - threshold, - owners: owners.map((owner) => owner.address), - saltNonce, - } - - const safeDeployProps = await getSafeDeployProps( - safeParams, - (txHash) => createSafeCallback(txHash, tx), - chain.chainId, - ) - - const gasLimit = await estimateSafeCreationGas(chain, provider, tx.from, safeParams) - - const options: DeploySafeProps['options'] = isEIP1559 - ? { - maxFeePerGas: maxFeePerGas?.toString(), - maxPriorityFeePerGas: maxPriorityFeePerGas?.toString(), - gasLimit: gasLimit.toString(), - } - : { gasPrice: maxFeePerGas?.toString(), gasLimit: gasLimit.toString() } - - await createNewSafe(provider, { - ...safeDeployProps, - options, - }) - setStatus(SafeCreationStatus.SUCCESS) - } - } catch (err) { - const _err = err as EthersError - const status = handleSafeCreationError(_err) - - setStatus(status) - - if (status !== SafeCreationStatus.SUCCESS) { - dispatch(showSafeCreationError(_err)) - } - } - - setIsCreating(false) - }, [ - chain, - createSafeCallback, - dispatch, - gasPriceLoading, - isCreating, - isEIP1559, - maxFeePerGas, - maxPriorityFeePerGas, - pendingSafe, - provider, - setPendingSafe, - setStatus, - wallet, - willRelay, - ]) - - const watchSafeTx = useCallback(async () => { - if (!pendingSafe?.tx || !pendingSafe?.txHash || !web3ReadOnly || isWatching) return - - setStatus(SafeCreationStatus.PROCESSING) - setIsWatching(true) - - const txStatus = await checkSafeCreationTx(web3ReadOnly, pendingSafe.tx, pendingSafe.txHash, dispatch) - setStatus(txStatus) - setIsWatching(false) - }, [isWatching, pendingSafe, web3ReadOnly, setStatus, dispatch]) - - // Create or monitor Safe creation - useEffect(() => { - if (status !== getInitialCreationStatus(willRelay)) return - - if (pendingSafe?.txHash && !isCreating) { - void watchSafeTx() - return - } - - if (pendingSafe?.taskId && !isCreating) { - waitForCreateSafeTx(pendingSafe.taskId, setStatus) - return - } - - void handleCreateSafe() - }, [ - handleCreateSafe, - isCreating, - pendingSafe?.taskId, - pendingSafe?.txHash, - setStatus, - status, - watchSafeTx, - willRelay, - ]) - - return { - handleCreateSafe, - } -} diff --git a/src/components/new-safe/create/steps/StatusStep/useSafeCreationEffects.ts b/src/components/new-safe/create/steps/StatusStep/useSafeCreationEffects.ts deleted file mode 100644 index e656fecd49..0000000000 --- a/src/components/new-safe/create/steps/StatusStep/useSafeCreationEffects.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Dispatch, SetStateAction } from 'react' -import { useEffect } from 'react' -import { pollSafeInfo } from '@/components/new-safe/create/logic' -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' -import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' -import { updateAddressBook } from '@/components/new-safe/create/logic/address-book' -import { useAppDispatch } from '@/store' -import useChainId from '@/hooks/useChainId' -import { usePendingSafe } from './usePendingSafe' -import { gtmSetSafeAddress } from '@/services/analytics/gtm' - -const useSafeCreationEffects = ({ - status, - setStatus, -}: { - status: SafeCreationStatus - setStatus: Dispatch> -}) => { - const dispatch = useAppDispatch() - const chainId = useChainId() - const [pendingSafe, setPendingSafe] = usePendingSafe() - - // Asynchronously wait for Safe creation - useEffect(() => { - if (status === SafeCreationStatus.SUCCESS && pendingSafe?.safeAddress) { - pollSafeInfo(chainId, pendingSafe.safeAddress) - .then(() => setStatus(SafeCreationStatus.INDEXED)) - .catch(() => setStatus(SafeCreationStatus.INDEX_FAILED)) - } - }, [chainId, pendingSafe?.safeAddress, status, setStatus]) - - // Warn about leaving the page before Safe creation - useEffect(() => { - if (status !== SafeCreationStatus.PROCESSING && status !== SafeCreationStatus.AWAITING) return - - const onBeforeUnload = (event: BeforeUnloadEvent) => { - event.preventDefault() - event.returnValue = 'Are you sure you want to leave before your Safe Account is fully created?' - return event.returnValue - } - - window.addEventListener('beforeunload', onBeforeUnload) - - return () => window.removeEventListener('beforeunload', onBeforeUnload) - }, [status]) - - // Add Safe to Added Safes and add owner and safe names to Address Book - useEffect(() => { - if (status === SafeCreationStatus.SUCCESS && pendingSafe?.safeAddress) { - dispatch( - updateAddressBook( - chainId, - pendingSafe.safeAddress, - pendingSafe.name, - pendingSafe.owners, - pendingSafe.threshold, - ), - ) - } - }, [status, chainId, dispatch, pendingSafe]) - - // Reset pending Safe on error - useEffect(() => { - if ( - status === SafeCreationStatus.WALLET_REJECTED || - status === SafeCreationStatus.ERROR || - status === SafeCreationStatus.REVERTED - ) { - if (pendingSafe?.txHash) { - setPendingSafe(pendingSafe ? { ...pendingSafe, txHash: undefined, tx: undefined } : undefined) - } - } - }, [pendingSafe, setPendingSafe, status]) - - // Tracking - useEffect(() => { - if (status === SafeCreationStatus.SUCCESS) { - pendingSafe?.safeAddress && gtmSetSafeAddress(pendingSafe.safeAddress) - trackEvent({ ...CREATE_SAFE_EVENTS.CREATED_SAFE, label: 'deployment' }) - return - } - - if (status === SafeCreationStatus.WALLET_REJECTED) { - trackEvent(CREATE_SAFE_EVENTS.REJECT_CREATE_SAFE) - return - } - }, [pendingSafe?.safeAddress, status]) -} - -export default useSafeCreationEffects diff --git a/src/components/new-safe/create/steps/StatusStep/useUndeployedSafe.ts b/src/components/new-safe/create/steps/StatusStep/useUndeployedSafe.ts new file mode 100644 index 0000000000..029019d86e --- /dev/null +++ b/src/components/new-safe/create/steps/StatusStep/useUndeployedSafe.ts @@ -0,0 +1,19 @@ +import { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { selectUndeployedSafes } from '@/features/counterfactual/store/undeployedSafesSlice' +import useChainId from '@/hooks/useChainId' +import { useAppSelector } from '@/store' + +// Returns the undeployed safe for the current network +const useUndeployedSafe = () => { + const chainId = useChainId() + const undeployedSafes = useAppSelector(selectUndeployedSafes) + const undeployedSafe = + undeployedSafes[chainId] && + Object.entries(undeployedSafes[chainId]).find((undeployedSafe) => { + return undeployedSafe[1].status.type === PayMethod.PayNow + }) + + return undeployedSafe || [] +} + +export default useUndeployedSafe diff --git a/src/components/new-safe/create/useSyncSafeCreationStep.ts b/src/components/new-safe/create/useSyncSafeCreationStep.ts index 90a32c0e7f..1e645d6c93 100644 --- a/src/components/new-safe/create/useSyncSafeCreationStep.ts +++ b/src/components/new-safe/create/useSyncSafeCreationStep.ts @@ -1,21 +1,22 @@ +import useUndeployedSafe from '@/components/new-safe/create/steps/StatusStep/useUndeployedSafe' import { useEffect } from 'react' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create/index' import useWallet from '@/hooks/wallets/useWallet' -import { usePendingSafe } from './steps/StatusStep/usePendingSafe' import useIsWrongChain from '@/hooks/useIsWrongChain' import { useRouter } from 'next/router' import { AppRoutes } from '@/config/routes' const useSyncSafeCreationStep = (setStep: StepRenderProps['setStep']) => { - const [pendingSafe] = usePendingSafe() + const [safeAddress, pendingSafe] = useUndeployedSafe() + const wallet = useWallet() const isWrongChain = useIsWrongChain() const router = useRouter() useEffect(() => { // Jump to the status screen if there is already a tx submitted - if (pendingSafe) { + if (pendingSafe && pendingSafe.status.status !== 'AWAITING_EXECUTION') { setStep(3) return } diff --git a/src/features/counterfactual/ActivateAccountFlow.tsx b/src/features/counterfactual/ActivateAccountFlow.tsx index b9c20b2221..4de9d8883a 100644 --- a/src/features/counterfactual/ActivateAccountFlow.tsx +++ b/src/features/counterfactual/ActivateAccountFlow.tsx @@ -88,7 +88,7 @@ const ActivateAccountFlow = () => { trackEvent(WALLET_EVENTS.ONCHAIN_INTERACTION) if (txHash) { - safeCreationDispatch(SafeCreationEvent.PROCESSING, { groupKey: CF_TX_GROUP_KEY, txHash }) + safeCreationDispatch(SafeCreationEvent.PROCESSING, { groupKey: CF_TX_GROUP_KEY, txHash, safeAddress }) } setTxFlow(undefined) } @@ -104,7 +104,7 @@ const ActivateAccountFlow = () => { try { if (willRelay) { const taskId = await relaySafeCreation(chain, owners, threshold, Number(saltNonce!), safeVersion) - safeCreationDispatch(SafeCreationEvent.RELAYING, { groupKey: CF_TX_GROUP_KEY, taskId }) + safeCreationDispatch(SafeCreationEvent.RELAYING, { groupKey: CF_TX_GROUP_KEY, taskId, safeAddress }) onSubmit() } else { diff --git a/src/features/counterfactual/CounterfactualHooks.tsx b/src/features/counterfactual/CounterfactualHooks.tsx index 0eae26fa8e..d6be915487 100644 --- a/src/features/counterfactual/CounterfactualHooks.tsx +++ b/src/features/counterfactual/CounterfactualHooks.tsx @@ -1,16 +1,13 @@ import CounterfactualSuccessScreen from '@/features/counterfactual/CounterfactualSuccessScreen' import dynamic from 'next/dynamic' -import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' const LazyCounterfactual = dynamic(() => import('./LazyCounterfactual')) function CounterfactualHooks() { - const isCounterfactualSafe = useIsCounterfactualSafe() - return ( <> - {isCounterfactualSafe && } + ) } diff --git a/src/features/counterfactual/CounterfactualStatusButton.tsx b/src/features/counterfactual/CounterfactualStatusButton.tsx index e4440e38e7..1d09f7aa11 100644 --- a/src/features/counterfactual/CounterfactualStatusButton.tsx +++ b/src/features/counterfactual/CounterfactualStatusButton.tsx @@ -1,4 +1,4 @@ -import { PendingSafeStatus, selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import useSafeInfo from '@/hooks/useSafeInfo' import InfoIcon from '@/public/images/notifications/info.svg' import { useAppSelector } from '@/store' @@ -27,30 +27,28 @@ export const LoopIcon = (props: SvgIconProps) => { ) } -const processingStates = [PendingSafeStatus.PROCESSING, PendingSafeStatus.RELAYING] - const CounterfactualStatusButton = () => { const { safe, safeAddress } = useSafeInfo() const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, safe.chainId, safeAddress)) if (safe.deployed) return null - const processing = undeployedSafe && processingStates.includes(undeployedSafe.status.status) + const isActivating = undeployedSafe?.status.status !== 'AWAITING_EXECUTION' return ( - {processing ? : } + {isActivating ? : } ) diff --git a/src/features/counterfactual/CounterfactualSuccessScreen.tsx b/src/features/counterfactual/CounterfactualSuccessScreen.tsx index bd5312f0de..f798e5b99e 100644 --- a/src/features/counterfactual/CounterfactualSuccessScreen.tsx +++ b/src/features/counterfactual/CounterfactualSuccessScreen.tsx @@ -1,16 +1,23 @@ +import EthHashInfo from '@/components/common/EthHashInfo' import { safeCreationPendingStatuses } from '@/features/counterfactual/hooks/usePendingSafeStatuses' import { SafeCreationEvent, safeCreationSubscribe } from '@/features/counterfactual/services/safeCreationEvents' +import { useCurrentChain } from '@/hooks/useChains' import { useEffect, useState } from 'react' import { Box, Button, Dialog, DialogContent, Typography } from '@mui/material' import CheckRoundedIcon from '@mui/icons-material/CheckRounded' const CounterfactualSuccessScreen = () => { const [open, setOpen] = useState(false) + const [safeAddress, setSafeAddress] = useState() + const chain = useCurrentChain() useEffect(() => { const unsubFns = Object.entries(safeCreationPendingStatuses).map(([event]) => - safeCreationSubscribe(event as SafeCreationEvent, async () => { - if (event === SafeCreationEvent.INDEXED) setOpen(true) + safeCreationSubscribe(event as SafeCreationEvent, async (detail) => { + if (event === SafeCreationEvent.INDEXED) { + setSafeAddress(detail.safeAddress) + setOpen(true) + } }), ) @@ -42,17 +49,23 @@ const CounterfactualSuccessScreen = () => { > + - Account is activated! - - - Your Safe Account was successfully deployed on chain. You can continue making improvements to your account - setup and security. + Your account is all set! + Start your journey to the smart account security now. + Use your address to receive funds {chain?.chainName && `on ${chain.chainName}`}. + + {safeAddress && ( + + + + )} + diff --git a/src/features/counterfactual/hooks/usePendingSafeStatuses.ts b/src/features/counterfactual/hooks/usePendingSafeStatuses.ts index 783dc89244..208a8f0cea 100644 --- a/src/features/counterfactual/hooks/usePendingSafeStatuses.ts +++ b/src/features/counterfactual/hooks/usePendingSafeStatuses.ts @@ -7,7 +7,7 @@ import { import { PendingSafeStatus, removeUndeployedSafe, - selectUndeployedSafe, + selectUndeployedSafes, updateUndeployedSafeStatus, } from '@/features/counterfactual/store/undeployedSafesSlice' import { checkSafeActionViaRelay, checkSafeActivation } from '@/features/counterfactual/utils' @@ -16,7 +16,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { isSmartContract, useWeb3ReadOnly } from '@/hooks/wallets/web3' import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' import { useAppDispatch, useAppSelector } from '@/store' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' export const safeCreationPendingStatuses: Partial> = { [SafeCreationEvent.PROCESSING]: PendingSafeStatus.PROCESSING, @@ -28,9 +28,7 @@ export const safeCreationPendingStatuses: Partial { - const chainId = useChainId() - const { safeAddress } = useSafeInfo() - const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, safeAddress)) + const undeployedSafesByChain = useAppSelector(selectUndeployedSafes) const provider = useWeb3ReadOnly() const dispatch = useAppDispatch() @@ -39,43 +37,49 @@ const usePendingSafeMonitor = (): void => { // Monitor pending safe creation mining/validating progress useEffect(() => { - if (undeployedSafe?.status.status === PendingSafeStatus.AWAITING_EXECUTION) { - monitoredSafes.current[safeAddress] = false - } + Object.entries(undeployedSafesByChain).forEach(([chainId, undeployedSafes]) => { + Object.entries(undeployedSafes).forEach(([safeAddress, undeployedSafe]) => { + if (undeployedSafe?.status.status === PendingSafeStatus.AWAITING_EXECUTION) { + monitoredSafes.current[safeAddress] = false + } - if (!provider || !undeployedSafe || undeployedSafe.status.status === PendingSafeStatus.AWAITING_EXECUTION) { - return - } + if (!provider || !undeployedSafe || undeployedSafe.status.status === PendingSafeStatus.AWAITING_EXECUTION) { + return + } - const monitorPendingSafe = async () => { - const { - status: { status, txHash, taskId, startBlock }, - } = undeployedSafe + const monitorPendingSafe = async () => { + const { + status: { status, txHash, taskId, startBlock }, + } = undeployedSafe - const isProcessing = status === PendingSafeStatus.PROCESSING && txHash !== undefined - const isRelaying = status === PendingSafeStatus.RELAYING && taskId !== undefined - const isMonitored = monitoredSafes.current[safeAddress] + const isProcessing = status === PendingSafeStatus.PROCESSING && txHash !== undefined + const isRelaying = status === PendingSafeStatus.RELAYING && taskId !== undefined + const isMonitored = monitoredSafes.current[safeAddress] - if ((!isProcessing && !isRelaying) || isMonitored) return + if ((!isProcessing && !isRelaying) || isMonitored) return - monitoredSafes.current[safeAddress] = true + monitoredSafes.current[safeAddress] = true - if (isProcessing) { - checkSafeActivation(provider, txHash, safeAddress, startBlock) - } + if (isProcessing) { + checkSafeActivation(provider, txHash, safeAddress, startBlock) + } - if (isRelaying) { - checkSafeActionViaRelay(taskId, safeAddress) - } - } + if (isRelaying) { + checkSafeActionViaRelay(taskId, safeAddress) + } + } - monitorPendingSafe() - }, [dispatch, provider, safeAddress, undeployedSafe]) + monitorPendingSafe() + }) + }) + }, [dispatch, provider, undeployedSafesByChain]) } const usePendingSafeStatus = (): void => { + const [safeAddress, setSafeAddress] = useState('') const dispatch = useAppDispatch() - const { safe, safeAddress } = useSafeInfo() + const { safe } = useSafeInfo() + const chainId = useChainId() const provider = useWeb3ReadOnly() usePendingSafeMonitor() @@ -103,25 +107,35 @@ const usePendingSafeStatus = (): void => { useEffect(() => { const unsubFns = Object.entries(safeCreationPendingStatuses).map(([event, status]) => safeCreationSubscribe(event as SafeCreationEvent, async (detail) => { + setSafeAddress(detail.safeAddress) + if (event === SafeCreationEvent.SUCCESS) { // TODO: Possible to add a label with_tx, without_tx? trackEvent(CREATE_SAFE_EVENTS.ACTIVATED_SAFE) - pollSafeInfo(safe.chainId, safeAddress).finally(() => { - safeCreationDispatch(SafeCreationEvent.INDEXED, { groupKey: detail.groupKey, safeAddress }) + pollSafeInfo(chainId, detail.safeAddress).finally(() => { + safeCreationDispatch(SafeCreationEvent.INDEXED, { + groupKey: detail.groupKey, + safeAddress: detail.safeAddress, + }) }) return } if (event === SafeCreationEvent.INDEXED) { - dispatch(removeUndeployedSafe({ chainId: safe.chainId, address: safeAddress })) + dispatch(removeUndeployedSafe({ chainId, address: detail.safeAddress })) } if (status === null) { dispatch( updateUndeployedSafeStatus({ - chainId: safe.chainId, - address: safeAddress, - status: { status: PendingSafeStatus.AWAITING_EXECUTION }, + chainId, + address: detail.safeAddress, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + startBlock: undefined, + txHash: undefined, + submittedAt: undefined, + }, }), ) return @@ -129,13 +143,14 @@ const usePendingSafeStatus = (): void => { dispatch( updateUndeployedSafeStatus({ - chainId: safe.chainId, - address: safeAddress, + chainId, + address: detail.safeAddress, status: { status, txHash: 'txHash' in detail ? detail.txHash : undefined, taskId: 'taskId' in detail ? detail.taskId : undefined, startBlock: await provider?.getBlockNumber(), + submittedAt: Date.now(), }, }), ) @@ -145,7 +160,7 @@ const usePendingSafeStatus = (): void => { return () => { unsubFns.forEach((unsub) => unsub()) } - }, [safe.chainId, dispatch, safeAddress, provider]) + }, [chainId, dispatch, provider]) } export default usePendingSafeStatus diff --git a/src/features/counterfactual/services/safeCreationEvents.ts b/src/features/counterfactual/services/safeCreationEvents.ts index 29e92c40e1..883f601562 100644 --- a/src/features/counterfactual/services/safeCreationEvents.ts +++ b/src/features/counterfactual/services/safeCreationEvents.ts @@ -13,10 +13,12 @@ export interface SafeCreationEvents { [SafeCreationEvent.PROCESSING]: { groupKey: string txHash: string + safeAddress: string } [SafeCreationEvent.RELAYING]: { groupKey: string taskId: string + safeAddress: string } [SafeCreationEvent.SUCCESS]: { groupKey: string @@ -29,10 +31,12 @@ export interface SafeCreationEvents { [SafeCreationEvent.FAILED]: { groupKey: string error: Error + safeAddress: string } [SafeCreationEvent.REVERTED]: { groupKey: string error: Error + safeAddress: string } } diff --git a/src/features/counterfactual/store/undeployedSafesSlice.ts b/src/features/counterfactual/store/undeployedSafesSlice.ts index 40b1b6bdb1..0196052e95 100644 --- a/src/features/counterfactual/store/undeployedSafesSlice.ts +++ b/src/features/counterfactual/store/undeployedSafesSlice.ts @@ -1,3 +1,4 @@ +import type { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { type RootState } from '@/store' import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import type { PredictedSafeProps } from '@safe-global/protocol-kit' @@ -10,9 +11,13 @@ export enum PendingSafeStatus { type UndeployedSafeStatus = { status: PendingSafeStatus + type: PayMethod txHash?: string taskId?: string startBlock?: number + submittedAt?: number + signerAddress?: string + signerNonce?: number | null } export type UndeployedSafe = { @@ -32,9 +37,9 @@ export const undeployedSafesSlice = createSlice({ reducers: { addUndeployedSafe: ( state, - action: PayloadAction<{ chainId: string; address: string; safeProps: PredictedSafeProps }>, + action: PayloadAction<{ chainId: string; address: string; type: PayMethod; safeProps: PredictedSafeProps }>, ) => { - const { chainId, address, safeProps } = action.payload + const { chainId, address, type, safeProps } = action.payload if (!state[chainId]) { state[chainId] = {} @@ -44,13 +49,14 @@ export const undeployedSafesSlice = createSlice({ props: safeProps, status: { status: PendingSafeStatus.AWAITING_EXECUTION, + type, }, } }, updateUndeployedSafeStatus: ( state, - action: PayloadAction<{ chainId: string; address: string; status: UndeployedSafeStatus }>, + action: PayloadAction<{ chainId: string; address: string; status: Omit }>, ) => { const { chainId, address, status } = action.payload @@ -58,7 +64,10 @@ export const undeployedSafesSlice = createSlice({ state[chainId][address] = { props: state[chainId][address].props, - status, + status: { + ...state[chainId][address].status, + ...status, + }, } }, diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts index 5812ba6eed..8b33717818 100644 --- a/src/features/counterfactual/utils.ts +++ b/src/features/counterfactual/utils.ts @@ -1,7 +1,7 @@ import type { NewSafeFormData } from '@/components/new-safe/create' -import { CREATION_MODAL_QUERY_PARM } from '@/components/new-safe/create/logic' import { LATEST_SAFE_VERSION, POLLING_INTERVAL } from '@/config/constants' import { AppRoutes } from '@/config/routes' +import { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' @@ -68,11 +68,12 @@ export const dispatchTxExecutionAndDeploySafe = async ( // @ts-ignore TODO: Check why TransactionResponse type doesn't work result = await signer.sendTransaction({ ...deploymentTx, gasLimit: gas }) } catch (error) { - safeCreationDispatch(SafeCreationEvent.FAILED, { ...eventParams, error: asError(error) }) + safeCreationDispatch(SafeCreationEvent.FAILED, { ...eventParams, error: asError(error), safeAddress: '' }) throw error } - safeCreationDispatch(SafeCreationEvent.PROCESSING, { ...eventParams, txHash: result!.hash }) + // TODO: Probably need to pass the actual safe address + safeCreationDispatch(SafeCreationEvent.PROCESSING, { ...eventParams, txHash: result!.hash, safeAddress: '' }) return result!.hash } @@ -142,6 +143,7 @@ export const createCounterfactualSafe = ( const undeployedSafe = { chainId: chain.chainId, address: safeAddress, + type: PayMethod.PayLater, safeProps: { safeAccountConfig: props.safeAccountConfig, safeDeploymentConfig: { @@ -169,7 +171,7 @@ export const createCounterfactualSafe = ( ) return router.push({ pathname: AppRoutes.home, - query: { safe: `${chain.shortName}:${safeAddress}`, [CREATION_MODAL_QUERY_PARM]: true }, + query: { safe: `${chain.shortName}:${safeAddress}` }, }) } @@ -197,20 +199,17 @@ async function retryGetTransaction(provider: Provider, txHash: string, maxAttemp throw new Error('Transaction not found') } -// TODO: Reuse this for safe creation flow instead of checkSafeCreationTx export const checkSafeActivation = async ( provider: Provider, txHash: string, safeAddress: string, startBlock?: number, ) => { - const TIMEOUT_TIME = 2 * 60 * 1000 // 2 minutes - try { const txResponse = await retryGetTransaction(provider, txHash) const replaceableTx = startBlock ? txResponse.replaceableTransaction(startBlock) : txResponse - const receipt = await replaceableTx?.wait(1, TIMEOUT_TIME) + const receipt = await replaceableTx?.wait(1) /** The receipt should always be non-null as we require 1 confirmation */ if (receipt === null) { @@ -221,6 +220,7 @@ export const checkSafeActivation = async ( safeCreationDispatch(SafeCreationEvent.REVERTED, { groupKey: CF_TX_GROUP_KEY, error: new Error('Transaction reverted'), + safeAddress, }) } @@ -239,14 +239,23 @@ export const checkSafeActivation = async ( return } + if (didRevert(_err.receipt)) { + safeCreationDispatch(SafeCreationEvent.REVERTED, { + groupKey: CF_TX_GROUP_KEY, + error: new Error('Transaction reverted'), + safeAddress, + }) + return + } + safeCreationDispatch(SafeCreationEvent.FAILED, { groupKey: CF_TX_GROUP_KEY, error: _err, + safeAddress, }) } } -// TODO: Reuse this for safe creation flow instead of waitForCreateSafeTx export const checkSafeActionViaRelay = (taskId: string, safeAddress: string) => { const TIMEOUT_TIME = 2 * 60 * 1000 // 2 minutes @@ -273,6 +282,7 @@ export const checkSafeActionViaRelay = (taskId: string, safeAddress: string) => safeCreationDispatch(SafeCreationEvent.FAILED, { groupKey: CF_TX_GROUP_KEY, error: new Error('Transaction failed'), + safeAddress, }) break default: @@ -288,6 +298,7 @@ export const checkSafeActionViaRelay = (taskId: string, safeAddress: string) => safeCreationDispatch(SafeCreationEvent.FAILED, { groupKey: CF_TX_GROUP_KEY, error: new Error('Transaction failed'), + safeAddress, }) clearInterval(intervalId) diff --git a/src/hooks/coreSDK/safeCoreSDK.ts b/src/hooks/coreSDK/safeCoreSDK.ts index f5fb90c761..eeaaaf5638 100644 --- a/src/hooks/coreSDK/safeCoreSDK.ts +++ b/src/hooks/coreSDK/safeCoreSDK.ts @@ -1,6 +1,7 @@ import chains from '@/config/chains' import type { UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import { getWeb3ReadOnly } from '@/hooks/wallets/web3' +import { UncheckedJsonRpcSigner } from '@/utils/providers/UncheckedJsonRpcSigner' import { getSafeSingletonDeployment, getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' import ExternalStore from '@/services/ExternalStore' import { Gnosis_safe__factory } from '@/types/contracts' @@ -30,7 +31,7 @@ export function assertValidSafeVersion(safeVersio } export const createEthersAdapter = async (provider: BrowserProvider) => { - const signer = await provider.getSigner(0) + const signer = new UncheckedJsonRpcSigner(provider, (await provider.getSigner()).address) return new EthersAdapter({ ethers, signerOrProvider: signer, diff --git a/src/services/analytics/events/createLoadSafe.ts b/src/services/analytics/events/createLoadSafe.ts index 32e860e2a8..991c84d294 100644 --- a/src/services/analytics/events/createLoadSafe.ts +++ b/src/services/analytics/events/createLoadSafe.ts @@ -61,10 +61,6 @@ export const CREATE_SAFE_EVENTS = { action: 'Activated Safe', category: CREATE_SAFE_CATEGORY, }, - GET_STARTED: { - action: 'Load Safe', - category: CREATE_SAFE_CATEGORY, - }, OPEN_HINT: { action: 'Open Hint', category: CREATE_SAFE_CATEGORY, diff --git a/src/services/tx/__tests__/txMonitor.test.ts b/src/services/tx/__tests__/txMonitor.test.ts index c87a161c58..cc46409c63 100644 --- a/src/services/tx/__tests__/txMonitor.test.ts +++ b/src/services/tx/__tests__/txMonitor.test.ts @@ -3,14 +3,13 @@ import * as txEvents from '@/services/tx/txEvents' import * as txMonitor from '@/services/tx/txMonitor' import { act } from '@testing-library/react' -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' import { toBeHex } from 'ethers' import { MockEip1193Provider } from '@/tests/mocks/providers' import { BrowserProvider, type JsonRpcProvider, type TransactionReceipt } from 'ethers' import { faker } from '@faker-js/faker' import { SimpleTxWatcher } from '@/utils/SimpleTxWatcher' -const { waitForTx, waitForRelayedTx, waitForCreateSafeTx } = txMonitor +const { waitForTx, waitForRelayedTx } = txMonitor const provider = new BrowserProvider(MockEip1193Provider) as unknown as JsonRpcProvider @@ -243,134 +242,6 @@ describe('txMonitor', () => { }) }) }) - - describe('waitForCreateSafeTx', () => { - it("sets the status to SUCCESS if taskStatus 'ExecSuccess'", async () => { - const mockData = { - task: { - taskState: 'ExecSuccess', - }, - } - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockData)) - - const mockFetch = jest.spyOn(global, 'fetch') - const setStatusSpy = jest.fn() - - waitForCreateSafeTx('0x1', setStatusSpy) - - await act(() => { - jest.advanceTimersByTime(15_000 + 1) - }) - - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(setStatusSpy).toHaveBeenCalledWith(SafeCreationStatus.SUCCESS) - }) - - it("sets the status to ERROR if taskStatus 'ExecReverted'", async () => { - const mockData = { - task: { - taskState: 'ExecReverted', - }, - } - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockData)) - - const mockFetch = jest.spyOn(global, 'fetch') - const setStatusSpy = jest.fn() - - waitForCreateSafeTx('0x1', setStatusSpy) - - await act(() => { - jest.advanceTimersByTime(15_000 + 1) - }) - - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(setStatusSpy).toHaveBeenCalledWith(SafeCreationStatus.ERROR) - }) - - it("sets the status to ERROR if taskStatus 'Blacklisted'", async () => { - const mockData = { - task: { - taskState: 'Blacklisted', - }, - } - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockData)) - - const mockFetch = jest.spyOn(global, 'fetch') - const setStatusSpy = jest.fn() - - waitForCreateSafeTx('0x1', setStatusSpy) - - await act(() => { - jest.advanceTimersByTime(15_000 + 1) - }) - - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(setStatusSpy).toHaveBeenCalledWith(SafeCreationStatus.ERROR) - }) - - it("sets the status to ERROR if taskStatus 'Cancelled'", async () => { - const mockData = { - task: { - taskState: 'Cancelled', - }, - } - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockData)) - - const mockFetch = jest.spyOn(global, 'fetch') - const setStatusSpy = jest.fn() - - waitForCreateSafeTx('0x1', setStatusSpy) - - await act(() => { - jest.advanceTimersByTime(15_000 + 1) - }) - - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(setStatusSpy).toHaveBeenCalledWith(SafeCreationStatus.ERROR) - }) - - it("sets the status to ERROR if taskStatus 'NotFound'", async () => { - const mockData = { - task: { - taskState: 'NotFound', - }, - } - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockData)) - - const mockFetch = jest.spyOn(global, 'fetch') - const setStatusSpy = jest.fn() - - waitForCreateSafeTx('0x1', setStatusSpy) - - await act(() => { - jest.advanceTimersByTime(15_000 + 1) - }) - - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(setStatusSpy).toHaveBeenCalledWith(SafeCreationStatus.ERROR) - }) - - it('sets the status to ERROR if the tx relaying timed out', async () => { - const mockData = { - task: { - taskState: 'WaitingForConfirmation', - }, - } - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockData)) - - const mockFetch = jest.spyOn(global, 'fetch') - const setStatusSpy = jest.fn() - - waitForCreateSafeTx('0x1', setStatusSpy) - - await act(() => { - jest.advanceTimersByTime(3 * 60_000 + 1) - }) - - expect(mockFetch).toHaveBeenCalled() - expect(setStatusSpy).toHaveBeenCalledWith(SafeCreationStatus.ERROR) - }) - }) }) describe('getRemainingTimeout', () => { diff --git a/src/services/tx/txMonitor.ts b/src/services/tx/txMonitor.ts index f0dcc0d5d2..2931808128 100644 --- a/src/services/tx/txMonitor.ts +++ b/src/services/tx/txMonitor.ts @@ -4,7 +4,6 @@ import { txDispatch, TxEvent } from '@/services/tx/txEvents' import { POLLING_INTERVAL } from '@/config/constants' import { Errors, logError } from '@/services/exceptions' -import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' import { asError } from '../exceptions/utils' import { type JsonRpcProvider, type TransactionReceipt } from 'ethers' import { SimpleTxWatcher } from '@/utils/SimpleTxWatcher' @@ -205,41 +204,3 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s clearInterval(intervalId) }, WAIT_FOR_RELAY_TIMEOUT) } - -export const waitForCreateSafeTx = (taskId: string, setStatus: (value: SafeCreationStatus) => void): void => { - let intervalId: NodeJS.Timeout - let failAfterTimeoutId: NodeJS.Timeout - - intervalId = setInterval(async () => { - const status = await getRelayTxStatus(taskId) - - // 404 - if (!status) { - return - } - - switch (status.task.taskState) { - case TaskState.ExecSuccess: - setStatus(SafeCreationStatus.SUCCESS) - break - case TaskState.ExecReverted: - case TaskState.Blacklisted: - case TaskState.Cancelled: - case TaskState.NotFound: - setStatus(SafeCreationStatus.ERROR) - break - default: - // Don't clear interval as we're still waiting for the tx to be relayed - return - } - - clearTimeout(failAfterTimeoutId) - clearInterval(intervalId) - }, POLLING_INTERVAL) - - failAfterTimeoutId = setTimeout(() => { - setStatus(SafeCreationStatus.ERROR) - - clearInterval(intervalId) - }, WAIT_FOR_RELAY_TIMEOUT) -}