From 74ed3ceee470d92b42a93ae683a442839dc37da4 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Thu, 8 Feb 2024 10:56:58 +0100 Subject: [PATCH] [Counterfactual] Safe creation (#3180) * feat: Create counterfactual 1/1 safes * fix: Add feature flag * fix: Lint issues * fix: Use incremental saltNonce for all safe creations * fix: Replace useCounterfactualBalance hook with get function and write tests * refactor: Move creation logic out of Review component * fix: useLoadBalance check for undefined value * fix: Extract saltNonce, safeAddress calculation into a hook * refactor: Rename redux slice * fix: Show error message in case saltNonce can't be retrieved * fix: Disable create button if deploy props are loading * fix: Revert hook change and update comment --- .../new-safe/create/logic/utils.test.ts | 50 ++++++ src/components/new-safe/create/logic/utils.ts | 17 ++ .../create/steps/ReviewStep/index.tsx | 148 +++++++++++------- .../create/steps/ReviewStep/styles.module.css | 4 + .../PushNotificationsBanner.test.tsx | 53 ++----- .../__tests__/SafeModules.test.tsx | 15 +- .../__tests__/TransactionGuards.test.tsx | 20 ++- .../transactions/SingleTx/SingleTx.test.tsx | 54 ++++--- .../flows/SignMessage/SignMessage.test.tsx | 39 ++--- .../tx/SignOrExecuteForm/hooks.test.ts | 52 ++++-- .../counterfactual/__tests__/utils.test.ts | 83 ++++++++++ .../store/undeployedSafesSlice.ts | 52 ++++++ src/features/counterfactual/utils.ts | 96 ++++++++++++ .../__tests__/WalletConnectContext.test.tsx | 82 +++++----- .../__tests__/useCompatibilityWarning.test.ts | 8 +- src/hooks/__tests__/usePendingActions.test.ts | 12 +- src/hooks/__tests__/usePendingTxs.test.ts | 6 +- .../__tests__/useInitSafeCoreSDK.test.ts | 5 +- src/hooks/coreSDK/safeCoreSDK.ts | 12 +- src/hooks/coreSDK/useInitSafeCoreSDK.ts | 6 +- src/hooks/loadables/useLoadBalances.ts | 12 +- src/hooks/loadables/useLoadSafeInfo.ts | 19 ++- src/hooks/loadables/useLoadSafeMessages.ts | 8 +- src/hooks/loadables/useLoadTxHistory.ts | 4 +- src/hooks/loadables/useLoadTxQueue.ts | 4 +- src/hooks/useCollectibles.ts | 4 +- src/hooks/useSafeInfo.ts | 5 +- .../useSafeWalletProvider.test.tsx | 4 +- src/store/index.ts | 3 + src/store/safeInfoSlice.ts | 7 +- src/tests/builders/safe.ts | 8 + src/utils/chains.ts | 1 + 32 files changed, 637 insertions(+), 256 deletions(-) create mode 100644 src/components/new-safe/create/logic/utils.test.ts create mode 100644 src/components/new-safe/create/logic/utils.ts create mode 100644 src/features/counterfactual/__tests__/utils.test.ts create mode 100644 src/features/counterfactual/store/undeployedSafesSlice.ts create mode 100644 src/features/counterfactual/utils.ts diff --git a/src/components/new-safe/create/logic/utils.test.ts b/src/components/new-safe/create/logic/utils.test.ts new file mode 100644 index 0000000000..ffc1da67e3 --- /dev/null +++ b/src/components/new-safe/create/logic/utils.test.ts @@ -0,0 +1,50 @@ +import * as creationUtils from '@/components/new-safe/create/logic/index' +import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' +import * as web3Utils from '@/hooks/wallets/web3' +import { faker } from '@faker-js/faker' +import type { DeploySafeProps } from '@safe-global/protocol-kit' +import { BrowserProvider, type Eip1193Provider } from 'ethers' + +describe('getAvailableSaltNonce', () => { + jest.spyOn(creationUtils, 'computeNewSafeAddress').mockReturnValue(Promise.resolve(faker.finance.ethereumAddress())) + + let mockProvider: BrowserProvider + let mockDeployProps: DeploySafeProps + + beforeAll(() => { + mockProvider = new BrowserProvider(jest.fn() as unknown as Eip1193Provider) + mockDeployProps = { + safeAccountConfig: { + threshold: 1, + owners: [faker.finance.ethereumAddress()], + fallbackHandler: faker.finance.ethereumAddress(), + }, + } + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return initial nonce if no contract is deployed to the computed address', async () => { + jest.spyOn(web3Utils, 'isSmartContract').mockReturnValue(Promise.resolve(false)) + const initialNonce = faker.string.numeric() + + const result = await getAvailableSaltNonce(mockProvider, { ...mockDeployProps, saltNonce: initialNonce }) + + expect(result).toEqual(initialNonce) + }) + + it('should return an increased nonce if a contract is deployed to the computed address', async () => { + jest.spyOn(web3Utils, 'isSmartContract').mockReturnValueOnce(Promise.resolve(true)) + const initialNonce = faker.string.numeric() + + const result = await getAvailableSaltNonce(mockProvider, { ...mockDeployProps, saltNonce: initialNonce }) + + jest.spyOn(web3Utils, 'isSmartContract').mockReturnValueOnce(Promise.resolve(false)) + + const increasedNonce = (Number(initialNonce) + 1).toString() + + expect(result).toEqual(increasedNonce) + }) +}) diff --git a/src/components/new-safe/create/logic/utils.ts b/src/components/new-safe/create/logic/utils.ts new file mode 100644 index 0000000000..e31e01bfd1 --- /dev/null +++ b/src/components/new-safe/create/logic/utils.ts @@ -0,0 +1,17 @@ +import { computeNewSafeAddress } from '@/components/new-safe/create/logic/index' +import { isSmartContract } from '@/hooks/wallets/web3' +import type { DeploySafeProps } from '@safe-global/protocol-kit' +import type { BrowserProvider } from 'ethers' + +export const getAvailableSaltNonce = async (provider: BrowserProvider, props: DeploySafeProps): Promise => { + const safeAddress = await computeNewSafeAddress(provider, props) + const isContractDeployed = await isSmartContract(provider, safeAddress) + + // Safe is already deployed so we try the next saltNonce + if (isContractDeployed) { + return getAvailableSaltNonce(provider, { ...props, saltNonce: (Number(props.saltNonce) + 1).toString() }) + } + + // We know that there will be a saltNonce but the type has it as optional + return props.saltNonce! +} diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index b6c7ddfc99..74e8033c2f 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -1,11 +1,16 @@ +import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' import ErrorMessage from '@/components/tx/ErrorMessage' +import { createCounterfactualSafe } from '@/features/counterfactual/utils' import useWalletCanPay from '@/hooks/useWalletCanPay' +import { useAppDispatch } from '@/store' +import { FEATURES } from '@/utils/chains' +import { useRouter } from 'next/router' import { useMemo, useState } from 'react' import { Button, Grid, Typography, Divider, Box, Alert } from '@mui/material' import lightPalette from '@/components/theme/lightPalette' import ChainIndicator from '@/components/common/ChainIndicator' import EthHashInfo from '@/components/common/EthHashInfo' -import { useCurrentChain } from '@/hooks/useChains' +import { useCurrentChain, useHasFeature } from '@/hooks/useChains' import useGasPrice, { getTotalFee } from '@/hooks/useGasPrice' import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas' import { formatVisualAmount } from '@/utils/formatters' @@ -101,10 +106,13 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps Date.now(), []) const [_, setPendingSafe] = usePendingSafe() const [executionMethod, setExecutionMethod] = useState(ExecutionMethod.RELAY) + const [submitError, setSubmitError] = useState() + const isCounterfactualEnabled = useHasFeature(FEATURES.COUNTERFACTUAL) const ownerAddresses = useMemo(() => data.owners.map((owner) => owner.address), [data.owners]) const [minRelays] = useLeastRemainingRelays(ownerAddresses) @@ -117,9 +125,9 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps owner.address), threshold: data.threshold, - saltNonce, + saltNonce: Date.now(), // This is not the final saltNonce but easier to use and will only result in a slightly higher gas estimation } - }, [data.owners, data.threshold, saltNonce]) + }, [data.owners, data.threshold]) const { gasLimit } = useEstimateSafeCreationGas(safeParams) @@ -133,6 +141,9 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps 0.001' + // Only 1 out of 1 safe setups are supported for now + const isCounterfactual = data.threshold === 1 && data.owners.length === 1 && isCounterfactualEnabled + const handleBack = () => { onBack(data) } @@ -140,28 +151,40 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { if (!wallet || !provider || !chain) return - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(chain.chainId, LATEST_SAFE_VERSION) + try { + const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract( + chain.chainId, + LATEST_SAFE_VERSION, + ) - const props: DeploySafeProps = { - safeAccountConfig: { - threshold: data.threshold, - owners: data.owners.map((owner) => owner.address), - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - }, - saltNonce: saltNonce.toString(), - } + const props: DeploySafeProps = { + safeAccountConfig: { + threshold: data.threshold, + owners: data.owners.map((owner) => owner.address), + fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), + }, + } - const safeAddress = await computeNewSafeAddress(provider, props) + const saltNonce = await getAvailableSaltNonce(provider, { ...props, saltNonce: '0' }) + const safeAddress = await computeNewSafeAddress(provider, { ...props, saltNonce }) - const pendingSafe = { - ...data, - saltNonce, - safeAddress, - willRelay, - } + if (isCounterfactual) { + createCounterfactualSafe(chain, safeAddress, saltNonce, data, dispatch, props, router) + return + } + + const pendingSafe = { + ...data, + saltNonce: Number(saltNonce), + safeAddress, + willRelay, + } - setPendingSafe(pendingSafe) - onSubmit(pendingSafe) + setPendingSafe(pendingSafe) + onSubmit(pendingSafe) + } catch (_err) { + setSubmitError('Error creating the Safe Account. Please try again later.') + } } const isSocialLogin = isSocialLoginWallet(wallet?.label) @@ -203,50 +226,57 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps - - - {canRelay && !isSocialLogin && ( - - + + + {canRelay && !isSocialLogin && ( + + + } /> - } - /> - - )} + + )} - - - - - {!willRelay && !isSocialLogin && ( - - You will have to confirm a transaction with your connected wallet. - - )} - - } - /> - + + + - {isWrongChain && } + {!willRelay && !isSocialLogin && ( + + You will have to confirm a transaction with your connected wallet. + + )} + + } + /> + - {!walletCanPay && !willRelay && ( - Your connected wallet doesn't have enough funds to execute this transaction - )} - + {isWrongChain && } + + {!walletCanPay && !willRelay && ( + + Your connected wallet doesn't have enough funds to execute this transaction + + )} + + + )} + {submitError && {submitError}} diff --git a/src/components/new-safe/create/steps/ReviewStep/styles.module.css b/src/components/new-safe/create/steps/ReviewStep/styles.module.css index 05cff69a60..2d4e1be420 100644 --- a/src/components/new-safe/create/steps/ReviewStep/styles.module.css +++ b/src/components/new-safe/create/steps/ReviewStep/styles.module.css @@ -9,3 +9,7 @@ text-decoration: line-through; color: var(--color-text-secondary); } + +.errorMessage { + margin-top: 0; +} diff --git a/src/components/settings/PushNotifications/PushNotificationsBanner/PushNotificationsBanner.test.tsx b/src/components/settings/PushNotifications/PushNotificationsBanner/PushNotificationsBanner.test.tsx index 448c997e01..d48d520c6c 100644 --- a/src/components/settings/PushNotifications/PushNotificationsBanner/PushNotificationsBanner.test.tsx +++ b/src/components/settings/PushNotifications/PushNotificationsBanner/PushNotificationsBanner.test.tsx @@ -1,9 +1,10 @@ import 'fake-indexeddb/auto' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { toBeHex } from 'ethers' import * as tracking from '@/services/analytics' import { set } from 'idb-keyval' import * as navigation from 'next/navigation' -import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { PushNotificationsBanner, _getSafesToRegister } from '.' import { createPushNotificationPrefsIndexedDb } from '@/services/push-notifications/preferences' @@ -95,6 +96,14 @@ describe('PushNotificationsBanner', () => { }) describe('PushNotificationsBanner', () => { + const extendedSafeInfo = { + ...extendedSafeInfoBuilder().build(), + chainId: '1', + address: { + value: toBeHex('0x123', 20), + }, + } + beforeEach(() => { // Reset indexedDB indexedDB = new IDBFactory() @@ -140,12 +149,7 @@ describe('PushNotificationsBanner', () => { safeInfo: { loading: false, error: undefined, - data: { - chainId: '1', - address: { - value: toBeHex('0x123', 20), - }, - } as unknown as SafeInfo, + data: extendedSafeInfo, }, }, }) @@ -187,12 +191,7 @@ describe('PushNotificationsBanner', () => { safeInfo: { loading: false, error: undefined, - data: { - chainId: '1', - address: { - value: toBeHex('0x123', 20), - }, - } as unknown as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -233,12 +232,7 @@ describe('PushNotificationsBanner', () => { safeInfo: { loading: false, error: undefined, - data: { - chainId: '1', - address: { - value: toBeHex('0x123', 20), - }, - } as unknown as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -277,12 +271,7 @@ describe('PushNotificationsBanner', () => { safeInfo: { loading: false, error: undefined, - data: { - chainId: '1', - address: { - value: toBeHex('0x123', 20), - }, - } as unknown as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -317,12 +306,7 @@ describe('PushNotificationsBanner', () => { safeInfo: { loading: false, error: undefined, - data: { - chainId: '1', - address: { - value: toBeHex('0x123', 20), - }, - } as unknown as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -366,12 +350,7 @@ describe('PushNotificationsBanner', () => { safeInfo: { loading: false, error: undefined, - data: { - chainId: '1', - address: { - value: toBeHex('0x123', 20), - }, - } as unknown as SafeInfo, + data: extendedSafeInfo, }, }, }, diff --git a/src/components/settings/SafeModules/__tests__/SafeModules.test.tsx b/src/components/settings/SafeModules/__tests__/SafeModules.test.tsx index f0204108a6..f68dafe367 100644 --- a/src/components/settings/SafeModules/__tests__/SafeModules.test.tsx +++ b/src/components/settings/SafeModules/__tests__/SafeModules.test.tsx @@ -1,19 +1,18 @@ +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { render, waitFor } from '@/tests/test-utils' import * as useSafeInfoHook from '@/hooks/useSafeInfo' import SafeModules from '..' -import type { AddressEx, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { zeroPadValue } from 'ethers' const MOCK_MODULE_1 = zeroPadValue('0x01', 20) const MOCK_MODULE_2 = zeroPadValue('0x02', 20) describe('SafeModules', () => { + const extendedSafeInfo = extendedSafeInfoBuilder().build() + it('should render placeholder label without any modules', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ - safe: { - modules: [] as AddressEx[], - chainId: '4', - } as SafeInfo, + safe: extendedSafeInfo, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -26,7 +25,7 @@ describe('SafeModules', () => { it('should render placeholder label if safe is loading', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ - safe: {} as SafeInfo, + safe: extendedSafeInfo, safeAddress: '', safeError: undefined, safeLoading: true, @@ -39,6 +38,7 @@ describe('SafeModules', () => { it('should render module addresses for defined modules', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, modules: [ { value: MOCK_MODULE_1, @@ -47,8 +47,7 @@ describe('SafeModules', () => { value: MOCK_MODULE_2, }, ], - chainId: '4', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, diff --git a/src/components/settings/TransactionGuards/__tests__/TransactionGuards.test.tsx b/src/components/settings/TransactionGuards/__tests__/TransactionGuards.test.tsx index 15e7b4b0b0..b318020784 100644 --- a/src/components/settings/TransactionGuards/__tests__/TransactionGuards.test.tsx +++ b/src/components/settings/TransactionGuards/__tests__/TransactionGuards.test.tsx @@ -1,6 +1,6 @@ +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { render, waitFor } from '@/tests/test-utils' import * as useSafeInfoHook from '@/hooks/useSafeInfo' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { zeroPadValue } from 'ethers' import TransactionGuards from '..' @@ -8,13 +8,11 @@ const MOCK_GUARD = zeroPadValue('0x01', 20) const EMPTY_LABEL = 'No transaction guard set' describe('TransactionGuards', () => { + const extendedSafeInfo = extendedSafeInfoBuilder().build() + it('should render placeholder label without an tx guard', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ - safe: { - guard: null, - chainId: '4', - version: '1.3.0', - } as any as SafeInfo, + safe: extendedSafeInfo, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -27,7 +25,7 @@ describe('TransactionGuards', () => { it('should render null if safe is loading', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ - safe: {} as SafeInfo, + safe: extendedSafeInfo, safeAddress: '', safeError: undefined, safeLoading: true, @@ -41,10 +39,11 @@ describe('TransactionGuards', () => { it('should render null if safe version < 1.3.0', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, guard: null, chainId: '4', version: '1.2.0', - } as any as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -58,12 +57,11 @@ describe('TransactionGuards', () => { it('should render tx guard address if defined', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, guard: { value: MOCK_GUARD, }, - chainId: '4', - version: '1.3.0', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, diff --git a/src/components/transactions/SingleTx/SingleTx.test.tsx b/src/components/transactions/SingleTx/SingleTx.test.tsx index eca17836e4..467f2805d8 100644 --- a/src/components/transactions/SingleTx/SingleTx.test.tsx +++ b/src/components/transactions/SingleTx/SingleTx.test.tsx @@ -1,7 +1,10 @@ +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { fireEvent, render } from '@/tests/test-utils' import SingleTx from '@/pages/transactions/tx' import * as useSafeInfo from '@/hooks/useSafeInfo' -import type { SafeInfo, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import * as gatewaySDK from '@safe-global/safe-gateway-typescript-sdk' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { waitFor } from '@testing-library/react' const MOCK_SAFE_ADDRESS = '0x0000000000000000000000000000000000005AFE' const SAFE_ADDRESS = '0x87a57cBf742CC1Fc702D0E9BF595b1E056693e2f' @@ -30,23 +33,27 @@ jest.mock('next/router', () => ({ }, })) -jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({ - ...jest.requireActual('@safe-global/safe-gateway-typescript-sdk'), - getTransactionDetails: jest.fn(() => Promise.resolve(txDetails)), -})) +const extendedSafeInfo = extendedSafeInfoBuilder().build() jest.spyOn(useSafeInfo, 'default').mockImplementation(() => ({ safeAddress: SAFE_ADDRESS, safe: { + ...extendedSafeInfo, chainId: '5', - } as SafeInfo, + }, safeError: undefined, safeLoading: false, safeLoaded: true, })) describe('SingleTx', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + it('renders ', async () => { + jest.spyOn(gatewaySDK, 'getTransactionDetails').mockImplementation(() => Promise.resolve(txDetails)) + const screen = render() const button = screen.queryByText('Details') @@ -56,28 +63,22 @@ describe('SingleTx', () => { }) it('shows an error when the transaction has failed to load', async () => { - const getTransactionDetails = jest.spyOn( - require('@safe-global/safe-gateway-typescript-sdk'), - 'getTransactionDetails', - ) - getTransactionDetails.mockImplementation(() => Promise.reject(new Error('Server error'))) + jest.spyOn(gatewaySDK, 'getTransactionDetails').mockImplementation(() => Promise.reject(new Error('Server error'))) const screen = render() - expect(await screen.findByText('Failed to load transaction')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('Failed to load transaction')).toBeInTheDocument() + }) - const button = screen.getByText('Details') - fireEvent.click(button!) - - expect(screen.getByText('Server error')).toBeInTheDocument() + await waitFor(() => { + fireEvent.click(screen.getByText('Details')) + expect(screen.getByText('Server error')).toBeInTheDocument() + }) }) it('shows an error when transaction is not from the opened Safe', async () => { - const getTransactionDetails = jest.spyOn( - require('@safe-global/safe-gateway-typescript-sdk'), - 'getTransactionDetails', - ) - getTransactionDetails.mockImplementation(() => + jest.spyOn(gatewaySDK, 'getTransactionDetails').mockImplementation(() => Promise.resolve({ ...txDetails, safeAddress: MOCK_SAFE_ADDRESS, @@ -86,11 +87,14 @@ describe('SingleTx', () => { const screen = render() - expect(await screen.findByText('Failed to load transaction')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('Failed to load transaction')).toBeInTheDocument() + }) - const button = screen.getByText('Details') - fireEvent.click(button!) + fireEvent.click(screen.getByText('Details')) - expect(screen.getByText('Transaction with this id was not found in this Safe Account')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('Transaction with this id was not found in this Safe Account')).toBeInTheDocument() + }) }) }) diff --git a/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx b/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx index 1d93175fca..c1098a8e54 100644 --- a/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx +++ b/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx @@ -1,3 +1,4 @@ +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { hexlify, zeroPadValue, toUtf8Bytes } from 'ethers' import type { SafeInfo, SafeMessage, SafeMessageListPage } from '@safe-global/safe-gateway-typescript-sdk' import { SafeMessageListItemType } from '@safe-global/safe-gateway-typescript-sdk' @@ -90,19 +91,21 @@ describe('SignMessage', () => { }) const mockUseSafeMessages = useSafeMessages as jest.Mock + const extendedSafeInfo = { + ...extendedSafeInfoBuilder().build(), + version: '1.3.0', + address: { + value: zeroPadValue('0x01', 20), + }, + chainId: '5', + threshold: 2, + } beforeEach(() => { jest.clearAllMocks() jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ - safe: { - version: '1.3.0', - address: { - value: zeroPadValue('0x01', 20), - }, - chainId: '5', - threshold: 2, - } as SafeInfo, + safe: extendedSafeInfo, safeAddress: zeroPadValue('0x01', 20), safeError: undefined, safeLoading: false, @@ -255,16 +258,10 @@ describe('SignMessage', () => { expect(proposalSpy).toHaveBeenCalledWith( expect.objectContaining({ - safe: { - version: '1.3.0', - address: { - value: zeroPadValue('0x01', 20), - }, - chainId: '5', - threshold: 2, - } as SafeInfo, + safe: extendedSafeInfo, message: 'Hello world!', safeAppId: 25, + //onboard: expect.anything(), }), ) @@ -363,15 +360,9 @@ describe('SignMessage', () => { expect(confirmationSpy).toHaveBeenCalledWith( expect.objectContaining({ - safe: { - version: '1.3.0', - address: { - value: zeroPadValue('0x01', 20), - }, - chainId: '5', - threshold: 2, - } as SafeInfo, + safe: extendedSafeInfo, message: 'Hello world!', + onboard: expect.anything(), }), ) diff --git a/src/components/tx/SignOrExecuteForm/hooks.test.ts b/src/components/tx/SignOrExecuteForm/hooks.test.ts index d0bb6f7125..356c8240de 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.test.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.test.ts @@ -1,7 +1,7 @@ +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { renderHook } from '@/tests/test-utils' import { zeroPadValue } from 'ethers' import { createSafeTx } from '@/tests/builders/safeTx' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import * as useSafeInfoHook from '@/hooks/useSafeInfo' import * as wallet from '@/hooks/wallets/useWallet' @@ -13,6 +13,8 @@ import { type OnboardAPI } from '@web3-onboard/core' import { useAlreadySigned, useImmediatelyExecutable, useIsExecutionLoop, useTxActions, useValidateNonce } from './hooks' describe('SignOrExecute hooks', () => { + const extendedSafeInfo = extendedSafeInfoBuilder().build() + beforeEach(() => { jest.clearAllMocks() @@ -45,13 +47,14 @@ describe('SignOrExecute hooks', () => { it('should return true if nonce is correct', () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: zeroPadValue('0x0000', 20), safeError: undefined, safeLoading: false, @@ -66,13 +69,14 @@ describe('SignOrExecute hooks', () => { it('should return false if nonce is incorrect', () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 90, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: zeroPadValue('0x0000', 20), safeError: undefined, safeLoading: false, @@ -92,12 +96,13 @@ describe('SignOrExecute hooks', () => { jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ safeAddress: address, safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: address }, owners: [{ value: address }], nonce: 100, chainId: '1', - } as SafeInfo, + }, safeLoaded: true, safeLoading: false, safeError: undefined, @@ -132,12 +137,13 @@ describe('SignOrExecute hooks', () => { jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ safeAddress: zeroPadValue('0x0000', 20), safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, owners: [{ value: zeroPadValue('0x0123', 20) }], threshold: 1, nonce: 100, - } as SafeInfo, + }, safeLoaded: true, safeLoading: false, safeError: undefined, @@ -154,13 +160,14 @@ describe('SignOrExecute hooks', () => { jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ safeAddress: zeroPadValue('0x0000', 20), safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, owners: [{ value: zeroPadValue('0x0123', 20) }], threshold: 2, nonce: 100, chainId: '1', - } as SafeInfo, + }, safeLoaded: true, safeLoading: false, safeError: undefined, @@ -177,13 +184,14 @@ describe('SignOrExecute hooks', () => { jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ safeAddress: zeroPadValue('0x0000', 20), safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, owners: [{ value: zeroPadValue('0x0123', 20) }], threshold: 1, nonce: 100, chainId: '1', - } as SafeInfo, + }, safeLoaded: true, safeLoading: false, safeError: undefined, @@ -201,13 +209,14 @@ describe('SignOrExecute hooks', () => { it('should return sign and execute actions', () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -225,13 +234,14 @@ describe('SignOrExecute hooks', () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -266,13 +276,14 @@ describe('SignOrExecute hooks', () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -295,13 +306,14 @@ describe('SignOrExecute hooks', () => { it('should execute a tx without a txId (immediate execution)', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -327,13 +339,14 @@ describe('SignOrExecute hooks', () => { it('should execute a tx with an id (existing tx)', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -359,13 +372,14 @@ describe('SignOrExecute hooks', () => { it('should throw an error if the tx is undefined', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -383,13 +397,15 @@ describe('SignOrExecute hooks', () => { it('should relay a tx execution', async () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, + ...extendedSafeInfoBuilder().build(), version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 1, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -424,13 +440,15 @@ describe('SignOrExecute hooks', () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, + ...extendedSafeInfoBuilder().build(), version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, @@ -476,13 +494,15 @@ describe('SignOrExecute hooks', () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfo, + ...extendedSafeInfoBuilder().build(), version: '1.3.0', address: { value: zeroPadValue('0x0000', 20) }, nonce: 100, threshold: 2, owners: [{ value: zeroPadValue('0x0123', 20) }, { value: zeroPadValue('0x0456', 20) }], chainId: '1', - } as SafeInfo, + }, safeAddress: '0x123', safeError: undefined, safeLoading: false, diff --git a/src/features/counterfactual/__tests__/utils.test.ts b/src/features/counterfactual/__tests__/utils.test.ts new file mode 100644 index 0000000000..917f4f3c2a --- /dev/null +++ b/src/features/counterfactual/__tests__/utils.test.ts @@ -0,0 +1,83 @@ +import { getCounterfactualBalance, getUndeployedSafeInfo } from '@/features/counterfactual/utils' +import { chainBuilder } from '@/tests/builders/chains' +import { faker } from '@faker-js/faker' +import type { PredictedSafeProps } from '@safe-global/protocol-kit' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { BrowserProvider, type Eip1193Provider } from 'ethers' + +describe('Counterfactual utils', () => { + describe('getUndeployedSafeInfo', () => { + it('should return undeployed safe info', async () => { + const undeployedSafe: PredictedSafeProps = { + safeAccountConfig: { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + }, + safeDeploymentConfig: {}, + } + const mockAddress = faker.finance.ethereumAddress() + const mockChainId = '1' + + const result = await getUndeployedSafeInfo(undeployedSafe, mockAddress, mockChainId) + + expect(result.nonce).toEqual(0) + expect(result.deployed).toEqual(false) + expect(result.address.value).toEqual(mockAddress) + expect(result.chainId).toEqual(mockChainId) + expect(result.threshold).toEqual(undeployedSafe.safeAccountConfig.threshold) + expect(result.owners[0].value).toEqual(undeployedSafe.safeAccountConfig.owners[0]) + }) + }) + + describe('getCounterfactualBalance', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return undefined if there is no provider', () => { + const mockSafeAddress = faker.finance.ethereumAddress() + const mockChain = chainBuilder().build() + const result = getCounterfactualBalance(mockSafeAddress, undefined, mockChain) + + expect(result).resolves.toBeUndefined() + }) + + it('should return undefined if there is no chain info', () => { + const mockSafeAddress = faker.finance.ethereumAddress() + const mockProvider = new BrowserProvider(jest.fn() as unknown as Eip1193Provider) + mockProvider.getBalance = jest.fn(() => Promise.resolve(1n)) + + const result = getCounterfactualBalance(mockSafeAddress, mockProvider, undefined) + + expect(result).resolves.toBeUndefined() + }) + + it('should return the native balance', () => { + const mockSafeAddress = faker.finance.ethereumAddress() + const mockProvider = new BrowserProvider(jest.fn() as unknown as Eip1193Provider) + const mockChain = chainBuilder().build() + const mockBalance = 1000000n + + mockProvider.getBalance = jest.fn(() => Promise.resolve(mockBalance)) + + const result = getCounterfactualBalance(mockSafeAddress, mockProvider, mockChain) + + expect(result).resolves.toEqual({ + fiatTotal: '0', + items: [ + { + tokenInfo: { + type: TokenType.NATIVE_TOKEN, + address: ZERO_ADDRESS, + ...mockChain.nativeCurrency, + }, + balance: mockBalance.toString(), + fiatBalance: '0', + fiatConversion: '0', + }, + ], + }) + }) + }) +}) diff --git a/src/features/counterfactual/store/undeployedSafesSlice.ts b/src/features/counterfactual/store/undeployedSafesSlice.ts new file mode 100644 index 0000000000..5d17fbf4ca --- /dev/null +++ b/src/features/counterfactual/store/undeployedSafesSlice.ts @@ -0,0 +1,52 @@ +import { type RootState } from '@/store' +import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' +import type { PredictedSafeProps } from '@safe-global/protocol-kit' + +type UndeployedSafesSlice = { [address: string]: PredictedSafeProps } + +type UndeployedSafesState = { [chainId: string]: UndeployedSafesSlice } + +const initialState: UndeployedSafesState = {} + +export const undeployedSafesSlice = createSlice({ + name: 'undeployedSafes', + initialState, + reducers: { + addUndeployedSafe: ( + state, + action: PayloadAction<{ chainId: string; address: string; safeProps: PredictedSafeProps }>, + ) => { + const { chainId, address, safeProps } = action.payload + + if (!state[chainId]) { + state[chainId] = {} + } + + state[chainId][address] = safeProps + }, + + removeUndeployedSafe: (state, action: PayloadAction<{ chainId: string; address: string }>) => { + const { chainId, address } = action.payload + if (!state[chainId]) return state + + delete state[chainId][address] + + if (Object.keys(state[chainId]).length > 0) return state + + delete state[chainId] + }, + }, +}) + +export const { removeUndeployedSafe, addUndeployedSafe } = undeployedSafesSlice.actions + +export const selectUndeployedSafes = (state: RootState): UndeployedSafesState => { + return state[undeployedSafesSlice.name] +} + +export const selectUndeployedSafe = createSelector( + [selectUndeployedSafes, (_, chainId: string, address: string) => [chainId, address]], + (undeployedSafes, [chainId, address]): PredictedSafeProps | undefined => { + return undeployedSafes[chainId]?.[address] + }, +) diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts new file mode 100644 index 0000000000..b58954338e --- /dev/null +++ b/src/features/counterfactual/utils.ts @@ -0,0 +1,96 @@ +import type { NewSafeFormData } from '@/components/new-safe/create' +import { LATEST_SAFE_VERSION } from '@/config/constants' +import { AppRoutes } from '@/config/routes' +import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import type { AppDispatch } from '@/store' +import { addOrUpdateSafe } from '@/store/addedSafesSlice' +import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { defaultSafeInfo } from '@/store/safeInfoSlice' +import type { DeploySafeProps, PredictedSafeProps } from '@safe-global/protocol-kit' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import type { SafeVersion } from '@safe-global/safe-core-sdk-types' +import { + type ChainInfo, + ImplementationVersionState, + type SafeBalanceResponse, + TokenType, +} from '@safe-global/safe-gateway-typescript-sdk' +import type { BrowserProvider } from 'ethers' +import type { NextRouter } from 'next/router' + +export const getUndeployedSafeInfo = (undeployedSafe: PredictedSafeProps, address: string, chainId: string) => { + return Promise.resolve({ + ...defaultSafeInfo, + address: { value: address }, + chainId, + owners: undeployedSafe.safeAccountConfig.owners.map((owner) => ({ value: owner })), + nonce: 0, + threshold: undeployedSafe.safeAccountConfig.threshold, + implementationVersionState: ImplementationVersionState.UP_TO_DATE, + fallbackHandler: { value: undeployedSafe.safeAccountConfig.fallbackHandler! }, + version: LATEST_SAFE_VERSION, + deployed: false, + }) +} + +export const getCounterfactualBalance = async (safeAddress: string, provider?: BrowserProvider, chain?: ChainInfo) => { + const balance = await provider?.getBalance(safeAddress) + + if (balance === undefined || !chain) return + + return { + fiatTotal: '0', + items: [ + { + tokenInfo: { + type: TokenType.NATIVE_TOKEN, + address: ZERO_ADDRESS, + ...chain?.nativeCurrency, + }, + balance: balance.toString(), + fiatBalance: '0', + fiatConversion: '0', + }, + ], + } +} + +export const createCounterfactualSafe = ( + chain: ChainInfo, + safeAddress: string, + saltNonce: string, + data: NewSafeFormData, + dispatch: AppDispatch, + props: DeploySafeProps, + router: NextRouter, +) => { + const undeployedSafe = { + chainId: chain.chainId, + address: safeAddress, + safeProps: { + safeAccountConfig: props.safeAccountConfig, + safeDeploymentConfig: { + saltNonce, + safeVersion: LATEST_SAFE_VERSION as SafeVersion, + }, + }, + } + + dispatch(addUndeployedSafe(undeployedSafe)) + dispatch(upsertAddressBookEntry({ chainId: chain.chainId, address: safeAddress, name: data.name })) + dispatch( + addOrUpdateSafe({ + safe: { + ...defaultSafeInfo, + address: { value: safeAddress, name: data.name }, + threshold: data.threshold, + owners: data.owners.map((owner) => ({ + value: owner.address, + name: owner.name || owner.ens, + })), + chainId: chain.chainId, + }, + }), + ) + router.push({ pathname: AppRoutes.home, query: { safe: `${chain.shortName}:${safeAddress}` } }) +} diff --git a/src/features/walletconnect/__tests__/WalletConnectContext.test.tsx b/src/features/walletconnect/__tests__/WalletConnectContext.test.tsx index 1019fad1b8..54ecde09ed 100644 --- a/src/features/walletconnect/__tests__/WalletConnectContext.test.tsx +++ b/src/features/walletconnect/__tests__/WalletConnectContext.test.tsx @@ -1,3 +1,4 @@ +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { toBeHex } from 'ethers' import { useContext } from 'react' import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' @@ -42,6 +43,14 @@ const TestComponent = () => { } describe('WalletConnectProvider', () => { + const extendedSafeInfo = { + ...extendedSafeInfoBuilder().build(), + address: { + value: toBeHex('0x123', 20), + }, + chainId: '5', + } + beforeEach(() => { jest.resetAllMocks() jest.restoreAllMocks() @@ -59,12 +68,7 @@ describe('WalletConnectProvider', () => { initialReduxState: { safeInfo: { loading: false, - data: { - address: { - value: toBeHex('0x123', 20), - }, - chainId: '5', - } as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -89,12 +93,7 @@ describe('WalletConnectProvider', () => { initialReduxState: { safeInfo: { loading: false, - data: { - address: { - value: toBeHex('0x123', 20), - }, - chainId: '5', - } as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -106,6 +105,14 @@ describe('WalletConnectProvider', () => { }) describe('updateSessions', () => { + const extendedSafeInfo = { + ...extendedSafeInfoBuilder().build(), + address: { + value: toBeHex('0x123', 20), + }, + chainId: '5', + } + const getUpdateSafeInfoComponent = (safeInfo: SafeInfo) => { // eslint-disable-next-line react/display-name return () => { @@ -114,7 +121,7 @@ describe('WalletConnectProvider', () => { dispatch( safeInfoSlice.actions.set({ loading: false, - data: safeInfo, + data: { ...extendedSafeInfo, ...safeInfo }, }), ) } @@ -141,12 +148,7 @@ describe('WalletConnectProvider', () => { initialReduxState: { safeInfo: { loading: false, - data: { - address: { - value: toBeHex('0x123', 20), - }, - chainId: '5', - } as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -183,11 +185,12 @@ describe('WalletConnectProvider', () => { safeInfo: { loading: false, data: { + ...extendedSafeInfo, address: { value: toBeHex('0x123', 20), }, chainId: '5', - } as SafeInfo, + }, }, }, }, @@ -220,11 +223,12 @@ describe('WalletConnectProvider', () => { safeInfo: { loading: false, data: { + ...extendedSafeInfo, address: { value: toBeHex('0x123', 20), }, chainId: '5', - } as SafeInfo, + }, }, }, }, @@ -237,6 +241,14 @@ describe('WalletConnectProvider', () => { }) describe('onRequest', () => { + const extendedSafeInfo = { + ...extendedSafeInfoBuilder().build(), + address: { + value: toBeHex('0x123', 20), + }, + chainId: '5', + } + it('does not continue with the request if there is no matching topic', async () => { jest.spyOn(WalletConnectWallet.prototype, 'init').mockImplementation(() => Promise.resolve()) jest.spyOn(WalletConnectWallet.prototype, 'updateSessions').mockImplementation(() => Promise.resolve()) @@ -261,12 +273,7 @@ describe('WalletConnectProvider', () => { initialReduxState: { safeInfo: { loading: false, - data: { - address: { - value: toBeHex('0x123', 20), - }, - chainId: '5', - } as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -325,12 +332,7 @@ describe('WalletConnectProvider', () => { initialReduxState: { safeInfo: { loading: false, - data: { - address: { - value: toBeHex('0x123', 20), - }, - chainId: '5', - } as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -397,12 +399,7 @@ describe('WalletConnectProvider', () => { initialReduxState: { safeInfo: { loading: false, - data: { - address: { - value: toBeHex('0x123', 20), - }, - chainId: '5', - } as SafeInfo, + data: extendedSafeInfo, }, }, }, @@ -477,12 +474,7 @@ describe('WalletConnectProvider', () => { initialReduxState: { safeInfo: { loading: false, - data: { - address: { - value: toBeHex('0x123', 20), - }, - chainId: '5', - } as SafeInfo, + data: extendedSafeInfo, }, }, }, diff --git a/src/features/walletconnect/components/WcProposalForm/__tests__/useCompatibilityWarning.test.ts b/src/features/walletconnect/components/WcProposalForm/__tests__/useCompatibilityWarning.test.ts index 747bf1eff9..039fe6d1f8 100644 --- a/src/features/walletconnect/components/WcProposalForm/__tests__/useCompatibilityWarning.test.ts +++ b/src/features/walletconnect/components/WcProposalForm/__tests__/useCompatibilityWarning.test.ts @@ -1,5 +1,6 @@ +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { renderHook } from '@/tests/test-utils' -import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import type { Web3WalletTypes } from '@walletconnect/web3wallet' import { useCompatibilityWarning } from '../useCompatibilityWarning' import * as wcUtils from '@/features/walletconnect/services/utils' @@ -141,9 +142,10 @@ describe('useCompatibilityWarning', () => { loading: false, error: undefined, data: { - address: {}, + ...extendedSafeInfoBuilder().build(), + address: { value: '' }, chainId: '1', - } as unknown as SafeInfo, + }, }, }, }) diff --git a/src/hooks/__tests__/usePendingActions.test.ts b/src/hooks/__tests__/usePendingActions.test.ts index cb476277cd..09c560a544 100644 --- a/src/hooks/__tests__/usePendingActions.test.ts +++ b/src/hooks/__tests__/usePendingActions.test.ts @@ -1,6 +1,7 @@ import usePendingActions from '@/hooks/usePendingActions' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { renderHook, waitFor } from '@/tests/test-utils' -import type { SafeInfo, TransactionListPage, TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import type { TransactionListPage, TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' import { ConflictType, DetailedExecutionInfoType, @@ -46,8 +47,9 @@ describe('usePendingActions hook', () => { jest.spyOn(useSafeInfo, 'default').mockReturnValue({ safeAddress: mockSafeAddress, safe: { + ...extendedSafeInfoBuilder().build(), chainId: '5', - } as SafeInfo, + }, safeError: undefined, safeLoading: false, safeLoaded: true, @@ -82,8 +84,9 @@ describe('usePendingActions hook', () => { jest.spyOn(useSafeInfo, 'default').mockReturnValue({ safeAddress: mockSafeAddress, safe: { + ...extendedSafeInfoBuilder().build(), chainId: '5', - } as SafeInfo, + }, safeError: undefined, safeLoading: false, safeLoaded: true, @@ -153,8 +156,9 @@ describe('usePendingActions hook', () => { jest.spyOn(useSafeInfo, 'default').mockReturnValue({ safeAddress: mockSafeAddress, safe: { + ...extendedSafeInfoBuilder().build(), chainId: '5', - } as SafeInfo, + }, safeError: undefined, safeLoading: false, safeLoaded: true, diff --git a/src/hooks/__tests__/usePendingTxs.test.ts b/src/hooks/__tests__/usePendingTxs.test.ts index a40d25f367..724964d5b5 100644 --- a/src/hooks/__tests__/usePendingTxs.test.ts +++ b/src/hooks/__tests__/usePendingTxs.test.ts @@ -1,6 +1,7 @@ import { type PendingTx } from '@/store/pendingTxsSlice' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { act, renderHook } from '@/tests/test-utils' -import type { Label, SafeInfo, Transaction } from '@safe-global/safe-gateway-typescript-sdk' +import type { Label, Transaction } from '@safe-global/safe-gateway-typescript-sdk' import * as useSafeInfoHook from '@/hooks/useSafeInfo' import { useHasPendingTxs, usePendingTxsQueue } from '../usePendingTxs' @@ -39,11 +40,12 @@ describe('usePendingTxsQueue', () => { jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ safe: { + ...extendedSafeInfoBuilder().build(), nonce: 100, threshold: 1, owners: [{ value: '0x123' }], chainId: '5', - } as SafeInfo, + }, safeAddress: '0x0000000000000000000000000000000000000001', safeError: undefined, safeLoading: false, diff --git a/src/hooks/coreSDK/__tests__/useInitSafeCoreSDK.test.ts b/src/hooks/coreSDK/__tests__/useInitSafeCoreSDK.test.ts index d11c92f58b..c47cd38ca3 100644 --- a/src/hooks/coreSDK/__tests__/useInitSafeCoreSDK.test.ts +++ b/src/hooks/coreSDK/__tests__/useInitSafeCoreSDK.test.ts @@ -1,10 +1,10 @@ +import type { ExtendedSafeInfo } from '@/store/safeInfoSlice' import { renderHook } from '@/tests/test-utils' import { useInitSafeCoreSDK } from '@/hooks/coreSDK/useInitSafeCoreSDK' import * as web3 from '@/hooks/wallets/web3' import * as router from 'next/router' import * as useSafeInfo from '@/hooks/useSafeInfo' import * as coreSDK from '@/hooks/coreSDK/safeCoreSDK' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' import { waitFor } from '@testing-library/react' import type Safe from '@safe-global/protocol-kit' @@ -24,7 +24,7 @@ describe('useInitSafeCoreSDK hook', () => { value: '0x1', }, implementationVersionState: ImplementationVersionState.UP_TO_DATE, - } as SafeInfo, + } as ExtendedSafeInfo, safeAddress: mockSafeAddress, safeLoaded: true, safeError: undefined, @@ -58,6 +58,7 @@ describe('useInitSafeCoreSDK hook', () => { provider: mockProvider, address: mockSafeInfo.safe.address.value, implementation: mockSafeInfo.safe.implementation.value, + undeployedSafe: undefined, }) await waitFor(() => { diff --git a/src/hooks/coreSDK/safeCoreSDK.ts b/src/hooks/coreSDK/safeCoreSDK.ts index 0f55b5d9ef..c039e9a1d1 100644 --- a/src/hooks/coreSDK/safeCoreSDK.ts +++ b/src/hooks/coreSDK/safeCoreSDK.ts @@ -5,7 +5,7 @@ import ExternalStore from '@/services/ExternalStore' import { Gnosis_safe__factory } from '@/types/contracts' import { invariant } from '@/utils/helpers' import type { BrowserProvider, Provider } from 'ethers' -import Safe from '@safe-global/protocol-kit' +import Safe, { type PredictedSafeProps } from '@safe-global/protocol-kit' import type { SafeVersion } from '@safe-global/safe-core-sdk-types' import { EthersAdapter } from '@safe-global/protocol-kit' import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' @@ -54,6 +54,7 @@ type SafeCoreSDKProps = { version: SafeInfo['version'] implementationVersionState: SafeInfo['implementationVersionState'] implementation: SafeInfo['implementation']['value'] + undeployedSafe?: PredictedSafeProps } // Safe Core SDK @@ -64,6 +65,7 @@ export const initSafeSDK = async ({ version, implementationVersionState, implementation, + undeployedSafe, }: SafeCoreSDKProps): Promise => { const safeVersion = version ?? (await Gnosis_safe__factory.connect(address, provider).VERSION()) let isL1SafeSingleton = chainId === chains.eth @@ -88,6 +90,14 @@ export const initSafeSDK = async ({ isL1SafeSingleton = true } + if (undeployedSafe) { + return Safe.create({ + ethAdapter: createReadOnlyEthersAdapter(provider), + isL1SafeSingleton: isL1SafeSingleton, + predictedSafe: undeployedSafe, + }) + } + return Safe.create({ ethAdapter: createReadOnlyEthersAdapter(provider), safeAddress: address, diff --git a/src/hooks/coreSDK/useInitSafeCoreSDK.ts b/src/hooks/coreSDK/useInitSafeCoreSDK.ts index 36912162c9..012502131e 100644 --- a/src/hooks/coreSDK/useInitSafeCoreSDK.ts +++ b/src/hooks/coreSDK/useInitSafeCoreSDK.ts @@ -1,10 +1,11 @@ +import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import { useEffect } from 'react' import { useRouter } from 'next/router' import useSafeInfo from '@/hooks/useSafeInfo' import { initSafeSDK, setSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import { trackError } from '@/services/exceptions' import ErrorCodes from '@/services/exceptions/ErrorCodes' -import { useAppDispatch } from '@/store' +import { useAppDispatch, useAppSelector } from '@/store' import { showNotification } from '@/store/notificationsSlice' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { parsePrefixedAddress, sameAddress } from '@/utils/addresses' @@ -18,6 +19,7 @@ export const useInitSafeCoreSDK = () => { const { query } = useRouter() const prefixedAddress = Array.isArray(query.safe) ? query.safe[0] : query.safe const { address } = parsePrefixedAddress(prefixedAddress || '') + const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, safe.chainId, address)) useEffect(() => { if (!safeLoaded || !web3ReadOnly || !sameAddress(address, safe.address.value)) { @@ -34,6 +36,7 @@ export const useInitSafeCoreSDK = () => { version: safe.version, implementationVersionState: safe.implementationVersionState, implementation: safe.implementation.value, + undeployedSafe, }) .then(setSafeSDK) .catch((_e) => { @@ -58,5 +61,6 @@ export const useInitSafeCoreSDK = () => { safe.version, safeLoaded, web3ReadOnly, + undeployedSafe, ]) } diff --git a/src/hooks/loadables/useLoadBalances.ts b/src/hooks/loadables/useLoadBalances.ts index a48c455146..3d52ffe067 100644 --- a/src/hooks/loadables/useLoadBalances.ts +++ b/src/hooks/loadables/useLoadBalances.ts @@ -1,3 +1,5 @@ +import { getCounterfactualBalance } from '@/features/counterfactual/utils' +import { useWeb3 } from '@/hooks/wallets/web3' import { useEffect, useMemo } from 'react' import { getBalances, type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' @@ -27,19 +29,25 @@ export const useLoadBalances = (): AsyncResult => { const currency = useAppSelector(selectCurrency) const isTrustedTokenList = useTokenListSetting() const { safe, safeAddress } = useSafeInfo() + const web3 = useWeb3() + const chain = useCurrentChain() const chainId = safe.chainId // Re-fetch assets when the entire SafeInfo updates - const [data, error, loading] = useAsync( + const [data, error, loading] = useAsync( () => { if (!chainId || !safeAddress || isTrustedTokenList === undefined) return + if (!safe.deployed) { + return getCounterfactualBalance(safeAddress, web3, chain) + } + return getBalances(chainId, safeAddress, currency, { trusted: isTrustedTokenList, }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [safeAddress, chainId, currency, isTrustedTokenList, pollCount], + [safeAddress, chainId, currency, isTrustedTokenList, pollCount, safe.deployed, web3, chain], false, // don't clear data between polls ) diff --git a/src/hooks/loadables/useLoadSafeInfo.ts b/src/hooks/loadables/useLoadSafeInfo.ts index 4a694444b1..96ae19e4ac 100644 --- a/src/hooks/loadables/useLoadSafeInfo.ts +++ b/src/hooks/loadables/useLoadSafeInfo.ts @@ -1,3 +1,6 @@ +import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import { getUndeployedSafeInfo } from '@/features/counterfactual/utils' +import { useAppSelector } from '@/store' import { useEffect } from 'react' import { getSafeInfo, type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import useAsync, { type AsyncResult } from '../useAsync' @@ -14,12 +17,22 @@ export const useLoadSafeInfo = (): AsyncResult => { const [pollCount, resetPolling] = useIntervalCounter(POLLING_INTERVAL) const { safe } = useSafeInfo() const isStoredSafeValid = safe.chainId === chainId && safe.address.value === address + const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, address)) - const [data, error, loading] = useAsync(() => { + const [data, error, loading] = useAsync(async () => { if (!chainId || !address) return - return getSafeInfo(chainId, address) + + /** + * This is the one place where we can't check for `safe.deployed` as we want to update that value + * when the local storage is cleared, so we have to check undeployedSafe + */ + if (undeployedSafe) return getUndeployedSafeInfo(undeployedSafe, address, chainId) + + const safeInfo = await getSafeInfo(chainId, address) + + return { ...safeInfo, deployed: true } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chainId, address, pollCount]) + }, [chainId, address, pollCount, undeployedSafe]) // Reset the counter when safe address/chainId changes useEffect(() => { diff --git a/src/hooks/loadables/useLoadSafeMessages.ts b/src/hooks/loadables/useLoadSafeMessages.ts index 7c695b39d8..867da23dcc 100644 --- a/src/hooks/loadables/useLoadSafeMessages.ts +++ b/src/hooks/loadables/useLoadSafeMessages.ts @@ -12,13 +12,13 @@ export const useLoadSafeMessages = (): AsyncResult => { const [data, error, loading] = useAsync( () => { - if (!safeLoaded) { - return - } + if (!safeLoaded) return + if (!safe.deployed) return Promise.resolve({ results: [] }) + return getSafeMessages(safe.chainId, safeAddress) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [safeLoaded, safe.chainId, safeAddress, safe.messagesTag], + [safeLoaded, safe.chainId, safeAddress, safe.messagesTag, safe.deployed], false, ) diff --git a/src/hooks/loadables/useLoadTxHistory.ts b/src/hooks/loadables/useLoadTxHistory.ts index a9b8dbeef3..284e139619 100644 --- a/src/hooks/loadables/useLoadTxHistory.ts +++ b/src/hooks/loadables/useLoadTxHistory.ts @@ -19,10 +19,12 @@ export const useLoadTxHistory = (): AsyncResult => { const [data, error, loading] = useAsync( () => { if (!safeLoaded) return + if (!safe.deployed) return Promise.resolve({ results: [] }) + return getTxHistory(chainId, safeAddress, hasDefaultTokenlist && showOnlyTrustedTransactions) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [safeLoaded, chainId, safeAddress, showOnlyTrustedTransactions, hasDefaultTokenlist, txHistoryTag], + [safeLoaded, chainId, safeAddress, showOnlyTrustedTransactions, hasDefaultTokenlist, txHistoryTag, safe.deployed], false, ) diff --git a/src/hooks/loadables/useLoadTxQueue.ts b/src/hooks/loadables/useLoadTxQueue.ts index 8cbaeacf66..e3ab1dc9cb 100644 --- a/src/hooks/loadables/useLoadTxQueue.ts +++ b/src/hooks/loadables/useLoadTxQueue.ts @@ -16,10 +16,12 @@ export const useLoadTxQueue = (): AsyncResult => { const [data, error, loading] = useAsync( () => { if (!safeLoaded) return + if (!safe.deployed) return Promise.resolve({ results: [] }) + return getTransactionQueue(chainId, safeAddress) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [safeLoaded, chainId, safeAddress, reloadTag], + [safeLoaded, chainId, safeAddress, reloadTag, safe.deployed], false, ) diff --git a/src/hooks/useCollectibles.ts b/src/hooks/useCollectibles.ts index f6257de6ac..ee1601ce08 100644 --- a/src/hooks/useCollectibles.ts +++ b/src/hooks/useCollectibles.ts @@ -9,8 +9,10 @@ export const useCollectibles = (pageUrl?: string): AsyncResult(() => { if (!safeAddress) return + if (!safe.deployed) return Promise.resolve({ results: [] }) + return getCollectiblesPage(safe.chainId, safeAddress, undefined, pageUrl) - }, [safeAddress, safe.chainId, pageUrl]) + }, [safeAddress, safe.chainId, pageUrl, safe.deployed]) // Log errors useEffect(() => { diff --git a/src/hooks/useSafeInfo.ts b/src/hooks/useSafeInfo.ts index 6575e20f95..ea22d5b197 100644 --- a/src/hooks/useSafeInfo.ts +++ b/src/hooks/useSafeInfo.ts @@ -1,11 +1,10 @@ import { useMemo } from 'react' import isEqual from 'lodash/isEqual' -import { type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' -import { defaultSafeInfo, selectSafeInfo } from '@/store/safeInfoSlice' +import { defaultSafeInfo, type ExtendedSafeInfo, selectSafeInfo } from '@/store/safeInfoSlice' const useSafeInfo = (): { - safe: SafeInfo + safe: ExtendedSafeInfo safeAddress: string safeLoaded: boolean safeLoading: boolean diff --git a/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx b/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx index 37c84502da..9150a12166 100644 --- a/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx +++ b/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx @@ -1,3 +1,4 @@ +import type { ExtendedSafeInfo } from '@/store/safeInfoSlice' import * as gateway from '@safe-global/safe-gateway-typescript-sdk' import * as router from 'next/router' @@ -43,7 +44,8 @@ describe('useSafeWalletProvider', () => { address: { value: '0x1234567890000000000000000000000000000000', }, - } as gateway.SafeInfo, + deployed: true, + } as unknown as ExtendedSafeInfo, }, }, }) diff --git a/src/store/index.ts b/src/store/index.ts index 5053c84f8a..8258a6d6ba 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -29,6 +29,7 @@ import { safeAppsSlice } from './safeAppsSlice' import { safeMessagesListener, safeMessagesSlice } from './safeMessagesSlice' import { pendingSafeMessagesSlice } from './pendingSafeMessagesSlice' import { batchSlice } from './batchSlice' +import { undeployedSafesSlice } from '@/features/counterfactual/store/undeployedSafesSlice' const rootReducer = combineReducers({ [chainsSlice.name]: chainsSlice.reducer, @@ -49,6 +50,7 @@ const rootReducer = combineReducers({ [safeMessagesSlice.name]: safeMessagesSlice.reducer, [pendingSafeMessagesSlice.name]: pendingSafeMessagesSlice.reducer, [batchSlice.name]: batchSlice.reducer, + [undeployedSafesSlice.name]: undeployedSafesSlice.reducer, }) const persistedSlices: (keyof PreloadedState)[] = [ @@ -61,6 +63,7 @@ const persistedSlices: (keyof PreloadedState)[] = [ safeAppsSlice.name, pendingSafeMessagesSlice.name, batchSlice.name, + undeployedSafesSlice.name, ] export const getPersistedState = () => { diff --git a/src/store/safeInfoSlice.ts b/src/store/safeInfoSlice.ts index a7dbcfafa7..d6a122f3a3 100644 --- a/src/store/safeInfoSlice.ts +++ b/src/store/safeInfoSlice.ts @@ -1,7 +1,9 @@ import { type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { makeLoadableSlice } from './common' -export const defaultSafeInfo: SafeInfo = { +export type ExtendedSafeInfo = SafeInfo & { deployed: boolean } + +export const defaultSafeInfo: ExtendedSafeInfo = { address: { value: '' }, chainId: '', nonce: -1, @@ -17,9 +19,10 @@ export const defaultSafeInfo: SafeInfo = { txQueuedTag: '', txHistoryTag: '', messagesTag: '', + deployed: true, } -const { slice, selector } = makeLoadableSlice('safeInfo', undefined as SafeInfo | undefined) +const { slice, selector } = makeLoadableSlice('safeInfo', undefined as ExtendedSafeInfo | undefined) export const safeInfoSlice = slice export const selectSafeInfo = selector diff --git a/src/tests/builders/safe.ts b/src/tests/builders/safe.ts index d1b6fbf6c5..91db6ba631 100644 --- a/src/tests/builders/safe.ts +++ b/src/tests/builders/safe.ts @@ -1,3 +1,4 @@ +import type { ExtendedSafeInfo } from '@/store/safeInfoSlice' import { faker } from '@faker-js/faker' import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' import type { SafeInfo, AddressEx } from '@safe-global/safe-gateway-typescript-sdk' @@ -37,3 +38,10 @@ export function safeInfoBuilder(): IBuilder { messagesTag: faker.string.numeric(), }) } + +export function extendedSafeInfoBuilder(): IBuilder { + return Builder.new().with({ + ...safeInfoBuilder().build(), + deployed: faker.datatype.boolean(), + }) +} diff --git a/src/utils/chains.ts b/src/utils/chains.ts index 471ca5bbdc..016458fd1e 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -18,6 +18,7 @@ export enum FEATURES { NATIVE_WALLETCONNECT = 'NATIVE_WALLETCONNECT', RECOVERY = 'RECOVERY', SOCIAL_LOGIN = 'SOCIAL_LOGIN', + COUNTERFACTUAL = 'COUNTERFACTUAL', } export const hasFeature = (chain: ChainInfo, feature: FEATURES): boolean => {