diff --git a/.github/workflows/e2e-ondemand.yml b/.github/workflows/e2e-ondemand.yml index 704f42f862..10c32d1a84 100644 --- a/.github/workflows/e2e-ondemand.yml +++ b/.github/workflows/e2e-ondemand.yml @@ -28,7 +28,6 @@ jobs: cypress/e2e/regression/*.cy.js cypress/e2e/safe-apps/*.cy.js cypress/e2e/smoke/*.cy.js - cypress/e2e/prodhealthcheck/*.cy.js group: 'Regression on demand tests' tag: 'regression' diff --git a/cypress/e2e/pages/create_wallet.pages.js b/cypress/e2e/pages/create_wallet.pages.js index 1c79e430c6..ce06ee6afa 100644 --- a/cypress/e2e/pages/create_wallet.pages.js +++ b/cypress/e2e/pages/create_wallet.pages.js @@ -17,6 +17,7 @@ const googleSignedinBtn = '[data-testid="signed-in-account-btn"]' export const accountInfoHeader = '[data-testid="open-account-center"]' export const reviewStepOwnerInfo = '[data-testid="review-step-owner-info"]' const reviewStepNextBtn = '[data-testid="review-step-next-btn"]' +const creationModalLetsGoBtn = '[data-testid="cf-creation-lets-go-btn"]' const safeCreationStatusInfo = '[data-testid="safe-status-info"]' const startUsingSafeBtn = '[data-testid="start-using-safe-btn"]' const sponsorIcon = '[data-testid="sponsor-icon"]' @@ -36,6 +37,8 @@ export const activateAccountBtn = '[data-testid="activate-account-btn-cf"]' const notificationsSwitch = '[data-testid="notifications-switch"]' export const addFundsSection = '[data-testid="add-funds-section"]' export const noTokensAlert = '[data-testid="no-tokens-alert"]' +const networkCheckbox = '[data-testid="network-checkbox"]' +const cancelIcon = '[data-testid="CancelIcon"]' const sponsorStr = 'Your account is sponsored by Goerli' const safeCreationProcessing = 'Transaction is being executed' @@ -61,8 +64,8 @@ export function checkNotificationsSwitchIs(status) { cy.get(notificationsSwitch).find('input').should(`be.${status}`) } -export function clickOnActivateAccountBtn() { - cy.get(activateAccountBtn).click() +export function clickOnActivateAccountBtn(index) { + cy.get(activateAccountBtn).eq(index).click() } export function clickOnQRCodeSwitch() { @@ -123,6 +126,12 @@ export function clickOnReviewStepNextBtn() { cy.get(reviewStepNextBtn).click() cy.get(reviewStepNextBtn, { timeout: 60000 }).should('not.exist') } + +export function clickOnLetsGoBtn() { + cy.get(creationModalLetsGoBtn).click() + cy.get(creationModalLetsGoBtn, { timeout: 60000 }).should('not.exist') +} + export function verifyOwnerInfoIsPresent() { return cy.get(reviewStepOwnerInfo).shoul('exist') } @@ -184,6 +193,17 @@ export function selectNetwork(network) { cy.get('li').parents('ul').contains(regex).click() } +export function selectMultiNetwork(index, network) { + cy.get('input').eq(index).click() + cy.get('input').eq(index).type(network) + cy.get(networkCheckbox).eq(0).click() +} + +export function clearNetworkInput(index) { + cy.get('input').eq(index).click() + cy.get(cancelIcon).click() +} + export function clickOnNextBtn() { cy.get(nextBtn).should('be.enabled').click() } @@ -257,8 +277,8 @@ export function verifyThresholdStringInSummaryStep(startThreshold, endThreshold) cy.contains(`${startThreshold} out of ${endThreshold}`) } -export function verifyNetworkInSummaryStep(network) { - cy.get('div').contains('Name').parent().parent().contains(network) +export function verifySafeNetworkNameInSummaryStep(name) { + cy.get('div').contains('Name').parent().parent().contains(name) } export function verifyEstimatedFeeInSummaryStep() { diff --git a/cypress/e2e/pages/load_safe.pages.js b/cypress/e2e/pages/load_safe.pages.js index 45626eebf2..503c9608c1 100644 --- a/cypress/e2e/pages/load_safe.pages.js +++ b/cypress/e2e/pages/load_safe.pages.js @@ -210,7 +210,7 @@ export function verifyDataInReviewSection(safeName, ownerName, threshold = null, cy.findByText(ownerName).should('be.visible') if (ownerAddress !== null) cy.get(safeDataForm).contains(ownerAddress).should('be.visible') if (threshold !== null) cy.get(safeDataForm).contains(threshold).should('be.visible') - if (network !== null) cy.get(sidebar.chainLogo).eq(1).contains(network).should('be.visible') + if (network !== null) cy.get(sidebar.chainLogo).eq(0).contains(network).should('be.visible') } export function clickOnAddBtn() { diff --git a/cypress/e2e/pages/owners.pages.js b/cypress/e2e/pages/owners.pages.js index 039cecaee7..bbbee06bf1 100644 --- a/cypress/e2e/pages/owners.pages.js +++ b/cypress/e2e/pages/owners.pages.js @@ -16,7 +16,7 @@ const newOwnerNonceInput = 'input[name="nonce"]' const thresholdInput = 'input[name="threshold"]' const thresHoldDropDownIcon = 'svg[data-testid="ArrowDropDownIcon"]' const thresholdList = 'ul[role="listbox"]' -const thresholdDropdown = 'div[aria-haspopup="listbox"]' +const thresholdDropdown = '[data-testid="threshold-selector"]' const thresholdOption = 'li[role="option"]' const existingOwnerAddressInput = (index) => `input[name="owners.${index}.address"]` const existingOwnerNameInput = (index) => `input[name="owners.${index}.name"]` @@ -80,7 +80,7 @@ export function verifyOwnerDeletionWindowDisplayed() { } function clickOnThresholdDropdown() { - cy.get(thresholdDropdown).eq(1).click() + cy.get(thresholdDropdown).eq(0).click() } export function getThresholdOptions() { diff --git a/cypress/e2e/regression/create_safe_cf.cy.js b/cypress/e2e/regression/create_safe_cf.cy.js index c1acac991c..bca5f989ae 100644 --- a/cypress/e2e/regression/create_safe_cf.cy.js +++ b/cypress/e2e/regression/create_safe_cf.cy.js @@ -2,10 +2,8 @@ import * as constants from '../../support/constants' import * as main from '../pages/main.page' import * as createwallet from '../pages/create_wallet.pages' import * as owner from '../pages/owners.pages' -import * as navigation from '../pages/navigation.page.js' import * as ls from '../../support/localstorage_data.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import * as safeapps from '../pages/safeapps.pages' import * as wallet from '../../support/utils/wallet.js' let staticSafes = [] @@ -13,15 +11,6 @@ const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) // DO NOT use OWNER_2_PRIVATE_KEY for safe creation. Used for CF safes. const signer = walletCredentials.OWNER_2_PRIVATE_KEY -const txOrder = [ - 'Activate Safe now', - 'Add another signer', - 'Set up recovery', - 'Swap tokens', - 'Custom transaction', - 'Send token', -] - describe('CF Safe regression tests', () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) @@ -31,19 +20,6 @@ describe('CF Safe regression tests', () => { cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_0) }) - it('Verify Add native assets and Create tx modals can be opened', () => { - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) - cy.reload() - wallet.connectSigner(signer) - owner.waitForConnectionStatus() - createwallet.clickOnAddFundsBtn() - main.verifyElementsIsVisible([createwallet.qrCode]) - navigation.clickOnModalCloseBtn(0) - - createwallet.clickOnCreateTxBtn() - navigation.clickOnModalCloseBtn(0) - }) - it('Verify "0 out of 2 step completed" is shown in the dashboard', () => { main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) cy.reload() @@ -70,68 +46,6 @@ describe('CF Safe regression tests', () => { createwallet.checkQRCodeSwitchStatus(constants.checkboxStates.unchecked) }) - it('Verify "Create new transaction" modal contains tx types in sequence', () => { - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) - cy.reload() - wallet.connectSigner(signer) - owner.waitForConnectionStatus() - createwallet.clickOnCreateTxBtn() - createwallet.checkAllTxTypesOrder(txOrder) - }) - - it('Verify "Add safe now" button takes to a tx "Activate account"', () => { - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) - cy.reload() - wallet.connectSigner(signer) - owner.waitForConnectionStatus() - createwallet.clickOnCreateTxBtn() - createwallet.clickOnTxType(txOrder[0]) - cy.contains(createwallet.deployWalletStr) - }) - - it('Verify "Add another Owner" takes to a tx Add owner', () => { - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) - cy.reload() - wallet.connectSigner(signer) - owner.waitForConnectionStatus() - createwallet.clickOnCreateTxBtn() - createwallet.clickOnTxType(txOrder[1]) - main.verifyTextVisibility([createwallet.addSignerStr]) - }) - - it('Verify "Setup recovery" button takes to the "Account recovery" flow', () => { - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) - cy.reload() - wallet.connectSigner(signer) - owner.waitForConnectionStatus() - createwallet.clickOnCreateTxBtn() - createwallet.clickOnTxType(txOrder[2]) - main.verifyTextVisibility([createwallet.accountRecoveryStr]) - }) - - it('Verify "Send token" takes to the tx form to send tokens', () => { - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) - cy.reload() - wallet.connectSigner(signer) - owner.waitForConnectionStatus() - createwallet.clickOnCreateTxBtn() - createwallet.clickOnTxType(txOrder[5]) - main.verifyTextVisibility([createwallet.sendTokensStr]) - }) - - it('Verify "Custom transaction" takes to the tx builder app ', () => { - const iframeSelector = `iframe[id="iframe-${constants.TX_Builder_url}"]` - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) - cy.reload() - wallet.connectSigner(signer) - owner.waitForConnectionStatus() - createwallet.clickOnCreateTxBtn() - createwallet.clickOnTxType(txOrder[4]) - main.getIframeBody(iframeSelector).within(() => { - cy.contains(safeapps.transactionBuilderStr) - }) - }) - it('Verify "Notifications" in the settings are disabled', () => { main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) cy.reload() @@ -151,7 +65,7 @@ describe('CF Safe regression tests', () => { cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_0) wallet.connectSigner(signer) owner.waitForConnectionStatus() - createwallet.clickOnActivateAccountBtn() - main.verifyElementsIsVisible([createwallet.activateAccountBtn]) + createwallet.clickOnActivateAccountBtn(1) + cy.contains(createwallet.deployWalletStr) }) }) diff --git a/cypress/e2e/regression/create_safe_simple.cy.js b/cypress/e2e/regression/create_safe_simple.cy.js index 620ad6e313..5aad0f0d26 100644 --- a/cypress/e2e/regression/create_safe_simple.cy.js +++ b/cypress/e2e/regression/create_safe_simple.cy.js @@ -15,16 +15,6 @@ describe('Safe creation tests', () => { owner.waitForConnectionStatus() }) - it('Verify Next button is disabled until switching to network is done', () => { - createwallet.clickOnContinueWithWalletBtn() - createwallet.selectNetwork(constants.networks.ethereum) - createwallet.clickOnCreateNewSafeBtn() - createwallet.checkNetworkChangeWarningMsg() - createwallet.verifyNextBtnIsDisabled() - createwallet.selectNetwork(constants.networks.sepolia) - createwallet.verifyNextBtnIsEnabled() - }) - // TODO: Check unit tests it('Verify error message is displayed if wallet name input exceeds 50 characters', () => { createwallet.clickOnContinueWithWalletBtn() @@ -84,7 +74,7 @@ describe('Safe creation tests', () => { createwallet.verifyOwnerAddressInSummaryStep(constants.DEFAULT_OWNER_ADDRESS) createwallet.verifyOwnerAddressInSummaryStep(constants.DEFAULT_OWNER_ADDRESS) createwallet.verifyThresholdStringInSummaryStep(1, 2) - createwallet.verifyNetworkInSummaryStep(constants.networks.sepolia) + createwallet.verifySafeNetworkNameInSummaryStep(constants.networks.sepolia.toLowerCase()) createwallet.clickOnBackBtn() createwallet.clickOnBackBtn() cy.wait(1000) @@ -95,8 +85,7 @@ describe('Safe creation tests', () => { createwallet.verifyOwnerAddressInSummaryStep(constants.DEFAULT_OWNER_ADDRESS) createwallet.verifyOwnerAddressInSummaryStep(constants.DEFAULT_OWNER_ADDRESS) createwallet.verifyThresholdStringInSummaryStep(1, 2) - createwallet.verifyNetworkInSummaryStep(constants.networks.sepolia) - createwallet.verifyEstimatedFeeInSummaryStep() + createwallet.verifySafeNetworkNameInSummaryStep(constants.networks.sepolia.toLowerCase()) }) it('Verify tip is displayed on right side for threshold 1/1', () => { @@ -133,6 +122,7 @@ describe('Safe creation tests', () => { ) .then(() => { createwallet.waitForConnectionMsgDisappear() + createwallet.selectMultiNetwork(1, constants.networks.sepolia.toLowerCase()) createwallet.clickOnNextBtn() createwallet.clickOnAddNewOwnerBtn() createwallet.clickOnSignerAddressInput(1) @@ -140,4 +130,13 @@ describe('Safe creation tests', () => { owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownerAdded) }) }) + + // Unskip when the bug is fixed + it.skip('Verify Next button is disabled until switching to network is done', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.verifyNextBtnIsEnabled() + createwallet.clearNetworkInput(1) + createwallet.verifyNextBtnIsDisabled() + }) }) diff --git a/cypress/e2e/smoke/create_safe_cf.cy.js b/cypress/e2e/smoke/create_safe_cf.cy.js index 3a92053830..8831f1fe9d 100644 --- a/cypress/e2e/smoke/create_safe_cf.cy.js +++ b/cypress/e2e/smoke/create_safe_cf.cy.js @@ -40,6 +40,7 @@ describe('[SMOKE] CF Safe creation tests', () => { }, ] checkDataLayerEvents(safe_created) + createwallet.clickOnLetsGoBtn() createwallet.verifyCFSafeCreated() }) }) diff --git a/cypress/e2e/smoke/import_export_data.cy.js b/cypress/e2e/smoke/import_export_data.cy.js index b862c91ad5..fb99cd6909 100644 --- a/cypress/e2e/smoke/import_export_data.cy.js +++ b/cypress/e2e/smoke/import_export_data.cy.js @@ -3,7 +3,6 @@ import * as file from '../pages/import_export.pages' import * as main from '../pages/main.page' import * as constants from '../../support/constants' import * as ls from '../../support/localstorage_data.js' -import * as createwallet from '../pages/create_wallet.pages' import * as sideBar from '../pages/sidebar.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' @@ -15,9 +14,7 @@ describe('[SMOKE] Import Export Data tests', () => { }) beforeEach(() => { - cy.visit(constants.dataSettingsUrl).then(() => { - createwallet.selectNetwork(constants.networks.sepolia) - }) + cy.visit(constants.dataSettingsUrl) }) it('[SMOKE] Verify Safe can be accessed after test file upload', () => { diff --git a/jest.setup.js b/jest.setup.js index 3a44ca67c0..08425f8b65 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -5,6 +5,7 @@ // Learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom/extend-expect' import { TextEncoder, TextDecoder } from 'util' +import { Headers, Request, Response } from 'node-fetch' jest.mock('@web3-onboard/coinbase', () => jest.fn()) jest.mock('@web3-onboard/injected-wallets', () => ({ ProviderLabel: { MetaMask: 'MetaMask' } })) @@ -12,6 +13,7 @@ jest.mock('@web3-onboard/keystone/dist/index', () => jest.fn()) jest.mock('@web3-onboard/ledger/dist/index', () => jest.fn()) jest.mock('@web3-onboard/trezor', () => jest.fn()) jest.mock('@web3-onboard/walletconnect', () => jest.fn()) +jest.mock('safe-client-gateway-sdk') const mockOnboardState = { chains: [], @@ -68,3 +70,8 @@ Object.defineProperty(Uint8Array, Symbol.hasInstance, { : Uint8Array[Symbol.hasInstance].call(this, potentialInstance) }, }) + +// These are required for safe-client-gateway-sdk +globalThis.Request = Request +globalThis.Response = Response +globalThis.Headers = Headers diff --git a/package.json b/package.json index dc80350c70..087ce79d84 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@safe-global/api-kit": "^2.4.6", "@safe-global/protocol-kit": "^4.1.1", "@safe-global/safe-apps-sdk": "^9.1.0", - "@safe-global/safe-deployments": "^1.37.8", + "@safe-global/safe-deployments": "^1.37.10", "@safe-global/safe-gateway-typescript-sdk": "3.22.3-beta.15", "@safe-global/safe-modules-deployments": "^2.2.1", "@sentry/react": "^7.91.0", @@ -91,6 +91,7 @@ "react-hook-form": "7.41.1", "react-papaparse": "^4.0.2", "react-redux": "^9.1.2", + "safe-client-gateway-sdk": "git+https://github.com/safe-global/safe-client-gateway-sdk.git#v1.53.0-next-7344903", "semver": "^7.6.3", "zodiac-roles-deployments": "^2.2.5" }, diff --git a/public/images/sidebar/multichain-account.svg b/public/images/sidebar/multichain-account.svg new file mode 100644 index 0000000000..bb6ebeafdc --- /dev/null +++ b/public/images/sidebar/multichain-account.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/address-book/EntryDialog/index.tsx b/src/components/address-book/EntryDialog/index.tsx index 4faa8c199b..e2d3202776 100644 --- a/src/components/address-book/EntryDialog/index.tsx +++ b/src/components/address-book/EntryDialog/index.tsx @@ -7,8 +7,8 @@ import ModalDialog from '@/components/common/ModalDialog' import NameInput from '@/components/common/NameInput' import useChainId from '@/hooks/useChainId' import { useAppDispatch } from '@/store' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' import madProps from '@/utils/mad-props' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' export type AddressEntry = { name: string @@ -22,13 +22,13 @@ function EntryDialog({ address: '', }, disableAddressInput = false, - chainId, + chainIds, currentChainId, }: { handleClose: () => void defaultValues?: AddressEntry disableAddressInput?: boolean - chainId?: string + chainIds?: string[] currentChainId: string }): ReactElement { const dispatch = useAppDispatch() @@ -41,7 +41,7 @@ function EntryDialog({ const { handleSubmit, formState } = methods const submitCallback = handleSubmit((data: AddressEntry) => { - dispatch(upsertAddressBookEntry({ ...data, chainId: chainId || currentChainId })) + dispatch(upsertAddressBookEntries({ ...data, chainIds: chainIds ?? [currentChainId] })) handleClose() }) @@ -51,7 +51,12 @@ function EntryDialog({ } return ( - + 1} + >
diff --git a/src/components/address-book/ImportDialog/index.tsx b/src/components/address-book/ImportDialog/index.tsx index dedd74a3f4..13262285e7 100644 --- a/src/components/address-book/ImportDialog/index.tsx +++ b/src/components/address-book/ImportDialog/index.tsx @@ -7,7 +7,7 @@ import type { ParseResult } from 'papaparse' import { type ReactElement, useState, type MouseEvent, useMemo } from 'react' import ModalDialog from '@/components/common/ModalDialog' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { useAppDispatch } from '@/store' import css from './styles.module.css' @@ -60,7 +60,7 @@ const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen for (const entry of entries) { const [address, name, chainId] = entry - dispatch(upsertAddressBookEntry({ address, name, chainId: chainId.trim() })) + dispatch(upsertAddressBookEntries({ address, name, chainIds: [chainId.trim()] })) } trackEvent({ ...ADDRESS_BOOK_EVENTS.IMPORT, label: entries.length }) diff --git a/src/components/common/ChainIndicator/index.tsx b/src/components/common/ChainIndicator/index.tsx index 13902e92ec..57c9fa0a94 100644 --- a/src/components/common/ChainIndicator/index.tsx +++ b/src/components/common/ChainIndicator/index.tsx @@ -5,8 +5,9 @@ import { useAppSelector } from '@/store' import { selectChainById, selectChains } from '@/store/chainsSlice' import css from './styles.module.css' import useChainId from '@/hooks/useChainId' -import { Skeleton } from '@mui/material' +import { Skeleton, Stack, Typography } from '@mui/material' import isEmpty from 'lodash/isEmpty' +import FiatValue from '../FiatValue' type ChainIndicatorProps = { chainId?: string @@ -14,7 +15,9 @@ type ChainIndicatorProps = { className?: string showUnknown?: boolean showLogo?: boolean + onlyLogo?: boolean responsive?: boolean + fiatValue?: string } const fallbackChainConfig = { @@ -29,11 +32,13 @@ const fallbackChainConfig = { const ChainIndicator = ({ chainId, + fiatValue, className, inline = false, showUnknown = true, showLogo = true, responsive = false, + onlyLogo = false, }: ChainIndicatorProps): ReactElement | null => { const currentChainId = useChainId() const id = chainId || currentChainId @@ -63,6 +68,7 @@ const ChainIndicator = ({ [css.indicator]: !inline, [css.withLogo]: showLogo, [css.responsive]: responsive, + [css.onlyLogo]: onlyLogo, })} > {showLogo && ( @@ -74,8 +80,16 @@ const ChainIndicator = ({ loading="lazy" /> )} - - {chainConfig.chainName} + {!onlyLogo && ( + + {chainConfig.chainName} + {fiatValue && ( + + + + )} + + )} ) : null } diff --git a/src/components/common/ChainIndicator/styles.module.css b/src/components/common/ChainIndicator/styles.module.css index e1ed054b6c..bf05c7ff13 100644 --- a/src/components/common/ChainIndicator/styles.module.css +++ b/src/components/common/ChainIndicator/styles.module.css @@ -26,6 +26,10 @@ justify-content: flex-start; } +.onlyLogo { + min-width: 0; +} + @media (max-width: 899.95px) { .indicator { min-width: 35px; diff --git a/src/components/common/CheckWallet/index.test.tsx b/src/components/common/CheckWallet/index.test.tsx index 6d28066171..8455fa4c93 100644 --- a/src/components/common/CheckWallet/index.test.tsx +++ b/src/components/common/CheckWallet/index.test.tsx @@ -1,10 +1,14 @@ -import { render } from '@/tests/test-utils' +import { getByLabelText, render } from '@/tests/test-utils' import CheckWallet from '.' import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import useIsWrongChain from '@/hooks/useIsWrongChain' import useWallet from '@/hooks/wallets/useWallet' import { chainBuilder } from '@/tests/builders/chains' +import { useIsWalletDelegate } from '@/hooks/useDelegates' +import { faker } from '@faker-js/faker' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import useSafeInfo from '@/hooks/useSafeInfo' // mock useWallet jest.mock('@/hooks/wallets/useWallet', () => ({ @@ -38,6 +42,25 @@ jest.mock('@/hooks/useIsWrongChain', () => ({ default: jest.fn(() => false), })) +jest.mock('@/hooks/useDelegates', () => ({ + __esModule: true, + useIsWalletDelegate: jest.fn(() => false), +})) + +jest.mock('@/hooks/useSafeInfo', () => ({ + __esModule: true, + default: jest.fn(() => { + const safeAddress = faker.finance.ethereumAddress() + return { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: true }) + .build(), + } + }), +})) + const renderButton = () => render({(isOk) => }) @@ -62,7 +85,7 @@ describe('CheckWallet', () => { expect(container.querySelector('button')).toBeDisabled() // Check the tooltip text - expect(container.querySelector('span[aria-label]')).toHaveAttribute('aria-label', 'Please connect your wallet') + getByLabelText(container, 'Please connect your wallet') }) it('should disable the button when the wallet is connected to the right chain but is not an owner', () => { @@ -95,19 +118,65 @@ describe('CheckWallet', () => { useIsOnlySpendingLimitBeneficiary as jest.MockedFunction ).mockReturnValueOnce(true) + const { container: allowContainer } = render( + {(isOk) => }, + ) + + expect(allowContainer.querySelector('button')).not.toBeDisabled() + }) + + it('should not disable the button for delegates', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;(useIsWalletDelegate as jest.MockedFunction).mockReturnValueOnce(true) + + const { container } = renderButton() + + expect(container.querySelector('button')).not.toBeDisabled() + }) + + it('should disable the button for counterfactual Safes', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: false }) + .build(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + const { container } = renderButton() expect(container.querySelector('button')).toBeDisabled() - expect(container.querySelector('span[aria-label]')).toHaveAttribute( - 'aria-label', - 'Your connected wallet is not a signer of this Safe Account', + getByLabelText(container, 'You need to activate the Safe before transacting') + }) + + it('should enable the button for counterfactual Safes if allowed', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: false }) + .build(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, ) - const { container: allowContainer } = render( - {(isOk) => }, + const { container } = render( + {(isOk) => }, ) - expect(allowContainer.querySelector('button')).not.toBeDisabled() + expect(container.querySelector('button')).toBeEnabled() }) it('should allow non-owners if specified', () => { @@ -119,4 +188,17 @@ describe('CheckWallet', () => { expect(container.querySelector('button')).not.toBeDisabled() }) + + it('should not allow non-owners that have a spending limit without allowing spending limits', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;( + useIsOnlySpendingLimitBeneficiary as jest.MockedFunction + ).mockReturnValueOnce(true) + + const { container: allowContainer } = render( + {(isOk) => }, + ) + + expect(allowContainer.querySelector('button')).toBeDisabled() + }) }) diff --git a/src/components/common/CheckWallet/index.tsx b/src/components/common/CheckWallet/index.tsx index b354fb8f56..65d6e2ab21 100644 --- a/src/components/common/CheckWallet/index.tsx +++ b/src/components/common/CheckWallet/index.tsx @@ -1,5 +1,5 @@ import { useIsWalletDelegate } from '@/hooks/useDelegates' -import { type ReactElement } from 'react' +import { useMemo, type ReactElement } from 'react' import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import useWallet from '@/hooks/wallets/useWallet' @@ -14,12 +14,13 @@ type CheckWalletProps = { allowNonOwner?: boolean noTooltip?: boolean checkNetwork?: boolean + allowUndeployedSafe?: boolean } enum Message { WalletNotConnected = 'Please connect your wallet', NotSafeOwner = 'Your connected wallet is not a signer of this Safe Account', - CounterfactualMultisig = 'You need to activate the Safe before transacting', + SafeNotActivated = 'You need to activate the Safe before transacting', } const CheckWallet = ({ @@ -28,28 +29,40 @@ const CheckWallet = ({ allowNonOwner, noTooltip, checkNetwork = false, + allowUndeployedSafe = false, }: CheckWalletProps): ReactElement => { const wallet = useWallet() const isSafeOwner = useIsSafeOwner() - const isSpendingLimit = useIsOnlySpendingLimitBeneficiary() + const isOnlySpendingLimit = useIsOnlySpendingLimitBeneficiary() const connectWallet = useConnectWallet() const isWrongChain = useIsWrongChain() const isDelegate = useIsWalletDelegate() const { safe } = useSafeInfo() - const isCounterfactualMultiSig = !allowNonOwner && !safe.deployed && safe.threshold > 1 + const isUndeployedSafe = !safe.deployed - const message = - wallet && - (isSafeOwner || allowNonOwner || (isSpendingLimit && allowSpendingLimit) || isDelegate) && - !isCounterfactualMultiSig - ? '' - : !wallet - ? Message.WalletNotConnected - : isCounterfactualMultiSig - ? Message.CounterfactualMultisig - : Message.NotSafeOwner + const message = useMemo(() => { + if (!wallet) { + return Message.WalletNotConnected + } + if (isUndeployedSafe && !allowUndeployedSafe) { + return Message.SafeNotActivated + } + + if (!allowNonOwner && !isSafeOwner && !isDelegate && (!isOnlySpendingLimit || !allowSpendingLimit)) { + return Message.NotSafeOwner + } + }, [ + allowNonOwner, + allowSpendingLimit, + allowUndeployedSafe, + isDelegate, + isOnlySpendingLimit, + isSafeOwner, + isUndeployedSafe, + wallet, + ]) if (checkNetwork && isWrongChain) return children(false) if (!message) return children(true) diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index cfa880fd6b..5d7cdbf2ff 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -109,9 +109,11 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => { -
- -
+ {safeAddress && ( +
+ +
+ )} ) } diff --git a/src/components/common/ModalDialog/index.tsx b/src/components/common/ModalDialog/index.tsx index c9d32eafe6..0e8050f2e1 100644 --- a/src/components/common/ModalDialog/index.tsx +++ b/src/components/common/ModalDialog/index.tsx @@ -20,7 +20,11 @@ interface DialogTitleProps { export const ModalDialogTitle = ({ children, onClose, hideChainIndicator = false, ...other }: DialogTitleProps) => { return ( - + {children} {!hideChainIndicator && } diff --git a/src/components/common/ModalDialog/styles.module.css b/src/components/common/ModalDialog/styles.module.css index dc1b741342..2b230f6bf3 100644 --- a/src/components/common/ModalDialog/styles.module.css +++ b/src/components/common/ModalDialog/styles.module.css @@ -1,6 +1,6 @@ .dialog :global .MuiDialogActions-root { border-top: 1px solid var(--color-border-light); - padding: var(--space-3); + padding: var(--space-2) var(--space-3); } .dialog :global .MuiDialogActions-root > :last-of-type:not(:first-of-type) { diff --git a/src/components/common/NameInput/index.tsx b/src/components/common/NameInput/index.tsx index ac3a1ed27c..be31c2ba94 100644 --- a/src/components/common/NameInput/index.tsx +++ b/src/components/common/NameInput/index.tsx @@ -25,6 +25,7 @@ const NameInput = ({ fullWidth required={required} className={inputCss.input} + onKeyDown={(e) => e.stopPropagation()} {...register(name, { maxLength: 50, required })} /> ) diff --git a/src/components/common/NetworkInput/index.tsx b/src/components/common/NetworkInput/index.tsx new file mode 100644 index 0000000000..2e070cfacc --- /dev/null +++ b/src/components/common/NetworkInput/index.tsx @@ -0,0 +1,93 @@ +import ChainIndicator from '@/components/common/ChainIndicator' +import { useDarkMode } from '@/hooks/useDarkMode' +import { useTheme } from '@mui/material/styles' +import { FormControl, InputLabel, ListSubheader, MenuItem, Select, Typography } from '@mui/material' +import partition from 'lodash/partition' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import css from './styles.module.css' +import { type ReactElement, useCallback, useMemo } from 'react' +import { Controller, useFormContext } from 'react-hook-form' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +const NetworkInput = ({ + name, + required = false, + chainConfigs, +}: { + name: string + required?: boolean + chainConfigs: (ChainInfo & { available: boolean })[] +}): ReactElement => { + const isDarkMode = useDarkMode() + const theme = useTheme() + const [testNets, prodNets] = useMemo(() => partition(chainConfigs, (config) => config.isTestnet), [chainConfigs]) + const { control } = useFormContext() || {} + + const renderMenuItem = useCallback( + (chainId: string, isDisabled: boolean) => { + const chain = chainConfigs.find((chain) => chain.chainId === chainId) + if (!chain) return null + return ( + + + {isDisabled && ( + + Not available + + )} + + ) + }, + [chainConfigs], + ) + + return ( + ( + + Network + + + )} + /> + ) +} + +export default NetworkInput diff --git a/src/components/common/NetworkInput/styles.module.css b/src/components/common/NetworkInput/styles.module.css new file mode 100644 index 0000000000..d0c269ffb8 --- /dev/null +++ b/src/components/common/NetworkInput/styles.module.css @@ -0,0 +1,62 @@ +.select { + height: 100%; +} + +.select:after, +.select:before { + display: none; +} + +.select *:focus-visible { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; +} + +.select :global .MuiSelect-select { + padding-right: 40px !important; + padding-left: 16px; + height: 100%; + display: flex; + align-items: center; +} + +.select :global .MuiSelect-icon { + margin-right: var(--space-2); +} + +.select :global .Mui-disabled { + pointer-events: none; +} + +.select :global .MuiMenuItem-root { + padding: 0; +} + +.listSubHeader { + text-transform: uppercase; + font-size: 11px; + font-weight: bold; + line-height: 32px; +} + +.newChip { + font-weight: bold; + letter-spacing: -0.1px; + margin-top: -18px; + margin-left: -14px; + transform: scale(0.7); +} + +.item { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.disabledChip { + background-color: var(--color-border-light); + border-radius: 4px; + color: var(--color-text-primary); + padding: 4px 8px; + margin-left: auto; +} diff --git a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx new file mode 100644 index 0000000000..5a4fbdcc68 --- /dev/null +++ b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx @@ -0,0 +1,161 @@ +import useChains, { useCurrentChain } from '@/hooks/useChains' +import useSafeAddress from '@/hooks/useSafeAddress' +import { useCallback, useEffect, type ReactElement } from 'react' +import { Checkbox, Autocomplete, TextField, Chip } from '@mui/material' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import ChainIndicator from '../ChainIndicator' +import css from './styles.module.css' +import { Controller, useFormContext, useWatch } from 'react-hook-form' +import { useRouter } from 'next/router' +import { getNetworkLink } from '.' +import { SetNameStepFields } from '@/components/new-safe/create/steps/SetNameStep' +import { getSafeSingletonDeployments } from '@safe-global/safe-deployments' +import { getLatestSafeVersion } from '@/utils/chains' +import { hasCanonicalDeployment } from '@/services/contracts/deployments' +import { hasMultiChainCreationFeatures } from '@/features/multichain/utils/utils' + +const NetworkMultiSelector = ({ + name, + isAdvancedFlow = false, +}: { + name: string + isAdvancedFlow?: boolean +}): ReactElement => { + const { configs } = useChains() + const router = useRouter() + const safeAddress = useSafeAddress() + const currentChain = useCurrentChain() + + const { + formState: { errors }, + control, + getValues, + setValue, + } = useFormContext() + + const selectedNetworks: ChainInfo[] = useWatch({ control, name: SetNameStepFields.networks }) + + const updateCurrentNetwork = useCallback( + (chains: ChainInfo[]) => { + if (chains.length !== 1) return + const shortName = chains[0].shortName + const networkLink = getNetworkLink(router, safeAddress, shortName) + router.replace(networkLink) + }, + [router, safeAddress], + ) + + const handleDelete = useCallback( + (deletedChainId: string) => { + const currentValues: ChainInfo[] = getValues(name) || [] + const updatedValues = currentValues.filter((chain) => chain.chainId !== deletedChainId) + updateCurrentNetwork(updatedValues) + setValue(name, updatedValues) + }, + [getValues, name, setValue, updateCurrentNetwork], + ) + + const isOptionDisabled = useCallback( + (optionNetwork: ChainInfo) => { + // Initially all networks are always available + if (selectedNetworks.length === 0) { + return false + } + + const firstSelectedNetwork = selectedNetworks[0] + + // do not allow multi chain safes for advanced setup flow. + if (isAdvancedFlow) return optionNetwork.chainId != firstSelectedNetwork.chainId + + // Check required feature toggles + const optionIsSelectedNetwork = firstSelectedNetwork.chainId === optionNetwork.chainId + if (!hasMultiChainCreationFeatures(optionNetwork) || !hasMultiChainCreationFeatures(firstSelectedNetwork)) { + return !optionIsSelectedNetwork + } + + // Check if required deployments are available + const optionHasCanonicalSingletonDeployment = hasCanonicalDeployment( + getSafeSingletonDeployments({ + network: optionNetwork.chainId, + version: getLatestSafeVersion(firstSelectedNetwork), + }), + optionNetwork.chainId, + ) + const selectedHasCanonicalSingletonDeployment = hasCanonicalDeployment( + getSafeSingletonDeployments({ + network: firstSelectedNetwork.chainId, + version: getLatestSafeVersion(firstSelectedNetwork), + }), + firstSelectedNetwork.chainId, + ) + + // Only 1.4.1 safes with canonical deployment addresses can be deployed as part of a multichain group + if (!selectedHasCanonicalSingletonDeployment) return !optionIsSelectedNetwork + return !optionHasCanonicalSingletonDeployment + }, + [isAdvancedFlow, selectedNetworks], + ) + + useEffect(() => { + if (selectedNetworks.length === 1 && selectedNetworks[0].chainId !== currentChain?.chainId) { + updateCurrentNetwork([selectedNetworks[0]]) + } + }, [selectedNetworks, currentChain, updateCurrentNetwork]) + + return ( + <> + ( + + selectedOptions.map((chain) => ( + } + label={chain.chainName} + onDelete={() => handleDelete(chain.chainId)} + className={css.multiChainChip} + > + )) + } + renderOption={(props, chain, { selected }) => ( +
  • + + +
  • + )} + getOptionLabel={(option) => option.chainName} + getOptionDisabled={isOptionDisabled} + renderInput={(params) => ( + + )} + filterOptions={(options, { inputValue }) => + options.filter((option) => option.chainName.toLowerCase().includes(inputValue.toLowerCase())) + } + isOptionEqualToValue={(option, value) => option.chainId === value.chainId} + onChange={(_, data) => { + updateCurrentNetwork(data) + return field.onChange(data) + }} + /> + )} + rules={{ required: true }} + /> + + ) +} + +export default NetworkMultiSelector diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 22ea173099..c2c0072b60 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -1,91 +1,377 @@ import ChainIndicator from '@/components/common/ChainIndicator' +import Track from '@/components/common/Track' import { useDarkMode } from '@/hooks/useDarkMode' +import { useAppSelector } from '@/store' +import { selectChains } from '@/store/chainsSlice' import { useTheme } from '@mui/material/styles' import Link from 'next/link' -import type { SelectChangeEvent } from '@mui/material' -import { ListSubheader, MenuItem, Select, Skeleton } from '@mui/material' +import { + Box, + ButtonBase, + CircularProgress, + Collapse, + Divider, + MenuItem, + Select, + Skeleton, + Stack, + Tooltip, + Typography, +} from '@mui/material' import partition from 'lodash/partition' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import useChains from '@/hooks/useChains' +import useChains, { useCurrentChain } from '@/hooks/useChains' +import type { NextRouter } from 'next/router' import { useRouter } from 'next/router' import css from './styles.module.css' import { useChainId } from '@/hooks/useChainId' -import { type ReactElement, useMemo } from 'react' -import { useCallback } from 'react' -import { AppRoutes } from '@/config/routes' -import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' -import useWallet from '@/hooks/wallets/useWallet' -import { useAppSelector } from '@/store' -import { selectChains } from '@/store/chainsSlice' +import { type ReactElement, useCallback, useMemo, useState } from 'react' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' + +import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' +import useSafeAddress from '@/hooks/useSafeAddress' +import { sameAddress } from '@/utils/addresses' +import uniq from 'lodash/uniq' +import { useCompatibleNetworks } from '@/features/multichain/hooks/useCompatibleNetworks' +import { useSafeCreationData } from '@/features/multichain/hooks/useSafeCreationData' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import PlusIcon from '@/public/images/common/plus.svg' +import useAddressBook from '@/hooks/useAddressBook' +import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain' +import { useGetSafeOverviewQuery } from '@/store/api/gateway' +import { InfoOutlined } from '@mui/icons-material' +import { selectUndeployedSafe } from '@/store/slices' +import { skipToken } from '@reduxjs/toolkit/query' +import { hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' + +const ChainIndicatorWithFiatBalance = ({ + isSelected, + chain, + safeAddress, +}: { + isSelected: boolean + chain: ChainInfo + safeAddress: string +}) => { + const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chain.chainId, safeAddress)) + const { data: safeOverview } = useGetSafeOverviewQuery( + undeployedSafe ? skipToken : { safeAddress, chainId: chain.chainId }, + ) + + return ( + + ) +} + +export const getNetworkLink = (router: NextRouter, safeAddress: string, networkShortName: string) => { + const isSafeOpened = safeAddress !== '' + + const query = ( + isSafeOpened + ? { + safe: `${networkShortName}:${safeAddress}`, + } + : { chain: networkShortName } + ) as { + safe?: string + chain?: string + safeViewRedirectURL?: string + } + + const route = { + pathname: router.pathname, + query, + } + + if (router.query?.safeViewRedirectURL) { + route.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() + } + + return route +} -const NetworkSelector = (props: { onChainSelect?: () => void }): ReactElement => { +const UndeployedNetworkMenuItem = ({ + chain, + isSelected = false, + onSelect, +}: { + chain: ChainInfo & { available: boolean } + isSelected?: boolean + onSelect: (chain: ChainInfo) => void +}) => { + const isDisabled = !chain.available + + return ( + + + onSelect(chain)} + disabled={isDisabled} + > + + + {isDisabled ? ( + + Not available + + ) : ( + + )} + + + + + ) +} + +const NetworkSkeleton = () => { + return ( + + + + + ) +} + +const TestnetDivider = () => { + return ( + + + Testnets + + + ) +} + +const UndeployedNetworks = ({ + deployedChains, + chains, + safeAddress, + closeNetworkSelect, +}: { + deployedChains: string[] + chains: ChainInfo[] + safeAddress: string + closeNetworkSelect: () => void +}) => { + const [open, setOpen] = useState(false) + const [replayOnChain, setReplayOnChain] = useState() + const addressBook = useAddressBook() + const safeName = addressBook[safeAddress] + const deployedChainInfos = useMemo( + () => chains.filter((chain) => deployedChains.includes(chain.chainId)), + [chains, deployedChains], + ) + const safeCreationResult = useSafeCreationData(safeAddress, deployedChainInfos) + const [safeCreationData, safeCreationDataError, safeCreationLoading] = safeCreationResult + + const allCompatibleChains = useCompatibleNetworks(safeCreationData) + const isUnsupportedSafeCreationVersion = Boolean(!allCompatibleChains?.length) + + const availableNetworks = useMemo( + () => + allCompatibleChains?.filter( + (config) => !deployedChains.includes(config.chainId) && hasMultiChainAddNetworkFeature(config), + ) || [], + [allCompatibleChains, deployedChains], + ) + + const [testNets, prodNets] = useMemo( + () => partition(availableNetworks, (config) => config.isTestnet), + [availableNetworks], + ) + + const noAvailableNetworks = useMemo(() => availableNetworks.every((config) => !config.available), [availableNetworks]) + + const onSelect = (chain: ChainInfo) => { + setReplayOnChain(chain) + } + + if (safeCreationLoading) { + return ( + + + + ) + } + + const errorMessage = + safeCreationDataError || (safeCreationData && noAvailableNetworks) ? ( + + {safeCreationDataError?.message && ( + + + + )} + Adding another network is not possible for this Safe. + + ) : isUnsupportedSafeCreationVersion ? ( + 'This account was created from an outdated mastercopy. Adding another network is not possible.' + ) : ( + '' + ) + + if (errorMessage) { + return ( + + + {errorMessage} + + + ) + } + + const onFormClose = () => { + setReplayOnChain(undefined) + closeNetworkSelect() + } + + const onShowAllNetworks = () => { + !open && trackEvent(OVERVIEW_EVENTS.SHOW_ALL_NETWORKS) + setOpen((prev) => !prev) + } + + return ( + <> + + +
    Show all networks
    + +
    +
    + + {!safeCreationData ? ( + + + + + ) : ( + <> + {prodNets.map((chain) => ( + + ))} + {testNets.length > 0 && } + {testNets.map((chain) => ( + + ))} + + )} + + {replayOnChain && safeCreationData && ( + + )} + + ) +} + +const NetworkSelector = ({ + onChainSelect, + offerSafeCreation = false, +}: { + onChainSelect?: () => void + offerSafeCreation?: boolean +}): ReactElement => { + const [open, setOpen] = useState(false) const isDarkMode = useDarkMode() const theme = useTheme() const { configs } = useChains() const chainId = useChainId() const router = useRouter() - const isWalletConnected = !!useWallet() - const [testNets, prodNets] = useMemo(() => partition(configs, (config) => config.isTestnet), [configs]) + const safeAddress = useSafeAddress() + const currentChain = useCurrentChain() const chains = useAppSelector(selectChains) - const getNetworkLink = useCallback( - (shortName: string) => { - const shouldKeepPath = !router.query.safe - - const route = { - pathname: shouldKeepPath - ? router.pathname - : isWalletConnected - ? AppRoutes.welcome.accounts - : AppRoutes.welcome.index, - query: { - chain: shortName, - } as { - chain: string - safeViewRedirectURL?: string - }, - } - - if (router.query?.safeViewRedirectURL) { - route.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() - } - - return route - }, - [router, isWalletConnected], - ) + const isSafeOpened = safeAddress !== '' - const onChange = (event: SelectChangeEvent) => { - event.preventDefault() // Prevent the link click + const addNetworkFeatureEnabled = hasMultiChainAddNetworkFeature(currentChain) - const newChainId = event.target.value - const shortName = configs.find((item) => item.chainId === newChainId)?.shortName - - if (shortName) { - trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: newChainId }) - router.push(getNetworkLink(shortName)) + const safesGrouped = useAllSafesGrouped() + const availableChainIds = useMemo(() => { + if (!isSafeOpened) { + // Offer all chains + return configs.map((config) => config.chainId) } - } + return uniq([ + chainId, + ...(safesGrouped.allMultiChainSafes + ?.find((item) => sameAddress(item.address, safeAddress)) + ?.safes.map((safe) => safe.chainId) ?? []), + ]) + }, [chainId, configs, isSafeOpened, safeAddress, safesGrouped.allMultiChainSafes]) + + const [testNets, prodNets] = useMemo( + () => + partition( + configs.filter((config) => availableChainIds.includes(config.chainId)), + (config) => config.isTestnet, + ), + [availableChainIds, configs], + ) const renderMenuItem = useCallback( (chainId: string, isSelected: boolean) => { const chain = chains.data.find((chain) => chain.chainId === chainId) if (!chain) return null + + const onSwitchNetwork = () => { + trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: chainId }) + } + return ( - - - + + + ) }, - [chains.data, getNetworkLink, props.onChainSelect], + [chains.data, onChainSelect, router, safeAddress], ) + const handleClose = () => { + setOpen(false) + } + + const handleOpen = () => { + setOpen(true) + offerSafeCreation && trackEvent({ ...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE, label: OVERVIEW_LABELS.top_bar }) + } + return configs.length ? ( ) : ( diff --git a/src/components/common/NetworkSelector/styles.module.css b/src/components/common/NetworkSelector/styles.module.css index 1703446ac1..f53b932c4c 100644 --- a/src/components/common/NetworkSelector/styles.module.css +++ b/src/components/common/NetworkSelector/styles.module.css @@ -33,10 +33,29 @@ } .listSubHeader { + background-color: var(--color-background-main); text-transform: uppercase; font-size: 11px; font-weight: bold; line-height: 32px; + text-align: center; + letter-spacing: 1px; + width: 100%; + margin-top: var(--space-1); +} + +[data-theme='dark'] .undeployedNetworksHeader { + background-color: var(--color-secondary-background); +} + +.plusIcon { + background-color: var(--color-background-main); + color: var(--color-border-main); + border-radius: 100%; + height: 20px; + width: 20px; + padding: 4px; + margin-left: auto; } .newChip { @@ -50,5 +69,20 @@ .item { display: flex; align-items: center; + justify-content: space-between; gap: var(--space-1); + width: 100%; +} + +.multiChainChip { + padding: var(--space-2) 0; + margin: 2px; + border-color: var(--color-border-main); +} + +.comingSoon { + background-color: var(--color-border-light); + border-radius: 4px; + color: var(--color-text-primary); + padding: 4px 8px; } diff --git a/src/components/common/NetworkSelector/useChangeNetworkLink.ts b/src/components/common/NetworkSelector/useChangeNetworkLink.ts new file mode 100644 index 0000000000..be1a7368ed --- /dev/null +++ b/src/components/common/NetworkSelector/useChangeNetworkLink.ts @@ -0,0 +1,27 @@ +import { AppRoutes } from '@/config/routes' +import useWallet from '@/hooks/wallets/useWallet' +import { useRouter } from 'next/router' + +export const useChangeNetworkLink = (networkShortName: string) => { + const router = useRouter() + const isWalletConnected = !!useWallet() + const pathname = router.pathname + + const shouldKeepPath = !router.query.safe + + const route = { + pathname: shouldKeepPath ? pathname : isWalletConnected ? AppRoutes.welcome.accounts : AppRoutes.welcome.index, + query: { + chain: networkShortName, + } as { + chain: string + safeViewRedirectURL?: string + }, + } + + if (router.query?.safeViewRedirectURL) { + route.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() + } + + return route +} diff --git a/src/components/common/SafeIcon/index.tsx b/src/components/common/SafeIcon/index.tsx index 4ecfdddbbb..271db349ef 100644 --- a/src/components/common/SafeIcon/index.tsx +++ b/src/components/common/SafeIcon/index.tsx @@ -1,8 +1,9 @@ import type { ReactElement } from 'react' -import { Box } from '@mui/material' +import { Box, Skeleton } from '@mui/material' import css from './styles.module.css' import Identicon, { type IdenticonProps } from '../Identicon' +import { useChain } from '@/hooks/useChains' interface ThresholdProps { threshold: number | string @@ -18,13 +19,35 @@ interface SafeIconProps extends IdenticonProps { threshold?: ThresholdProps['threshold'] owners?: ThresholdProps['owners'] size?: number + chainId?: string + isSubItem?: boolean } -const SafeIcon = ({ address, threshold, owners, size }: SafeIconProps): ReactElement => ( -
    - {threshold && owners ? : null} - -
    -) +const ChainIcon = ({ chainId }: { chainId: string }) => { + const chainConfig = useChain(chainId) + + if (!chainConfig) { + return + } + + return ( + {`${chainConfig.chainName} + ) +} + +const SafeIcon = ({ address, threshold, owners, size, chainId, isSubItem = false }: SafeIconProps): ReactElement => { + return ( +
    + {threshold && owners ? : null} + {isSubItem && chainId ? : } +
    + ) +} export default SafeIcon diff --git a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx index b92a675f2e..4f7ac6bfdc 100644 --- a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx +++ b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx @@ -5,8 +5,8 @@ import SafeTokenWidget from '..' import { toBeHex } from 'ethers' import { AppRoutes } from '@/config/routes' import useSafeTokenAllocation, { useSafeVotingPower } from '@/hooks/useSafeTokenAllocation' -import * as safePass from '@/store/safePass' -import type { CampaignLeaderboardEntry } from '@/store/safePass' +import * as safePass from '@/store/api/safePass' +import type { CampaignLeaderboardEntry } from '@/store/api/safePass' jest.mock('@/hooks/useChainId') diff --git a/src/components/common/SafeTokenWidget/index.tsx b/src/components/common/SafeTokenWidget/index.tsx index 7082745ca8..6b527cd726 100644 --- a/src/components/common/SafeTokenWidget/index.tsx +++ b/src/components/common/SafeTokenWidget/index.tsx @@ -15,7 +15,7 @@ import { useSanctionedAddress } from '@/hooks/useSanctionedAddress' import useSafeAddress from '@/hooks/useSafeAddress' import { skipToken } from '@reduxjs/toolkit/query/react' import { useDarkMode } from '@/hooks/useDarkMode' -import { useGetOwnGlobalCampaignRankQuery } from '@/store/safePass' +import { useGetOwnGlobalCampaignRankQuery } from '@/store/api/safePass' import { formatAmount } from '@/utils/formatNumber' const TOKEN_DECIMALS = 18 diff --git a/src/components/dashboard/FirstSteps/index.tsx b/src/components/dashboard/FirstSteps/index.tsx index cf4dde16b3..a508a899fc 100644 --- a/src/components/dashboard/FirstSteps/index.tsx +++ b/src/components/dashboard/FirstSteps/index.tsx @@ -15,6 +15,7 @@ import { useAppDispatch, useAppSelector } from '@/store' import { selectSettings, setQrShortName } from '@/store/settingsSlice' import { selectOutgoingTransactions } from '@/store/txHistorySlice' import { getExplorerLink } from '@/utils/gateway' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import classnames from 'classnames' import { type ReactNode, useState } from 'react' import { Card, WidgetBody, WidgetContainer } from '@/components/dashboard/styled' @@ -25,6 +26,7 @@ import CheckCircleOutlineRoundedIcon from '@mui/icons-material/CheckCircleOutlin import LightbulbOutlinedIcon from '@mui/icons-material/LightbulbOutlined' import css from './styles.module.css' import ActivateAccountButton from '@/features/counterfactual/ActivateAccountButton' +import { isReplayedSafeProps } from '@/features/counterfactual/utils' const calculateProgress = (items: boolean[]) => { const totalNumberOfItems = items.length @@ -58,7 +60,9 @@ const StatusCard = ({ {title} - {content} + + {content} + {children} ) @@ -258,24 +262,27 @@ const FirstTransactionWidget = ({ completed }: { completed: boolean }) => { ) } -const ActivateSafeWidget = () => { +const ActivateSafeWidget = ({ chain }: { chain: ChainInfo | undefined }) => { const [open, setOpen] = useState(false) - const title = 'Activate your Safe account.' + const title = `Activate account ${chain ? 'on ' + chain.chainName : ''}` + const content = 'Activate your account to start using all benefits of Safe' return ( <> - Activate your Safe + First interaction } title={title} completed={false} - content="" + content={content} > - + + + setOpen(false)} /> @@ -304,6 +311,7 @@ const FirstSteps = () => { const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, safe.chainId, safeAddress)) const isMultiSig = safe.threshold > 1 + const isReplayedSafe = undeployedSafe && isReplayedSafeProps(undeployedSafe?.props) const hasNonZeroBalance = balances && (balances.items.length > 1 || BigInt(balances.items[0]?.balance || 0) > 0) const hasOutgoingTransactions = !!outgoingTransactions && outgoingTransactions.length > 0 @@ -383,8 +391,8 @@ const FirstSteps = () => { {isActivating ? ( - ) : isMultiSig ? ( - + ) : isMultiSig || isReplayedSafe ? ( + ) : ( )} diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 3009984244..a680f09f85 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -16,6 +16,7 @@ import css from './styles.module.css' import SwapWidget from '@/features/swap/components/SwapWidget' import useIsSwapFeatureEnabled from '@/features/swap/hooks/useIsSwapFeatureEnabled' import { useSafeTokenEnabled } from '@/hooks/useSafeTokenEnabled' +import { InconsistentSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning' const RecoveryHeader = dynamic(() => import('@/features/recovery/components/RecoveryHeader')) @@ -32,6 +33,10 @@ const Dashboard = (): ReactElement => { {supportsRecovery && } + + + + diff --git a/src/components/new-safe/create/AdvancedCreateSafe.tsx b/src/components/new-safe/create/AdvancedCreateSafe.tsx index 3c434e3bfd..963c8961d3 100644 --- a/src/components/new-safe/create/AdvancedCreateSafe.tsx +++ b/src/components/new-safe/create/AdvancedCreateSafe.tsx @@ -33,7 +33,16 @@ const AdvancedCreateSafe = () => { title: 'Select network and name of your Safe Account', subtitle: 'Select the network on which to create your Safe Account', render: (data, onSubmit, onBack, setStep) => ( - + {}} + setDynamicHint={() => {}} + /> ), }, { @@ -84,6 +93,7 @@ const AdvancedCreateSafe = () => { const initialStep = 0 const initialData: NewSafeFormData = { name: '', + networks: [], owners: [], threshold: 1, saltNonce: 0, @@ -115,7 +125,7 @@ const AdvancedCreateSafe = () => { - {activeStep < 2 && } + {activeStep < 2 && } {wallet?.address && } diff --git a/src/components/new-safe/create/OverviewWidget/index.tsx b/src/components/new-safe/create/OverviewWidget/index.tsx index a9eb6f8aca..a6119321e9 100644 --- a/src/components/new-safe/create/OverviewWidget/index.tsx +++ b/src/components/new-safe/create/OverviewWidget/index.tsx @@ -1,6 +1,4 @@ -import ChainIndicator from '@/components/common/ChainIndicator' import WalletOverview from 'src/components/common/WalletOverview' -import { useCurrentChain } from '@/hooks/useChains' import useWallet from '@/hooks/wallets/useWallet' import { Box, Card, Grid, Typography } from '@mui/material' import type { ReactElement } from 'react' @@ -8,16 +6,24 @@ import SafeLogo from '@/public/images/logo-no-text.svg' import css from '@/components/new-safe/create/OverviewWidget/styles.module.css' import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import NetworkLogosList from '@/features/multichain/components/NetworkLogosList' const LOGO_DIMENSIONS = '22px' -const OverviewWidget = ({ safeName }: { safeName: string }): ReactElement | null => { +const OverviewWidget = ({ safeName, networks }: { safeName: string; networks: ChainInfo[] }): ReactElement | null => { const wallet = useWallet() - const chain = useCurrentChain() const rows = [ ...(wallet ? [{ title: 'Wallet', component: }] : []), - ...(chain ? [{ title: 'Network', component: }] : []), ...(safeName !== '' ? [{ title: 'Name', component: {safeName} }] : []), + ...(networks.length + ? [ + { + title: 'Network(s)', + component: , + }, + ] + : []), ] return ( diff --git a/src/components/new-safe/create/OverviewWidget/styles.module.css b/src/components/new-safe/create/OverviewWidget/styles.module.css index c7e87b7dbe..6119485d8f 100644 --- a/src/components/new-safe/create/OverviewWidget/styles.module.css +++ b/src/components/new-safe/create/OverviewWidget/styles.module.css @@ -19,4 +19,5 @@ justify-content: space-between; align-items: center; border-top: 1px solid var(--color-border-light); + gap: var(--space-1); } diff --git a/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts b/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts index 172c33ebbe..42bb6210bb 100644 --- a/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts +++ b/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts @@ -12,11 +12,22 @@ import { JsonRpcProvider } from 'ethers' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { waitFor } from '@testing-library/react' import { type EIP1193Provider } from '@web3-onboard/core' +import { type ReplayedSafeProps } from '@/store/slices' +import { faker } from '@faker-js/faker' -const mockProps = { - owners: [], - threshold: 1, - saltNonce: 1, +const mockProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: faker.finance.ethereumAddress(), + paymentReceiver: ZERO_ADDRESS, + }, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '0', + safeVersion: '1.3.0', } describe('useEstimateSafeCreationGas', () => { @@ -28,7 +39,7 @@ describe('useEstimateSafeCreationGas', () => { jest .spyOn(safeContracts, 'getReadOnlyProxyFactoryContract') .mockResolvedValue({ getAddress: () => ZERO_ADDRESS } as unknown as SafeProxyFactoryContractImplementationType) - jest.spyOn(sender, 'encodeSafeCreationTx').mockReturnValue(Promise.resolve(EMPTY_DATA)) + jest.spyOn(sender, 'encodeSafeCreationTx').mockReturnValue(EMPTY_DATA) jest.spyOn(wallet, 'default').mockReturnValue({} as ConnectedWallet) }) diff --git a/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts b/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts index ff9ff360d1..5cd1317202 100644 --- a/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts +++ b/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts @@ -1,11 +1,13 @@ import { renderHook } from '@/tests/test-utils' import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCreationStep' import * as wallet from '@/hooks/wallets/useWallet' +import * as currentChain from '@/hooks/useChains' import * as localStorage from '@/services/local-storage/useLocalStorage' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import * as useIsWrongChain from '@/hooks/useIsWrongChain' import * as useRouter from 'next/router' import { type NextRouter } from 'next/router' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' describe('useSyncSafeCreationStep', () => { beforeEach(() => { @@ -20,7 +22,7 @@ describe('useSyncSafeCreationStep', () => { } as unknown as NextRouter) const mockSetStep = jest.fn() - renderHook(() => useSyncSafeCreationStep(mockSetStep)) + renderHook(() => useSyncSafeCreationStep(mockSetStep, [])) expect(mockSetStep).toHaveBeenCalledWith(0) }) @@ -28,11 +30,11 @@ describe('useSyncSafeCreationStep', () => { it('should go to the first 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(useIsWrongChain, 'default').mockReturnValue(true) + jest.spyOn(currentChain, 'useCurrentChain').mockReturnValue({ chainId: '100' } as ChainInfo) const mockSetStep = jest.fn() - renderHook(() => useSyncSafeCreationStep(mockSetStep)) + renderHook(() => useSyncSafeCreationStep(mockSetStep, [{ chainId: '4' } as ChainInfo])) expect(mockSetStep).toHaveBeenCalledWith(0) }) @@ -44,7 +46,7 @@ describe('useSyncSafeCreationStep', () => { const mockSetStep = jest.fn() - renderHook(() => useSyncSafeCreationStep(mockSetStep)) + renderHook(() => useSyncSafeCreationStep(mockSetStep, [])) expect(mockSetStep).not.toHaveBeenCalled() }) diff --git a/src/components/new-safe/create/index.tsx b/src/components/new-safe/create/index.tsx index 7683691eb1..53409fb77a 100644 --- a/src/components/new-safe/create/index.tsx +++ b/src/components/new-safe/create/index.tsx @@ -21,9 +21,11 @@ import { HelpCenterArticle } from '@/config/constants' import { type SafeVersion } from '@safe-global/safe-core-sdk-types' import { getLatestSafeVersion } from '@/utils/chains' import { useCurrentChain } from '@/hooks/useChains' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' export type NewSafeFormData = { name: string + networks: ChainInfo[] threshold: number owners: NamedAddress[] saltNonce: number @@ -104,15 +106,25 @@ const CreateSafe = () => { const chain = useCurrentChain() const [safeName, setSafeName] = useState('') + const [overviewNetworks, setOverviewNetworks] = useState() + const [dynamicHint, setDynamicHint] = useState() const [activeStep, setActiveStep] = useState(0) const CreateSafeSteps: TxStepperProps['steps'] = [ { - title: 'Select network and name of your Safe Account', - subtitle: 'Select the network on which to create your Safe Account', + title: 'Set up the basics', + subtitle: 'Give a name to your account and select which networks to deploy it on.', render: (data, onSubmit, onBack, setStep) => ( - + ), }, { @@ -158,9 +170,10 @@ const CreateSafe = () => { const initialStep = 0 const initialData: NewSafeFormData = { name: '', + networks: [], owners: [], threshold: 1, - saltNonce: Date.now(), + saltNonce: 0, safeVersion: getLatestSafeVersion(chain) as SafeVersion, } @@ -189,7 +202,7 @@ const CreateSafe = () => { - {activeStep < 2 && } + {activeStep < 2 && } {wallet?.address && } diff --git a/src/components/new-safe/create/logic/address-book.ts b/src/components/new-safe/create/logic/address-book.ts index e803e07aac..09f16d2ce2 100644 --- a/src/components/new-safe/create/logic/address-book.ts +++ b/src/components/new-safe/create/logic/address-book.ts @@ -1,11 +1,11 @@ import type { AppThunk } from '@/store' import { addOrUpdateSafe } from '@/store/addedSafesSlice' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { defaultSafeInfo } from '@/store/safeInfoSlice' import type { NamedAddress } from '@/components/new-safe/create/types' export const updateAddressBook = ( - chainId: string, + chainIds: string[], address: string, name: string, owners: NamedAddress[], @@ -13,8 +13,8 @@ export const updateAddressBook = ( ): AppThunk => { return (dispatch) => { dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds, address, name, }), @@ -23,24 +23,26 @@ export const updateAddressBook = ( owners.forEach((owner) => { const entryName = owner.name || owner.ens if (entryName) { - dispatch(upsertAddressBookEntry({ chainId, address: owner.address, name: entryName })) + dispatch(upsertAddressBookEntries({ chainIds, address: owner.address, name: entryName })) } }) - dispatch( - addOrUpdateSafe({ - safe: { - ...defaultSafeInfo, - address: { value: address, name }, - threshold, - owners: owners.map((owner) => ({ - value: owner.address, - name: owner.name || owner.ens, - })), - chainId, - nonce: 0, - }, - }), - ) + chainIds.forEach((chainId) => { + dispatch( + addOrUpdateSafe({ + safe: { + ...defaultSafeInfo, + address: { value: address, name }, + threshold, + owners: owners.map((owner) => ({ + value: owner.address, + name: owner.name || owner.ens, + })), + chainId, + nonce: 0, + }, + }), + ) + }) } } diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index 8d93077d34..dc04191981 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -5,7 +5,11 @@ import type { CompatibilityFallbackHandlerContractImplementationType } from '@sa import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import * as web3 from '@/hooks/wallets/web3' import * as sdkHelpers from '@/services/tx/tx-sender/sdk' -import { getRedirect, relaySafeCreation } from '@/components/new-safe/create/logic/index' +import { + relaySafeCreation, + getRedirect, + createNewUndeployedSafeWithoutSalt, +} from '@/components/new-safe/create/logic/index' import { relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' import { @@ -21,6 +25,17 @@ import * as gateway from '@safe-global/safe-gateway-typescript-sdk' import { FEATURES, getLatestSafeVersion } from '@/utils/chains' import { type FEATURES as GatewayFeatures } from '@safe-global/safe-gateway-typescript-sdk' import { chainBuilder } from '@/tests/builders/chains' +import { type ReplayedSafeProps } from '@/store/slices' +import { faker } from '@faker-js/faker' +import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' +import { + getFallbackHandlerDeployment, + getProxyFactoryDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, + getSafeToL2SetupDeployment, +} from '@safe-global/safe-deployments' +import { Safe_to_l2_setup__factory } from '@/types/contracts' const provider = new JsonRpcProvider(undefined, { name: 'ethereum', chainId: 1 }) @@ -30,80 +45,119 @@ const latestSafeVersion = getLatestSafeVersion( .build(), ) -describe('createNewSafeViaRelayer', () => { - const owner1 = toBeHex('0x1', 20) - const owner2 = toBeHex('0x2', 20) +const safeToL2SetupDeployment = getSafeToL2SetupDeployment() +const safeToL2SetupAddress = safeToL2SetupDeployment?.defaultAddress +const safeToL2SetupInterface = Safe_to_l2_setup__factory.createInterface() + +describe('create/logic', () => { + describe('createNewSafeViaRelayer', () => { + const owner1 = toBeHex('0x1', 20) + const owner2 = toBeHex('0x2', 20) - const mockChainInfo = chainBuilder() - .with({ - chainId: '1', - l2: false, - features: [FEATURES.SAFE_141 as unknown as GatewayFeatures], + const mockChainInfo = chainBuilder() + .with({ + chainId: '1', + l2: false, + features: [FEATURES.SAFE_141 as unknown as GatewayFeatures], + }) + .build() + + beforeAll(() => { + jest.resetAllMocks() + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => provider) }) - .build() - beforeAll(() => { - jest.resetAllMocks() - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => provider) - }) + it('returns taskId if create Safe successfully relayed', async () => { + const mockSafeProvider = { + getExternalProvider: jest.fn(), + getExternalSigner: jest.fn(), + getChainId: jest.fn().mockReturnValue(BigInt(1)), + } as unknown as SafeProvider + + jest.spyOn(gateway, 'relayTransaction').mockResolvedValue({ taskId: '0x123' }) + jest.spyOn(sdkHelpers, 'getSafeProvider').mockImplementation(() => mockSafeProvider) + + jest.spyOn(contracts, 'getReadOnlyFallbackHandlerContract').mockResolvedValue({ + getAddress: () => '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4', + } as unknown as CompatibilityFallbackHandlerContractImplementationType) + + const expectedSaltNonce = 69 + const expectedThreshold = 1 + const proxyFactoryAddress = await (await getReadOnlyProxyFactoryContract(latestSafeVersion)).getAddress() + const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(latestSafeVersion) + const safeContractAddress = await ( + await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) + ).getAddress() + + const undeployedSafeProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [owner1, owner2], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), + paymentReceiver: ZERO_ADDRESS, + payment: 0, + paymentToken: ZERO_ADDRESS, + }, + safeVersion: latestSafeVersion, + factoryAddress: proxyFactoryAddress, + masterCopy: safeContractAddress, + saltNonce: '69', + } - it('returns taskId if create Safe successfully relayed', async () => { - const mockSafeProvider = { - getExternalProvider: jest.fn(), - getExternalSigner: jest.fn(), - getChainId: jest.fn().mockReturnValue(BigInt(1)), - } as unknown as SafeProvider - - jest.spyOn(gateway, 'relayTransaction').mockResolvedValue({ taskId: '0x123' }) - jest.spyOn(sdkHelpers, 'getSafeProvider').mockImplementation(() => mockSafeProvider) - - jest.spyOn(contracts, 'getReadOnlyFallbackHandlerContract').mockResolvedValue({ - getAddress: () => '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4', - } as unknown as CompatibilityFallbackHandlerContractImplementationType) - - const expectedSaltNonce = 69 - const expectedThreshold = 1 - const proxyFactoryAddress = await (await getReadOnlyProxyFactoryContract(latestSafeVersion)).getAddress() - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(latestSafeVersion) - const safeContractAddress = await ( - await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) - ).getAddress() - - const expectedInitializer = Gnosis_safe__factory.createInterface().encodeFunctionData('setup', [ - [owner1, owner2], - expectedThreshold, - ZERO_ADDRESS, - EMPTY_DATA, - await readOnlyFallbackHandlerContract.getAddress(), - ZERO_ADDRESS, - 0, - ZERO_ADDRESS, - ]) - - const expectedCallData = Proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ - safeContractAddress, - expectedInitializer, - expectedSaltNonce, - ]) - - const taskId = await relaySafeCreation(mockChainInfo, [owner1, owner2], expectedThreshold, expectedSaltNonce) - - expect(taskId).toEqual('0x123') - expect(relayTransaction).toHaveBeenCalledTimes(1) - expect(relayTransaction).toHaveBeenCalledWith('1', { - to: proxyFactoryAddress, - data: expectedCallData, - version: latestSafeVersion, + const expectedInitializer = Gnosis_safe__factory.createInterface().encodeFunctionData('setup', [ + [owner1, owner2], + expectedThreshold, + ZERO_ADDRESS, + EMPTY_DATA, + await readOnlyFallbackHandlerContract.getAddress(), + ZERO_ADDRESS, + 0, + ZERO_ADDRESS, + ]) + + const expectedCallData = Proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + safeContractAddress, + expectedInitializer, + expectedSaltNonce, + ]) + + const taskId = await relaySafeCreation(mockChainInfo, undeployedSafeProps) + + expect(taskId).toEqual('0x123') + expect(relayTransaction).toHaveBeenCalledTimes(1) + expect(relayTransaction).toHaveBeenCalledWith('1', { + to: proxyFactoryAddress, + data: expectedCallData, + version: latestSafeVersion, + }) }) - }) - it('should throw an error if relaying fails', () => { - const relayFailedError = new Error('Relay failed') - jest.spyOn(gateway, 'relayTransaction').mockRejectedValue(relayFailedError) + it('should throw an error if relaying fails', () => { + const relayFailedError = new Error('Relay failed') + jest.spyOn(gateway, 'relayTransaction').mockRejectedValue(relayFailedError) - expect(relaySafeCreation(mockChainInfo, [owner1, owner2], 1, 69)).rejects.toEqual(relayFailedError) - }) + const undeployedSafeProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [owner1, owner2], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: faker.finance.ethereumAddress(), + paymentReceiver: ZERO_ADDRESS, + payment: 0, + paymentToken: ZERO_ADDRESS, + }, + safeVersion: latestSafeVersion, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '69', + } + expect(relaySafeCreation(mockChainInfo, undeployedSafeProps)).rejects.toEqual(relayFailedError) + }) + }) describe('getRedirect', () => { it("should redirect to home for any redirect that doesn't start with /apps", () => { const expected = { @@ -126,4 +180,175 @@ describe('createNewSafeViaRelayer', () => { ) }) }) + + describe('createNewUndeployedSafeWithoutSalt', () => { + it('should throw errors if no deployments are found', () => { + expect(() => + createNewUndeployedSafeWithoutSalt( + '1.4.1', + { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + }, + chainBuilder().with({ chainId: 'NON_EXISTING' }).build(), + ), + ).toThrowError(new Error('No Safe deployment found')) + }) + + it('should use l1 masterCopy and no migration on l1s without multichain feature', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.4.1', + safeSetup, + chainBuilder() + .with({ chainId: '1' }) + // Multichain creation is toggled off + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL] as any }) + .with({ l2: false }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '1' })?.defaultAddress, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.4.1', + masterCopy: getSafeSingletonDeployment({ version: '1.4.1', network: '1' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '1' })?.defaultAddress, + }) + }) + + it('should use l2 masterCopy and no migration on l2s without multichain feature', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.4.1', + safeSetup, + chainBuilder() + .with({ chainId: '137' }) + // Multichain creation is toggled off + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL] as any }) + .with({ l2: true }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.4.1', + masterCopy: getSafeL2SingletonDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + }) + }) + + it('should use l2 masterCopy and no migration on l2s with multichain feature but on old version', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.3.0', + safeSetup, + chainBuilder() + .with({ chainId: '137' }) + // Multichain creation is toggled on + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ l2: true }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.3.0', network: '137' })?.defaultAddress, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.3.0', + masterCopy: getSafeL2SingletonDeployment({ version: '1.3.0', network: '137' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.3.0', network: '137' })?.defaultAddress, + }) + }) + + it('should use l1 masterCopy and migration on l2s with multichain feature', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + const safeL2SingletonDeployment = getSafeL2SingletonDeployment({ + version: '1.4.1', + network: '137', + })?.defaultAddress + expect( + createNewUndeployedSafeWithoutSalt( + '1.4.1', + safeSetup, + chainBuilder() + .with({ chainId: '137' }) + // Multichain creation is toggled on + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ l2: true }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + to: safeToL2SetupAddress, + data: + safeL2SingletonDeployment && + safeToL2SetupInterface.encodeFunctionData('setupToL2', [safeL2SingletonDeployment]), + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.4.1', + masterCopy: getSafeSingletonDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + }) + }) + + it('should use l2 masterCopy and no migration on zkSync', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.3.0', + safeSetup, + chainBuilder() + .with({ chainId: '324' }) + // Multichain and 1.4.1 creation is toggled off + .with({ features: [FEATURES.COUNTERFACTUAL] as any }) + .with({ l2: true }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.3.0', network: '324' })?.networkAddresses['324'], + to: ZERO_ADDRESS, + data: EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.3.0', + masterCopy: getSafeL2SingletonDeployment({ version: '1.3.0', network: '324' })?.networkAddresses['324'], + factoryAddress: getProxyFactoryDeployment({ version: '1.3.0', network: '324' })?.networkAddresses['324'], + }) + }) + }) }) diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index c22e8fcb12..d7d629eb17 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -1,24 +1,33 @@ import type { SafeVersion } from '@safe-global/safe-core-sdk-types' import { type Eip1193Provider, type Provider } from 'ethers' +import semverSatisfies from 'semver/functions/satisfies' import { getSafeInfo, type SafeInfo, type ChainInfo, relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' -import { - getReadOnlyFallbackHandlerContract, - getReadOnlyGnosisSafeContract, - getReadOnlyProxyFactoryContract, -} from '@/services/contracts/safeContracts' +import { getReadOnlyProxyFactoryContract } from '@/services/contracts/safeContracts' import type { UrlObject } from 'url' import { AppRoutes } from '@/config/routes' import { SAFE_APPS_EVENTS, trackEvent } from '@/services/analytics' import { predictSafeAddress, SafeFactory, SafeProvider } from '@safe-global/protocol-kit' -import type Safe from '@safe-global/protocol-kit' -import type { DeploySafeProps } from '@safe-global/protocol-kit' +import type { DeploySafeProps, PredictedSafeProps } from '@safe-global/protocol-kit' import { isValidSafeVersion } from '@/hooks/coreSDK/safeCoreSDK' import { backOff } from 'exponential-backoff' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { getLatestSafeVersion } from '@/utils/chains' +import { + getCompatibilityFallbackHandlerDeployment, + getProxyFactoryDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, + getSafeToL2SetupDeployment, +} from '@safe-global/safe-deployments' import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' +import type { ReplayedSafeProps, UndeployedSafeProps } from '@/store/slices' +import { activateReplayedSafe, isPredictedSafeProps } from '@/features/counterfactual/utils' +import { getSafeContractDeployment } from '@/services/contracts/deployments' +import { Safe__factory, Safe_proxy_factory__factory, Safe_to_l2_setup__factory } from '@/types/contracts' +import { createWeb3 } from '@/hooks/wallets/web3' +import { hasMultiChainCreationFeatures } from '@/features/multichain/utils/utils' export type SafeCreationProps = { owners: string[] @@ -26,11 +35,15 @@ export type SafeCreationProps = { saltNonce: number } -const getSafeFactory = async (provider: Eip1193Provider, safeVersion: SafeVersion): Promise => { +const getSafeFactory = async ( + provider: Eip1193Provider, + safeVersion: SafeVersion, + isL1SafeSingleton?: boolean, +): Promise => { if (!isValidSafeVersion(safeVersion)) { throw new Error('Invalid Safe version') } - return SafeFactory.init({ provider, safeVersion }) + return SafeFactory.init({ provider, safeVersion, isL1SafeSingleton }) } /** @@ -38,18 +51,28 @@ const getSafeFactory = async (provider: Eip1193Provider, safeVersion: SafeVersio */ export const createNewSafe = async ( provider: Eip1193Provider, - props: DeploySafeProps, + undeployedSafeProps: UndeployedSafeProps, safeVersion: SafeVersion, -): Promise => { - const safeFactory = await getSafeFactory(provider, safeVersion) - return safeFactory.deploySafe(props) + chain: ChainInfo, + options: DeploySafeProps['options'], + callback: (txHash: string) => void, + isL1SafeSingleton?: boolean, +): Promise => { + const safeFactory = await getSafeFactory(provider, safeVersion, isL1SafeSingleton) + + if (isPredictedSafeProps(undeployedSafeProps)) { + await safeFactory.deploySafe({ ...undeployedSafeProps, options, callback }) + } else { + const txResponse = await activateReplayedSafe(chain, undeployedSafeProps, createWeb3(provider), options) + callback(txResponse.hash) + } } /** * Compute the new counterfactual Safe address before it is actually created */ export const computeNewSafeAddress = async ( - provider: Eip1193Provider, + provider: Eip1193Provider | string, props: DeploySafeProps, chain: ChainInfo, safeVersion?: SafeVersion, @@ -67,49 +90,30 @@ export const computeNewSafeAddress = async ( }) } +export const encodeSafeSetupCall = (safeAccountConfig: ReplayedSafeProps['safeAccountConfig']) => { + return Safe__factory.createInterface().encodeFunctionData('setup', [ + safeAccountConfig.owners, + safeAccountConfig.threshold, + safeAccountConfig.to, + safeAccountConfig.data, + safeAccountConfig.fallbackHandler, + ZERO_ADDRESS, + 0, + safeAccountConfig.paymentReceiver, + ]) +} + /** * Encode a Safe creation transaction NOT using the Core SDK because it doesn't support that * This is used for gas estimation. */ -export const encodeSafeCreationTx = async ({ - owners, - threshold, - saltNonce, - chain, - safeVersion, -}: SafeCreationProps & { chain: ChainInfo; safeVersion?: SafeVersion }) => { - const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) - const readOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, usedSafeVersion) - const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion) - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(usedSafeVersion) - - const callData = { - owners, - threshold, - to: ZERO_ADDRESS, - data: EMPTY_DATA, - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - paymentToken: ZERO_ADDRESS, - payment: 0, - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - } - - // @ts-ignore union type is too complex - const setupData = readOnlySafeContract.encode('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) +export const encodeSafeCreationTx = (undeployedSafe: UndeployedSafeProps, chain: ChainInfo) => { + const replayedSafeProps = assertNewUndeployedSafeProps(undeployedSafe, chain) - return readOnlyProxyContract.encode('createProxyWithNonce', [ - await readOnlySafeContract.getAddress(), - setupData, - BigInt(saltNonce), + return Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + replayedSafeProps.masterCopy, + encodeSafeSetupCall(replayedSafeProps.safeAccountConfig), + BigInt(replayedSafeProps.saltNonce), ]) } @@ -117,11 +121,11 @@ export const estimateSafeCreationGas = async ( chain: ChainInfo, provider: Provider, from: string, - safeParams: SafeCreationProps, + undeployedSafe: UndeployedSafeProps, safeVersion?: SafeVersion, ): Promise => { const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(safeVersion ?? getLatestSafeVersion(chain)) - const encodedSafeCreationTx = await encodeSafeCreationTx({ ...safeParams, chain }) + const encodedSafeCreationTx = encodeSafeCreationTx(undeployedSafe, chain) const gas = await provider.estimateGas({ from, @@ -174,58 +178,128 @@ export const getRedirect = ( return redirectUrl + `${appendChar}safe=${address}` } -export const relaySafeCreation = async ( +export const relaySafeCreation = async (chain: ChainInfo, undeployedSafeProps: UndeployedSafeProps) => { + const replayedSafeProps = assertNewUndeployedSafeProps(undeployedSafeProps, chain) + const encodedSafeCreationTx = encodeSafeCreationTx(replayedSafeProps, chain) + + const relayResponse = await relayTransaction(chain.chainId, { + to: replayedSafeProps.factoryAddress, + data: encodedSafeCreationTx, + version: replayedSafeProps.safeVersion, + }) + + return relayResponse.taskId +} + +export type UndeployedSafeWithoutSalt = Omit + +/** + * Creates a new undeployed Safe without default config: + * + * Always use the L1 MasterCopy and add a migration to L2 in to the setup. + * Use our ecosystem ID as paymentReceiver. + * + */ +export const createNewUndeployedSafeWithoutSalt = ( + safeVersion: SafeVersion, + safeAccountConfig: Pick, chain: ChainInfo, - owners: string[], - threshold: number, - saltNonce: number, - version?: SafeVersion, -) => { - const latestSafeVersion = getLatestSafeVersion(chain) - - const safeVersion = version ?? latestSafeVersion - - const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(safeVersion) - const proxyFactoryAddress = await readOnlyProxyFactoryContract.getAddress() - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(safeVersion) - const fallbackHandlerAddress = await readOnlyFallbackHandlerContract.getAddress() - const readOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, safeVersion) - const safeContractAddress = await readOnlySafeContract.getAddress() - - const callData = { - owners, - threshold, - to: ZERO_ADDRESS, - data: EMPTY_DATA, - fallbackHandler: fallbackHandlerAddress, - paymentToken: ZERO_ADDRESS, - payment: 0, - paymentReceiver: ECOSYSTEM_ID_ADDRESS, +): UndeployedSafeWithoutSalt => { + // Create universal deployment Data across chains: + const fallbackHandlerDeployment = getCompatibilityFallbackHandlerDeployment({ + version: safeVersion, + network: chain.chainId, + }) + const fallbackHandlerAddress = fallbackHandlerDeployment?.networkAddresses[chain.chainId] + const safeL2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) + const safeL2Address = safeL2Deployment?.networkAddresses[chain.chainId] + + const safeL1Deployment = getSafeSingletonDeployment({ version: safeVersion, network: chain.chainId }) + const safeL1Address = safeL1Deployment?.networkAddresses[chain.chainId] + + const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chain.chainId }) + const safeFactoryAddress = safeFactoryDeployment?.networkAddresses[chain.chainId] + + if (!safeL2Address || !safeL1Address || !safeFactoryAddress || !fallbackHandlerAddress) { + throw new Error('No Safe deployment found') } - // @ts-ignore - const initializer = readOnlySafeContract.encode('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) + const safeToL2SetupDeployment = getSafeToL2SetupDeployment({ version: '1.4.1', network: chain.chainId }) + const safeToL2SetupAddress = safeToL2SetupDeployment?.networkAddresses[chain.chainId] + const safeToL2SetupInterface = Safe_to_l2_setup__factory.createInterface() - const createProxyWithNonceCallData = readOnlyProxyFactoryContract.encode('createProxyWithNonce', [ - safeContractAddress, - initializer, - BigInt(saltNonce), - ]) + // Only do migration if the chain supports multiChain deployments. + const includeMigration = hasMultiChainCreationFeatures(chain) && semverSatisfies(safeVersion, '>=1.4.1') - const relayResponse = await relayTransaction(chain.chainId, { - to: proxyFactoryAddress, - data: createProxyWithNonceCallData, + const masterCopy = includeMigration ? safeL1Address : chain.l2 ? safeL2Address : safeL1Address + + const replayedSafe: Omit = { + factoryAddress: safeFactoryAddress, + masterCopy, + safeAccountConfig: { + threshold: safeAccountConfig.threshold, + owners: safeAccountConfig.owners, + fallbackHandler: fallbackHandlerAddress, + to: includeMigration && safeToL2SetupAddress ? safeToL2SetupAddress : ZERO_ADDRESS, + data: includeMigration ? safeToL2SetupInterface.encodeFunctionData('setupToL2', [safeL2Address]) : EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion, + } + + return replayedSafe +} + +/** + * Migrates a counterfactual Safe from the pre multichain era to the new predicted Safe data + * @param predictedSafeProps + * @param chain + * @returns + */ +export const migrateLegacySafeProps = (predictedSafeProps: PredictedSafeProps, chain: ChainInfo): ReplayedSafeProps => { + const safeVersion = predictedSafeProps.safeDeploymentConfig?.safeVersion + const saltNonce = predictedSafeProps.safeDeploymentConfig?.saltNonce + const { chainId } = chain + if (!safeVersion || !saltNonce) { + throw new Error('Undeployed Safe with incomplete data.') + } + + const fallbackHandlerDeployment = getCompatibilityFallbackHandlerDeployment({ version: safeVersion, + network: chainId, }) + const fallbackHandlerAddress = fallbackHandlerDeployment?.defaultAddress - return relayResponse.taskId + const masterCopyDeployment = getSafeContractDeployment(chain, safeVersion) + const masterCopyAddress = masterCopyDeployment?.defaultAddress + + const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chainId }) + const safeFactoryAddress = safeFactoryDeployment?.defaultAddress + + if (!masterCopyAddress || !safeFactoryAddress || !fallbackHandlerAddress) { + throw new Error('No Safe deployment found') + } + + return { + factoryAddress: safeFactoryAddress, + masterCopy: masterCopyAddress, + safeAccountConfig: { + threshold: predictedSafeProps.safeAccountConfig.threshold, + owners: predictedSafeProps.safeAccountConfig.owners, + fallbackHandler: predictedSafeProps.safeAccountConfig.fallbackHandler ?? fallbackHandlerAddress, + to: predictedSafeProps.safeAccountConfig.to ?? ZERO_ADDRESS, + data: predictedSafeProps.safeAccountConfig.data ?? EMPTY_DATA, + paymentReceiver: predictedSafeProps.safeAccountConfig.paymentReceiver ?? ZERO_ADDRESS, + }, + safeVersion, + saltNonce, + } +} + +export const assertNewUndeployedSafeProps = (props: UndeployedSafeProps, chain: ChainInfo): ReplayedSafeProps => { + if (isPredictedSafeProps(props)) { + return migrateLegacySafeProps(props, chain) + } + + return props } diff --git a/src/components/new-safe/create/logic/utils.test.ts b/src/components/new-safe/create/logic/utils.test.ts index 0a9f543785..94818a43bc 100644 --- a/src/components/new-safe/create/logic/utils.test.ts +++ b/src/components/new-safe/create/logic/utils.test.ts @@ -1,15 +1,22 @@ import * as creationUtils from '@/components/new-safe/create/logic/index' import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' -import * as walletUtils from '@/utils/wallets' import { faker } from '@faker-js/faker' -import type { DeploySafeProps } from '@safe-global/protocol-kit' -import { MockEip1193Provider } from '@/tests/mocks/providers' import { chainBuilder } from '@/tests/builders/chains' +import { type ReplayedSafeProps } from '@/store/slices' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import * as web3Hooks from '@/hooks/wallets/web3' +import { type JsonRpcProvider, id } from 'ethers' +import { Safe_proxy_factory__factory } from '@/types/contracts' +import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' + +// Proxy Factory 1.3.0 creation code +const mockProxyCreationCode = + '0x608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea2646970667358221220d1429297349653a4918076d650332de1a1068c5f3e07c5c82360c277770b955264736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f7669646564' describe('getAvailableSaltNonce', () => { jest.spyOn(creationUtils, 'computeNewSafeAddress').mockReturnValue(Promise.resolve(faker.finance.ethereumAddress())) - let mockDeployProps: DeploySafeProps + let mockDeployProps: ReplayedSafeProps beforeAll(() => { mockDeployProps = { @@ -17,7 +24,16 @@ describe('getAvailableSaltNonce', () => { threshold: 1, owners: [faker.finance.ethereumAddress()], fallbackHandler: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ casing: 'lower', length: 64 }), + to: faker.finance.ethereumAddress(), + paymentReceiver: faker.finance.ethereumAddress(), + payment: 0, + paymentToken: ZERO_ADDRESS, }, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + safeVersion: '1.4.1', + saltNonce: '0', } }) @@ -26,34 +42,131 @@ describe('getAvailableSaltNonce', () => { }) it('should return initial nonce if no contract is deployed to the computed address', async () => { - jest.spyOn(walletUtils, 'isSmartContract').mockReturnValue(Promise.resolve(false)) + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockReturnValue({ + getCode: jest.fn().mockReturnValue('0x'), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider) + const initialNonce = faker.string.numeric() - const mockChain = chainBuilder().build() + const mockChain = chainBuilder().with({ chainId: '1' }).build() - const result = await getAvailableSaltNonce( - MockEip1193Provider, - { ...mockDeployProps, saltNonce: initialNonce }, - mockChain, - ) + const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) expect(result).toEqual(initialNonce) }) it('should return an increased nonce if a contract is deployed to the computed address', async () => { - jest.spyOn(walletUtils, 'isSmartContract').mockReturnValueOnce(Promise.resolve(true)) + let requiredTries = 3 + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockReturnValue({ + getCode: jest + .fn() + .mockImplementation(() => (requiredTries-- > 0 ? faker.string.hexadecimal({ length: 64 }) : '0x')), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider) + const initialNonce = faker.string.numeric() + const mockChain = chainBuilder().with({ chainId: '1' }).build() + const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) + + expect(result).toEqual((Number(initialNonce) + 3).toString()) + }) + + it('should skip known addresses without checking getCode', async () => { + const mockProvider = { + getCode: jest.fn().mockImplementation(() => '0x'), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider const initialNonce = faker.string.numeric() + + const replayedProps = { ...mockDeployProps, saltNonce: initialNonce } + const knownAddresses = [await predictAddressBasedOnReplayData(replayedProps, mockProvider)] + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockReturnValue(mockProvider) const mockChain = chainBuilder().build() + const result = await getAvailableSaltNonce({}, replayedProps, [mockChain], knownAddresses) + + // The known address (initialNonce) will be skipped + expect(result).toEqual((Number(initialNonce) + 1).toString()) + expect(mockProvider.getCode).toHaveBeenCalledTimes(1) + }) - const result = await getAvailableSaltNonce( - MockEip1193Provider, - { ...mockDeployProps, saltNonce: initialNonce }, - mockChain, - ) + it('should check cross chain', async () => { + const mockMainnet = chainBuilder().with({ chainId: '1' }).build() + const mockGnosis = chainBuilder().with({ chainId: '100' }).build() + + // We mock that on GnosisChain the first nonce is already deployed + const mockGnosisProvider = { + getCode: jest.fn().mockImplementation(() => '0x'), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '100' }), + } as unknown as JsonRpcProvider + + // We Mock that on Mainnet the first two nonces are already deployed + let mainnetTriesRequired = 2 + const mockMainnetProvider = { + getCode: jest + .fn() + .mockImplementation(() => (mainnetTriesRequired-- > 0 ? faker.string.hexadecimal({ length: 64 }) : '0x')), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider + const initialNonce = faker.string.numeric() - jest.spyOn(walletUtils, 'isSmartContract').mockReturnValueOnce(Promise.resolve(false)) + const replayedProps = { ...mockDeployProps, saltNonce: initialNonce } + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockImplementation((chain) => { + if (chain.chainId === '100') { + return mockGnosisProvider + } + if (chain.chainId === '1') { + return mockMainnetProvider + } + throw new Error('Web3Provider not found') + }) - const increasedNonce = (Number(initialNonce) + 1).toString() + const result = await getAvailableSaltNonce({}, replayedProps, [mockMainnet, mockGnosis], []) - expect(result).toEqual(increasedNonce) + // The known address (initialNonce) will be skipped + expect(result).toEqual((Number(initialNonce) + 2).toString()) }) }) diff --git a/src/components/new-safe/create/logic/utils.ts b/src/components/new-safe/create/logic/utils.ts index ff63058759..68b5993aee 100644 --- a/src/components/new-safe/create/logic/utils.ts +++ b/src/components/new-safe/create/logic/utils.ts @@ -1,29 +1,72 @@ -import { computeNewSafeAddress } from '@/components/new-safe/create/logic/index' import { isSmartContract } from '@/utils/wallets' -import type { DeploySafeProps } from '@safe-global/protocol-kit' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' -import type { Eip1193Provider } from 'ethers' +import { sameAddress } from '@/utils/addresses' +import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' +import { type ReplayedSafeProps } from '@/store/slices' +import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' +import chains from '@/config/chains' +import { computeNewSafeAddress } from '.' export const getAvailableSaltNonce = async ( - provider: Eip1193Provider, - props: DeploySafeProps, - chain: ChainInfo, - safeVersion?: SafeVersion, + customRpcs: { + [chainId: string]: string + }, + replayedSafe: ReplayedSafeProps, + chainInfos: ChainInfo[], + // All addresses from the sidebar disregarding the chain. This is an optimization to reduce RPC calls + knownSafeAddresses: string[], ): Promise => { - const safeAddress = await computeNewSafeAddress(provider, props, chain, safeVersion) - const isContractDeployed = await isSmartContract(safeAddress) + let isAvailableOnAllChains = true + const allRPCs = chainInfos.map((chain) => { + const rpcUrl = customRpcs?.[chain.chainId] || getRpcServiceUrl(chain.rpcUri) + // Turn into Eip1993Provider + return { + rpcUrl, + chainId: chain.chainId, + } + }) + + for (const chain of chainInfos) { + const rpcUrl = allRPCs.find((rpc) => chain.chainId === rpc.chainId)?.rpcUrl + if (!rpcUrl) { + throw new Error(`No RPC available for ${chain.chainName}`) + } + const web3ReadOnly = createWeb3ReadOnly(chain, rpcUrl) + if (!web3ReadOnly) { + throw new Error('Could not initiate RPC') + } + let safeAddress: string + if (chain.chainId === chains['zksync']) { + // ZK-sync is using a different create2 method which is supported by the SDK + safeAddress = await computeNewSafeAddress( + rpcUrl, + { + safeAccountConfig: replayedSafe.safeAccountConfig, + saltNonce: replayedSafe.saltNonce, + }, + chain, + replayedSafe.safeVersion, + ) + } else { + safeAddress = await predictAddressBasedOnReplayData(replayedSafe, web3ReadOnly) + } + const isKnown = knownSafeAddresses.some((knownAddress) => sameAddress(knownAddress, safeAddress)) + if (isKnown || (await isSmartContract(safeAddress, web3ReadOnly))) { + // We found a chain where the nonce is used up + isAvailableOnAllChains = false + break + } + } // Safe is already deployed so we try the next saltNonce - if (isContractDeployed) { + if (!isAvailableOnAllChains) { return getAvailableSaltNonce( - provider, - { ...props, saltNonce: (Number(props.saltNonce) + 1).toString() }, - chain, - safeVersion, + customRpcs, + { ...replayedSafe, saltNonce: (Number(replayedSafe.saltNonce) + 1).toString() }, + chainInfos, + knownSafeAddresses, ) } - // We know that there will be a saltNonce but the type has it as optional - return props.saltNonce! + return replayedSafe.saltNonce } diff --git a/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx b/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx index f7627ef664..46aa38cba9 100644 --- a/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx +++ b/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx @@ -32,7 +32,7 @@ const ADVANCED_OPTIONS_STEP_FORM_ID = 'create-safe-advanced-options-step-form' const AdvancedOptionsStep = ({ onSubmit, onBack, data, setStep }: StepRenderProps): ReactElement => { const wallet = useWallet() - useSyncSafeCreationStep(setStep) + useSyncSafeCreationStep(setStep, data.networks) const chain = useCurrentChain() const formMethods = useForm({ @@ -54,6 +54,7 @@ const AdvancedOptionsStep = ({ onSubmit, onBack, data, setStep }: StepRenderProp if (!chain || !readOnlyFallbackHandlerContract || !wallet) { return undefined } + return computeNewSafeAddress( wallet.provider, { diff --git a/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx b/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx index cdedec2bd6..5e2b5077b8 100644 --- a/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx +++ b/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx @@ -1,4 +1,3 @@ -import CounterfactualHint from '@/features/counterfactual/CounterfactualHint' import useAddressBook from '@/hooks/useAddressBook' import useWallet from '@/hooks/wallets/useWallet' import { Button, SvgIcon, MenuItem, Tooltip, Typography, Divider, Box, Grid, TextField } from '@mui/material' @@ -46,7 +45,7 @@ const OwnerPolicyStep = ({ name: defaultOwnerAddressBookName || wallet?.ens || '', address: wallet?.address || '', } - useSyncSafeCreationStep(setStep) + useSyncSafeCreationStep(setStep, data.networks) const formMethods = useForm({ mode: 'onChange', @@ -75,11 +74,11 @@ const OwnerPolicyStep = ({ const isDisabled = !formState.isValid - useSafeSetupHints(threshold, ownerFields.length, setDynamicHint) + useSafeSetupHints(setDynamicHint, threshold, ownerFields.length) const handleBack = () => { const formData = getValues() - onBack(formData) + onBack({ ...data, ...formData }) } const onFormSubmit = handleSubmit((data) => { @@ -143,7 +142,7 @@ const OwnerPolicyStep = ({ control={control} name="threshold" render={({ field }) => ( - + {ownerFields.map((_, idx) => ( {idx + 1} @@ -157,8 +156,6 @@ const OwnerPolicyStep = ({ out of {ownerFields.length} signer(s) - - {ownerFields.length > 1 && } diff --git a/src/components/new-safe/create/steps/OwnerPolicyStep/useSafeSetupHints.ts b/src/components/new-safe/create/steps/OwnerPolicyStep/useSafeSetupHints.ts index ec431b533f..6cd05c182b 100644 --- a/src/components/new-safe/create/steps/OwnerPolicyStep/useSafeSetupHints.ts +++ b/src/components/new-safe/create/steps/OwnerPolicyStep/useSafeSetupHints.ts @@ -2,34 +2,43 @@ import { useEffect } from 'react' import type { CreateSafeInfoItem } from '@/components/new-safe/create/CreateSafeInfos' export const useSafeSetupHints = ( - threshold: number, - noOwners: number, setHint: (hint: CreateSafeInfoItem | undefined) => void, + threshold?: number, + numberOfOwners?: number, + multiChain?: boolean, ) => { useEffect(() => { const safeSetupWarningSteps: { title: string; text: string }[] = [] // 1/n warning - if (threshold === 1) { + if (numberOfOwners && threshold === 1) { safeSetupWarningSteps.push({ - title: `1/${noOwners} policy`, + title: `1/${numberOfOwners} policy`, text: 'Use a threshold higher than one to prevent losing access to your Safe Account in case a signer key is lost or compromised.', }) } // n/n warning - if (threshold === noOwners && noOwners > 1) { + if (threshold === numberOfOwners && numberOfOwners && numberOfOwners > 1) { safeSetupWarningSteps.push({ - title: `${noOwners}/${noOwners} policy`, + title: `${numberOfOwners}/${numberOfOwners} policy`, text: 'Use a threshold which is lower than the total number of signers of your Safe Account in case a signer loses access to their account and needs to be replaced.', }) } + // n/n warning + if (multiChain) { + safeSetupWarningSteps.push({ + title: `Same address. Many networks.`, + text: 'You can choose which networks to deploy your account on and will need to deploy them one by one after creation.', + }) + } + setHint({ title: 'Safe Account setup', variant: 'info', steps: safeSetupWarningSteps }) // Clear dynamic hints when the step / hook unmounts return () => { setHint(undefined) } - }, [threshold, noOwners, setHint]) + }, [threshold, numberOfOwners, setHint, multiChain]) } diff --git a/src/components/new-safe/create/steps/ReviewStep/index.test.tsx b/src/components/new-safe/create/steps/ReviewStep/index.test.tsx index 23a7a67032..a677f08cb6 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.test.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.test.tsx @@ -38,6 +38,7 @@ describe('ReviewStep', () => { it('should display a pay now pay later option for counterfactual safe setups', () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -55,6 +56,7 @@ describe('ReviewStep', () => { it('should display a pay later option as selected by default for counterfactual safe setups', () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -71,6 +73,7 @@ describe('ReviewStep', () => { it('should not display the network fee for counterfactual safes', () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -88,6 +91,7 @@ describe('ReviewStep', () => { it('should not display the execution method for counterfactual safes', () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -105,6 +109,7 @@ describe('ReviewStep', () => { it('should display the network fee for counterfactual safes if the user selects pay now', async () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -128,6 +133,7 @@ describe('ReviewStep', () => { it('should display the execution method for counterfactual safes if the user selects pay now and there is relaying', async () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -148,4 +154,41 @@ describe('ReviewStep', () => { expect(getByText(/Who will pay gas fees:/)).toBeInTheDocument() }) + + it('should display the execution method for counterfactual safes if the user selects pay now and there is relaying', async () => { + const mockMultiChainInfo = [ + { + chainId: '100', + chainName: 'Gnosis Chain', + l2: false, + nativeCurrency: { + symbol: 'ETH', + }, + }, + { + chainId: '1', + chainName: 'Ethereum', + l2: false, + nativeCurrency: { + symbol: 'ETH', + }, + }, + ] as ChainInfo[] + const mockData: NewSafeFormData = { + name: 'Test', + networks: mockMultiChainInfo, + threshold: 1, + owners: [{ name: '', address: '0x1' }], + saltNonce: 0, + safeVersion: LATEST_SAFE_VERSION as SafeVersion, + } + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + jest.spyOn(relay, 'hasRemainingRelays').mockReturnValue(true) + + const { getByText } = render( + , + ) + + expect(getByText(/activate your account/)).toBeInTheDocument() + }) }) diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index fde7545e66..05860bbb61 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -1,13 +1,17 @@ -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 NetworkLogosList from '@/features/multichain/components/NetworkLogosList' import { getTotalFeeFormatted } from '@/hooks/useGasPrice' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' -import { computeNewSafeAddress, createNewSafe, relaySafeCreation } from '@/components/new-safe/create/logic' +import { + computeNewSafeAddress, + createNewSafe, + createNewUndeployedSafeWithoutSalt, + 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' import layoutCss from '@/components/new-safe/create/styles.module.css' import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas' @@ -16,7 +20,7 @@ import ReviewRow from '@/components/new-safe/ReviewRow' import ErrorMessage from '@/components/tx/ErrorMessage' import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' import PayNowPayLater, { PayMethod } from '@/features/counterfactual/PayNowPayLater' -import { CF_TX_GROUP_KEY, createCounterfactualSafe } from '@/features/counterfactual/utils' +import { CF_TX_GROUP_KEY, replayCounterfactualSafeDeployment } from '@/features/counterfactual/utils' import { useCurrentChain, useHasFeature } from '@/hooks/useChains' import useGasPrice from '@/hooks/useGasPrice' import useIsWrongChain from '@/hooks/useIsWrongChain' @@ -24,21 +28,30 @@ import { useLeastRemainingRelays } from '@/hooks/useRemainingRelays' import useWalletCanPay from '@/hooks/useWalletCanPay' import useWallet from '@/hooks/wallets/useWallet' 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 { gtmSetChainId, gtmSetSafeAddress } from '@/services/analytics/gtm' import { asError } from '@/services/exceptions/utils' -import { useAppDispatch } from '@/store' +import { useAppDispatch, useAppSelector } from '@/store' 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 { Box, Button, CircularProgress, Divider, Grid, Tooltip, Typography } from '@mui/material' 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 { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' +import ChainIndicator from '@/components/common/ChainIndicator' +import NetworkWarning from '../../NetworkWarning' +import useAllSafes from '@/components/welcome/MyAccounts/useAllSafes' +import { uniq } from 'lodash' +import { selectRpc } from '@/store/settingsSlice' +import { AppRoutes } from '@/config/routes' +import { type ReplayedSafeProps } from '@/store/slices' +import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' +import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' +import { type DeploySafeProps } from '@safe-global/protocol-kit' +import { updateAddressBook } from '../../logic/address-book' +import chains from '@/config/chains' export const NetworkFee = ({ totalFee, @@ -66,16 +79,36 @@ export const SafeSetupOverview = ({ name, owners, threshold, + networks, }: { name?: string owners: NamedAddress[] threshold: number + networks: ChainInfo[] }) => { - const chain = useCurrentChain() - return ( - } /> + 1 ? 'Networks' : 'Network'} + value={ + + {networks.map((safeItem) => ( + + + + ))} + + } + arrow + > + + + + + } + /> {name && {name}} />} - {threshold} out of {owners.length} signer(s) + {threshold} out of {owners.length} {owners.length > 1 ? 'signers' : 'signer'} } /> @@ -110,12 +143,13 @@ export const SafeSetupOverview = ({ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps) => { const isWrongChain = useIsWrongChain() - useSyncSafeCreationStep(setStep) + useSyncSafeCreationStep(setStep, data.networks) const chain = useCurrentChain() const wallet = useWallet() const dispatch = useAppDispatch() const router = useRouter() const [gasPrice] = useGasPrice() + const customRpc = useAppSelector(selectRpc) const [payMethod, setPayMethod] = useState(PayMethod.PayLater) const [executionMethod, setExecutionMethod] = useState(ExecutionMethod.RELAY) const [isCreating, setIsCreating] = useState(false) @@ -126,19 +160,38 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps data.owners.map((owner) => owner.address), [data.owners]) const [minRelays] = useLeastRemainingRelays(ownerAddresses) + const isMultiChainDeployment = data.networks.length > 1 + // Every owner has remaining relays and relay method is selected const canRelay = hasRemainingRelays(minRelays) const willRelay = canRelay && executionMethod === ExecutionMethod.RELAY - const safeParams = useMemo(() => { - return { - owners: data.owners.map((owner) => owner.address), - threshold: data.threshold, - 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]) + const newSafeProps = useMemo( + () => + chain + ? createNewUndeployedSafeWithoutSalt( + data.safeVersion, + { + owners: data.owners.map((owner) => owner.address), + threshold: data.threshold, + }, + chain, + ) + : undefined, + [chain, data.owners, data.safeVersion, data.threshold], + ) - const { gasLimit } = useEstimateSafeCreationGas(safeParams, data.safeVersion) + const safePropsForGasEstimation = useMemo(() => { + return newSafeProps + ? { + ...newSafeProps, + saltNonce: Date.now().toString(), + } + : undefined + }, [newSafeProps]) + + // We estimate with a random nonce as we'll just slightly overestimates like this + const { gasLimit } = useEstimateSafeCreationGas(safePropsForGasEstimation, data.safeVersion) const maxFeePerGas = gasPrice?.maxFeePerGas const maxPriorityFeePerGas = gasPrice?.maxPriorityFeePerGas @@ -147,43 +200,98 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps uniq(allSafes?.map((safe) => safe.address)), [allSafes]) + + const customRPCs = useAppSelector(selectRpc) const handleBack = () => { onBack(data) } - const createSafe = async () => { - if (!wallet || !chain) return + const handleCreateSafeClick = async () => { + try { + if (!wallet || !chain || !newSafeProps) return + + setIsCreating(true) + + // Figure out the shared available nonce across chains + const nextAvailableNonce = await getAvailableSaltNonce( + customRPCs, + { ...newSafeProps, saltNonce: '0' }, + data.networks, + knownAddresses, + ) - setIsCreating(true) + const replayedSafeWithNonce = { ...newSafeProps, saltNonce: nextAvailableNonce } - try { - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(data.safeVersion) - - const props: DeploySafeProps = { - safeAccountConfig: { - threshold: data.threshold, - owners: data.owners.map((owner) => owner.address), - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - }, + const customRpcUrl = customRpc[chain.chainId] + const provider = createWeb3ReadOnly(chain, customRpcUrl) + if (!provider) return + + let safeAddress: string + + if (chain.chainId === chains['zksync']) { + safeAddress = await computeNewSafeAddress( + customRpcUrl || getRpcServiceUrl(chain.rpcUri), + { + safeAccountConfig: replayedSafeWithNonce.safeAccountConfig, + saltNonce: nextAvailableNonce, + }, + chain, + replayedSafeWithNonce.safeVersion, + ) + } else { + safeAddress = await predictAddressBasedOnReplayData(replayedSafeWithNonce, provider) } - const saltNonce = await getAvailableSaltNonce( - wallet.provider, - { ...props, saltNonce: '0' }, - chain, - data.safeVersion, + for (const network of data.networks) { + await createSafe(network, replayedSafeWithNonce, safeAddress) + } + + // Update addressbook with owners and Safe on all chosen networks + dispatch( + updateAddressBook( + data.networks.map((network) => network.chainId), + safeAddress, + data.name, + data.owners, + data.threshold, + ), ) - const safeAddress = await computeNewSafeAddress(wallet.provider, { ...props, saltNonce }, chain, data.safeVersion) - if (isCounterfactual && payMethod === PayMethod.PayLater) { + gtmSetChainId(chain.chainId) + + if (isCounterfactualEnabled && payMethod === PayMethod.PayLater) { + await router?.push({ + pathname: AppRoutes.home, + query: { safe: `${data.networks[0].shortName}:${safeAddress}` }, + }) + safeCreationDispatch(SafeCreationEvent.AWAITING_EXECUTION, { + groupKey: CF_TX_GROUP_KEY, + safeAddress, + networks: data.networks, + }) + } + } catch (err) { + console.error(err) + setSubmitError('Error creating the Safe Account. Please try again later.') + } finally { + setIsCreating(false) + } + } + + const createSafe = async (chain: ChainInfo, props: ReplayedSafeProps, safeAddress: string) => { + if (!wallet) return + + gtmSetChainId(chain.chainId) + + try { + if (isCounterfactualEnabled && payMethod === PayMethod.PayLater) { gtmSetSafeAddress(safeAddress) trackEvent({ ...OVERVIEW_EVENTS.PROCEED_WITH_TX, label: 'counterfactual', category: CREATE_SAFE_CATEGORY }) - createCounterfactualSafe(chain, safeAddress, saltNonce, data, dispatch, props, PayMethod.PayLater, router) + replayCounterfactualSafeDeployment(chain.chainId, safeAddress, props, data.name, dispatch, payMethod) trackEvent({ ...CREATE_SAFE_EVENTS.CREATED_SAFE, label: 'counterfactual' }) return } @@ -198,7 +306,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { // Create a counterfactual Safe - createCounterfactualSafe(chain, safeAddress, saltNonce, data, dispatch, props, PayMethod.PayNow) + replayCounterfactualSafeDeployment(chain.chainId, safeAddress, props, data.name, dispatch, payMethod) if (taskId) { safeCreationDispatch(SafeCreationEvent.RELAYING, { groupKey: CF_TX_GROUP_KEY, taskId, safeAddress }) @@ -219,26 +327,19 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { - onSubmitCallback(undefined, txHash) - }, - }, + props, data.safeVersion, + chain, + options, + (txHash) => { + onSubmitCallback(undefined, txHash) + }, + true, ) } } catch (_err) { @@ -256,32 +357,50 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps - + - {isCounterfactual && ( + {isCounterfactualEnabled && ( <> - + {canRelay && payMethod === PayMethod.PayNow && ( - - - } - /> - + <> + + + } + /> + + + )} + + {showNetworkWarning && ( + + + )} {payMethod === PayMethod.PayNow && ( @@ -297,7 +416,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps )} - {!isCounterfactual && ( + {!isCounterfactualEnabled && ( <> @@ -333,7 +452,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps - + {showNetworkWarning && } {!walletCanPay && !willRelay && ( @@ -360,12 +479,12 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps diff --git a/src/components/new-safe/create/steps/SetNameStep/index.tsx b/src/components/new-safe/create/steps/SetNameStep/index.tsx index 62c9b1b011..88df9c58c7 100644 --- a/src/components/new-safe/create/steps/SetNameStep/index.tsx +++ b/src/components/new-safe/create/steps/SetNameStep/index.tsx @@ -1,15 +1,11 @@ import { InputAdornment, Tooltip, SvgIcon, Typography, Box, Divider, Button, Grid } from '@mui/material' -import { FormProvider, useForm } from 'react-hook-form' +import { FormProvider, useForm, useWatch } from 'react-hook-form' import { useMnemonicSafeName } from '@/hooks/useMnemonicName' import InfoIcon from '@/public/images/notifications/info.svg' -import NetworkSelector from '@/components/common/NetworkSelector' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' -import css from '@/components/new-safe/create/steps/SetNameStep/styles.module.css' import layoutCss from '@/components/new-safe/create/styles.module.css' -import useIsWrongChain from '@/hooks/useIsWrongChain' -import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import NameInput from '@/components/common/NameInput' import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' import { AppRoutes } from '@/config/routes' @@ -21,14 +17,23 @@ import { type SafeVersion } from '@safe-global/safe-core-sdk-types' import { useCurrentChain } from '@/hooks/useChains' import { useEffect } from 'react' import { getLatestSafeVersion } from '@/utils/chains' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useSafeSetupHints } from '../OwnerPolicyStep/useSafeSetupHints' +import type { CreateSafeInfoItem } from '../../CreateSafeInfos' +import NetworkMultiSelector from '@/components/common/NetworkSelector/NetworkMultiSelector' +import { useAppSelector } from '@/store' +import { selectChainById } from '@/store/chainsSlice' +import useWallet from '@/hooks/wallets/useWallet' type SetNameStepForm = { name: string + networks: ChainInfo[] safeVersion: SafeVersion } -enum SetNameStepFields { +export enum SetNameStepFields { name = 'name', + networks = 'networks', safeVersion = 'safeVersion', } @@ -38,27 +43,46 @@ function SetNameStep({ data, onSubmit, setSafeName, -}: StepRenderProps & { setSafeName: (name: string) => void }) { + setOverviewNetworks, + setDynamicHint, + isAdvancedFlow = false, +}: StepRenderProps & { + setSafeName: (name: string) => void + setOverviewNetworks: (networks: ChainInfo[]) => void + setDynamicHint: (hints: CreateSafeInfoItem | undefined) => void + isAdvancedFlow?: boolean +}) { const router = useRouter() - const fallbackName = useMnemonicSafeName() - const isWrongChain = useIsWrongChain() - - const chain = useCurrentChain() + const currentChain = useCurrentChain() + const wallet = useWallet() + const walletChain = useAppSelector((state) => selectChainById(state, wallet?.chainId || '')) + const initialState = data.networks.length ? data.networks : walletChain ? [walletChain] : [] const formMethods = useForm({ mode: 'all', - defaultValues: data, + defaultValues: { + ...data, + networks: initialState, + }, }) const { handleSubmit, setValue, + control, formState: { errors, isValid }, } = formMethods - const onFormSubmit = (data: Pick) => { + const networks: ChainInfo[] = useWatch({ control, name: SetNameStepFields.networks }) + const isMultiChain = networks.length > 1 + const fallbackName = useMnemonicSafeName(isMultiChain) + useSafeSetupHints(setDynamicHint, undefined, undefined, isMultiChain) + + const onFormSubmit = (data: Pick) => { const name = data.name || fallbackName setSafeName(name) + setOverviewNetworks(data.networks) + onSubmit({ ...data, name }) if (data.name) { @@ -71,19 +95,19 @@ function SetNameStep({ router.push(AppRoutes.welcome.index) } - // whenever the chain switches we need to update the latest Safe version + // whenever the chain switches we need to update the latest Safe version and selected chain useEffect(() => { - setValue(SetNameStepFields.safeVersion, getLatestSafeVersion(chain)) - }, [chain, setValue]) + setValue(SetNameStepFields.safeVersion, getLatestSafeVersion(currentChain)) + }, [currentChain, setValue]) - const isDisabled = isWrongChain || !isValid + const isDisabled = !isValid return ( - + - - - - + + + + Select Networks + + + Choose which networks you want your account to be active on. You can add more networks later.{' '} + + @@ -122,10 +151,6 @@ function SetNameStep({ . - - - - diff --git a/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx b/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx index fc0b04589a..2504c4c0b2 100644 --- a/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx +++ b/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx @@ -9,6 +9,11 @@ import FailedIcon from '@/public/images/common/tx-failed.svg' const getStep = (status: SafeCreationEvent) => { switch (status) { + case SafeCreationEvent.AWAITING_EXECUTION: + return { + description: 'Your account is awaiting activation', + instruction: 'Activate the account to unlock all features of your smart wallet', + } case SafeCreationEvent.PROCESSING: case SafeCreationEvent.RELAYING: return { diff --git a/src/components/new-safe/create/steps/StatusStep/index.tsx b/src/components/new-safe/create/steps/StatusStep/index.tsx index cd896b7963..546ceaa94d 100644 --- a/src/components/new-safe/create/steps/StatusStep/index.tsx +++ b/src/components/new-safe/create/steps/StatusStep/index.tsx @@ -2,7 +2,6 @@ import { useCounter } from '@/components/common/Notifications/useCounter' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' import { getRedirect } from '@/components/new-safe/create/logic' -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' @@ -18,6 +17,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { getLatestSafeVersion } from '@/utils/chains' +import { isPredictedSafeProps } from '@/features/counterfactual/utils' const SPEED_UP_THRESHOLD_IN_SECONDS = 15 @@ -53,7 +53,6 @@ export const CreateSafeStatus = ({ if (!chain || !safeAddress) return if (status === SafeCreationEvent.SUCCESS) { - dispatch(updateAddressBook(chain.chainId, safeAddress, data.name, data.owners, data.threshold)) const redirect = getRedirect(chain.shortName, safeAddress, router.query?.safeViewRedirectURL) if (typeof redirect !== 'string' || redirect.startsWith('/')) { router.push(redirect) @@ -74,7 +73,7 @@ export const CreateSafeStatus = ({ const tryAgain = () => { trackEvent(CREATE_SAFE_EVENTS.RETRY_CREATE_SAFE) - if (!pendingSafe) { + if (!pendingSafe || !isPredictedSafeProps(pendingSafe.props)) { setStep(0) return } @@ -84,6 +83,7 @@ export const CreateSafeStatus = ({ setStepData?.({ owners: pendingSafe.props.safeAccountConfig.owners.map((owner) => ({ name: '', address: owner })), name: '', + networks: [], threshold: pendingSafe.props.safeAccountConfig.threshold, saltNonce: Number(pendingSafe.props.safeDeploymentConfig?.saltNonce), safeAddress, diff --git a/src/components/new-safe/create/useEstimateSafeCreationGas.ts b/src/components/new-safe/create/useEstimateSafeCreationGas.ts index 4cbe4c04ac..4adcef1f5b 100644 --- a/src/components/new-safe/create/useEstimateSafeCreationGas.ts +++ b/src/components/new-safe/create/useEstimateSafeCreationGas.ts @@ -2,11 +2,12 @@ import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import useWallet from '@/hooks/wallets/useWallet' import useAsync from '@/hooks/useAsync' import { useCurrentChain } from '@/hooks/useChains' -import { estimateSafeCreationGas, type SafeCreationProps } from '@/components/new-safe/create/logic' +import { estimateSafeCreationGas } from '@/components/new-safe/create/logic' import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import { type UndeployedSafeProps } from '@/store/slices' export const useEstimateSafeCreationGas = ( - safeParams: SafeCreationProps, + undeployedSafe: UndeployedSafeProps | undefined, safeVersion?: SafeVersion, ): { gasLimit?: bigint @@ -18,10 +19,10 @@ export const useEstimateSafeCreationGas = ( const wallet = useWallet() const [gasLimit, gasLimitError, gasLimitLoading] = useAsync(() => { - if (!wallet?.address || !chain || !web3ReadOnly) return + if (!wallet?.address || !chain || !web3ReadOnly || !undeployedSafe) return - return estimateSafeCreationGas(chain, web3ReadOnly, wallet.address, safeParams, safeVersion) - }, [wallet, chain, web3ReadOnly, safeParams, safeVersion]) + return estimateSafeCreationGas(chain, web3ReadOnly, wallet.address, undeployedSafe, safeVersion) + }, [wallet?.address, chain, web3ReadOnly, undeployedSafe, safeVersion]) return { gasLimit, gasLimitError, gasLimitLoading } } diff --git a/src/components/new-safe/create/useSyncSafeCreationStep.ts b/src/components/new-safe/create/useSyncSafeCreationStep.ts index 5ba811a03d..a1b1c281e6 100644 --- a/src/components/new-safe/create/useSyncSafeCreationStep.ts +++ b/src/components/new-safe/create/useSyncSafeCreationStep.ts @@ -2,19 +2,20 @@ 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 useIsWrongChain from '@/hooks/useIsWrongChain' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useCurrentChain } from '@/hooks/useChains' -const useSyncSafeCreationStep = (setStep: StepRenderProps['setStep']) => { +const useSyncSafeCreationStep = (setStep: StepRenderProps['setStep'], networks: ChainInfo[]) => { const wallet = useWallet() - const isWrongChain = useIsWrongChain() + const currentChain = useCurrentChain() useEffect(() => { - // Jump to choose name and network step if the wallet is connected to the wrong chain and there is no pending Safe - if (!wallet || isWrongChain) { + // Jump to choose name and network step if there is no pending Safe or if the selected network does not match the connected network + if (!wallet || (networks.length === 1 && currentChain?.chainId !== networks[0].chainId)) { setStep(0) return } - }, [wallet, setStep, isWrongChain]) + }, [currentChain?.chainId, networks, setStep, wallet]) } export default useSyncSafeCreationStep diff --git a/src/components/new-safe/load/steps/SafeReviewStep/index.tsx b/src/components/new-safe/load/steps/SafeReviewStep/index.tsx index d414e59419..3ebd7ae2d7 100644 --- a/src/components/new-safe/load/steps/SafeReviewStep/index.tsx +++ b/src/components/new-safe/load/steps/SafeReviewStep/index.tsx @@ -13,10 +13,10 @@ import { useAppDispatch } from '@/store' import { useRouter } from 'next/router' import { addOrUpdateSafe } from '@/store/addedSafesSlice' import { defaultSafeInfo } from '@/store/safeInfoSlice' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' import { LOAD_SAFE_EVENTS, OPEN_SAFE_LABELS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { AppRoutes } from '@/config/routes' import ReviewRow from '@/components/new-safe/ReviewRow' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' const SafeReviewStep = ({ data, onBack }: StepRenderProps) => { const chain = useCurrentChain() @@ -44,8 +44,8 @@ const SafeReviewStep = ({ data, onBack }: StepRenderProps) => ) dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds: [chainId], address: safeAddress, name: safeName, }), @@ -59,8 +59,8 @@ const SafeReviewStep = ({ data, onBack }: StepRenderProps) => } dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds: [chainId], address, name: entryName, }), diff --git a/src/components/settings/FallbackHandler/__tests__/index.test.tsx b/src/components/settings/FallbackHandler/__tests__/index.test.tsx index 3d7abd727f..4e5d144a6d 100644 --- a/src/components/settings/FallbackHandler/__tests__/index.test.tsx +++ b/src/components/settings/FallbackHandler/__tests__/index.test.tsx @@ -1,3 +1,4 @@ +import { TWAP_FALLBACK_HANDLER } from '@/features/swap/helpers/utils' import { chainBuilder } from '@/tests/builders/chains' import { render, waitFor } from '@/tests/test-utils' @@ -238,4 +239,50 @@ describe('FallbackHandler', () => { expect(fbHandler.container).toBeEmptyDOMElement() }) + + it('should display a message in case it is a TWAP fallback handler', () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '1', + fallbackHandler: { + value: TWAP_FALLBACK_HANDLER, + }, + }, + } as unknown as ReturnType), + ) + + const { getByText } = render() + + expect( + getByText( + "This is CoW's fallback handler. It is needed for this Safe to be able to use the TWAP feature for Swaps.", + ), + ).toBeInTheDocument() + }) + + it('should not display a message in case it is a TWAP fallback handler on an unsupported network', () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '10', + fallbackHandler: { + value: TWAP_FALLBACK_HANDLER, + }, + }, + } as unknown as ReturnType), + ) + + const { queryByText } = render() + + expect( + queryByText( + "This is CoW's fallback handler. It is needed for this Safe to be able to use the TWAP feature for Swaps.", + ), + ).not.toBeInTheDocument() + }) }) diff --git a/src/components/settings/FallbackHandler/index.tsx b/src/components/settings/FallbackHandler/index.tsx index 7aa77ac6ec..3bdd6f8ff2 100644 --- a/src/components/settings/FallbackHandler/index.tsx +++ b/src/components/settings/FallbackHandler/index.tsx @@ -1,4 +1,5 @@ -import { TWAP_FALLBACK_HANDLER } from '@/features/swap/helpers/utils' +import { TWAP_FALLBACK_HANDLER, TWAP_FALLBACK_HANDLER_NETWORKS } from '@/features/swap/helpers/utils' +import { getCompatibilityFallbackHandlerDeployments } from '@safe-global/safe-deployments' import NextLink from 'next/link' import { Typography, Box, Grid, Paper, Link, Alert } from '@mui/material' import semverSatisfies from 'semver/functions/satisfies' @@ -7,7 +8,6 @@ import type { ReactElement } from 'react' import EthHashInfo from '@/components/common/EthHashInfo' import useSafeInfo from '@/hooks/useSafeInfo' -import { getFallbackHandlerContractDeployment } from '@/services/contracts/deployments' import { HelpCenterArticle } from '@/config/constants' import ExternalLink from '@/components/common/ExternalLink' import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp' @@ -22,11 +22,12 @@ export const FallbackHandler = (): ReactElement | null => { const supportsFallbackHandler = !!safe.version && semverSatisfies(safe.version, FALLBACK_HANDLER_VERSION) - const fallbackHandlerDeployment = useMemo(() => { - if (!chain) { + const fallbackHandlerDeployments = useMemo(() => { + if (!chain || !safe.version) { return undefined } - return getFallbackHandlerContractDeployment(chain, safe.version) + + return getCompatibilityFallbackHandlerDeployments({ network: chain?.chainId, version: safe.version }) }, [safe.version, chain]) if (!supportsFallbackHandler) { @@ -35,8 +36,10 @@ export const FallbackHandler = (): ReactElement | null => { const hasFallbackHandler = !!safe.fallbackHandler const isOfficial = - hasFallbackHandler && safe.fallbackHandler?.value === fallbackHandlerDeployment?.networkAddresses[safe.chainId] - const isTWAPFallbackHandler = safe.fallbackHandler?.value === TWAP_FALLBACK_HANDLER + safe.fallbackHandler && + fallbackHandlerDeployments?.networkAddresses[safe.chainId].includes(safe.fallbackHandler.value) + const isTWAPFallbackHandler = + safe.fallbackHandler?.value === TWAP_FALLBACK_HANDLER && TWAP_FALLBACK_HANDLER_NETWORKS.includes(safe.chainId) const warning = !hasFallbackHandler ? ( <> @@ -101,7 +104,7 @@ export const FallbackHandler = (): ReactElement | null => { {safe.fallbackHandler && ( { if (data.name !== name) { dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds: [chainId], address, name: data.name, }), diff --git a/src/components/sidebar/NewTxButton/index.tsx b/src/components/sidebar/NewTxButton/index.tsx index 2c57814b06..9f953643e7 100644 --- a/src/components/sidebar/NewTxButton/index.tsx +++ b/src/components/sidebar/NewTxButton/index.tsx @@ -1,3 +1,5 @@ +import ActivateAccountButton from '@/features/counterfactual/ActivateAccountButton' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' import { type ReactElement, useContext } from 'react' import Button from '@mui/material/Button' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' @@ -8,12 +10,17 @@ import WatchlistAddButton from '../WatchlistAddButton' const NewTxButton = (): ReactElement => { const { setTxFlow } = useContext(TxModalContext) + const isCounterfactualSafe = useIsCounterfactualSafe() const onClick = () => { setTxFlow(, undefined, false) trackEvent({ ...OVERVIEW_EVENTS.NEW_TRANSACTION, label: 'sidebar' }) } + if (isCounterfactualSafe) { + return + } + return ( {(isOk) => diff --git a/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx b/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx new file mode 100644 index 0000000000..79fb34c4c7 --- /dev/null +++ b/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx @@ -0,0 +1,109 @@ +import type { MouseEvent } from 'react' +import { useState, type ReactElement } from 'react' +import ListItemIcon from '@mui/material/ListItemIcon' +import IconButton from '@mui/material/IconButton' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import MenuItem from '@mui/material/MenuItem' +import ListItemText from '@mui/material/ListItemText' + +import EntryDialog from '@/components/address-book/EntryDialog' +import EditIcon from '@/public/images/common/edit.svg' +import PlusIcon from '@/public/images/common/plus.svg' +import ContextMenu from '@/components/common/ContextMenu' +import { trackEvent, OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { SvgIcon } from '@mui/material' +import { AppRoutes } from '@/config/routes' +import router from 'next/router' +import { CreateSafeOnNewChain } from '@/features/multichain/components/CreateSafeOnNewChain' + +enum ModalType { + RENAME = 'rename', + ADD_CHAIN = 'add_chain', +} + +const defaultOpen = { [ModalType.RENAME]: false, [ModalType.ADD_CHAIN]: false } + +const MultiAccountContextMenu = ({ + name, + address, + chainIds, + addNetwork, +}: { + name: string + address: string + chainIds: string[] + addNetwork: boolean +}): ReactElement => { + const [anchorEl, setAnchorEl] = useState() + const [open, setOpen] = useState(defaultOpen) + + const handleOpenContextMenu = (e: MouseEvent) => { + e.stopPropagation() + setAnchorEl(e.currentTarget) + } + + const handleCloseContextMenu = (event: MouseEvent) => { + event.stopPropagation() + setAnchorEl(undefined) + } + + const handleOpenModal = + (type: ModalType, event: typeof OVERVIEW_EVENTS.SIDEBAR_RENAME | typeof OVERVIEW_EVENTS.ADD_NEW_NETWORK) => + (e: MouseEvent) => { + const trackingLabel = + router.pathname === AppRoutes.welcome.accounts ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + handleCloseContextMenu(e) + setOpen((prev) => ({ ...prev, [type]: true })) + + trackEvent({ ...event, label: trackingLabel }) + } + + const handleCloseModal = () => { + setOpen(defaultOpen) + } + + return ( + <> + + ({ color: palette.border.main })} /> + + + + + + + Rename + + {addNetwork && ( + + + + + Add another network + + )} + + + {open[ModalType.RENAME] && ( + + )} + + {open[ModalType.ADD_CHAIN] && ( + + )} + + ) +} + +export default MultiAccountContextMenu diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx index e019e5ee83..5d1e77d867 100644 --- a/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/src/components/sidebar/SafeListContextMenu/index.tsx @@ -12,28 +12,35 @@ import { useAppSelector } from '@/store' import { selectAddedSafes } from '@/store/addedSafesSlice' import EditIcon from '@/public/images/common/edit.svg' import DeleteIcon from '@/public/images/common/delete.svg' +import PlusIcon from '@/public/images/common/plus.svg' import ContextMenu from '@/components/common/ContextMenu' import { trackEvent, OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' import { SvgIcon } from '@mui/material' import useAddressBook from '@/hooks/useAddressBook' import { AppRoutes } from '@/config/routes' import router from 'next/router' +import { CreateSafeOnNewChain } from '@/features/multichain/components/CreateSafeOnNewChain' enum ModalType { RENAME = 'rename', REMOVE = 'remove', + ADD_CHAIN = 'add_chain', } -const defaultOpen = { [ModalType.RENAME]: false, [ModalType.REMOVE]: false } +const defaultOpen = { [ModalType.RENAME]: false, [ModalType.REMOVE]: false, [ModalType.ADD_CHAIN]: false } const SafeListContextMenu = ({ name, address, chainId, + addNetwork, + rename, }: { name: string address: string chainId: string + addNetwork: boolean + rename: boolean }): ReactElement => { const addedSafes = useAppSelector((state) => selectAddedSafes(state, chainId)) const isAdded = !!addedSafes?.[address] @@ -73,12 +80,14 @@ const SafeListContextMenu = ({ ({ color: palette.border.main })} /> - - - - - {hasName ? 'Rename' : 'Give name'} - + {rename && ( + + + + + {hasName ? 'Rename' : 'Give name'} + + )} {isAdded && ( @@ -88,13 +97,22 @@ const SafeListContextMenu = ({ Remove )} + + {addNetwork && ( + + + + + Add another network + + )} {open[ModalType.RENAME] && ( )} @@ -102,6 +120,16 @@ const SafeListContextMenu = ({ {open[ModalType.REMOVE] && ( )} + + {open[ModalType.ADD_CHAIN] && ( + + )} ) } diff --git a/src/components/sidebar/SafeListRemoveDialog/index.tsx b/src/components/sidebar/SafeListRemoveDialog/index.tsx index 17718eb6a4..6332ba7a78 100644 --- a/src/components/sidebar/SafeListRemoveDialog/index.tsx +++ b/src/components/sidebar/SafeListRemoveDialog/index.tsx @@ -12,6 +12,7 @@ import Track from '@/components/common/Track' import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' import { AppRoutes } from '@/config/routes' import router from 'next/router' +import { removeAddressBookEntry } from '@/store/addressBookSlice' const SafeListRemoveDialog = ({ handleClose, @@ -31,6 +32,7 @@ const SafeListRemoveDialog = ({ const handleConfirm = () => { dispatch(removeSafe({ chainId, address })) + dispatch(removeAddressBookEntry({ chainId, address })) handleClose() } diff --git a/src/components/sidebar/SidebarNavigation/index.tsx b/src/components/sidebar/SidebarNavigation/index.tsx index 587e47a379..0c77c43cf6 100644 --- a/src/components/sidebar/SidebarNavigation/index.tsx +++ b/src/components/sidebar/SidebarNavigation/index.tsx @@ -20,6 +20,7 @@ import { trackEvent } from '@/services/analytics' import { SWAP_EVENTS, SWAP_LABELS } from '@/services/analytics/events/swaps' import { GeoblockingContext } from '@/components/common/GeoblockingProvider' import { STAKE_EVENTS, STAKE_LABELS } from '@/services/analytics/events/stake' +import { Tooltip } from '@mui/material' const getSubdirectory = (pathname: string): string => { return pathname.split('/')[1] @@ -27,6 +28,8 @@ const getSubdirectory = (pathname: string): string => { const geoBlockedRoutes = [AppRoutes.swap, AppRoutes.stake] +const undeployedSafeBlockedRoutes = [AppRoutes.swap, AppRoutes.stake, AppRoutes.apps.index] + const customSidebarEvents: { [key: string]: { event: any; label: string } } = { [AppRoutes.swap]: { event: SWAP_EVENTS.OPEN_SWAPS, label: SWAP_LABELS.sidebar }, [AppRoutes.stake]: { event: STAKE_EVENTS.OPEN_STAKE, label: STAKE_LABELS.sidebar }, @@ -40,7 +43,7 @@ const Navigation = (): ReactElement => { const queueSize = useQueuedTxsLength() const isBlockedCountry = useContext(GeoblockingContext) - const enabledNavItems = useMemo(() => { + const visibleNavItems = useMemo(() => { return navItems.filter((item) => { if (isBlockedCountry && geoBlockedRoutes.includes(item.href)) { return false @@ -50,6 +53,12 @@ const Navigation = (): ReactElement => { }) }, [chain, isBlockedCountry]) + const enabledNavItems = useMemo(() => { + return safe.deployed + ? visibleNavItems + : visibleNavItems.filter((item) => !undeployedSafeBlockedRoutes.includes(item.href)) + }, [safe.deployed, visibleNavItems]) + const getBadge = (item: NavItem) => { // Indicate whether the current Safe needs an upgrade if (item.href === AppRoutes.settings.setup) { @@ -74,9 +83,9 @@ const Navigation = (): ReactElement => { return ( - {enabledNavItems.map((item) => { + {visibleNavItems.map((item) => { const isSelected = currentSubdirectory === getSubdirectory(item.href) - + const isDisabled = item.disabled || !enabledNavItems.includes(item) let ItemTag = item.tag ? item.tag : null if (item.href === AppRoutes.transactions.history) { @@ -84,26 +93,34 @@ const Navigation = (): ReactElement => { } return ( - handleNavigationClick(item.href)} + - handleNavigationClick(item.href)} + key={item.href} > - {item.icon && {item.icon}} - - - {item.label} - - {ItemTag} - - - + + {item.icon && {item.icon}} + + + {item.label} + + {ItemTag} + + + + ) })} diff --git a/src/components/transactions/SingleTx/index.tsx b/src/components/transactions/SingleTx/index.tsx index c2bd9e52a6..034630d3e9 100644 --- a/src/components/transactions/SingleTx/index.tsx +++ b/src/components/transactions/SingleTx/index.tsx @@ -13,7 +13,7 @@ import ExpandableTransactionItem, { } from '@/components/transactions/TxListItem/ExpandableTransactionItem' import GroupLabel from '../GroupLabel' import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' -import { useGetTransactionDetailsQuery } from '@/store/gateway' +import { useGetTransactionDetailsQuery } from '@/store/api/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' import { asError } from '@/services/exceptions/utils' diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx index 8b6cfb3024..dfe3114835 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx @@ -7,6 +7,9 @@ import css from './styles.module.css' import accordionCss from '@/styles/accordion.module.css' import CodeIcon from '@mui/icons-material/Code' import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' +import { sameAddress } from '@/utils/addresses' +import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' +import { useCurrentChain } from '@/hooks/useChains' type SingleTxDecodedProps = { tx: InternalTransaction @@ -18,12 +21,16 @@ type SingleTxDecodedProps = { } export const SingleTxDecoded = ({ tx, txData, actionTitle, variant, expanded, onChange }: SingleTxDecodedProps) => { + const chain = useCurrentChain() const isNativeTransfer = tx.value !== '0' && (!tx.data || isEmptyHexData(tx.data)) const method = tx.dataDecoded?.method || (isNativeTransfer ? 'native transfer' : 'contract interaction') const addressInfo = txData.addressInfoIndex?.[tx.to] const name = addressInfo?.name + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = chain && safeToL2MigrationDeployment?.networkAddresses[chain.chainId] + const singleTxData = { to: { value: tx.to }, value: tx.value, @@ -31,7 +38,7 @@ export const SingleTxDecoded = ({ tx, txData, actionTitle, variant, expanded, on dataDecoded: tx.dataDecoded, hexData: tx.data ?? undefined, addressInfoIndex: txData.addressInfoIndex, - trustedDelegateCallTarget: false, // Nested delegate calls are always untrusted + trustedDelegateCallTarget: sameAddress(tx.to, safeToL2MigrationAddress), // We only trusted a nested Migration } return ( diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx index 37f8735a4d..50bdbfc53f 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx @@ -12,6 +12,7 @@ import MethodCall from './MethodCall' import useSafeAddress from '@/hooks/useSafeAddress' import { sameAddress } from '@/utils/addresses' import { DelegateCallWarning } from '@/components/transactions/Warning' +import { isMigrateToL2TxData } from '@/utils/transaction-guards' interface Props { txData: TransactionDetails['txData'] @@ -58,9 +59,11 @@ export const DecodedData = ({ txData, toInfo }: Props): ReactElement | null => { decodedData = } + const isL2Migration = isMigrateToL2TxData(txData, chainInfo?.chainId) + return ( - {isDelegateCall && } + {isDelegateCall && } {method ? ( diff --git a/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx b/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx new file mode 100644 index 0000000000..636769abc7 --- /dev/null +++ b/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx @@ -0,0 +1,82 @@ +import DecodedTx from '@/components/tx/DecodedTx' +import useAsync from '@/hooks/useAsync' +import { useCurrentChain } from '@/hooks/useChains' +import useDecodeTx from '@/hooks/useDecodeTx' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import { getMultiSendContractDeployment } from '@/services/contracts/deployments' +import { createTx } from '@/services/tx/tx-sender/create' +import { Safe__factory } from '@/types/contracts' +import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { zeroPadValue } from 'ethers' +import DecodedData from '../DecodedData' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import { MigrateToL2Information } from '@/components/tx/SignOrExecuteForm/MigrateToL2Information' +import { Box } from '@mui/material' + +export const MigrationToL2TxData = ({ txDetails }: { txDetails: TransactionDetails }) => { + const readOnlyProvider = useWeb3ReadOnly() + const chain = useCurrentChain() + const { safe } = useSafeInfo() + const sdk = useSafeSDK() + // Reconstruct real tx + const [realSafeTx, realSafeTxError, realSafeTxLoading] = useAsync(async () => { + // Fetch tx receipt from backend + if (!txDetails.txHash || !chain || !sdk) { + return undefined + } + const txResult = await readOnlyProvider?.getTransaction(txDetails.txHash) + const txData = txResult?.data + + // Search for a Safe Tx to MultiSend contract + const safeInterface = Safe__factory.createInterface() + const execTransactionSelector = safeInterface.getFunction('execTransaction').selector.slice(2, 10) + const multiSendDeployment = getMultiSendContractDeployment(chain, safe.version) + const multiSendAddress = multiSendDeployment?.networkAddresses[chain.chainId] + if (!multiSendAddress) { + return undefined + } + const searchString = `${execTransactionSelector}${zeroPadValue(multiSendAddress, 32).slice(2)}` + const indexOfTx = txData?.indexOf(searchString) + if (indexOfTx && txData) { + // Now we need to find the tx Data + const parsedTx = safeInterface.parseTransaction({ data: `0x${txData.slice(indexOfTx)}` }) + + const execTxArgs = parsedTx?.args + if (!execTxArgs || execTxArgs.length < 10) { + return undefined + } + return createTx({ + to: execTxArgs[0], + value: execTxArgs[1].toString(), + data: execTxArgs[2], + operation: Number(execTxArgs[3]), + safeTxGas: execTxArgs[4].toString(), + baseGas: execTxArgs[5].toString(), + gasPrice: execTxArgs[6].toString(), + gasToken: execTxArgs[7].toString(), + refundReceiver: execTxArgs[8], + }) + } + }, [readOnlyProvider, txDetails.txHash, chain, safe.version, sdk]) + + const [decodedRealTx, decodedRealTxError] = useDecodeTx(realSafeTx) + + const decodedDataUnavailable = !realSafeTx && !realSafeTxLoading + + return ( + + + {realSafeTxError ? ( + {realSafeTxError.message} + ) : decodedRealTxError ? ( + {decodedRealTxError.message} + ) : decodedDataUnavailable ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/transactions/TxDetails/TxData/index.tsx b/src/components/transactions/TxDetails/TxData/index.tsx index 000f56357f..2c92a9493c 100644 --- a/src/components/transactions/TxDetails/TxData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/index.tsx @@ -5,6 +5,7 @@ import { isStakingTxExitInfo } from '@/utils/transaction-guards' import { isCancellationTxInfo, isCustomTxInfo, + isMigrateToL2TxData, isMultisigDetailedExecutionInfo, isOrderTxInfo, isSettingsChangeTxInfo, @@ -20,6 +21,7 @@ import RejectionTxInfo from '@/components/transactions/TxDetails/TxData/Rejectio import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' import TransferTxInfo from '@/components/transactions/TxDetails/TxData/Transfer' import useChainId from '@/hooks/useChainId' +import { MigrationToL2TxData } from './MigrationToL2TxData' import SwapOrder from '@/features/swap/components/SwapOrder' import StakingTxDepositDetails from '@/features/stake/components/StakingTxDepositDetails' import StakingTxExitDetails from '@/features/stake/components/StakingTxExitDetails' @@ -71,6 +73,9 @@ const TxData = ({ return } + if (isMigrateToL2TxData(txDetails.txData, chainId)) { + return + } return } diff --git a/src/components/transactions/TxDetails/index.tsx b/src/components/transactions/TxDetails/index.tsx index 1387362d0b..d64a0d225f 100644 --- a/src/components/transactions/TxDetails/index.tsx +++ b/src/components/transactions/TxDetails/index.tsx @@ -33,7 +33,7 @@ import useIsPending from '@/hooks/useIsPending' import { isImitation, isTrustedTx } from '@/utils/transactions' import { useHasFeature } from '@/hooks/useChains' import { FEATURES } from '@/utils/chains' -import { useGetTransactionDetailsQuery } from '@/store/gateway' +import { useGetTransactionDetailsQuery } from '@/store/api/gateway' import { asError } from '@/services/exceptions/utils' import { POLLING_INTERVAL } from '@/config/constants' diff --git a/src/components/transactions/TxInfo/index.tsx b/src/components/transactions/TxInfo/index.tsx index 7d4675e823..c5ab73339f 100644 --- a/src/components/transactions/TxInfo/index.tsx +++ b/src/components/transactions/TxInfo/index.tsx @@ -19,6 +19,7 @@ import { isNativeTokenTransfer, isSettingsChangeTxInfo, isTransferTxInfo, + isMigrateToL2TxInfo, isStakingTxDepositInfo, isStakingTxExitInfo, isStakingTxWithdrawInfo, @@ -118,6 +119,10 @@ const SettingsChangeTx = ({ info }: { info: SettingsChange }): ReactElement => { return <> } +const MigrationToL2Tx = (): ReactElement => { + return <>Migrate base contract +} + const TxInfo = ({ info, ...rest }: { info: TransactionInfo; omitSign?: boolean; withLogo?: boolean }): ReactElement => { if (isSettingsChangeTxInfo(info)) { return @@ -131,6 +136,10 @@ const TxInfo = ({ info, ...rest }: { info: TransactionInfo; omitSign?: boolean; return } + if (isMigrateToL2TxInfo(info)) { + return + } + if (isCreationTxInfo(info)) { return } diff --git a/src/components/tx-flow/SafeTxProvider.tsx b/src/components/tx-flow/SafeTxProvider.tsx index b4864a1f4a..b964f7546c 100644 --- a/src/components/tx-flow/SafeTxProvider.tsx +++ b/src/components/tx-flow/SafeTxProvider.tsx @@ -1,10 +1,13 @@ -import { createContext, useState, useEffect } from 'react' +import { createContext, useState, useEffect, useCallback } from 'react' import type { Dispatch, ReactNode, SetStateAction, ReactElement } from 'react' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { createTx } from '@/services/tx/tx-sender' import { useRecommendedNonce, useSafeTxGas } from '../tx/SignOrExecuteForm/hooks' import { Errors, logError } from '@/services/exceptions' import type { EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useCurrentChain } from '@/hooks/useChains' +import { prependSafeToL2Migration } from '@/utils/transactions' export const SafeTxContext = createContext<{ safeTx?: SafeTransaction @@ -42,6 +45,25 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => const [nonceNeeded, setNonceNeeded] = useState(true) const [safeTxGas, setSafeTxGas] = useState() + const { safe } = useSafeInfo() + const chain = useCurrentChain() + + const setAndMigrateSafeTx: Dispatch> = useCallback( + ( + value: SafeTransaction | undefined | ((prevState: SafeTransaction | undefined) => SafeTransaction | undefined), + ) => { + let safeTx: SafeTransaction | undefined + if (typeof value === 'function') { + safeTx = value(safeTx) + } else { + safeTx = value + } + + prependSafeToL2Migration(safeTx, safe, chain).then(setSafeTx) + }, + [chain, safe], + ) + // Signed txs cannot be updated const isSigned = safeTx && safeTx.signatures.size > 0 @@ -73,7 +95,7 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => value={{ safeTx, safeTxError, - setSafeTx, + setSafeTx: setAndMigrateSafeTx, setSafeTxError, safeMessage, setSafeMessage, diff --git a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx index e2913389c5..7806b28b97 100644 --- a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx +++ b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx @@ -7,7 +7,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' import { createSwapOwnerTx, createAddOwnerTx } from '@/services/tx/tx-sender' import { useAppDispatch } from '@/store' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { SafeTxContext } from '../../SafeTxProvider' import type { AddOwnerFlowProps } from '.' import type { ReplaceOwnerFlowProps } from '../ReplaceOwner' @@ -15,6 +15,7 @@ import { OwnerList } from '../../common/OwnerList' import MinusIcon from '@/public/images/common/minus.svg' import EthHashInfo from '@/components/common/EthHashInfo' import commonCss from '@/components/tx-flow/common/styles.module.css' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwnerFlowProps }) => { const dispatch = useAppDispatch() @@ -43,8 +44,8 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn const addAddressBookEntryAndSubmit = () => { if (typeof newOwner.name !== 'undefined') { dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds: [chainId], address: newOwner.address, name: newOwner.name, }), @@ -73,6 +74,8 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn )} + + Any transaction requires the confirmation of: diff --git a/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx index 78d8958f5f..8fe4425152 100644 --- a/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx +++ b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx @@ -10,6 +10,7 @@ import { ChangeThresholdFlowFieldNames } from '@/components/tx-flow/flows/Change import type { ChangeThresholdFlowProps } from '@/components/tx-flow/flows/ChangeThreshold' import commonCss from '@/components/tx-flow/common/styles.module.css' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) => { const { safe } = useSafeInfo() @@ -28,6 +29,8 @@ const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) return ( + +
    Any transaction will require the confirmation of: diff --git a/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx index e5c9515885..1aab7a96fb 100644 --- a/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx +++ b/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx @@ -36,7 +36,7 @@ import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletReject import useUserNonce from '@/components/tx/AdvancedParams/useUserNonce' import { getLatestSafeVersion } from '@/utils/chains' import { HexEncodedData } from '@/components/transactions/HexEncodedData' -import { useGetMultipleTransactionDetailsQuery } from '@/store/gateway' +import { useGetMultipleTransactionDetailsQuery } from '@/store/api/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' diff --git a/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx b/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx index 8477fb570d..4b15885928 100644 --- a/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx +++ b/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx @@ -13,6 +13,7 @@ import type { RemoveOwnerFlowProps } from '.' import EthHashInfo from '@/components/common/EthHashInfo' import commonCss from '@/components/tx-flow/common/styles.module.css' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' export const ReviewRemoveOwner = ({ params }: { params: RemoveOwnerFlowProps }): ReactElement => { const addressBook = useAddressBook() @@ -46,6 +47,8 @@ export const ReviewRemoveOwner = ({ params }: { params: RemoveOwnerFlowProps }): hasExplorer /> + + diff --git a/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx b/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx index e2b99097f9..f29129a3af 100644 --- a/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx +++ b/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx @@ -65,7 +65,7 @@ export const SetThreshold = ({ Any transaction requires the confirmation of: - {safe.owners.slice(1).map((_, idx) => ( {idx + 1} diff --git a/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx b/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx index b2e6fd8295..7df0c7021e 100644 --- a/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx +++ b/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx @@ -229,6 +229,7 @@ describe('ApprovalEditor', () => { to: tokenAddress, data: ERC20_INTERFACE.encodeFunctionData('transfer', [spenderAddress, '25']), value: '0', + operation: OperationType.Call, }, { to: tokenAddress, @@ -336,6 +337,7 @@ describe('ApprovalEditor', () => { to: tokenAddress, data: ERC20_INTERFACE.encodeFunctionData('transfer', [spenderAddress, '25']), value: '0', + operation: OperationType.Call, }, { to: tokenAddress, diff --git a/src/components/tx/DecodedTx/index.tsx b/src/components/tx/DecodedTx/index.tsx index 6ea7df0e39..09912275da 100644 --- a/src/components/tx/DecodedTx/index.tsx +++ b/src/components/tx/DecodedTx/index.tsx @@ -13,7 +13,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' import accordionCss from '@/styles/accordion.module.css' import HelpToolTip from './HelpTooltip' -import { useGetTransactionDetailsQuery } from '@/store/gateway' +import { useGetTransactionDetailsQuery } from '@/store/api/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' import { asError } from '@/services/exceptions/utils' diff --git a/src/components/tx/ErrorMessage/index.tsx b/src/components/tx/ErrorMessage/index.tsx index 924e944737..d272d1d11e 100644 --- a/src/components/tx/ErrorMessage/index.tsx +++ b/src/components/tx/ErrorMessage/index.tsx @@ -1,5 +1,5 @@ import { type ReactElement, type ReactNode, type SyntheticEvent, useState } from 'react' -import { Link, Typography, SvgIcon } from '@mui/material' +import { Link, Typography, SvgIcon, AlertTitle } from '@mui/material' import classNames from 'classnames' import WarningIcon from '@/public/images/notifications/warning.svg' import InfoIcon from '@/public/images/notifications/info.svg' @@ -12,11 +12,13 @@ const ErrorMessage = ({ error, className, level = 'error', + title, }: { children: ReactNode error?: Error & { reason?: string } className?: string level?: 'error' | 'warning' | 'info' + title?: string }): ReactElement => { const [showDetails, setShowDetails] = useState(false) @@ -31,12 +33,19 @@ const ErrorMessage = ({ `${palette[level].main} !important` }} />
    + {title && ( + + + {title} + + + )} {children} {error && ( diff --git a/src/components/tx/ErrorMessage/styles.module.css b/src/components/tx/ErrorMessage/styles.module.css index 1fda0fddc0..242085d84f 100644 --- a/src/components/tx/ErrorMessage/styles.module.css +++ b/src/components/tx/ErrorMessage/styles.module.css @@ -11,7 +11,6 @@ .container.warning { background-color: var(--color-warning-background); - color: var(--color-warning-dark); } .container.info { diff --git a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts index c99b10daa1..1fa5166634 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts @@ -25,9 +25,9 @@ import { KnownContracts, getModuleInstance } from '@gnosis.pm/zodiac' import useWallet from '@/hooks/wallets/useWallet' import { useHasFeature } from '@/hooks/useChains' import { FEATURES } from '@/utils/chains' -import { decodeMultiSendTxs } from '@/utils/transactions' import { encodeMultiSendData } from '@safe-global/protocol-kit' import { Multi_send__factory } from '@/types/contracts' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' const ROLES_V2_SUPPORTED_CHAINS = Object.keys(chains) const multiSendInterface = Multi_send__factory.createInterface() @@ -50,7 +50,7 @@ export const useMetaTransactions = (safeTx?: SafeTransaction): MetaTransactionDa if (metaTx.operation === OperationType.DelegateCall) { // try decoding as multisend try { - const baseTransactions = decodeMultiSendTxs(metaTx.data) + const baseTransactions = decodeMultiSendData(metaTx.data) if (baseTransactions.length > 0) { return baseTransactions.map((tx) => ({ ...tx, operation: OperationType.Call })) } diff --git a/src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx b/src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx new file mode 100644 index 0000000000..6240d7b4de --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx @@ -0,0 +1,37 @@ +import { Alert, AlertTitle, Box, SvgIcon, Typography } from '@mui/material' +import InfoOutlinedIcon from '@/public/images/notifications/info.svg' +import NamedAddressInfo from '@/components/common/NamedAddressInfo' + +export const MigrateToL2Information = ({ + variant, + newMasterCopy, +}: { + variant: 'history' | 'queue' + newMasterCopy?: string +}) => { + return ( + + }> + + + Migration to compatible base contract + + + + {variant === 'history' + ? 'This Safe was using an incompatible base contract. This transaction includes the migration to a supported base contract.' + : 'This Safe is currently using an incompatible base contract. The transaction was automatically modified to first migrate to a supported base contract.'} + + + {newMasterCopy && ( + + + New contract + + + + )} + + + ) +} diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index 9aa8133dad..2fcbde8c56 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -35,9 +35,11 @@ import { Blockaid } from '../security/blockaid' import TxData from '@/components/transactions/TxDetails/TxData' import ConfirmationOrder from '@/components/tx/ConfirmationOrder' import { useApprovalInfos } from '../ApprovalEditor/hooks/useApprovalInfos' +import { MigrateToL2Information } from './MigrateToL2Information' +import { extractMigrationL2MasterCopyAddress } from '@/utils/transactions' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/gateway' +import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' @@ -123,6 +125,8 @@ export const SignOrExecuteForm = ({ const { safe } = useSafeInfo() const isSafeOwner = useIsSafeOwner() const isCounterfactualSafe = !safe.deployed + const multiChainMigrationTarget = extractMigrationL2MasterCopyAddress(safeTx) + const isMultiChainMigration = !!multiChainMigrationTarget // Check if a Zodiac Roles mod is enabled and if the user is a member of any role that allows the transaction const roles = useRoles( @@ -165,6 +169,8 @@ export const SignOrExecuteForm = ({ {props.children} + {isMultiChainMigration && } + {decodedData && ( }> @@ -211,7 +217,7 @@ export const SignOrExecuteForm = ({ - + {!isMultiChainMigration && } diff --git a/src/components/tx/security/blockaid/index.tsx b/src/components/tx/security/blockaid/index.tsx index 79cfe84608..d350032a13 100644 --- a/src/components/tx/security/blockaid/index.tsx +++ b/src/components/tx/security/blockaid/index.tsx @@ -14,7 +14,6 @@ import BlockaidIcon from '@/public/images/transactions/blockaid-icon.svg' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { type SecurityWarningProps, mapSecuritySeverity } from '../utils' import { BlockaidHint } from './BlockaidHint' -import Warning from '@/public/images/notifications/alert.svg' import { SecuritySeverity } from '@/services/security/modules/types' export const REASON_MAPPING: Record = { @@ -65,7 +64,6 @@ const BlockaidResultWarning = ({ <> } className={css.customAlert} sx={ needsRiskConfirmation @@ -136,7 +134,7 @@ const ResultDescription = ({ const BlockaidError = () => { return ( - } className={css.customAlert}> + Proceed with caution diff --git a/src/components/tx/security/useRecipientModule.ts b/src/components/tx/security/useRecipientModule.ts deleted file mode 100644 index dbfd50d26c..0000000000 --- a/src/components/tx/security/useRecipientModule.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useMemo } from 'react' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' - -import useAddressBook from '@/hooks/useAddressBook' -import useAsync from '@/hooks/useAsync' -import useSafeInfo from '@/hooks/useSafeInfo' -import { useWeb3ReadOnly } from '@/hooks/wallets/web3' -import { RecipientAddressModule } from '@/services/security/modules/RecipientAddressModule' -import type { RecipientAddressModuleResponse } from '@/services/security/modules/RecipientAddressModule' -import type { SecurityResponse } from '@/services/security/modules/types' - -const RecipientAddressModuleInstance = new RecipientAddressModule() - -// TODO: Not being used right now -export const useRecipientModule = (safeTransaction: SafeTransaction | undefined) => { - const { safe, safeLoaded } = useSafeInfo() - const web3ReadOnly = useWeb3ReadOnly() - const addressBook = useAddressBook() - - const knownAddresses = useMemo(() => { - const owners = safe.owners.map((owner) => owner.value) - const addressBookAddresses = Object.keys(addressBook) - - return Array.from(new Set(owners.concat(addressBookAddresses))) - }, [addressBook, safe.owners]) - - return useAsync>(() => { - if (!safeTransaction || !web3ReadOnly || !safeLoaded) { - return - } - - return RecipientAddressModuleInstance.scanTransaction({ - chainId: safe.chainId, - safeTransaction, - knownAddresses, - provider: web3ReadOnly, - }) - }, [safeTransaction, web3ReadOnly, safeLoaded, safe.chainId, knownAddresses]) -} diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index cd162f0d65..c2dda30b86 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -1,7 +1,7 @@ import { LoopIcon } from '@/features/counterfactual/CounterfactualStatusButton' import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' -import type { ChainInfo, SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' -import { useCallback, useMemo } from 'react' +import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo } from 'react' import { ListItemButton, Box, Typography, Chip, Skeleton } from '@mui/material' import Link from 'next/link' import SafeIcon from '@/components/common/SafeIcon' @@ -24,14 +24,19 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' import type { SafeItem } from './useAllSafes' import FiatValue from '@/components/common/FiatValue' import QueueActions from './QueueActions' - +import { useGetHref } from './useGetHref' +import { extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features/counterfactual/utils' +import { useGetSafeOverviewQuery } from '@/store/api/gateway' +import useWallet from '@/hooks/wallets/useWallet' +import { skipToken } from '@reduxjs/toolkit/query' +import { hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' type AccountItemProps = { safeItem: SafeItem safeOverview?: SafeOverview onLinkClick?: () => void } -const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) => { +const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => { const { chainId, address } = safeItem const chain = useAppSelector((state) => selectChainById(state, chainId)) const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, address)) @@ -40,24 +45,11 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) const router = useRouter() const isCurrentSafe = chainId === currChainId && sameAddress(safeAddress, address) const isWelcomePage = router.pathname === AppRoutes.welcome.accounts - const isSingleTxPage = router.pathname === AppRoutes.transactions.tx + const { address: walletAddress } = useWallet() ?? {} const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar - /** - * Navigate to the dashboard when selecting a safe on the welcome page, - * navigate to the history when selecting a safe on a single tx page, - * otherwise keep the current route - */ - const getHref = useCallback( - (chain: ChainInfo, address: string) => { - return { - pathname: isWelcomePage ? AppRoutes.home : isSingleTxPage ? AppRoutes.transactions.history : router.pathname, - query: { ...router.query, safe: `${chain.shortName}:${address}` }, - } - }, - [isWelcomePage, isSingleTxPage, router.pathname, router.query], - ) + const getHref = useGetHref(router) const href = useMemo(() => { return chain ? getHref(chain, address) : '' @@ -67,6 +59,26 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) const isActivating = undeployedSafe?.status.status !== 'AWAITING_EXECUTION' + const counterfactualSetup = undeployedSafe + ? extractCounterfactualSafeSetup(undeployedSafe, chain?.chainId) + : undefined + + const addNetworkFeatureEnabled = hasMultiChainAddNetworkFeature(chain) + const isReplayable = + addNetworkFeatureEnabled && + !safeItem.isWatchlist && + (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props)) + + const { data: safeOverview } = useGetSafeOverviewQuery( + undeployedSafe + ? skipToken + : { + chainId: safeItem.chainId, + safeAddress: safeItem.address, + walletAddress, + }, + ) + return ( - + @@ -109,19 +126,19 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) )} - + + + {safeOverview ? ( ) : undeployedSafe ? null : ( - + )} - - - + { + const [open, setOpen] = useState(false) + + return ( + <> + + + + + {open && ( + setOpen(false)} + currentName={currentName} + safeAddress={safeAddress} + deployedChainIds={deployedChains} + /> + )} + + ) +} diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/components/welcome/MyAccounts/MultiAccountItem.tsx new file mode 100644 index 0000000000..edadf108bd --- /dev/null +++ b/src/components/welcome/MyAccounts/MultiAccountItem.tsx @@ -0,0 +1,214 @@ +import { selectUndeployedSafes } from '@/features/counterfactual/store/undeployedSafesSlice' +import NetworkLogosList from '@/features/multichain/components/NetworkLogosList' +import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { useCallback, useMemo, useState } from 'react' +import { + ListItemButton, + Box, + Typography, + Skeleton, + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Tooltip, +} from '@mui/material' +import SafeIcon from '@/components/common/SafeIcon' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' +import { AppRoutes } from '@/config/routes' +import { useAppSelector } from '@/store' +import css from './styles.module.css' +import { selectAllAddressBooks } from '@/store/addressBookSlice' +import useSafeAddress from '@/hooks/useSafeAddress' +import { sameAddress } from '@/utils/addresses' +import classnames from 'classnames' +import { useRouter } from 'next/router' +import FiatValue from '@/components/common/FiatValue' +import { type MultiChainSafeItem } from './useAllSafesGrouped' +import { shortenAddress } from '@/utils/formatters' +import { type SafeItem } from './useAllSafes' +import SubAccountItem from './SubAccountItem' +import { getSafeSetups, getSharedSetup, hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' +import { AddNetworkButton } from './AddNetworkButton' +import { isPredictedSafeProps } from '@/features/counterfactual/utils' +import ChainIndicator from '@/components/common/ChainIndicator' +import MultiAccountContextMenu from '@/components/sidebar/SafeListContextMenu/MultiAccountContextMenu' +import { useGetMultipleSafeOverviewsQuery } from '@/store/api/gateway' +import useWallet from '@/hooks/wallets/useWallet' +import { selectCurrency } from '@/store/settingsSlice' +import { selectChains } from '@/store/chainsSlice' + +type MultiAccountItemProps = { + multiSafeAccountItem: MultiChainSafeItem + safeOverviews?: SafeOverview[] + onLinkClick?: () => void +} + +const MultichainIndicator = ({ safes }: { safes: SafeItem[] }) => { + return ( + + Multichain account on: + {safes.map((safeItem) => ( + + + + ))} + + } + arrow + > + + + + + ) +} + +const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountItemProps) => { + const { address, safes } = multiSafeAccountItem + const undeployedSafes = useAppSelector(selectUndeployedSafes) + const safeAddress = useSafeAddress() + const router = useRouter() + const isCurrentSafe = sameAddress(safeAddress, address) + const isWelcomePage = router.pathname === AppRoutes.welcome.accounts + const [expanded, setExpanded] = useState(isCurrentSafe) + const chains = useAppSelector(selectChains) + + const deployedChainIds = useMemo(() => safes.map((safe) => safe.chainId), [safes]) + + const isWatchlist = useMemo( + () => multiSafeAccountItem.safes.every((safe) => safe.isWatchlist), + [multiSafeAccountItem.safes], + ) + + const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + const toggleExpand = () => { + !expanded && trackEvent({ ...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE, label: trackingLabel }) + setExpanded((prev) => !prev) + } + + const allAddressBooks = useAppSelector(selectAllAddressBooks) + const name = useMemo(() => { + return Object.values(allAddressBooks).find((ab) => ab[address] !== undefined)?.[address] + }, [address, allAddressBooks]) + + const currency = useAppSelector(selectCurrency) + const { address: walletAddress } = useWallet() ?? {} + const deployedSafes = useMemo( + () => safes.filter((safe) => undeployedSafes[safe.chainId]?.[safe.address] === undefined), + [safes, undeployedSafes], + ) + const { data: safeOverviews } = useGetMultipleSafeOverviewsQuery({ currency, walletAddress, safes: deployedSafes }) + + const safeSetups = useMemo( + () => getSafeSetups(safes, safeOverviews ?? [], undeployedSafes), + [safeOverviews, safes, undeployedSafes], + ) + const sharedSetup = getSharedSetup(safeSetups) + + const totalFiatValue = useMemo( + () => safeOverviews?.reduce((prev, current) => prev + Number(current.fiatTotal), 0), + [safeOverviews], + ) + + const hasReplayableSafe = useMemo( + () => + safes.some((safeItem) => { + const undeployedSafe = undeployedSafes[safeItem.chainId]?.[safeItem.address] + const chain = chains.data.find((chain) => chain.chainId === safeItem.chainId) + const addNetworkFeatureEnabled = hasMultiChainAddNetworkFeature(chain) + + // We can only replay deployed Safes and new counterfactual Safes. + return (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props)) && addNetworkFeatureEnabled + }), + [chains.data, safes, undeployedSafes], + ) + + const findOverview = useCallback( + (item: SafeItem) => { + return safeOverviews?.find( + (overview) => item.chainId === overview.chainId && sameAddress(overview.address.value, item.address), + ) + }, + [safeOverviews], + ) + + return ( + + + + + + + + + {name && ( + + {name} + + )} + + {shortenAddress(address)} + + + + + {totalFiatValue !== undefined ? ( + + ) : ( + + )} + + + + + + + {safes.map((safeItem) => ( + + ))} + + {!isWatchlist && hasReplayableSafe && ( + <> + + + safe.chainId)} + /> + + + )} + + + + ) +} + +export default MultiAccountItem diff --git a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx index da910de584..a45afcf35a 100644 --- a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx +++ b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx @@ -1,14 +1,15 @@ -import { type ReactElement, type ReactNode, useState, useCallback, useEffect } from 'react' +import { type ReactElement, type ReactNode, useState, useCallback, useEffect, useMemo } from 'react' import { Paper, Typography } from '@mui/material' import AccountItem from './AccountItem' import { type SafeItem } from './useAllSafes' import css from './styles.module.css' -import useSafeOverviews from './useSafeOverviews' -import { sameAddress } from '@/utils/addresses' import InfiniteScroll from '@/components/common/InfiniteScroll' +import { type MultiChainSafeItem } from './useAllSafesGrouped' +import MultiAccountItem from './MultiAccountItem' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' type PaginatedSafeListProps = { - safes?: SafeItem[] + safes?: (SafeItem | MultiChainSafeItem)[] title: ReactNode noSafesMessage?: ReactNode action?: ReactElement @@ -16,50 +17,47 @@ type PaginatedSafeListProps = { } type SafeListPageProps = { - safes: SafeItem[] + safes: (SafeItem | MultiChainSafeItem)[] onLinkClick: PaginatedSafeListProps['onLinkClick'] } -const PAGE_SIZE = 10 - -const SafeListPage = ({ safes, onLinkClick }: SafeListPageProps) => { - const [overviews] = useSafeOverviews(safes) - - const findOverview = (item: SafeItem) => { - return overviews?.find( - (overview) => item.chainId === overview.chainId && sameAddress(overview.address.value, item.address), - ) - } +const DEFAULT_PAGE_SIZE = 10 +export const SafeListPage = ({ safes, onLinkClick }: SafeListPageProps) => { return ( <> - {safes.map((item) => ( - - ))} + {safes.map((item) => + isMultiChainSafeItem(item) ? ( + + ) : ( + + ), + )} ) } -const AllSafeListPages = ({ safes, onLinkClick }: SafeListPageProps) => { - const totalPages = Math.ceil(safes.length / PAGE_SIZE) - const [pages, setPages] = useState([]) +const AllSafeListPages = ({ + safes, + onLinkClick, + pageSize = DEFAULT_PAGE_SIZE, +}: SafeListPageProps & { pageSize?: number }) => { + const totalPages = Math.ceil(safes.length / pageSize) + const [pages, setPages] = useState<(SafeItem | MultiChainSafeItem)[][]>([]) const onNextPage = useCallback(() => { setPages((prev) => { const pageIndex = prev.length - const nextPage = safes.slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE) + const nextPage = safes.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize) return prev.concat([nextPage]) }) - }, [safes]) + }, [safes, pageSize]) useEffect(() => { - setPages([safes.slice(0, PAGE_SIZE)]) - }, [safes]) + if (safes.length > 0) { + setPages([safes.slice(0, pageSize)]) + } + }, [safes, pageSize]) return ( <> @@ -73,6 +71,13 @@ const AllSafeListPages = ({ safes, onLinkClick }: SafeListPageProps) => { } const PaginatedSafeList = ({ safes, title, action, noSafesMessage, onLinkClick }: PaginatedSafeListProps) => { + const multiChainSafes = useMemo(() => safes?.filter(isMultiChainSafeItem), [safes]) + const singleChainSafes = useMemo(() => safes?.filter((safe) => !isMultiChainSafeItem(safe)), [safes]) + + const totalMultiChainSafes = multiChainSafes?.length ?? 0 + const totalSingleChainSafes = singleChainSafes?.length ?? 0 + const totalSafes = totalMultiChainSafes + totalSingleChainSafes + return (
    @@ -90,11 +95,26 @@ const PaginatedSafeList = ({ safes, title, action, noSafesMessage, onLinkClick } {action}
    - {safes && safes.length > 0 ? ( - + {totalSafes > 0 ? ( + <> + {multiChainSafes && multiChainSafes.length > 0 && ( + + )} + {singleChainSafes && singleChainSafes.length > 0 && ( + + )} + ) : ( - - {safes ? noSafesMessage : 'Loading...'} + + {noSafesMessage} )}
    diff --git a/src/components/welcome/MyAccounts/SubAccountItem.tsx b/src/components/welcome/MyAccounts/SubAccountItem.tsx new file mode 100644 index 0000000000..de8d1724e0 --- /dev/null +++ b/src/components/welcome/MyAccounts/SubAccountItem.tsx @@ -0,0 +1,129 @@ +import { LoopIcon } from '@/features/counterfactual/CounterfactualStatusButton' +import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo } from 'react' +import { ListItemButton, Box, Typography, Chip, Skeleton } from '@mui/material' +import Link from 'next/link' +import SafeIcon from '@/components/common/SafeIcon' +import Track from '@/components/common/Track' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { AppRoutes } from '@/config/routes' +import { useAppSelector } from '@/store' +import { selectChainById } from '@/store/chainsSlice' +import css from './styles.module.css' +import { selectAllAddressBooks } from '@/store/addressBookSlice' +import SafeListContextMenu from '@/components/sidebar/SafeListContextMenu' +import useSafeAddress from '@/hooks/useSafeAddress' +import useChainId from '@/hooks/useChainId' +import { sameAddress } from '@/utils/addresses' +import classnames from 'classnames' +import { useRouter } from 'next/router' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' +import type { SafeItem } from './useAllSafes' +import FiatValue from '@/components/common/FiatValue' +import QueueActions from './QueueActions' +import { useGetHref } from './useGetHref' +import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' + +type SubAccountItem = { + safeItem: SafeItem + safeOverview?: SafeOverview + onLinkClick?: () => void +} + +const SubAccountItem = ({ onLinkClick, safeItem, safeOverview }: SubAccountItem) => { + const { chainId, address } = safeItem + const chain = useAppSelector((state) => selectChainById(state, chainId)) + const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, address)) + const safeAddress = useSafeAddress() + const currChainId = useChainId() + const router = useRouter() + const isCurrentSafe = chainId === currChainId && sameAddress(safeAddress, address) + const isWelcomePage = router.pathname === AppRoutes.welcome.accounts + + const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + const getHref = useGetHref(router) + + const href = useMemo(() => { + return chain ? getHref(chain, address) : '' + }, [chain, getHref, address]) + + const name = useAppSelector(selectAllAddressBooks)[chainId]?.[address] + + const isActivating = undeployedSafe?.status.status !== 'AWAITING_EXECUTION' + + const cfSafeSetup = extractCounterfactualSafeSetup(undeployedSafe, chain?.chainId) + + return ( + + + + + + + + + {name && ( + + {name} + + )} + + {chain?.chainName} + + {undeployedSafe && ( +
    + + ) : ( + + ) + } + className={classnames(css.chip, { + [css.pendingAccount]: isActivating, + })} + /> +
    + )} +
    + + + {safeOverview ? ( + + ) : undeployedSafe ? null : ( + + )} + + + + + {undeployedSafe && ( + + )} + + +
    + ) +} + +export default SubAccountItem diff --git a/src/components/welcome/MyAccounts/__tests__/useSafeOverviews.test.ts b/src/components/welcome/MyAccounts/__tests__/useSafeOverviews.test.ts deleted file mode 100644 index dc20ee92cd..0000000000 --- a/src/components/welcome/MyAccounts/__tests__/useSafeOverviews.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import useSafeOverviews from '../useSafeOverviews' -import * as balances from '@/hooks/loadables/useLoadBalances' -import * as sdk from '@safe-global/safe-gateway-typescript-sdk' -import * as useWallet from '@/hooks/wallets/useWallet' -import * as store from '@/store' -import type { Eip1193Provider } from 'ethers' -import { renderHook } from '@testing-library/react' -import { act } from 'react-dom/test-utils' - -jest.spyOn(balances, 'useTokenListSetting').mockReturnValue(false) -jest.spyOn(store, 'useAppSelector').mockReturnValue('USD') -jest - .spyOn(useWallet, 'default') - .mockReturnValue({ label: 'MetaMask', chainId: '1', address: '0x1234', provider: null as unknown as Eip1193Provider }) - -describe('useSafeOverviews', () => { - it('should filter out undefined addresses', async () => { - const spy = jest.spyOn(sdk, 'getSafeOverviews').mockResolvedValue([]) - const safes = [ - { address: '0x1234', chainId: '1' }, - { address: undefined as unknown as string, chainId: '2' }, - { address: '0x5678', chainId: '3' }, - ] - - renderHook(() => useSafeOverviews(safes)) - - await act(() => Promise.resolve()) - - expect(spy).toHaveBeenCalledWith(['1:0x1234', '3:0x5678'], { - currency: 'USD', - exclude_spam: false, - trusted: true, - wallet_address: '0x1234', - }) - }) - - it('should filter out undefined chain ids', async () => { - const spy = jest.spyOn(sdk, 'getSafeOverviews').mockResolvedValue([]) - const safes = [ - { address: '0x1234', chainId: '1' }, - { address: '0x5678', chainId: undefined as unknown as string }, - { address: '0x5678', chainId: '3' }, - ] - - renderHook(() => useSafeOverviews(safes)) - - await act(() => Promise.resolve()) - - expect(spy).toHaveBeenCalledWith(['1:0x1234', '3:0x5678'], { - currency: 'USD', - exclude_spam: false, - trusted: true, - wallet_address: '0x1234', - }) - }) -}) diff --git a/src/components/welcome/MyAccounts/index.tsx b/src/components/welcome/MyAccounts/index.tsx index 193d65fac9..8eb8491f3a 100644 --- a/src/components/welcome/MyAccounts/index.tsx +++ b/src/components/welcome/MyAccounts/index.tsx @@ -2,7 +2,6 @@ import { useMemo } from 'react' import { Box, Button, Link, SvgIcon, Typography } from '@mui/material' import madProps from '@/utils/mad-props' import CreateButton from './CreateButton' -import useAllSafes, { type SafeItems } from './useAllSafes' import Track from '@/components/common/Track' import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' import { DataWidget } from '@/components/welcome/MyAccounts/DataWidget' @@ -15,20 +14,44 @@ import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWallet import useWallet from '@/hooks/wallets/useWallet' import { useRouter } from 'next/router' import useTrackSafesCount from './useTrackedSafesCount' +import { type AllSafesGrouped, useAllSafesGrouped, type MultiChainSafeItem } from './useAllSafesGrouped' +import { type SafeItem } from './useAllSafes' const NO_SAFES_MESSAGE = "You don't have any Safe Accounts yet" const NO_WATCHED_MESSAGE = 'Watch any Safe Account to keep an eye on its activity' type AccountsListProps = { - safes?: SafeItems | undefined + safes: AllSafesGrouped onLinkClick?: () => void } const AccountsList = ({ safes, onLinkClick }: AccountsListProps) => { const wallet = useWallet() const router = useRouter() - const ownedSafes = useMemo(() => safes?.filter(({ isWatchlist }) => !isWatchlist), [safes]) - const watchlistSafes = useMemo(() => safes?.filter(({ isWatchlist }) => isWatchlist), [safes]) + // We consider a multiChain account owned if at least one of the multiChain accounts is not on the watchlist + const ownedMultiChainSafes = useMemo( + () => safes.allMultiChainSafes?.filter((account) => account.safes.some(({ isWatchlist }) => !isWatchlist)), + [safes], + ) + + // If all safes of a multichain account are on the watchlist we put the entire account on the watchlist + const watchlistMultiChainSafes = useMemo( + () => safes.allMultiChainSafes?.filter((account) => !account.safes.some(({ isWatchlist }) => !isWatchlist)), + [safes], + ) + + const ownedSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( + () => [...(ownedMultiChainSafes ?? []), ...(safes.allSingleSafes?.filter(({ isWatchlist }) => !isWatchlist) ?? [])], + [safes, ownedMultiChainSafes], + ) + const watchlistSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( + () => [ + ...(watchlistMultiChainSafes ?? []), + ...(safes.allSingleSafes?.filter(({ isWatchlist }) => isWatchlist) ?? []), + ], + [safes, watchlistMultiChainSafes], + ) + useTrackSafesCount(ownedSafes, watchlistSafes, wallet) const isLoginPage = router.pathname === AppRoutes.welcome.accounts @@ -97,7 +120,7 @@ const AccountsList = ({ safes, onLinkClick }: AccountsListProps) => { } const MyAccounts = madProps(AccountsList, { - safes: useAllSafes, + safes: useAllSafesGrouped, }) export default MyAccounts diff --git a/src/components/welcome/MyAccounts/styles.module.css b/src/components/welcome/MyAccounts/styles.module.css index 4b97a15341..485a31323a 100644 --- a/src/components/welcome/MyAccounts/styles.module.css +++ b/src/components/welcome/MyAccounts/styles.module.css @@ -44,6 +44,35 @@ background-color: var(--color-background-light) !important; } +.currentListItem.multiListItem { + border: 1px solid var(--color-border-light); + background-color: none; +} + +.listItem :global .MuiAccordion-root, +.listItem :global .MuiAccordion-root:hover > .MuiAccordionSummary-root { + background-color: transparent; +} + +.listItem :global .MuiAccordion-root.Mui-expanded { + background-color: var(--color-background-paper); +} + +.listItem.subItem { + margin-bottom: 8px; +} + +.subItem .borderLeft { + top: 0; + bottom: 0; + position: absolute; + border-radius: 6px; + border: 1px solid var(--color-border-light); +} +.subItem.currentListItem .borderLeft { + border-left: 4px solid var(--color-secondary-light); +} + .listItem > :first-child { flex: 1; width: 90%; @@ -52,31 +81,15 @@ .safeLink { display: grid; padding: var(--space-2) var(--space-1) var(--space-2) var(--space-2); - grid-template-columns: auto 3fr 2fr auto; + grid-template-columns: 60px 3fr 3fr minmax(auto, 2fr); align-items: center; } -@media (max-width: 599.95px) { - .safeLink { - grid-template-columns: auto 1fr auto; - grid-template-areas: - 'a b d' - 'a c d'; - } - - .safeLink :nth-child(1) { - grid-area: a; - } - .safeLink :nth-child(2) { - grid-area: b; - } - .safeLink :nth-child(3) { - grid-area: c; - text-align: left; - } - .safeLink :nth-child(4) { - grid-area: d; - } +.safeSubLink { + display: grid; + padding: var(--space-2) var(--space-1) var(--space-2) var(--space-2); + grid-template-columns: 60px 3fr minmax(auto, 2fr); + align-items: center; } .safeName, @@ -135,6 +148,23 @@ color: var(--color-info-dark) !important; } +.multiChains { + display: flex; + justify-content: flex-end; +} + +.multiChains > span { + margin-left: -5px; + border-radius: 50%; + width: 24px; + height: 24px; + outline: 2px solid var(--color-background-paper); +} + +.chainIndicator { + justify-content: flex-end; +} + @media (max-width: 899.95px) { .container { width: auto; @@ -145,6 +175,33 @@ } } +@media (max-width: 599.95px) { + .safeLink { + grid-template-columns: auto 1fr auto; + grid-template-areas: + 'a b d' + 'a c d'; + } + + .safeLink :nth-child(1) { + grid-area: a; + } + .safeLink :nth-child(2) { + grid-area: b; + } + .safeLink :nth-child(3) { + grid-area: c; + text-align: left; + } + .safeLink :nth-child(4) { + grid-area: d; + } + + .multiChains { + justify-content: flex-start; + } +} + @container my-accounts-container (max-width: 500px) { .myAccounts { margin: 0; diff --git a/src/components/welcome/MyAccounts/useAllSafesGrouped.ts b/src/components/welcome/MyAccounts/useAllSafesGrouped.ts new file mode 100644 index 0000000000..8877a7a1c0 --- /dev/null +++ b/src/components/welcome/MyAccounts/useAllSafesGrouped.ts @@ -0,0 +1,40 @@ +import { groupBy } from 'lodash' +import useAllSafes, { type SafeItem, type SafeItems } from './useAllSafes' +import { useMemo } from 'react' +import { sameAddress } from '@/utils/addresses' + +export type MultiChainSafeItem = { address: string; safes: SafeItem[] } + +export type AllSafesGrouped = { + allSingleSafes: SafeItems | undefined + allMultiChainSafes: MultiChainSafeItem[] | undefined +} + +const getMultiChainAccounts = (safes: SafeItems): MultiChainSafeItem[] => { + const groupedByAddress = groupBy(safes, (safe) => safe.address) + const multiChainSafeItems = Object.entries(groupedByAddress) + .filter((entry) => entry[1].length > 1) + .map((entry): MultiChainSafeItem => ({ address: entry[0], safes: entry[1] })) + + return multiChainSafeItems +} + +export const useAllSafesGrouped = () => { + const allSafes = useAllSafes() + + return useMemo(() => { + if (!allSafes) { + return { allMultiChainSafes: undefined, allSingleSafes: undefined } + } + // Extract all multichain Accounts and single Safes + const allMultiChainSafes = getMultiChainAccounts(allSafes) + const allSingleSafes = allSafes.filter( + (safe) => !allMultiChainSafes.some((multiSafe) => sameAddress(multiSafe.address, safe.address)), + ) + + return { + allMultiChainSafes, + allSingleSafes, + } + }, [allSafes]) +} diff --git a/src/components/welcome/MyAccounts/useGetHref.ts b/src/components/welcome/MyAccounts/useGetHref.ts new file mode 100644 index 0000000000..939cbb22d2 --- /dev/null +++ b/src/components/welcome/MyAccounts/useGetHref.ts @@ -0,0 +1,24 @@ +import { AppRoutes } from '@/config/routes' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type NextRouter } from 'next/router' +import { useCallback } from 'react' + +/** + * Navigate to the dashboard when selecting a safe on the welcome page, + * navigate to the history when selecting a safe on a single tx page, + * otherwise keep the current route + */ +export const useGetHref = (router: NextRouter) => { + const isWelcomePage = router.pathname === AppRoutes.welcome.accounts + const isSingleTxPage = router.pathname === AppRoutes.transactions.tx + + return useCallback( + (chain: ChainInfo, address: string) => { + return { + pathname: isWelcomePage ? AppRoutes.home : isSingleTxPage ? AppRoutes.transactions.history : router.pathname, + query: { ...router.query, safe: `${chain.shortName}:${address}` }, + } + }, + [isSingleTxPage, isWelcomePage, router.pathname, router.query], + ) +} diff --git a/src/components/welcome/MyAccounts/useSafeOverviews.ts b/src/components/welcome/MyAccounts/useSafeOverviews.ts deleted file mode 100644 index 7c3b2ff2e1..0000000000 --- a/src/components/welcome/MyAccounts/useSafeOverviews.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useMemo } from 'react' -import { useTokenListSetting } from '@/hooks/loadables/useLoadBalances' -import useAsync, { type AsyncResult } from '@/hooks/useAsync' -import useWallet from '@/hooks/wallets/useWallet' -import { useAppSelector } from '@/store' -import { selectCurrency } from '@/store/settingsSlice' -import { type SafeOverview, getSafeOverviews } from '@safe-global/safe-gateway-typescript-sdk' - -const _cache: Record = {} - -type SafeParams = { - address: string - chainId: string -} - -// EIP155 address format -const makeSafeId = ({ chainId, address }: SafeParams) => `${chainId}:${address}` as `${number}:0x${string}` - -const validateSafeParams = ({ chainId, address }: SafeParams) => chainId != null && address != null - -function useSafeOverviews(safes: Array): AsyncResult { - const excludeSpam = useTokenListSetting() || false - const currency = useAppSelector(selectCurrency) - const wallet = useWallet() - const walletAddress = wallet?.address - const safesIds = useMemo(() => safes.filter(validateSafeParams).map(makeSafeId), [safes]) - - const [data, error, isLoading] = useAsync(async () => { - return await getSafeOverviews(safesIds, { - trusted: true, - exclude_spam: excludeSpam, - currency, - wallet_address: walletAddress, - }) - }, [safesIds, excludeSpam, currency, walletAddress]) - - const cacheKey = safesIds.join() - const result = data ?? _cache[cacheKey] - - // Cache until the next page load - _cache[cacheKey] = result - - return useMemo(() => [result, error, isLoading], [result, error, isLoading]) -} - -export default useSafeOverviews diff --git a/src/components/welcome/MyAccounts/useTrackedSafesCount.ts b/src/components/welcome/MyAccounts/useTrackedSafesCount.ts index 06289dac02..1b0b0ec3ff 100644 --- a/src/components/welcome/MyAccounts/useTrackedSafesCount.ts +++ b/src/components/welcome/MyAccounts/useTrackedSafesCount.ts @@ -2,15 +2,17 @@ import { AppRoutes } from '@/config/routes' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { useRouter } from 'next/router' import { useEffect } from 'react' -import type { SafeItems } from './useAllSafes' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' +import { type SafeItem } from './useAllSafes' +import { type MultiChainSafeItem } from './useAllSafesGrouped' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' let isOwnedSafesTracked = false let isWatchlistTracked = false const useTrackSafesCount = ( - ownedSafes: SafeItems | undefined, - watchlistSafes: SafeItems | undefined, + ownedSafes: (MultiChainSafeItem | SafeItem)[] | undefined, + watchlistSafes: (MultiChainSafeItem | SafeItem)[] | undefined, wallet: ConnectedWallet | null, ) => { const router = useRouter() @@ -22,15 +24,23 @@ const useTrackSafesCount = ( }, [wallet?.address]) useEffect(() => { + const totalSafesOwned = ownedSafes?.reduce( + (prev, current) => prev + (isMultiChainSafeItem(current) ? current.safes.length : 1), + 0, + ) if (wallet && !isOwnedSafesTracked && ownedSafes && ownedSafes.length > 0 && isLoginPage) { - trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_OWNED, label: ownedSafes.length }) + trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_OWNED, label: totalSafesOwned }) isOwnedSafesTracked = true } }, [isLoginPage, ownedSafes, wallet]) useEffect(() => { + const totalSafesWatched = watchlistSafes?.reduce( + (prev, current) => prev + (isMultiChainSafeItem(current) ? current.safes.length : 1), + 0, + ) if (watchlistSafes && isLoginPage && watchlistSafes.length > 0 && !isWatchlistTracked) { - trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_WATCHLIST, label: watchlistSafes.length }) + trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_WATCHLIST, label: totalSafesWatched }) isWatchlistTracked = true } }, [isLoginPage, watchlistSafes]) diff --git a/src/config/constants.ts b/src/config/constants.ts index bfcdc8b63f..b5a7c61708 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -110,3 +110,4 @@ export const CHAINALYSIS_OFAC_CONTRACT = '0x40c57923924b5c5c5455c48d93317139adda export const SAFE_PASS_URL = 'community.safe.global' export const ECOSYSTEM_ID_ADDRESS = process.env.NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS || '0x0000000000000000000000000000000000000000' +export const MULTICHAIN_HELP_ARTICLE = `${HELP_CENTER_URL}/en/articles/222612-multi-chain-safe` diff --git a/src/features/counterfactual/ActivateAccountButton.tsx b/src/features/counterfactual/ActivateAccountButton.tsx index 7b2c4efa43..c6cd0e3b7e 100644 --- a/src/features/counterfactual/ActivateAccountButton.tsx +++ b/src/features/counterfactual/ActivateAccountButton.tsx @@ -25,12 +25,13 @@ const ActivateAccountButton = () => { return ( - + {(isOk) => ( diff --git a/src/features/counterfactual/PayNowPayLater.tsx b/src/features/counterfactual/PayNowPayLater.tsx index 78e2cecece..feea2304b4 100644 --- a/src/features/counterfactual/PayNowPayLater.tsx +++ b/src/features/counterfactual/PayNowPayLater.tsx @@ -14,6 +14,7 @@ import { } from '@mui/material' import css from './styles.module.css' +import ErrorMessage from '@/components/tx/ErrorMessage' export const enum PayMethod { PayNow = 'PayNow', @@ -23,11 +24,13 @@ export const enum PayMethod { const PayNowPayLater = ({ totalFee, canRelay, + isMultiChain, payMethod, setPayMethod, }: { totalFee: string canRelay: boolean + isMultiChain: boolean payMethod: PayMethod setPayMethod: Dispatch> }) => { @@ -40,25 +43,41 @@ const PayNowPayLater = ({ return ( <> - Before you continue + Before we continue... + {isMultiChain && ( + + You will need to activate your account separately on each network. Make sure you have funds on your + wallet to pay the network fee. + + )} + {isMultiChain && ( + + + + + + Start exploring the accounts now, and activate them later to start making transactions + + + )} - - There will be a one-time network fee to activate your smart account wallet. - - - - - - - - If you choose to pay later, the fee will be included with the first transaction you make. - + There will be a one-time activation fee + {!isMultiChain && ( + + + + + + If you choose to pay later, the fee will be included with the first transaction you make. + + + )} @@ -66,46 +85,48 @@ const PayNowPayLater = ({ Safe doesn't profit from the fees. - - - - Pay now - - {canRelay ? ( - 'Sponsored free transaction' - ) : ( - <> - ≈ {totalFee} {chain?.nativeCurrency.symbol} - - )} - - - } - control={} - /> + {!isMultiChain && ( + + + + Pay now + + {canRelay ? ( + 'Sponsored free transaction' + ) : ( + <> + ≈ {totalFee} {chain?.nativeCurrency.symbol} + + )} + + + } + control={} + /> - - Pay later - - with the first transaction - - - } - control={} - /> - - + + Pay later + + with the first transaction + + + } + control={} + /> + + + )} ) } diff --git a/src/features/counterfactual/__tests__/utils.test.ts b/src/features/counterfactual/__tests__/utils.test.ts index fc033c167f..56b223028b 100644 --- a/src/features/counterfactual/__tests__/utils.test.ts +++ b/src/features/counterfactual/__tests__/utils.test.ts @@ -11,11 +11,13 @@ 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 { type BrowserProvider, type JsonRpcProvider } from 'ethers' +import { PendingSafeStatus } from '../store/undeployedSafesSlice' +import { PayMethod } from '../PayNowPayLater' describe('Counterfactual utils', () => { describe('getUndeployedSafeInfo', () => { it('should return undeployed safe info', () => { - const undeployedSafe: PredictedSafeProps = { + const undeployedSafeProps: PredictedSafeProps = { safeAccountConfig: { owners: [faker.finance.ethereumAddress()], threshold: 1, @@ -25,14 +27,21 @@ describe('Counterfactual utils', () => { const mockAddress = faker.finance.ethereumAddress() const mockChainId = '1' - const result = getUndeployedSafeInfo(undeployedSafe, mockAddress, chainBuilder().with({ chainId: '1' }).build()) + const result = getUndeployedSafeInfo( + { + props: undeployedSafeProps, + status: { status: PendingSafeStatus.AWAITING_EXECUTION, type: PayMethod.PayLater }, + }, + mockAddress, + chainBuilder().with({ chainId: '1' }).build(), + ) 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]) + expect(result.threshold).toEqual(undeployedSafeProps.safeAccountConfig.threshold) + expect(result.owners[0].value).toEqual(undeployedSafeProps.safeAccountConfig.owners[0]) }) }) diff --git a/src/features/counterfactual/hooks/usePendingSafeStatuses.ts b/src/features/counterfactual/hooks/usePendingSafeStatuses.ts index 39d5259224..70ee6b03dd 100644 --- a/src/features/counterfactual/hooks/usePendingSafeStatuses.ts +++ b/src/features/counterfactual/hooks/usePendingSafeStatuses.ts @@ -22,6 +22,7 @@ import { isSmartContract } from '@/utils/wallets' import { gtmSetSafeAddress } from '@/services/analytics/gtm' export const safeCreationPendingStatuses: Partial> = { + [SafeCreationEvent.AWAITING_EXECUTION]: PendingSafeStatus.AWAITING_EXECUTION, [SafeCreationEvent.PROCESSING]: PendingSafeStatus.PROCESSING, [SafeCreationEvent.RELAYING]: PendingSafeStatus.RELAYING, [SafeCreationEvent.SUCCESS]: null, diff --git a/src/features/counterfactual/services/safeCreationEvents.ts b/src/features/counterfactual/services/safeCreationEvents.ts index 668d185c36..1923dd0a13 100644 --- a/src/features/counterfactual/services/safeCreationEvents.ts +++ b/src/features/counterfactual/services/safeCreationEvents.ts @@ -1,7 +1,9 @@ import type { PayMethod } from '@/features/counterfactual/PayNowPayLater' import EventBus from '@/services/EventBus' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' export enum SafeCreationEvent { + AWAITING_EXECUTION = 'AWAITING_EXECUTION', PROCESSING = 'PROCESSING', RELAYING = 'RELAYING', SUCCESS = 'SUCCESS', @@ -11,6 +13,11 @@ export enum SafeCreationEvent { } export interface SafeCreationEvents { + [SafeCreationEvent.AWAITING_EXECUTION]: { + groupKey: string + safeAddress: string + networks: ChainInfo[] + } [SafeCreationEvent.PROCESSING]: { groupKey: string txHash: string diff --git a/src/features/counterfactual/store/undeployedSafesSlice.ts b/src/features/counterfactual/store/undeployedSafesSlice.ts index de3a66c532..d24f9909fe 100644 --- a/src/features/counterfactual/store/undeployedSafesSlice.ts +++ b/src/features/counterfactual/store/undeployedSafesSlice.ts @@ -2,7 +2,8 @@ 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' -import { selectChainIdAndSafeAddress } from '@/store/common' +import { selectChainIdAndSafeAddress, selectSafeAddress } from '@/store/common' +import { type SafeVersion } from '@safe-global/safe-core-sdk-types' export enum PendingSafeStatus { AWAITING_EXECUTION = 'AWAITING_EXECUTION', @@ -21,9 +22,28 @@ type UndeployedSafeStatus = { signerNonce?: number | null } +export type ReplayedSafeProps = { + factoryAddress: string + masterCopy: string + safeAccountConfig: { + threshold: number + owners: string[] + fallbackHandler: string + to: string + data: string + paymentToken?: string + payment?: number + paymentReceiver: string + } + saltNonce: string + safeVersion: SafeVersion +} + +export type UndeployedSafeProps = PredictedSafeProps | ReplayedSafeProps + export type UndeployedSafe = { status: UndeployedSafeStatus - props: PredictedSafeProps + props: UndeployedSafeProps } type UndeployedSafesSlice = { [address: string]: UndeployedSafe } @@ -38,7 +58,12 @@ export const undeployedSafesSlice = createSlice({ reducers: { addUndeployedSafe: ( state, - action: PayloadAction<{ chainId: string; address: string; type: PayMethod; safeProps: PredictedSafeProps }>, + action: PayloadAction<{ + chainId: string + address: string + type: PayMethod + safeProps: PredictedSafeProps | ReplayedSafeProps + }>, ) => { const { chainId, address, type, safeProps } = action.payload @@ -103,6 +128,15 @@ export const selectUndeployedSafe = createSelector( }, ) +export const selectUndeployedSafesByAddress = createSelector( + [selectUndeployedSafes, selectSafeAddress], + (undeployedSafes, [address]): UndeployedSafe[] => { + return Object.values(undeployedSafes) + .flatMap((value) => value[address]) + .filter(Boolean) + }, +) + export const selectIsUndeployedSafe = createSelector([selectUndeployedSafe], (undeployedSafe) => { return !!undeployedSafe }) diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts index 1042177a69..e04772b29b 100644 --- a/src/features/counterfactual/utils.ts +++ b/src/features/counterfactual/utils.ts @@ -1,10 +1,14 @@ -import type { NewSafeFormData } from '@/components/new-safe/create' import { getLatestSafeVersion } from '@/utils/chains' import { POLLING_INTERVAL } from '@/config/constants' -import { AppRoutes } from '@/config/routes' import type { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' -import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import { + addUndeployedSafe, + type UndeployedSafeProps, + type ReplayedSafeProps, + type UndeployedSafe, + PendingSafeStatus, +} from '@/features/counterfactual/store/undeployedSafesSlice' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import { getWeb3ReadOnly } from '@/hooks/wallets/web3' import { asError } from '@/services/exceptions/utils' @@ -12,14 +16,12 @@ import ExternalStore from '@/services/ExternalStore' import { getSafeSDKWithSigner, getUncheckedSigner, tryOffChainTxSigning } from '@/services/tx/tx-sender/sdk' import { getRelayTxStatus, TaskState } from '@/services/tx/txMonitor' import type { AppDispatch } from '@/store' -import { addOrUpdateSafe } from '@/store/addedSafesSlice' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' import { defaultSafeInfo } from '@/store/safeInfoSlice' import { didRevert, type EthersError } from '@/utils/ethers-utils' import { assertProvider, assertTx, assertWallet } from '@/utils/helpers' -import type { DeploySafeProps, PredictedSafeProps } from '@safe-global/protocol-kit' +import { type DeploySafeProps, type PredictedSafeProps } from '@safe-global/protocol-kit' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import type { SafeTransaction, TransactionOptions } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction, SafeVersion, TransactionOptions } from '@safe-global/safe-core-sdk-types' import { type ChainInfo, ImplementationVersionState, @@ -27,21 +29,29 @@ import { TokenType, } from '@safe-global/safe-gateway-typescript-sdk' import type { BrowserProvider, ContractTransactionResponse, Eip1193Provider, Provider } from 'ethers' -import type { NextRouter } from 'next/router' +import { getSafeL2SingletonDeployments, getSafeSingletonDeployments } from '@safe-global/safe-deployments' +import { sameAddress } from '@/utils/addresses' + +import { encodeSafeCreationTx } from '@/components/new-safe/create/logic' + +export const getUndeployedSafeInfo = (undeployedSafe: UndeployedSafe, address: string, chain: ChainInfo) => { + const safeSetup = extractCounterfactualSafeSetup(undeployedSafe, chain.chainId) -export const getUndeployedSafeInfo = (undeployedSafe: PredictedSafeProps, address: string, chain: ChainInfo) => { + if (!safeSetup) { + throw Error('Could not determine Safe Setup.') + } const latestSafeVersion = getLatestSafeVersion(chain) return { ...defaultSafeInfo, address: { value: address }, chainId: chain.chainId, - owners: undeployedSafe.safeAccountConfig.owners.map((owner) => ({ value: owner })), + owners: safeSetup.owners.map((owner) => ({ value: owner })), nonce: 0, - threshold: undeployedSafe.safeAccountConfig.threshold, + threshold: safeSetup.threshold, implementationVersionState: ImplementationVersionState.UP_TO_DATE, - fallbackHandler: { value: undeployedSafe.safeAccountConfig.fallbackHandler! }, - version: undeployedSafe.safeDeploymentConfig?.safeVersion || latestSafeVersion, + fallbackHandler: { value: safeSetup.fallbackHandler! }, + version: safeSetup?.safeVersion || latestSafeVersion, deployed: false, } } @@ -133,50 +143,36 @@ export const getCounterfactualBalance = async ( } } -export const createCounterfactualSafe = ( - chain: ChainInfo, +export const replayCounterfactualSafeDeployment = ( + chainId: string, safeAddress: string, - saltNonce: string, - data: NewSafeFormData, + replayedSafeProps: ReplayedSafeProps, + name: string, dispatch: AppDispatch, - props: DeploySafeProps, payMethod: PayMethod, - router?: NextRouter, ) => { const undeployedSafe = { - chainId: chain.chainId, + chainId, address: safeAddress, type: payMethod, - safeProps: { - safeAccountConfig: props.safeAccountConfig, - safeDeploymentConfig: { - saltNonce, - safeVersion: data.safeVersion, - }, - }, + safeProps: replayedSafeProps, } - 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, + const setup = extractCounterfactualSafeSetup( + { + props: replayedSafeProps, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: payMethod, }, - }), + }, + chainId, ) + if (!setup) { + throw Error('Safe Setup could not be decoded') + } - router?.push({ - pathname: AppRoutes.home, - query: { safe: `${chain.shortName}:${safeAddress}` }, - }) + dispatch(addUndeployedSafe(undeployedSafe)) } const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -316,3 +312,86 @@ export const checkSafeActionViaRelay = (taskId: string, safeAddress: string, typ clearInterval(intervalId) }, TIMEOUT_TIME) } + +export const isReplayedSafeProps = (props: UndeployedSafeProps): props is ReplayedSafeProps => + 'safeAccountConfig' in props && 'masterCopy' in props && 'factoryAddress' in props && 'saltNonce' in props + +export const isPredictedSafeProps = (props: UndeployedSafeProps): props is PredictedSafeProps => + 'safeAccountConfig' in props && !('masterCopy' in props) + +export const determineMasterCopyVersion = (masterCopy: string, chainId: string): SafeVersion | undefined => { + const SAFE_VERSIONS: SafeVersion[] = ['1.4.1', '1.3.0', '1.2.0', '1.1.1', '1.0.0'] + return SAFE_VERSIONS.find((version) => { + const isL1Singleton = () => { + const deployments = getSafeSingletonDeployments({ version })?.networkAddresses[chainId] + + if (Array.isArray(deployments)) { + return deployments.some((deployment) => sameAddress(masterCopy, deployment)) + } + return sameAddress(masterCopy, deployments) + } + + const isL2Singleton = () => { + const deployments = getSafeL2SingletonDeployments({ version })?.networkAddresses[chainId] + + if (Array.isArray(deployments)) { + return deployments.some((deployment) => sameAddress(masterCopy, deployment)) + } + return sameAddress(masterCopy, deployments) + } + + return isL1Singleton() || isL2Singleton() + }) +} + +export const extractCounterfactualSafeSetup = ( + undeployedSafe: UndeployedSafe | undefined, + chainId: string | undefined, +): + | { + owners: string[] + threshold: number + fallbackHandler: string | undefined + safeVersion: SafeVersion | undefined + saltNonce: string | undefined + } + | undefined => { + if (!undeployedSafe || !chainId) { + return undefined + } + if (isPredictedSafeProps(undeployedSafe.props)) { + return { + owners: undeployedSafe.props.safeAccountConfig.owners, + threshold: undeployedSafe.props.safeAccountConfig.threshold, + fallbackHandler: undeployedSafe.props.safeAccountConfig.fallbackHandler, + safeVersion: undeployedSafe.props.safeDeploymentConfig?.safeVersion, + saltNonce: undeployedSafe.props.safeDeploymentConfig?.saltNonce, + } + } else { + const { owners, threshold, fallbackHandler } = undeployedSafe.props.safeAccountConfig + + return { + owners, + threshold: Number(threshold), + fallbackHandler, + safeVersion: undeployedSafe.props.safeVersion, + saltNonce: undeployedSafe.props.saltNonce, + } + } +} + +export const activateReplayedSafe = async ( + chain: ChainInfo, + props: ReplayedSafeProps, + provider: BrowserProvider, + options: DeploySafeProps['options'], +) => { + const data = encodeSafeCreationTx(props, chain) + + return (await provider.getSigner()).sendTransaction({ + ...options, + to: props.factoryAddress, + data, + value: '0', + }) +} diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx new file mode 100644 index 0000000000..73a072b5e0 --- /dev/null +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -0,0 +1,275 @@ +import ModalDialog from '@/components/common/ModalDialog' +import NetworkInput from '@/components/common/NetworkInput' +import { updateAddressBook } from '@/components/new-safe/create/logic/address-book' +import ErrorMessage from '@/components/tx/ErrorMessage' +import useAddressBook from '@/hooks/useAddressBook' +import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' +import { gtmSetChainId } from '@/services/analytics/gtm' +import { showNotification } from '@/store/notificationsSlice' +import { Box, Button, CircularProgress, DialogActions, DialogContent, Stack, Typography } from '@mui/material' +import { FormProvider, useForm } from 'react-hook-form' +import { useSafeCreationData } from '../../hooks/useSafeCreationData' +import { replayCounterfactualSafeDeployment } from '@/features/counterfactual/utils' + +import useChains from '@/hooks/useChains' +import { useAppDispatch, useAppSelector } from '@/store' +import { selectRpc } from '@/store/settingsSlice' +import { createWeb3ReadOnly } from '@/hooks/wallets/web3' +import { hasMultiChainAddNetworkFeature, predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' +import { sameAddress } from '@/utils/addresses' +import ExternalLink from '@/components/common/ExternalLink' +import { useRouter } from 'next/router' +import ChainIndicator from '@/components/common/ChainIndicator' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo, useState } from 'react' +import { useCompatibleNetworks } from '../../hooks/useCompatibleNetworks' +import { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { MULTICHAIN_HELP_ARTICLE } from '@/config/constants' + +type CreateSafeOnNewChainForm = { + chainId: string +} + +type ReplaySafeDialogProps = { + safeAddress: string + safeCreationResult: ReturnType + replayableChains?: ReturnType + chain?: ChainInfo + currentName: string | undefined + open: boolean + onClose: () => void + isUnsupportedSafeCreationVersion?: boolean +} + +const ReplaySafeDialog = ({ + safeAddress, + chain, + currentName, + open, + onClose, + safeCreationResult, + replayableChains, + isUnsupportedSafeCreationVersion, +}: ReplaySafeDialogProps) => { + const formMethods = useForm({ + mode: 'all', + defaultValues: { + chainId: chain?.chainId || '', + }, + }) + const { handleSubmit, formState } = formMethods + const router = useRouter() + const addressBook = useAddressBook() + + const customRpc = useAppSelector(selectRpc) + const dispatch = useAppDispatch() + const [creationError, setCreationError] = useState() + const [isSubmitting, setIsSubmitting] = useState(false) + + // Load some data + const [safeCreationData, safeCreationDataError, safeCreationDataLoading] = safeCreationResult + + const onCancel = () => { + trackEvent({ ...OVERVIEW_EVENTS.CANCEL_ADD_NEW_NETWORK }) + onClose() + } + + const onFormSubmit = handleSubmit(async (data) => { + setIsSubmitting(true) + + try { + const selectedChain = chain ?? replayableChains?.find((config) => config.chainId === data.chainId) + if (!safeCreationData || !selectedChain) { + return + } + + // We need to create a readOnly provider of the deployed chain + const customRpcUrl = selectedChain ? customRpc?.[selectedChain.chainId] : undefined + const provider = createWeb3ReadOnly(selectedChain, customRpcUrl) + if (!provider) { + return + } + + // 1. Double check that the creation Data will lead to the correct address + const predictedAddress = await predictAddressBasedOnReplayData(safeCreationData, provider) + if (!sameAddress(safeAddress, predictedAddress)) { + setCreationError(new Error('The replayed Safe leads to an unexpected address')) + return + } + + gtmSetChainId(selectedChain.chainId) + + trackEvent({ ...OVERVIEW_EVENTS.SUBMIT_ADD_NEW_NETWORK, label: selectedChain.chainId }) + + // 2. Replay Safe creation and add it to the counterfactual Safes + replayCounterfactualSafeDeployment( + selectedChain.chainId, + safeAddress, + safeCreationData, + currentName || '', + dispatch, + PayMethod.PayLater, + ) + + trackEvent({ ...OVERVIEW_EVENTS.PROCEED_WITH_TX, label: 'counterfactual', category: CREATE_SAFE_CATEGORY }) + trackEvent({ ...CREATE_SAFE_EVENTS.CREATED_SAFE, label: 'counterfactual' }) + + router.push({ + query: { + safe: `${selectedChain.shortName}:${safeAddress}`, + }, + }) + + trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: selectedChain.chainId }) + + dispatch( + updateAddressBook( + [selectedChain.chainId], + safeAddress, + currentName || '', + safeCreationData.safeAccountConfig.owners.map((owner) => ({ + address: owner, + name: addressBook[owner] || '', + })), + safeCreationData.safeAccountConfig.threshold, + ), + ) + + dispatch( + showNotification({ + variant: 'success', + groupKey: 'replay-safe-success', + message: `Successfully added your account on ${selectedChain.chainName}`, + }), + ) + } catch (err) { + console.error(err) + } finally { + setIsSubmitting(false) + + // Close modal + onClose() + } + }) + + const submitDisabled = + isUnsupportedSafeCreationVersion || + !!safeCreationDataError || + safeCreationDataLoading || + !formState.isValid || + isSubmitting + + const noChainsAvailable = + !chain && safeCreationData && replayableChains && replayableChains.filter((chain) => chain.available).length === 0 + + return ( + + + + + + Add this Safe to another network with the same address. + + {chain && ( + + + + )} + + + The Safe will use the initial setup of the copied Safe. Any changes to owners, threshold, modules or the + Safe's version will not be reflected in the copy. + + + {safeCreationDataLoading ? ( + + + Loading Safe data + + ) : safeCreationDataError ? ( + + Could not determine the Safe creation parameters. + + ) : isUnsupportedSafeCreationVersion ? ( + + This account was created from an outdated mastercopy. Adding another network is not possible. + + ) : noChainsAvailable ? ( + This Safe cannot be replayed on any chains. + ) : ( + <>{!chain && } + )} + + {creationError && ( + + The Safe could not be created with the same address. + + )} + + + + + {isUnsupportedSafeCreationVersion ? ( + + + Read more + + + + ) : ( + <> + + + + )} + + + + ) +} + +export const CreateSafeOnNewChain = ({ + safeAddress, + deployedChainIds, + ...props +}: Omit< + ReplaySafeDialogProps, + 'safeCreationResult' | 'replayableChains' | 'chain' | 'isUnsupportedSafeCreationVersion' +> & { + deployedChainIds: string[] +}) => { + const { configs } = useChains() + const deployedChains = useMemo( + () => configs.filter((config) => config.chainId === deployedChainIds[0]), + [configs, deployedChainIds], + ) + + const safeCreationResult = useSafeCreationData(safeAddress, deployedChains) + const allCompatibleChains = useCompatibleNetworks(safeCreationResult[0]) + const isUnsupportedSafeCreationVersion = Boolean(!allCompatibleChains?.length) + const replayableChains = useMemo( + () => + allCompatibleChains?.filter( + (config) => !deployedChainIds.includes(config.chainId) && hasMultiChainAddNetworkFeature(config), + ) || [], + [allCompatibleChains, deployedChainIds], + ) + + return ( + + ) +} + +export const CreateSafeOnSpecificChain = ({ ...props }: Omit) => { + return +} diff --git a/src/features/multichain/components/NetworkLogosList/index.tsx b/src/features/multichain/components/NetworkLogosList/index.tsx new file mode 100644 index 0000000000..39c9bd6486 --- /dev/null +++ b/src/features/multichain/components/NetworkLogosList/index.tsx @@ -0,0 +1,28 @@ +import ChainIndicator from '@/components/common/ChainIndicator' +import { Box } from '@mui/material' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import css from './styles.module.css' + +const NetworkLogosList = ({ + networks, + showHasMore = false, +}: { + networks: Pick[] + showHasMore?: boolean +}) => { + const MAX_NUM_VISIBLE_CHAINS = 4 + const visibleChains = showHasMore ? networks.slice(0, MAX_NUM_VISIBLE_CHAINS) : networks + + return ( + + {visibleChains.map((chain) => ( + + ))} + {showHasMore && networks.length > MAX_NUM_VISIBLE_CHAINS && ( + +{networks.length - MAX_NUM_VISIBLE_CHAINS} + )} + + ) +} + +export default NetworkLogosList diff --git a/src/features/multichain/components/NetworkLogosList/styles.module.css b/src/features/multichain/components/NetworkLogosList/styles.module.css new file mode 100644 index 0000000000..136e26c2c0 --- /dev/null +++ b/src/features/multichain/components/NetworkLogosList/styles.module.css @@ -0,0 +1,25 @@ +.networks { + display: flex; + flex-wrap: wrap; + margin-left: 6px; + row-gap: 4px; +} + +.networks img { + margin-left: -6px; + outline: 2px solid var(--color-background-paper); + border-radius: 50%; +} + +.moreChainsIndicator { + margin-left: -5px; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--color-border-light); + outline: 2px solid var(--color-background-paper); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} diff --git a/src/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning.tsx b/src/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning.tsx new file mode 100644 index 0000000000..70b3a809b2 --- /dev/null +++ b/src/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning.tsx @@ -0,0 +1,16 @@ +import { Alert } from '@mui/material' +import { useIsMultichainSafe } from '../../hooks/useIsMultichainSafe' +import { useCurrentChain } from '@/hooks/useChains' + +export const ChangeSignerSetupWarning = () => { + const isMultichainSafe = useIsMultichainSafe() + const currentChain = useCurrentChain() + + if (!isMultichainSafe) return + + return ( + + {`Signers are not consistent across networks on this account. Changing signers will only affect the account on ${currentChain?.chainName}`} + + ) +} diff --git a/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx b/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx new file mode 100644 index 0000000000..33a5417525 --- /dev/null +++ b/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx @@ -0,0 +1,73 @@ +import { useIsMultichainSafe } from '../../hooks/useIsMultichainSafe' +import useChains, { useCurrentChain } from '@/hooks/useChains' +import ErrorMessage from '@/components/tx/ErrorMessage' +import useSafeAddress from '@/hooks/useSafeAddress' +import { useAppSelector } from '@/store' +import { selectCurrency, selectUndeployedSafes, useGetMultipleSafeOverviewsQuery } from '@/store/slices' +import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' +import { sameAddress } from '@/utils/addresses' +import { useMemo } from 'react' +import { getDeviatingSetups, getSafeSetups } from '@/features/multichain/utils/utils' +import { Box, Typography } from '@mui/material' +import ChainIndicator from '@/components/common/ChainIndicator' + +const ChainIndicatorList = ({ chainIds }: { chainIds: string[] }) => { + const { configs } = useChains() + + return ( + <> + {chainIds.map((chainId, index) => { + const chain = configs.find((chain) => chain.chainId === chainId) + return ( + + + + {chain && chain.chainName} + {index === chainIds.length - 1 ? '.' : ','} + + + ) + })} + + ) +} + +export const InconsistentSignerSetupWarning = () => { + const isMultichainSafe = useIsMultichainSafe() + const safeAddress = useSafeAddress() + const currentChain = useCurrentChain() + const currency = useAppSelector(selectCurrency) + const undeployedSafes = useAppSelector(selectUndeployedSafes) + const { allMultiChainSafes } = useAllSafesGrouped() + + const multiChainGroupSafes = useMemo( + () => allMultiChainSafes?.find((account) => sameAddress(safeAddress, account.safes[0].address))?.safes ?? [], + [allMultiChainSafes, safeAddress], + ) + const deployedSafes = useMemo( + () => multiChainGroupSafes.filter((safe) => undeployedSafes[safe.chainId]?.[safe.address] === undefined), + [multiChainGroupSafes, undeployedSafes], + ) + const { data: safeOverviews } = useGetMultipleSafeOverviewsQuery({ safes: deployedSafes, currency }) + + const safeSetups = useMemo( + () => getSafeSetups(multiChainGroupSafes, safeOverviews ?? [], undeployedSafes), + [multiChainGroupSafes, safeOverviews, undeployedSafes], + ) + const deviatingSetups = getDeviatingSetups(safeSetups, currentChain?.chainId) + const deviatingChainIds = deviatingSetups.map((setup) => setup?.chainId) + + if (!isMultichainSafe || !deviatingChainIds.length) return + + return ( + + + Signers are different on these networks of this account: + + + + To manage your account easier and to prevent lose of funds, we recommend keeping the same signers. + + + ) +} diff --git a/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts b/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts new file mode 100644 index 0000000000..9aa0f7a208 --- /dev/null +++ b/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts @@ -0,0 +1,208 @@ +import { renderHook } from '@/tests/test-utils' +import { useCompatibleNetworks } from '../useCompatibleNetworks' +import { type ReplayedSafeProps } from '@/store/slices' +import { faker } from '@faker-js/faker' +import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' +import { chainBuilder } from '@/tests/builders/chains' +import { + getSafeSingletonDeployments, + getSafeL2SingletonDeployments, + getProxyFactoryDeployments, + getCompatibilityFallbackHandlerDeployments, +} from '@safe-global/safe-deployments' +import * as useChains from '@/hooks/useChains' + +const L1_111_MASTERCOPY_DEPLOYMENTS = getSafeSingletonDeployments({ version: '1.1.1' })?.deployments +const L1_130_MASTERCOPY_DEPLOYMENTS = getSafeSingletonDeployments({ version: '1.3.0' })?.deployments +const L1_141_MASTERCOPY_DEPLOYMENTS = getSafeSingletonDeployments({ version: '1.4.1' })?.deployments + +const L2_130_MASTERCOPY_DEPLOYMENTS = getSafeL2SingletonDeployments({ version: '1.3.0' })?.deployments +const L2_141_MASTERCOPY_DEPLOYMENTS = getSafeL2SingletonDeployments({ version: '1.4.1' })?.deployments + +const PROXY_FACTORY_111_DEPLOYMENTS = getProxyFactoryDeployments({ version: '1.1.1' })?.deployments +const PROXY_FACTORY_130_DEPLOYMENTS = getProxyFactoryDeployments({ version: '1.3.0' })?.deployments +const PROXY_FACTORY_141_DEPLOYMENTS = getProxyFactoryDeployments({ version: '1.4.1' })?.deployments + +const FALLBACK_HANDLER_130_DEPLOYMENTS = getCompatibilityFallbackHandlerDeployments({ version: '1.3.0' })?.deployments +const FALLBACK_HANDLER_141_DEPLOYMENTS = getCompatibilityFallbackHandlerDeployments({ version: '1.4.1' })?.deployments + +describe('useCompatibleNetworks', () => { + beforeAll(() => { + jest.spyOn(useChains, 'default').mockReturnValue({ + configs: [ + chainBuilder().with({ chainId: '1' }).build(), + chainBuilder().with({ chainId: '10' }).build(), // This has the eip155 and then the canonical addresses + chainBuilder().with({ chainId: '100' }).build(), // This has the canonical and then the eip155 addresses + chainBuilder().with({ chainId: '324' }).build(), // ZkSync has different addresses for all versions + chainBuilder().with({ chainId: '480' }).build(), // Worldchain has 1.4.1 but not 1.1.1 + ], + }) + }) + + it('should return empty list without any creation data', () => { + const { result } = renderHook(() => useCompatibleNetworks(undefined)) + expect(result.current).toHaveLength(0) + }) + + it('should set available to false for unknown contracts', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: faker.finance.ethereumAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + const creationData: ReplayedSafeProps = { + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '0', + safeAccountConfig: callData, + safeVersion: '1.4.1', + } + const { result } = renderHook(() => useCompatibleNetworks(creationData)) + expect(result.current.every((config) => config.available)).toEqual(false) + }) + + it('should set everything to available except zkSync for 1.4.1 Safes', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: FALLBACK_HANDLER_141_DEPLOYMENTS?.canonical?.address!, + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, + masterCopy: L1_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + safeAccountConfig: callData, + safeVersion: '1.4.1', + } + const { result } = renderHook(() => useCompatibleNetworks(creationData)) + expect(result.current).toHaveLength(5) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480']) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true]) + } + + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, + masterCopy: L2_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + safeAccountConfig: callData, + safeVersion: '1.4.1', + } + const { result } = renderHook(() => useCompatibleNetworks(creationData)) + expect(result.current).toHaveLength(5) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480']) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true]) + } + }) + + it('should mark compatible chains as available', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: ZERO_ADDRESS, + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + // 1.3.0, L1 and canonical + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.canonical?.address!, + masterCopy: L1_130_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + safeAccountConfig: { ...callData, fallbackHandler: FALLBACK_HANDLER_130_DEPLOYMENTS?.canonical?.address! }, + safeVersion: '1.3.0', + } + const { result } = renderHook(() => useCompatibleNetworks(creationData)) + expect(result.current).toHaveLength(5) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480']) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true]) + } + + // 1.3.0, L2 and canonical + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.canonical?.address!, + masterCopy: L2_130_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + safeAccountConfig: { ...callData, fallbackHandler: FALLBACK_HANDLER_130_DEPLOYMENTS?.canonical?.address! }, + safeVersion: '1.3.0', + } + const { result } = renderHook(() => useCompatibleNetworks(creationData)) + expect(result.current).toHaveLength(5) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480']) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true]) + } + + // 1.3.0, L1 and EIP155 is not available on Worldchain + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.eip155?.address!, + masterCopy: L1_130_MASTERCOPY_DEPLOYMENTS?.eip155?.address!, + saltNonce: '0', + safeAccountConfig: { ...callData, fallbackHandler: FALLBACK_HANDLER_130_DEPLOYMENTS?.eip155?.address! }, + safeVersion: '1.3.0', + } + const { result } = renderHook(() => useCompatibleNetworks(creationData)) + expect(result.current).toHaveLength(5) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480']) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, false]) + } + + // 1.3.0, L2 and EIP155 + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.eip155?.address!, + masterCopy: L2_130_MASTERCOPY_DEPLOYMENTS?.eip155?.address!, + saltNonce: '0', + safeAccountConfig: { ...callData, fallbackHandler: FALLBACK_HANDLER_130_DEPLOYMENTS?.eip155?.address! }, + safeVersion: '1.3.0', + } + const { result } = renderHook(() => useCompatibleNetworks(creationData)) + expect(result.current).toHaveLength(5) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480']) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, false]) + } + }) + + it('should set everything to not available for 1.1.1 Safes', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: faker.finance.ethereumAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_111_DEPLOYMENTS?.canonical?.address!, + masterCopy: L1_111_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + safeAccountConfig: callData, + safeVersion: '1.1.1', + } + const { result } = renderHook(() => useCompatibleNetworks(creationData)) + expect(result.current).toHaveLength(5) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480']) + expect(result.current.map((chain) => chain.available)).toEqual([false, false, false, false, false]) + }) +}) diff --git a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts new file mode 100644 index 0000000000..1ea05115a2 --- /dev/null +++ b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -0,0 +1,841 @@ +import { fakerChecksummedAddress, renderHook, waitFor } from '@/tests/test-utils' +import { SAFE_CREATION_DATA_ERRORS, useSafeCreationData } from '../useSafeCreationData' +import { faker } from '@faker-js/faker' +import { PendingSafeStatus, type UndeployedSafe } from '@/store/slices' +import { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { chainBuilder } from '@/tests/builders/chains' +import * as sdk from '@/services/tx/tx-sender/sdk' +import * as cgwSdk from 'safe-client-gateway-sdk' +import * as web3 from '@/hooks/wallets/web3' +import { encodeMultiSendData, type SafeProvider } from '@safe-global/protocol-kit' +import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' +import { type JsonRpcProvider } from 'ethers' +import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { getSafeSingletonDeployment, getSafeToL2SetupDeployment } from '@safe-global/safe-deployments' + +const setupToL2Address = getSafeToL2SetupDeployment({ version: '1.4.1' })?.defaultAddress! + +describe('useSafeCreationData', () => { + beforeAll(() => { + jest.spyOn(sdk, 'getSafeProvider').mockReturnValue({ + getChainId: jest.fn().mockReturnValue('1'), + getExternalProvider: jest.fn(), + getExternalSigner: jest.fn(), + } as unknown as SafeProvider) + }) + it('should return undefined without chain info', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfos: ChainInfo[] = [] + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([undefined, undefined, false]) + }) + }) + + it('should return the replayedSafe when copying one', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1' }).build()] + const undeployedSafe: UndeployedSafe = { + props: { + factoryAddress: faker.finance.ethereumAddress(), + saltNonce: '420', + masterCopy: faker.finance.ethereumAddress(), + safeVersion: '1.3.0', + safeAccountConfig: { + owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + threshold: 1, + data: faker.string.hexadecimal({ length: 64 }), + to: setupToL2Address, + fallbackHandler: faker.finance.ethereumAddress(), + payment: 0, + paymentToken: ZERO_ADDRESS, + paymentReceiver: ZERO_ADDRESS, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + } + + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { + initialReduxState: { + undeployedSafes: { + '1': { + [safeAddress]: undeployedSafe, + }, + }, + }, + }) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([undeployedSafe.props, undefined, false]) + }) + }) + + it('should work for replayedSafe without payment info', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1' }).build()] + const undeployedSafe: UndeployedSafe = { + props: { + factoryAddress: faker.finance.ethereumAddress(), + saltNonce: '420', + masterCopy: faker.finance.ethereumAddress(), + safeVersion: '1.3.0', + safeAccountConfig: { + owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + threshold: 1, + data: faker.string.hexadecimal({ length: 64 }), + to: setupToL2Address, + fallbackHandler: faker.finance.ethereumAddress(), + paymentReceiver: ZERO_ADDRESS, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + } + + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { + initialReduxState: { + undeployedSafes: { + '1': { + [safeAddress]: undeployedSafe, + }, + }, + }, + }) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([ + { + ...undeployedSafe.props, + safeAccountConfig: { ...undeployedSafe.props.safeAccountConfig }, + }, + undefined, + false, + ]) + }) + }) + + it('should return undefined without chain info', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfos: ChainInfo[] = [] + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([undefined, undefined, false]) + }) + }) + + it('should throw an error for replayed Safe it uses a unknown to address', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1' }).build()] + const undeployedSafe: UndeployedSafe = { + props: { + factoryAddress: faker.finance.ethereumAddress(), + saltNonce: '420', + masterCopy: faker.finance.ethereumAddress(), + safeVersion: '1.3.0', + safeAccountConfig: { + owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + threshold: 1, + data: faker.string.hexadecimal({ length: 64 }), + to: faker.finance.ethereumAddress(), + fallbackHandler: faker.finance.ethereumAddress(), + payment: 0, + paymentToken: ZERO_ADDRESS, + paymentReceiver: ZERO_ADDRESS, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + } + + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { + initialReduxState: { + undeployedSafes: { + '1': { + [safeAddress]: undeployedSafe, + }, + }, + }, + }) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNKNOWN_SETUP_MODULES), false]) + }) + }) + + it('should throw an error for legacy counterfactual Safes', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + const undeployedSafe = { + props: { + safeAccountConfig: { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + }, + safeDeploymentConfig: { + saltNonce: '69', + safeVersion: '1.3.0', + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + } + + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { + initialReduxState: { + undeployedSafes: { + '1': { + [safeAddress]: undeployedSafe as UndeployedSafe, + }, + }, + }, + }) + + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.LEGACY_COUNTERFATUAL), false]) + }) + }) + + it('should throw an error if creation data cannot be found', async () => { + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + response: new Response(), + data: undefined, + } as any) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA), false]) + }) + }) + + it('should throw an error if Safe creation data is incomplete', async () => { + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: null, + setupData: null, + }, + response: new Response(), + }) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA), false]) + }) + }) + + it('should throw an error if Safe setupData is empty', async () => { + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: faker.finance.ethereumAddress(), + setupData: '0x', + }, + response: new Response(), + }) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA), false]) + }) + }) + + it('should throw an error if outdated masterCopy is being used', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + setupToL2Address, + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: getSafeSingletonDeployment({ version: '1.1.1' })?.defaultAddress, + setupData, + }, + response: new Response(), + }) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([ + undefined, + new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_IMPLEMENTATION), + false, + ]) + }) + }) + + it('should throw an error if unknown masterCopy is being used', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + ZERO_ADDRESS, + EMPTY_DATA, + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: faker.finance.ethereumAddress(), + setupData, + }, + response: new Response(), + }) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([ + undefined, + new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_IMPLEMENTATION), + false, + ]) + }) + }) + + it('should throw an error if the Safe creation uses reimbursement', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + setupToL2Address, + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 420, + faker.finance.ethereumAddress(), + ]) + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress, + setupData, + }, + response: new Response(), + }) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.PAYMENT_SAFE), false]) + }) + }) + + it('should throw an error if the Safe creation uses an unknown setupModules call', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + faker.finance.ethereumAddress(), + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + ZERO_ADDRESS, + 0, + faker.finance.ethereumAddress(), + ]) + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress, + setupData, + }, + response: new Response(), + }) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNKNOWN_SETUP_MODULES), false]) + }) + }) + + it('should throw an error if RPC could not be created', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + setupToL2Address, + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + ZERO_ADDRESS, + 0, + faker.finance.ethereumAddress(), + ]) + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue(undefined) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_PROVIDER), false]) + }) + }) + + it('should throw an error if RPC cannot find the tx hash', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + setupToL2Address, + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + ZERO_ADDRESS, + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: () => Promise.resolve(null), + } as unknown as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.TX_NOT_FOUND), false]) + }) + }) + + it('should throw an Error if an unsupported creation method is found', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + setupToL2Address, + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + ZERO_ADDRESS, + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress! + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (mockTxHash === txHash) { + return Promise.resolve({ + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithCallback', [ + mockMasterCopyAddress, + setupData, + 69, + faker.finance.ethereumAddress(), + ]), + }) + } + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION), false]) + }) + }) + + it('should throw an error if the setup data does not match', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + setupToL2Address, + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + ZERO_ADDRESS, + 0, + faker.finance.ethereumAddress(), + ]) + + const nonMatchingSetupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + setupToL2Address, + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress! + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (mockTxHash === txHash) { + return Promise.resolve({ + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + mockMasterCopyAddress, + nonMatchingSetupData, + 69, + ]), + }) + } + return Promise.resolve(null) + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION), false]) + }) + }) + + it('should throw an error if the masterCopies do not match', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + setupToL2Address, + faker.string.hexadecimal({ length: 64, casing: 'lower' }), + faker.finance.ethereumAddress(), + ZERO_ADDRESS, + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress! + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (mockTxHash === txHash) { + return Promise.resolve({ + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + faker.finance.ethereumAddress(), + setupData, + 69, + ]), + }) + } + return Promise.resolve(null) + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION), false]) + }) + }) + + it('should return transaction data for direct Safe creation txs', async () => { + const safeProps = { + owners: [fakerChecksummedAddress(), fakerChecksummedAddress()], + threshold: 1, + to: setupToL2Address, + data: faker.string.hexadecimal({ length: 64, casing: 'lower' }), + fallbackHandler: fakerChecksummedAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: fakerChecksummedAddress(), + } + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + safeProps.owners, + safeProps.threshold, + safeProps.to, + safeProps.data, + safeProps.fallbackHandler, + safeProps.paymentToken, + safeProps.payment, + safeProps.paymentReceiver, + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = fakerChecksummedAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress! + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: fakerChecksummedAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (mockTxHash === txHash) { + return Promise.resolve({ + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + mockMasterCopyAddress, + setupData, + 69, + ]), + }) + } + return Promise.resolve(null) + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([ + { + factoryAddress: mockFactoryAddress, + masterCopy: mockMasterCopyAddress, + safeAccountConfig: safeProps, + saltNonce: '69', + safeVersion: '1.3.0', + }, + undefined, + false, + ]) + }) + }) + + it('should return transaction data for creation bundles', async () => { + const safeProps = { + owners: [fakerChecksummedAddress(), fakerChecksummedAddress()], + threshold: 1, + to: setupToL2Address, + data: faker.string.hexadecimal({ length: 64, casing: 'lower' }), + fallbackHandler: fakerChecksummedAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: fakerChecksummedAddress(), + } + + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + safeProps.owners, + safeProps.threshold, + safeProps.to, + safeProps.data, + safeProps.fallbackHandler, + safeProps.paymentToken, + safeProps.payment, + safeProps.paymentReceiver, + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.4.1' })?.defaultAddress! + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (txHash === mockTxHash) { + const deploymentTx = { + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + mockMasterCopyAddress, + setupData, + 69, + ]), + value: '0', + operation: 0, + } + const someOtherTx = { + to: faker.finance.ethereumAddress(), + value: '0', + operation: 0, + data: faker.string.hexadecimal({ length: 64 }), + } + + const multiSendData = encodeMultiSendData([deploymentTx, someOtherTx]) + return Promise.resolve({ + to: faker.finance.ethereumAddress(), + data: Multi_send__factory.createInterface().encodeFunctionData('multiSend', [multiSendData]), + }) + } + return Promise.resolve(null) + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([ + { + factoryAddress: mockFactoryAddress, + masterCopy: mockMasterCopyAddress, + safeAccountConfig: safeProps, + safeVersion: '1.4.1', + saltNonce: '69', + }, + undefined, + false, + ]) + }) + }) +}) diff --git a/src/features/multichain/hooks/useCompatibleNetworks.ts b/src/features/multichain/hooks/useCompatibleNetworks.ts new file mode 100644 index 0000000000..ded3c949c4 --- /dev/null +++ b/src/features/multichain/hooks/useCompatibleNetworks.ts @@ -0,0 +1,47 @@ +import { type ReplayedSafeProps } from '@/features/counterfactual/store/undeployedSafesSlice' +import useChains from '@/hooks/useChains' +import { hasMatchingDeployment } from '@/services/contracts/deployments' +import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import { + getCompatibilityFallbackHandlerDeployments, + getProxyFactoryDeployments, + getSafeL2SingletonDeployments, + getSafeSingletonDeployments, +} from '@safe-global/safe-deployments' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +const SUPPORTED_VERSIONS: SafeVersion[] = ['1.4.1', '1.3.0'] + +/** + * Returns all chains where the creations's masterCopy and factory are deployed. + * @param creation + */ +export const useCompatibleNetworks = ( + creation: ReplayedSafeProps | undefined, +): (ChainInfo & { available: boolean })[] => { + const { configs } = useChains() + + if (!creation) { + return [] + } + + const { masterCopy, factoryAddress, safeAccountConfig } = creation + + const { fallbackHandler } = safeAccountConfig + + return configs.map((config) => { + return { + ...config, + available: + (hasMatchingDeployment(getSafeSingletonDeployments, masterCopy, config.chainId, SUPPORTED_VERSIONS) || + hasMatchingDeployment(getSafeL2SingletonDeployments, masterCopy, config.chainId, SUPPORTED_VERSIONS)) && + hasMatchingDeployment(getProxyFactoryDeployments, factoryAddress, config.chainId, SUPPORTED_VERSIONS) && + hasMatchingDeployment( + getCompatibilityFallbackHandlerDeployments, + fallbackHandler, + config.chainId, + SUPPORTED_VERSIONS, + ), + } + }) +} diff --git a/src/features/multichain/hooks/useIsMultichainSafe.ts b/src/features/multichain/hooks/useIsMultichainSafe.ts new file mode 100644 index 0000000000..89205b3e7c --- /dev/null +++ b/src/features/multichain/hooks/useIsMultichainSafe.ts @@ -0,0 +1,14 @@ +import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' +import useSafeAddress from '@/hooks/useSafeAddress' +import { sameAddress } from '@/utils/addresses' +import { useMemo } from 'react' + +export const useIsMultichainSafe = () => { + const safeAddress = useSafeAddress() + const { allMultiChainSafes } = useAllSafesGrouped() + + return useMemo( + () => allMultiChainSafes?.some((account) => sameAddress(safeAddress, account.safes[0].address)), + [allMultiChainSafes, safeAddress], + ) +} diff --git a/src/features/multichain/hooks/useSafeCreationData.ts b/src/features/multichain/hooks/useSafeCreationData.ts new file mode 100644 index 0000000000..b8e35abcb9 --- /dev/null +++ b/src/features/multichain/hooks/useSafeCreationData.ts @@ -0,0 +1,189 @@ +import useAsync, { type AsyncResult } from '@/hooks/useAsync' +import { createWeb3ReadOnly } from '@/hooks/wallets/web3' +import { type UndeployedSafe, selectRpc, type ReplayedSafeProps, selectUndeployedSafes } from '@/store/slices' +import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' +import { sameAddress } from '@/utils/addresses' +import { getCreationTransaction } from 'safe-client-gateway-sdk' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useAppSelector } from '@/store' +import { determineMasterCopyVersion, isPredictedSafeProps } from '@/features/counterfactual/utils' +import { logError } from '@/services/exceptions' +import ErrorCodes from '@/services/exceptions/ErrorCodes' +import { asError } from '@/services/exceptions/utils' +import semverSatisfies from 'semver/functions/satisfies' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { getSafeToL2SetupDeployment } from '@safe-global/safe-deployments' +import { type SafeAccountConfig } from '@safe-global/protocol-kit' + +export const SAFE_CREATION_DATA_ERRORS = { + TX_NOT_FOUND: 'The Safe creation transaction could not be found. Please retry later.', + NO_CREATION_DATA: 'The Safe creation information for this Safe could not be found or is incomplete.', + UNSUPPORTED_SAFE_CREATION: 'The method this Safe was created with is not supported.', + NO_PROVIDER: 'The RPC provider for the origin network is not available.', + LEGACY_COUNTERFATUAL: 'This undeployed Safe cannot be replayed. Please activate the Safe first.', + PAYMENT_SAFE: 'The Safe creation used reimbursement. Adding networks to such Safes is not supported.', + UNSUPPORTED_IMPLEMENTATION: + 'The Safe was created using an unsupported or outdated implementation. Adding networks to this Safe is not possible.', + UNKNOWN_SETUP_MODULES: 'The Safe creation is using an unknown internal call', +} + +export const decodeSetupData = (setupData: string): ReplayedSafeProps['safeAccountConfig'] => { + const [owners, threshold, to, data, fallbackHandler, paymentToken, payment, paymentReceiver] = + Safe__factory.createInterface().decodeFunctionData('setup', setupData) + + return { + owners: [...owners], + threshold: Number(threshold), + to, + data, + fallbackHandler, + paymentToken, + payment: Number(payment), + paymentReceiver, + } +} + +const getUndeployedSafeCreationData = async (undeployedSafe: UndeployedSafe): Promise => { + if (isPredictedSafeProps(undeployedSafe.props)) { + throw new Error(SAFE_CREATION_DATA_ERRORS.LEGACY_COUNTERFATUAL) + } + + // We already have a replayed Safe. In this case we can return the identical data + return undeployedSafe.props +} + +const validateAccountConfig = (safeAccountConfig: SafeAccountConfig) => { + // Safes that used the reimbursement logic are not supported + if ( + (safeAccountConfig.payment && safeAccountConfig.payment > 0) || + (safeAccountConfig.paymentToken && safeAccountConfig.paymentToken !== ZERO_ADDRESS) + ) { + throw new Error(SAFE_CREATION_DATA_ERRORS.PAYMENT_SAFE) + } + + const setupToL2Address = getSafeToL2SetupDeployment({ version: '1.4.1' })?.defaultAddress + if (safeAccountConfig.to !== ZERO_ADDRESS && !sameAddress(safeAccountConfig.to, setupToL2Address)) { + // Unknown setupModules calls cannot be replayed as the target contract is likely not deployed across chains + throw new Error(SAFE_CREATION_DATA_ERRORS.UNKNOWN_SETUP_MODULES) + } +} + +const proxyFactoryInterface = Safe_proxy_factory__factory.createInterface() +const createProxySelector = proxyFactoryInterface.getFunction('createProxyWithNonce').selector + +/** + * Loads the creation data from the CGW or infers it from an undeployed Safe. + * + * Throws errors for the reasons in {@link SAFE_CREATION_DATA_ERRORS}. + * Checking the cheap cases not requiring RPC calls first. + */ +const getCreationDataForChain = async ( + chain: ChainInfo, + undeployedSafe: UndeployedSafe, + safeAddress: string, + customRpc: { [chainId: string]: string }, +): Promise => { + // 1. The safe is counterfactual + if (undeployedSafe) { + const undeployedCreationData = await getUndeployedSafeCreationData(undeployedSafe) + validateAccountConfig(undeployedCreationData.safeAccountConfig) + + return undeployedCreationData + } + + const { data: creation } = await getCreationTransaction({ + path: { + chainId: chain.chainId, + safeAddress, + }, + }) + + if (!creation || !creation.masterCopy || !creation.setupData || creation.setupData === '0x') { + throw new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA) + } + + // Safes that were deployed with an unknown mastercopy or < 1.3.0 are not supported. + const safeVersion = determineMasterCopyVersion(creation.masterCopy, chain.chainId) + if (!safeVersion || semverSatisfies(safeVersion, '<1.3.0')) { + throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_IMPLEMENTATION) + } + + const safeAccountConfig = decodeSetupData(creation.setupData) + + validateAccountConfig(safeAccountConfig) + + // We need to create a readOnly provider of the deployed chain + const customRpcUrl = chain ? customRpc?.[chain.chainId] : undefined + const provider = createWeb3ReadOnly(chain, customRpcUrl) + + if (!provider) { + throw new Error(SAFE_CREATION_DATA_ERRORS.NO_PROVIDER) + } + + // Fetch saltNonce by fetching the transaction from the RPC. + const tx = await provider.getTransaction(creation.transactionHash) + if (!tx) { + throw new Error(SAFE_CREATION_DATA_ERRORS.TX_NOT_FOUND) + } + const txData = tx.data + const startOfTx = txData.indexOf(createProxySelector.slice(2, 10)) + if (startOfTx === -1) { + throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) + } + + // decode tx + const [masterCopy, initializer, saltNonce] = proxyFactoryInterface.decodeFunctionData( + 'createProxyWithNonce', + `0x${txData.slice(startOfTx)}`, + ) + + const txMatches = + sameAddress(masterCopy, creation.masterCopy) && + (initializer as string)?.toLowerCase().includes(creation.setupData?.toLowerCase()) + + if (!txMatches) { + // We found the wrong tx. This tx seems to deploy multiple Safes at once. This is not supported yet. + throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) + } + + return { + factoryAddress: creation.factoryAddress, + masterCopy: creation.masterCopy, + safeAccountConfig, + saltNonce: saltNonce.toString(), + safeVersion, + } +} + +/** + * Fetches the data with which the given Safe was originally created. + * Useful to replay a Safe creation. + */ +export const useSafeCreationData = (safeAddress: string, chains: ChainInfo[]): AsyncResult => { + const customRpc = useAppSelector(selectRpc) + + const undeployedSafes = useAppSelector(selectUndeployedSafes) + + return useAsync(async () => { + let lastError: Error | undefined = undefined + try { + for (const chain of chains) { + const undeployedSafe = undeployedSafes[chain.chainId]?.[safeAddress] + + try { + const creationData = await getCreationDataForChain(chain, undeployedSafe, safeAddress, customRpc) + return creationData + } catch (err) { + lastError = asError(err) + } + } + if (lastError) { + // We want to know why the creation was not possible by throwing one of the errors + throw lastError + } + } catch (err) { + logError(ErrorCodes._816, err) + throw err + } + }, [chains, customRpc, safeAddress, undeployedSafes]) +} diff --git a/src/features/multichain/utils/utils.test.ts b/src/features/multichain/utils/utils.test.ts new file mode 100644 index 0000000000..0735b11d5a --- /dev/null +++ b/src/features/multichain/utils/utils.test.ts @@ -0,0 +1,483 @@ +import { faker } from '@faker-js/faker/locale/af_ZA' +import { getDeviatingSetups, getSafeSetups, getSharedSetup, isMultiChainSafeItem } from './utils' +import { PendingSafeStatus } from '@/store/slices' +import { PayMethod } from '@/features/counterfactual/PayNowPayLater' + +describe('multiChain/utils', () => { + describe('isMultiChainSafeItem', () => { + it('should return true for MultiChainSafeIem', () => { + expect( + isMultiChainSafeItem({ + address: faker.finance.ethereumAddress(), + safes: [ + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isWatchlist: false, + }, + ], + }), + ).toBeTruthy() + }) + + it('should return false for SafeItem', () => { + expect( + isMultiChainSafeItem({ + address: faker.finance.ethereumAddress(), + chainId: '1', + isWatchlist: false, + }), + ).toBeFalsy() + }) + }) + + describe('getSharedSetup', () => { + it('should return undefined if no setup info is available', () => { + expect(getSharedSetup([])).toBeUndefined() + expect(getSharedSetup([undefined])).toBeUndefined() + expect(getSharedSetup([undefined, undefined])).toBeUndefined() + }) + + it('should return undefined if some of the setups are undefined', () => { + const safeSetups = [ + { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + chainId: '1', + }, + undefined, + ] + expect(getSharedSetup(safeSetups)).toBeUndefined() + }) + + it('should return undefined if the owners do not match', () => { + // 2 Safes. One with 1 and one with 2 owners. + const owners1 = [faker.finance.ethereumAddress()] + const owners2 = [...owners1, faker.finance.ethereumAddress()] + const safeSetups = [ + { + owners: owners1, + threshold: 1, + chainId: '1', + }, + { + owners: owners2, + threshold: 1, + chainId: '100', + }, + ] + + expect(getSharedSetup(safeSetups)).toBeUndefined() + }) + it('should return undefined if the threshold does not match', () => { + const owners = [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()] + + const safeSetups = [ + { + owners, + threshold: 1, + chainId: '1', + }, + { + owners, + threshold: 2, + chainId: '100', + }, + ] + + expect(getSharedSetup(safeSetups)).toBeUndefined() + }) + + it('should return the shared setup if owners and threshold matches', () => { + const owners = [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()] + + const safeSetups = [ + { + owners, + threshold: 2, + chainId: '1', + }, + { + owners, + threshold: 2, + chainId: '100', + }, + ] + + expect(getSharedSetup(safeSetups)).toEqual({ owners, threshold: 2 }) + }) + }) + + describe('getDeviatingSetups', () => { + it('should return empty array if no setup data is provided', () => { + expect(getDeviatingSetups([], '1')).toEqual([]) + }) + + it('should return empty array if current chainId is not defined', () => { + const safeSetups = [ + { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + chainId: '1', + }, + ] + expect(getDeviatingSetups(safeSetups, undefined)).toEqual([]) + }) + + it('should return empty array if all setups are the same', () => { + const owner1 = faker.finance.ethereumAddress() + const owner2 = faker.finance.ethereumAddress() + + const safeSetups = [ + { + owners: [owner1, owner2], + threshold: 2, + chainId: '1', + }, + { + owners: [owner1, owner2], + threshold: 2, + chainId: '5', + }, + { + owners: [owner1, owner2], + threshold: 2, + chainId: '100', + }, + ] + expect(getDeviatingSetups(safeSetups, '1')).toEqual([]) + }) + + it('should return all setups that are different from the current one', () => { + const currentChainId = '1' + const owner1 = faker.finance.ethereumAddress() + const owner2 = faker.finance.ethereumAddress() + const owner3 = faker.finance.ethereumAddress() + + const currentSetup = { owners: [owner1, owner2], threshold: 2 } + const differentOwnersSetup = { owners: [owner2, owner3], threshold: 2 } + const differentThresholdSetup = { owners: [owner1, owner2], threshold: 1 } + const differentOwnersAndThresholdSetup = { owners: [owner1], threshold: 1 } + + const safeSetups = [ + { + ...currentSetup, + chainId: currentChainId, + }, + { + ...currentSetup, + chainId: '4', + }, + { + ...differentOwnersSetup, + chainId: '5', + }, + { + ...differentThresholdSetup, + chainId: '100', + }, + { + ...differentOwnersAndThresholdSetup, + chainId: '11155111', + }, + ] + expect(getDeviatingSetups(safeSetups, currentChainId)).toEqual([ + { + ...differentOwnersSetup, + chainId: '5', + }, + { + ...differentThresholdSetup, + chainId: '100', + }, + { + ...differentOwnersAndThresholdSetup, + chainId: '11155111', + }, + ]) + }) + + it('should return empty array if setup data for current chain is not found', () => { + const currentChainId = '1' + + const safeSetups = [ + { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + chainId: '10', + }, + { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + chainId: '5', + }, + { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + chainId: '100', + }, + ] + expect(getDeviatingSetups(safeSetups, currentChainId)).toEqual([]) + }) + }) + + describe('getSafeSetups', () => { + it('should return an empty array if no setup infos available', () => { + expect( + getSafeSetups( + [ + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isWatchlist: false, + }, + ], + [], + {}, + ), + ).toEqual([]) + }) + + it('should return undefined if no setup infos available', () => { + expect( + getSafeSetups( + [ + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isWatchlist: false, + }, + ], + [], + {}, + ), + ).toEqual([]) + }) + + it('should return the setup data if deployed safes have setup data available', () => { + const address = faker.finance.ethereumAddress() + const ownerAddress1 = faker.finance.ethereumAddress() + const ownerAddress2 = faker.finance.ethereumAddress() + + expect( + getSafeSetups( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners: [{ value: ownerAddress1 }], + queued: 0, + threshold: 1, + }, + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '100', + fiatTotal: '0', + owners: [{ value: ownerAddress2 }], + queued: 0, + threshold: 2, + }, + ], + {}, + ), + ).toEqual([ + { owners: [ownerAddress1], threshold: 1, chainId: '1' }, + { owners: [ownerAddress2], threshold: 2, chainId: '100' }, + ]) + }) + + it('should return the setup data if undeployed safes have setup data available', () => { + const address = faker.finance.ethereumAddress() + + const ownerAddress1 = faker.finance.ethereumAddress() + const ownerAddress2 = faker.finance.ethereumAddress() + + expect( + getSafeSetups( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [], + { + ['1']: { + [address]: { + props: { + safeAccountConfig: { + owners: [ownerAddress1], + threshold: 1, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + }, + ['100']: { + [address]: { + props: { + safeAccountConfig: { + owners: [ownerAddress2], + threshold: 2, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + }, + }, + ), + ).toEqual([ + { owners: [ownerAddress1], threshold: 1, chainId: '1' }, + { owners: [ownerAddress2], threshold: 2, chainId: '100' }, + ]) + }) + + it('should only return setup data where if setup data is available', () => { + const address = faker.finance.ethereumAddress() + + const ownerAddress1 = faker.finance.ethereumAddress() + const ownerAddress2 = faker.finance.ethereumAddress() + + expect( + getSafeSetups( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + { + address, + chainId: '5', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners: [{ value: ownerAddress1 }], + queued: 0, + threshold: 1, + }, + ], + { + ['100']: { + [address]: { + props: { + safeAccountConfig: { + owners: [ownerAddress2], + threshold: 2, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + }, + }, + ), + ).toEqual([ + { owners: [ownerAddress1], threshold: 1, chainId: '1' }, + { owners: [ownerAddress2], threshold: 2, chainId: '100' }, + ]) + }) + + it('should return setup data for a mix of deployed and undeployed safes', () => { + const address = faker.finance.ethereumAddress() + + const ownerAddress1 = faker.finance.ethereumAddress() + const ownerAddress2 = faker.finance.ethereumAddress() + + expect( + getSafeSetups( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners: [{ value: ownerAddress1 }], + queued: 0, + threshold: 1, + }, + ], + { + ['100']: { + [address]: { + props: { + safeAccountConfig: { + owners: [ownerAddress2], + threshold: 2, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + }, + }, + ), + ).toEqual([ + { owners: [ownerAddress1], threshold: 1, chainId: '1' }, + { owners: [ownerAddress2], threshold: 2, chainId: '100' }, + ]) + }) + }) +}) diff --git a/src/features/multichain/utils/utils.ts b/src/features/multichain/utils/utils.ts new file mode 100644 index 0000000000..1f60254d53 --- /dev/null +++ b/src/features/multichain/utils/utils.ts @@ -0,0 +1,134 @@ +import type { DecodedDataResponse } from '@safe-global/safe-gateway-typescript-sdk' + +import { type ChainInfo, type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { type UndeployedSafesState, type ReplayedSafeProps } from '@/store/slices' +import { sameAddress } from '@/utils/addresses' +import { Safe_proxy_factory__factory } from '@/types/contracts' +import { keccak256, ethers, solidityPacked, getCreate2Address, type Provider } from 'ethers' +import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' +import { encodeSafeSetupCall } from '@/components/new-safe/create/logic' +import { memoize } from 'lodash' +import { FEATURES, hasFeature } from '@/utils/chains' +import { type SafeItem } from '@/components/welcome/MyAccounts/useAllSafes' +import { type MultiChainSafeItem } from '@/components/welcome/MyAccounts/useAllSafesGrouped' + +type SafeSetup = { + owners: string[] + threshold: number + chainId: string +} + +export const isChangingSignerSetup = (decodedData: DecodedDataResponse | undefined) => { + return decodedData?.method === 'addOwnerWithThreshold' || decodedData?.method === 'removeOwner' +} + +export const isMultiChainSafeItem = (safe: SafeItem | MultiChainSafeItem): safe is MultiChainSafeItem => { + if ('safes' in safe && 'address' in safe) { + return true + } + return false +} + +const areOwnersMatching = (owners1: string[], owners2: string[]) => + owners1.length === owners2.length && owners1.every((owner) => owners2.some((owner2) => sameAddress(owner, owner2))) + +export const getSafeSetups = ( + safes: SafeItem[], + safeOverviews: SafeOverview[], + undeployedSafes: UndeployedSafesState, +): (SafeSetup | undefined)[] => { + const safeSetups = safes.map((safeItem) => { + const undeployedSafe = undeployedSafes?.[safeItem.chainId]?.[safeItem.address] + if (undeployedSafe) { + const counterfactualSetup = extractCounterfactualSafeSetup(undeployedSafe, safeItem.chainId) + if (!counterfactualSetup) return undefined + return { + owners: counterfactualSetup.owners, + threshold: counterfactualSetup.threshold, + chainId: safeItem.chainId, + } + } + const foundOverview = safeOverviews?.find( + (overview) => overview.chainId === safeItem.chainId && sameAddress(overview.address.value, safeItem.address), + ) + if (!foundOverview) return undefined + return { + owners: foundOverview.owners.map((owner) => owner.value), + threshold: foundOverview.threshold, + chainId: safeItem.chainId, + } + }) + return safeSetups +} + +export const getSharedSetup = (safeSetups: (SafeSetup | undefined)[]): Omit | undefined => { + const comparisonSetup = safeSetups[0] + + if (!comparisonSetup) return undefined + + const allMatching = safeSetups.every( + (setup) => + setup && areOwnersMatching(setup.owners, comparisonSetup.owners) && setup.threshold === comparisonSetup.threshold, + ) + + const { owners, threshold } = comparisonSetup + return allMatching ? { owners, threshold } : undefined +} + +export const getDeviatingSetups = ( + safeSetups: (SafeSetup | undefined)[], + currentChainId: string | undefined, +): SafeSetup[] => { + const currentSafeSetup = safeSetups.find((setup) => setup?.chainId === currentChainId) + if (!currentChainId || !currentSafeSetup) return [] + + const deviatingSetups = safeSetups + .filter((setup): setup is SafeSetup => Boolean(setup)) + .filter((setup) => { + return ( + setup && + (!areOwnersMatching(setup.owners, currentSafeSetup.owners) || setup.threshold !== currentSafeSetup.threshold) + ) + }) + return deviatingSetups +} + +const memoizedGetProxyCreationCode = memoize( + async (factoryAddress: string, provider: Provider) => { + return Safe_proxy_factory__factory.connect(factoryAddress, provider).proxyCreationCode() + }, + async (factoryAddress, provider) => `${factoryAddress}${(await provider.getNetwork()).chainId}`, +) + +export const predictAddressBasedOnReplayData = async (safeCreationData: ReplayedSafeProps, provider: Provider) => { + const setupData = encodeSafeSetupCall(safeCreationData.safeAccountConfig) + + // Step 1: Hash the initializer + const initializerHash = keccak256(setupData) + + // Step 2: Encode the initializerHash and saltNonce using abi.encodePacked equivalent + const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [safeCreationData.saltNonce])]) + + // Step 3: Hash the encoded value to get the final salt + const salt = keccak256(encoded) + + // Get Proxy creation code + const proxyCreationCode = await memoizedGetProxyCreationCode(safeCreationData.factoryAddress, provider) + + const constructorData = safeCreationData.masterCopy + const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2) + return getCreate2Address(safeCreationData.factoryAddress, salt, keccak256(initCode)) +} + +export const hasMultiChainCreationFeatures = (chain: ChainInfo): boolean => { + return ( + hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_CREATION) && + hasFeature(chain, FEATURES.COUNTERFACTUAL) && + hasFeature(chain, FEATURES.SAFE_141) + ) +} + +export const hasMultiChainAddNetworkFeature = (chain: ChainInfo | undefined): boolean => { + if (!chain) return false + return hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_ADD_NETWORK) && hasFeature(chain, FEATURES.COUNTERFACTUAL) +} diff --git a/src/features/recovery/services/recovery-state.ts b/src/features/recovery/services/recovery-state.ts index 6303750e05..70941f90dc 100644 --- a/src/features/recovery/services/recovery-state.ts +++ b/src/features/recovery/services/recovery-state.ts @@ -8,7 +8,7 @@ import { toBeHex, type JsonRpcProvider, type TransactionReceipt } from 'ethers' import { trimTrailingSlash } from '@/utils/url' import { sameAddress } from '@/utils/addresses' import { isMultiSendCalldata } from '@/utils/transaction-calldata' -import { decodeMultiSendTxs } from '@/utils/transactions' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' export const MAX_RECOVERER_PAGE_SIZE = 100 @@ -47,7 +47,7 @@ export function _isMaliciousRecovery({ const BASE_MULTI_SEND_CALL_ONLY_VERSION = '1.3.0' const isMultiSend = isMultiSendCalldata(transaction.data) - const transactions = isMultiSend ? decodeMultiSendTxs(transaction.data) : [transaction] + const transactions = isMultiSend ? decodeMultiSendData(transaction.data) : [transaction] if (!isMultiSend) { // Calling the Safe itself diff --git a/src/features/recovery/services/transaction-list.ts b/src/features/recovery/services/transaction-list.ts index 084acf0273..8f37c43efd 100644 --- a/src/features/recovery/services/transaction-list.ts +++ b/src/features/recovery/services/transaction-list.ts @@ -6,11 +6,11 @@ import { isChangeThresholdCalldata, isMultiSendCalldata, } from '@/utils/transaction-calldata' -import { decodeMultiSendTxs } from '@/utils/transactions' import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' import { Interface } from 'ethers' import type { BaseTransaction } from '@safe-global/safe-apps-sdk' import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' function decodeOwnerManagementTransaction(safe: SafeInfo, transaction: BaseTransaction): SafeInfo { const safeDeployment = getSafeSingletonDeployment({ network: safe.chainId, version: safe.version ?? undefined }) @@ -54,7 +54,7 @@ function decodeOwnerManagementTransaction(safe: SafeInfo, transaction: BaseTrans } export function getRecoveredSafeInfo(safe: SafeInfo, transaction: BaseTransaction): SafeInfo { - const transactions = isMultiSendCalldata(transaction.data) ? decodeMultiSendTxs(transaction.data) : [transaction] + const transactions = isMultiSendCalldata(transaction.data) ? decodeMultiSendData(transaction.data) : [transaction] return transactions.reduce((acc, cur) => { return decodeOwnerManagementTransaction(acc, cur) diff --git a/src/features/speedup/components/SpeedUpModal.tsx b/src/features/speedup/components/SpeedUpModal.tsx index 50a641b1b1..b529480268 100644 --- a/src/features/speedup/components/SpeedUpModal.tsx +++ b/src/features/speedup/components/SpeedUpModal.tsx @@ -27,7 +27,7 @@ import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' import { trackError } from '@/services/exceptions' import ErrorCodes from '@/services/exceptions/ErrorCodes' import CheckWallet from '@/components/common/CheckWallet' -import { useLazyGetTransactionDetailsQuery } from '@/store/gateway' +import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' type Props = { diff --git a/src/features/stake/components/StakePage/index.tsx b/src/features/stake/components/StakePage/index.tsx index b42b388f44..e6c4b855ec 100644 --- a/src/features/stake/components/StakePage/index.tsx +++ b/src/features/stake/components/StakePage/index.tsx @@ -4,7 +4,7 @@ import WidgetDisclaimer from '@/components/common/WidgetDisclaimer' import useStakeConsent from '@/features/stake/useStakeConsent' import StakingWidget from '../StakingWidget' import { useRouter } from 'next/router' -import { useGetIsSanctionedQuery } from '@/store/ofac' +import { useGetIsSanctionedQuery } from '@/store/api/ofac' import { skipToken } from '@reduxjs/toolkit/query/react' import useWallet from '@/hooks/wallets/useWallet' import useSafeInfo from '@/hooks/useSafeInfo' diff --git a/src/features/swap/helpers/utils.ts b/src/features/swap/helpers/utils.ts index ad745ea18a..dfc8328343 100644 --- a/src/features/swap/helpers/utils.ts +++ b/src/features/swap/helpers/utils.ts @@ -24,6 +24,9 @@ function asDecimal(amount: number | bigint, decimals: number): number { export const TWAP_FALLBACK_HANDLER = '0x2f55e8b20D0B9FEFA187AA7d00B6Cbe563605bF5' +// https://github.com/cowprotocol/composable-cow/blob/main/networks.json +export const TWAP_FALLBACK_HANDLER_NETWORKS = ['1', '100', '11155111', '42161'] + export const getExecutionPrice = ( order: Pick, ): number => { diff --git a/src/features/swap/index.tsx b/src/features/swap/index.tsx index 9ac20f238d..12735115ba 100644 --- a/src/features/swap/index.tsx +++ b/src/features/swap/index.tsx @@ -37,7 +37,7 @@ import { import { calculateFeePercentageInBps } from '@/features/swap/helpers/fee' import { UiOrderTypeToOrderType } from '@/features/swap/helpers/utils' import { FEATURES } from '@/utils/chains' -import { useGetIsSanctionedQuery } from '@/store/ofac' +import { useGetIsSanctionedQuery } from '@/store/api/ofac' import { skipToken } from '@reduxjs/toolkit/query/react' import { getKeyWithTrueValue } from '@/utils/helpers' diff --git a/src/hooks/__tests__/useSafeNotifications.test.ts b/src/hooks/__tests__/useSafeNotifications.test.ts index e361781943..1f28a42fc0 100644 --- a/src/hooks/__tests__/useSafeNotifications.test.ts +++ b/src/hooks/__tests__/useSafeNotifications.test.ts @@ -130,10 +130,11 @@ describe('useSafeNotifications', () => { implementation: { value: '0x123' }, implementationVersionState: 'UNKNOWN', version: '1.3.0', + nonce: 1, address: { value: '0x1', }, - chainId: '5', + chainId: '10', }, }) @@ -154,6 +155,33 @@ describe('useSafeNotifications', () => { }, }) }) + it('should show a notification when the mastercopy is invalid but can be migrated', () => { + ;(useSafeInfo as jest.Mock).mockReturnValue({ + safe: { + implementation: { value: '0x123' }, + implementationVersionState: 'UNKNOWN', + version: '1.3.0', + nonce: 0, + address: { + value: '0x1', + }, + chainId: '10', + }, + }) + + // render the hook + const { result } = renderHook(() => useSafeNotifications()) + + // check that the notification was shown + expect(result.current).toBeUndefined() + expect(showNotification).toHaveBeenCalledWith({ + variant: 'info', + message: `This Safe Account was created with an unsupported base contract. + It is possible to migrate it to a compatible base contract. This migration will be automatically included with your first transaction.`, + groupKey: 'invalid-mastercopy', + link: undefined, + }) + }) it('should not show a notification when the mastercopy is valid', async () => { ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { @@ -163,7 +191,7 @@ describe('useSafeNotifications', () => { address: { value: '0x1', }, - chainId: '5', + chainId: '10', }, }) diff --git a/src/hooks/__tests__/useSanctionedAddress.test.ts b/src/hooks/__tests__/useSanctionedAddress.test.ts index 4c79e822d8..11eb578fdc 100644 --- a/src/hooks/__tests__/useSanctionedAddress.test.ts +++ b/src/hooks/__tests__/useSanctionedAddress.test.ts @@ -4,7 +4,7 @@ import useSafeAddress from '../useSafeAddress' import useWallet from '../wallets/useWallet' import { faker } from '@faker-js/faker' import { connectedWalletBuilder } from '@/tests/builders/wallet' -import * as ofac from '@/store/ofac' +import * as ofac from '@/store/api/ofac' import { skipToken } from '@reduxjs/toolkit/query' jest.mock('@/hooks/useSafeAddress') diff --git a/src/hooks/coreSDK/safeCoreSDK.ts b/src/hooks/coreSDK/safeCoreSDK.ts index 87a6f130b6..1637ed0300 100644 --- a/src/hooks/coreSDK/safeCoreSDK.ts +++ b/src/hooks/coreSDK/safeCoreSDK.ts @@ -11,6 +11,7 @@ import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import semverSatisfies from 'semver/functions/satisfies' import { isValidMasterCopy } from '@/services/contracts/safeContracts' import { sameAddress } from '@/utils/addresses' +import { isPredictedSafeProps } from '@/features/counterfactual/utils' export const isLegacyVersion = (safeVersion: string): boolean => { const LEGACY_VERSION = '<1.3.0' @@ -81,11 +82,15 @@ export const initSafeSDK = async ({ } if (undeployedSafe) { - return Safe.init({ - provider: provider._getConnection().url, - isL1SafeSingleton, - predictedSafe: undeployedSafe.props, - }) + if (isPredictedSafeProps(undeployedSafe.props)) { + return Safe.init({ + provider: provider._getConnection().url, + isL1SafeSingleton, + predictedSafe: undeployedSafe.props, + }) + } + // We cannot initialize a Core SDK for replayed Safes yet. + return } return Safe.init({ provider: provider._getConnection().url, diff --git a/src/hooks/loadables/useLoadSafeInfo.ts b/src/hooks/loadables/useLoadSafeInfo.ts index 2b8f55b6b5..2f0d5c75ff 100644 --- a/src/hooks/loadables/useLoadSafeInfo.ts +++ b/src/hooks/loadables/useLoadSafeInfo.ts @@ -28,7 +28,7 @@ export const useLoadSafeInfo = (): AsyncResult => { * 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.props, address, chain) + if (undeployedSafe) return getUndeployedSafeInfo(undeployedSafe, address, chain) const safeInfo = await getSafeInfo(chainId, address) diff --git a/src/hooks/useDelegates.ts b/src/hooks/useDelegates.ts index 74c643ce33..c669ed93c0 100644 --- a/src/hooks/useDelegates.ts +++ b/src/hooks/useDelegates.ts @@ -1,6 +1,6 @@ import useSafeInfo from '@/hooks/useSafeInfo' import useWallet from '@/hooks/wallets/useWallet' -import { useGetDelegatesQuery } from '@/store/gateway' +import { useGetDelegatesQuery } from '@/store/api/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' const useDelegates = () => { diff --git a/src/hooks/useMnemonicName/dict.ts b/src/hooks/useMnemonicName/dict.ts index d45be71e76..54fd034722 100644 --- a/src/hooks/useMnemonicName/dict.ts +++ b/src/hooks/useMnemonicName/dict.ts @@ -1,259 +1,3 @@ -/** - * The word lists are from https://github.com/mmkal/ts/tree/main/packages/memorable-moniker/src/dict - */ -export const animalsDict = ` -aardvark -albatross -alligator -alpaca -ant -anteater -antelope -ape -armadillo -baboon -badger -barracuda -bat -bear -beaver -bee -binturong -bird -bison -bluebird -boar -bobcat -buffalo -butterfly -camel -capybara -caracal -caribou -cassowary -cat -caterpillar -cattle -chameleon -chamois -cheetah -chicken -chimpanzee -chinchilla -chough -coati -cobra -cockroach -cod -cormorant -cougar -coyote -crab -crane -cricket -crocodile -crow -cuckoo -curlew -deer -degu -dhole -dingo -dinosaur -dog -dogfish -dolphin -donkey -dotterel -dove -dragonfly -duck -dugong -dunlin -eagle -echidna -eel -eland -elephant -elk -emu -falcon -ferret -finch -fish -flamingo -fly -fox -frog -gaur -gazelle -gecko -gerbil -giraffe -gnat -gnu -goat -goldfinch -goosander -goose -gorilla -goshawk -grasshopper -grouse -guanaco -gull -hamster -hare -hawk -hedgehog -heron -herring -hippopotamus -hoatzin -hoopoe -hornet -horse -human -hummingbird -hyena -ibex -ibis -iguana -impala -jackal -jaguar -jay -jellyfish -jerboa -kangaroo -kingfisher -kinkajou -koala -kookaburra -kouprey -kudu -lapwing -lark -lemur -leopard -lion -lizard -llama -lobster -locust -loris -louse -lynx -lyrebird -macaque -macaw -magpie -mallard -mammoth -manatee -mandrill -marmoset -marmot -meerkat -mink -mole -mongoose -monkey -moose -mosquito -mouse -myna -narwhal -newt -nightingale -octopus -okapi -opossum -orangutan -oryx -ostrich -otter -owl -oyster -panther -parrot -panda -partridge -peafowl -pelican -penguin -pheasant -pig -pigeon -pika -pony -porcupine -porpoise -pug -quail -quelea -quetzal -rabbit -raccoon -ram -rat -raven -reindeer -rhea -rhinoceros -rook -salamander -salmon -sand -sandpiper -sardine -seahorse -seal -shark -sheep -shrew -siamang -skunk -sloth -snail -snake -spider -squid -squirrel -starling -stegosaurus -swan -tamarin -tapir -tarsier -termite -tiger -toad -toucan -turaco -turkey -turtle -umbrellabird -vinegaroon -viper -vulture -wallaby -walrus -wasp -waxwing -weasel -whale -wobbegong -wolf -wolverine -wombat -woodpecker -worm -wren -yak -zebra -` - export const adjectivesDict = ` admirable energetic diff --git a/src/hooks/useMnemonicName/index.ts b/src/hooks/useMnemonicName/index.ts index ab01de0c0f..4caf08a442 100644 --- a/src/hooks/useMnemonicName/index.ts +++ b/src/hooks/useMnemonicName/index.ts @@ -1,8 +1,7 @@ import { useMemo } from 'react' import { useCurrentChain } from '@/hooks/useChains' -import { animalsDict, adjectivesDict } from './dict' +import { adjectivesDict } from './dict' -const animals: string[] = animalsDict.trim().split(/\s+/) const adjectives: string[] = adjectivesDict.trim().split(/\s+/) export const capitalize = (word: string) => (word.length > 0 ? `${word.charAt(0).toUpperCase()}${word.slice(1)}` : word) @@ -11,16 +10,12 @@ const getRandomItem = (arr: T[]): T => { return arr[Math.floor(arr.length * Math.random())] } -export const getRandomName = (noun = capitalize(getRandomItem(animals))): string => { - const adj = capitalize(getRandomItem(adjectives)) - return `${adj} ${noun}` +export const getRandomAdjective = (): string => { + return capitalize(getRandomItem(adjectives)) } -export const useMnemonicName = (noun?: string): string => { - return useMemo(() => getRandomName(noun), [noun]) -} - -export const useMnemonicSafeName = (): string => { - const networkName = useCurrentChain()?.chainName - return useMnemonicName(`${networkName} Safe`) +export const useMnemonicSafeName = (multiChain?: boolean): string => { + const currentNetwork = useCurrentChain()?.chainName + const adjective = useMemo(() => getRandomAdjective(), []) + return `${adjective} ${multiChain ? 'Multi-Chain' : currentNetwork} Safe` } diff --git a/src/hooks/useMnemonicName/useMnemonicName.test.ts b/src/hooks/useMnemonicName/useMnemonicName.test.ts index 7dbb3bc3dc..783f0aad9e 100644 --- a/src/hooks/useMnemonicName/useMnemonicName.test.ts +++ b/src/hooks/useMnemonicName/useMnemonicName.test.ts @@ -1,4 +1,4 @@ -import { getRandomName, useMnemonicName, useMnemonicSafeName } from '.' +import { getRandomAdjective, useMnemonicSafeName } from '.' import { renderHook } from '@/tests/test-utils' import { chainBuilder } from '@/tests/builders/chains' @@ -11,34 +11,20 @@ jest.mock('@/hooks/useChains', () => ({ describe('useMnemonicName tests', () => { it('should generate a random name', () => { - expect(getRandomName()).toMatch(/^[A-Z][a-z-]+ [A-Z][a-z]+$/) - expect(getRandomName()).toMatch(/^[A-Z][a-z-]+ [A-Z][a-z]+$/) - expect(getRandomName()).toMatch(/^[A-Z][a-z-]+ [A-Z][a-z]+$/) + expect(getRandomAdjective()).toMatch(/^[A-Z][a-z-]+/) + expect(getRandomAdjective()).toMatch(/^[A-Z][a-z-]+/) + expect(getRandomAdjective()).toMatch(/^[A-Z][a-z-]+/) }) - it('should work as a hook', () => { - const { result } = renderHook(() => useMnemonicName()) - expect(result.current).toMatch(/^[A-Z][a-z-]+ [A-Z][a-z]+$/) - }) - - it('should work as a hook with a noun param', () => { - const { result } = renderHook(() => useMnemonicName('test')) - expect(result.current).toMatch(/^[A-Z][a-z-]+ test$/) - }) - - it('should change if the noun changes', () => { - let noun = 'test' - const { result, rerender } = renderHook(() => useMnemonicName(noun)) - expect(result.current).toMatch(/^[A-Z][a-z-]+ test$/) - - noun = 'changed' - rerender() - expect(result.current).toMatch(/^[A-Z][a-z-]+ changed$/) - }) - - it('should return a random safe name', () => { + it('should return a random safe name with current chain', () => { const { result } = renderHook(() => useMnemonicSafeName()) const regex = new RegExp(`^[A-Z][a-z-]+ ${mockChain.chainName} Safe$`) expect(result.current).toMatch(regex) }) + + it('should return a random safe name indicating a multichain safe', () => { + const { result } = renderHook(() => useMnemonicSafeName(true)) + const regex = new RegExp(`^[A-Z][a-z-]+ Multi-Chain Safe$`) + expect(result.current).toMatch(regex) + }) }) diff --git a/src/hooks/useSafeNotifications.ts b/src/hooks/useSafeNotifications.ts index ccde77d687..0797a7a730 100644 --- a/src/hooks/useSafeNotifications.ts +++ b/src/hooks/useSafeNotifications.ts @@ -4,7 +4,7 @@ import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript import useSafeInfo from './useSafeInfo' import { useAppDispatch } from '@/store' import { AppRoutes } from '@/config/routes' -import { isValidMasterCopy } from '@/services/contracts/safeContracts' +import { isMigrationToL2Possible, isValidMasterCopy } from '@/services/contracts/safeContracts' import { useRouter } from 'next/router' import useIsSafeOwner from './useIsSafeOwner' import { isValidSafeVersion } from './coreSDK/safeCoreSDK' @@ -131,25 +131,31 @@ const useSafeNotifications = (): void => { /** * Show a notification when the Safe master copy is not supported */ - useEffect(() => { if (isValidMasterCopy(safe.implementationVersionState)) return + const isMigrationPossible = isMigrationToL2Possible(safe) + + const message = isMigrationPossible + ? `This Safe Account was created with an unsupported base contract. + It is possible to migrate it to a compatible base contract. This migration will be automatically included with your first transaction.` + : `This Safe Account was created with an unsupported base contract. + The web interface might not work correctly. + We recommend using the command line interface instead.` + const id = dispatch( showNotification({ - variant: 'warning', - message: `This Safe Account was created with an unsupported base contract. - The web interface might not work correctly. - We recommend using the command line interface instead.`, + variant: isMigrationPossible ? 'info' : 'warning', + message, groupKey: 'invalid-mastercopy', - link: CLI_LINK, + link: isMigrationPossible ? undefined : CLI_LINK, }), ) return () => { dispatch(closeNotification({ id })) } - }, [dispatch, safe.implementationVersionState]) + }, [dispatch, safe, safe.implementationVersionState]) } export default useSafeNotifications diff --git a/src/hooks/useSanctionedAddress.ts b/src/hooks/useSanctionedAddress.ts index ee6257e57a..ac198ea26f 100644 --- a/src/hooks/useSanctionedAddress.ts +++ b/src/hooks/useSanctionedAddress.ts @@ -1,4 +1,4 @@ -import { useGetIsSanctionedQuery } from '@/store/ofac' +import { useGetIsSanctionedQuery } from '@/store/api/ofac' import useSafeAddress from './useSafeAddress' import useWallet from './wallets/useWallet' import { skipToken } from '@reduxjs/toolkit/query/react' diff --git a/src/hooks/useTxNotifications.ts b/src/hooks/useTxNotifications.ts index 5311a8e8a6..c778e43d00 100644 --- a/src/hooks/useTxNotifications.ts +++ b/src/hooks/useTxNotifications.ts @@ -14,7 +14,7 @@ import useSafeAddress from './useSafeAddress' import { getExplorerLink } from '@/utils/gateway' import { isWalletRejection } from '@/utils/wallets' import { getTxLink } from '@/utils/tx-link' -import { useLazyGetTransactionDetailsQuery } from '@/store/gateway' +import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' const TxNotifications = { [TxEvent.SIGN_FAILED]: 'Failed to sign. Please try again.', diff --git a/src/hooks/useTxTracking.ts b/src/hooks/useTxTracking.ts index 44a1e570dd..836bb81be9 100644 --- a/src/hooks/useTxTracking.ts +++ b/src/hooks/useTxTracking.ts @@ -2,7 +2,7 @@ import { trackEvent, WALLET_EVENTS } from '@/services/analytics' import { TxEvent, txSubscribe } from '@/services/tx/txEvents' import { useEffect } from 'react' import useChainId from './useChainId' -import { useLazyGetTransactionDetailsQuery } from '@/store/gateway' +import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' const events = { [TxEvent.SIGNED]: WALLET_EVENTS.OFFCHAIN_SIGNATURE, diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 40b210f09b..2fa0067620 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -8,6 +8,7 @@ import CssBaseline from '@mui/material/CssBaseline' import type { Theme } from '@mui/material/styles' import { ThemeProvider } from '@mui/material/styles' import { setBaseUrl as setGatewayBaseUrl } from '@safe-global/safe-gateway-typescript-sdk' +import { setBaseUrl as setNewGatewayBaseUrl } from 'safe-client-gateway-sdk' import { CacheProvider, type EmotionCache } from '@emotion/react' import SafeThemeProvider from '@/components/theme/SafeThemeProvider' import '@/styles/globals.css' @@ -51,6 +52,7 @@ const reduxStore = makeStore() const InitApp = (): null => { setGatewayBaseUrl(GATEWAY_URL) + setNewGatewayBaseUrl(GATEWAY_URL) useHydrateStore(reduxStore) useAdjustUrl() useGtm() diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index c6d5152f8f..e8b0babe2a 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -31,6 +31,18 @@ export const OVERVIEW_EVENTS = { action: 'Remove from watchlist', category: OVERVIEW_CATEGORY, }, + ADD_NEW_NETWORK: { + action: 'Add new network', + category: OVERVIEW_CATEGORY, + }, + SUBMIT_ADD_NEW_NETWORK: { + action: 'Submit add new network', + category: OVERVIEW_CATEGORY, + }, + CANCEL_ADD_NEW_NETWORK: { + action: 'Cancel add new network', + category: OVERVIEW_CATEGORY, + }, DELETED_FROM_WATCHLIST: { action: 'Deleted from watchlist', category: OVERVIEW_CATEGORY, @@ -117,6 +129,16 @@ export const OVERVIEW_EVENTS = { category: OVERVIEW_CATEGORY, //label: OPEN_SAFE_LABELS }, + // Track clicks on links to Safe Accounts + EXPAND_MULTI_SAFE: { + action: 'Expand multi Safe', + category: OVERVIEW_CATEGORY, + //label: OPEN_SAFE_LABELS + }, + SHOW_ALL_NETWORKS: { + action: 'Show all networks', + category: OVERVIEW_CATEGORY, + }, // Track actual Safe views SAFE_VIEWED: { event: EventType.SAFE_OPENED, diff --git a/src/services/contracts/__tests__/safeContracts.test.ts b/src/services/contracts/__tests__/safeContracts.test.ts index 6e269bdc85..d81dcc21c1 100644 --- a/src/services/contracts/__tests__/safeContracts.test.ts +++ b/src/services/contracts/__tests__/safeContracts.test.ts @@ -1,5 +1,11 @@ import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' -import { _getValidatedGetContractProps, isValidMasterCopy, _getMinimumMultiSendCallOnlyVersion } from '../safeContracts' +import { + _getValidatedGetContractProps, + isValidMasterCopy, + _getMinimumMultiSendCallOnlyVersion, + isMigrationToL2Possible, +} from '../safeContracts' +import { safeInfoBuilder } from '@/tests/builders/safe' describe('safeContracts', () => { describe('isValidMasterCopy', () => { @@ -63,4 +69,18 @@ describe('safeContracts', () => { expect(_getMinimumMultiSendCallOnlyVersion('1.4.1')).toBe('1.4.1') }) }) + + describe('isMigrationToL2Possible', () => { + it('should not be possible to migrate Safes on chains without migration lib', () => { + expect(isMigrationToL2Possible(safeInfoBuilder().with({ nonce: 0, chainId: '69420' }).build())).toBeFalsy() + }) + + it('should not be possible to migrate Safes with nonce > 0', () => { + expect(isMigrationToL2Possible(safeInfoBuilder().with({ nonce: 2, chainId: '10' }).build())).toBeFalsy() + }) + + it('should be possible to migrate Safes with nonce 0 on chains with migration lib', () => { + expect(isMigrationToL2Possible(safeInfoBuilder().with({ nonce: 0, chainId: '10' }).build())).toBeTruthy() + }) + }) }) diff --git a/src/services/contracts/deployments.ts b/src/services/contracts/deployments.ts index cfec7b48bc..ad454b084a 100644 --- a/src/services/contracts/deployments.ts +++ b/src/services/contracts/deployments.ts @@ -3,14 +3,56 @@ import { getSafeSingletonDeployment, getSafeL2SingletonDeployment, getMultiSendCallOnlyDeployment, + getMultiSendDeployment, getFallbackHandlerDeployment, getProxyFactoryDeployment, getSignMessageLibDeployment, getCreateCallDeployment, } from '@safe-global/safe-deployments' -import type { SingletonDeployment, DeploymentFilter } from '@safe-global/safe-deployments' +import type { SingletonDeployment, DeploymentFilter, SingletonDeploymentV2 } from '@safe-global/safe-deployments' import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { getLatestSafeVersion } from '@/utils/chains' +import { sameAddress } from '@/utils/addresses' +import { type SafeVersion } from '@safe-global/safe-core-sdk-types' + +const toNetworkAddressList = (addresses: string | string[]) => (Array.isArray(addresses) ? addresses : [addresses]) + +export const hasCanonicalDeployment = (deployment: SingletonDeploymentV2 | undefined, chainId: string) => { + const canonicalAddress = deployment?.deployments.canonical?.address + + if (!canonicalAddress) { + return false + } + + const networkAddresses = toNetworkAddressList(deployment.networkAddresses[chainId]) + + return networkAddresses.some((networkAddress) => sameAddress(canonicalAddress, networkAddress)) +} + +/** + * Checks if any of the deployments returned by the `getDeployments` function for the given `network` and `versions` contain a deployment for the `contractAddress` + * + * @param getDeployments function to get the contract deployments + * @param contractAddress address that should be included in the deployments + * @param network chainId that is getting checked + * @param versions supported Safe versions + * @returns true if a matching deployment was found + */ +export const hasMatchingDeployment = ( + getDeployments: (filter?: DeploymentFilter) => SingletonDeploymentV2 | undefined, + contractAddress: string, + network: string, + versions: SafeVersion[], +): boolean => { + return versions.some((version) => { + const deployments = getDeployments({ version, network }) + if (!deployments) { + return false + } + const deployedAddresses = toNetworkAddressList(deployments.networkAddresses[network] ?? []) + return deployedAddresses.some((deployedAddress) => sameAddress(deployedAddress, contractAddress)) + }) +} export const _tryDeploymentVersions = ( getDeployment: (filter?: DeploymentFilter) => SingletonDeployment | undefined, @@ -68,6 +110,10 @@ export const getMultiSendCallOnlyContractDeployment = (chain: ChainInfo, safeVer return _tryDeploymentVersions(getMultiSendCallOnlyDeployment, chain, safeVersion) } +export const getMultiSendContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { + return _tryDeploymentVersions(getMultiSendDeployment, chain, safeVersion) +} + export const getFallbackHandlerContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { return _tryDeploymentVersions(getFallbackHandlerDeployment, chain, safeVersion) } diff --git a/src/services/contracts/safeContracts.ts b/src/services/contracts/safeContracts.ts index 0352409483..8a545fae69 100644 --- a/src/services/contracts/safeContracts.ts +++ b/src/services/contracts/safeContracts.ts @@ -15,6 +15,7 @@ import type { SafeVersion } from '@safe-global/safe-core-sdk-types' import { assertValidSafeVersion, getSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import semver from 'semver' import { getLatestSafeVersion } from '@/utils/chains' +import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' // `UNKNOWN` is returned if the mastercopy does not match supported ones // @see https://github.com/safe-global/safe-client-gateway/blob/main/src/routes/safes/handlers/safes.rs#L28-L31 @@ -23,6 +24,13 @@ export const isValidMasterCopy = (implementationVersionState: SafeInfo['implemen return implementationVersionState !== ImplementationVersionState.UNKNOWN } +export const isMigrationToL2Possible = (safe: SafeInfo): boolean => { + return ( + safe.nonce === 0 && + Boolean(getSafeToL2MigrationDeployment({ network: safe.chainId })?.networkAddresses[safe.chainId]) + ) +} + export const _getValidatedGetContractProps = ( safeVersion: SafeInfo['version'], ): Pick => { @@ -64,12 +72,16 @@ export const getCurrentGnosisSafeContract = async (safe: SafeInfo, provider: str return getGnosisSafeContract(safe, safeProvider) } -export const getReadOnlyGnosisSafeContract = async (chain: ChainInfo, safeVersion: SafeInfo['version']) => { +export const getReadOnlyGnosisSafeContract = async ( + chain: ChainInfo, + safeVersion: SafeInfo['version'], + isL1?: boolean, +) => { const version = safeVersion ?? getLatestSafeVersion(chain) const safeProvider = getSafeProvider() - const isL1SafeSingleton = !_isL2(chain, _getValidatedGetContractProps(version).safeVersion) + const isL1SafeSingleton = isL1 ?? !_isL2(chain, _getValidatedGetContractProps(version).safeVersion) return getSafeContractInstance( _getValidatedGetContractProps(version).safeVersion, @@ -105,13 +117,14 @@ export const getReadOnlyMultiSendCallOnlyContract = async (safeVersion: SafeInfo // GnosisSafeProxyFactory -export const getReadOnlyProxyFactoryContract = async (safeVersion: SafeInfo['version']) => { +export const getReadOnlyProxyFactoryContract = async (safeVersion: SafeInfo['version'], contractAddress?: string) => { const safeProvider = getSafeProvider() return getSafeProxyFactoryContractInstance( _getValidatedGetContractProps(safeVersion).safeVersion, safeProvider, safeProvider.getExternalProvider(), + contractAddress, ) } diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts index 59f1dbb7c7..3a6e098b0e 100644 --- a/src/services/exceptions/ErrorCodes.ts +++ b/src/services/exceptions/ErrorCodes.ts @@ -69,6 +69,7 @@ enum ErrorCodes { _813 = '813: Failed to cancel recovery', _814 = '814: Failed to speed up transaction', _815 = '815: Error executing a transaction through a role', + _816 = '816: Error computing replay Safe creation data', _900 = '900: Error loading Safe App', _901 = '901: Error processing Safe Apps SDK request', diff --git a/src/services/security/modules/ApprovalModule/index.ts b/src/services/security/modules/ApprovalModule/index.ts index 747f8ab777..d513507b0c 100644 --- a/src/services/security/modules/ApprovalModule/index.ts +++ b/src/services/security/modules/ApprovalModule/index.ts @@ -3,12 +3,12 @@ import { INCREASE_ALLOWANCE_SIGNATURE_HASH, } from '@/components/tx/ApprovalEditor/utils/approvals' import { ERC20__factory } from '@/types/contracts' -import { decodeMultiSendTxs } from '@/utils/transactions' import { normalizeTypedData } from '@/utils/web3' import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' import { type EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' import { id } from 'ethers' import { type SecurityResponse, type SecurityModule, SecuritySeverity } from '../types' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' export type ApprovalModuleResponse = Approval[] @@ -120,7 +120,7 @@ export class ApprovalModule implements SecurityModule ApprovalModule.scanInnerTransaction(tx, index))) } else { approvalInfos.push(...ApprovalModule.scanInnerTransaction({ to: safeTransaction.data.to, data: safeTxData }, 0)) diff --git a/src/services/security/modules/RecipientAddressModule/index.test.ts b/src/services/security/modules/RecipientAddressModule/index.test.ts deleted file mode 100644 index b8aef115bb..0000000000 --- a/src/services/security/modules/RecipientAddressModule/index.test.ts +++ /dev/null @@ -1,991 +0,0 @@ -import * as sdk from '@safe-global/safe-gateway-typescript-sdk' -import { OperationType } from '@safe-global/safe-core-sdk-types' -import { toBeHex } from 'ethers' -import type { JsonRpcProvider } from 'ethers' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' - -import * as walletUtils from '@/utils/wallets' -import { RecipientAddressModule } from '.' -import { - createMockSafeTransaction, - getMockErc20TransferCalldata, - getMockErc721TransferFromCalldata, - getMockErc721SafeTransferFromCalldata, - getMockErc721SafeTransferFromWithBytesCalldata, - getMockMultiSendCalldata, -} from '@/tests/transactions' - -describe('RecipientAddressModule', () => { - const isSmartContractSpy = jest.spyOn(walletUtils, 'isSmartContract') - - const mockGetBalance = jest.fn() - const mockProvider = { - getBalance: mockGetBalance, - } as unknown as JsonRpcProvider - - const mockGetSafeInfo = jest.spyOn(sdk, 'getSafeInfo') - - beforeEach(() => { - jest.clearAllMocks() - }) - - const RecipientAddressModuleInstance = new RecipientAddressModule() - - it('should not warn if the address(es) is/are known', async () => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.reject('Safe not found')) - - const recipient = toBeHex('0x1', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [recipient], - }) - - expect(result).toEqual({ - severity: 0, - }) - - // Don't check further if the recipient is known - expect(isSmartContractSpy).not.toHaveBeenCalled() - }) - - describe('it should warn if the address(es) is/are not known', () => { - beforeEach(() => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.reject('Safe not found')) - }) - - // ERC-20 - it('should warn about recipient of ERC-20 transfer recipients', async () => { - const erc20 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc20TransferCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc20, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - // ERC-721 - it('should warn about recipient of ERC-721 transferFrom recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721TransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256,bytes) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromWithBytesCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - // multiSend - it('should warn about recipient(s) of multiSend recipients', async () => { - const multiSend = toBeHex('0x01', 20) - - const recipient1 = toBeHex('0x02', 20) - const recipient2 = toBeHex('0x03', 20) - - const data = getMockMultiSendCalldata([recipient1, recipient2]) - - const safeTransaction = createMockSafeTransaction({ - to: multiSend, - data, - operation: OperationType.DelegateCall, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(2) - expect(mockGetBalance).toHaveBeenCalledTimes(2) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient1, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient2, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - // Other - // Covered in test below: "should warn about recipient of native transfer recipients / should not warn if the address(es) is/are used" - }) - - it('should warn about recipient of native transfer recipients / should not warn if the address(es) is/are used', async () => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.reject('Safe not found')) - - const recipient = toBeHex('0x01', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - describe('it should warn if the address(es) is/are unused', () => { - beforeEach(() => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(0n)) - mockGetSafeInfo.mockImplementation(() => Promise.reject('Safe not found')) - }) - - // ERC-20 - it('should warn about recipient of ERC-20 transfer recipients', async () => { - const erc20 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc20TransferCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc20, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - // ERC-721 - it('should warn about recipient of ERC-721 transferFrom recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721TransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256,bytes) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromWithBytesCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - // multiSend - it('should warn about recipient(s) of multiSend recipients', async () => { - const multiSend = toBeHex('0x01', 20) - - const recipient1 = toBeHex('0x02', 20) - const recipient2 = toBeHex('0x03', 20) - - const data = getMockMultiSendCalldata([recipient1, recipient2]) - - const safeTransaction = createMockSafeTransaction({ - to: multiSend, - data, - operation: OperationType.DelegateCall, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(2) - expect(mockGetBalance).toHaveBeenCalledTimes(2) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient1, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient1, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - { - severity: 1, - address: recipient2, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient2, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - // Other - it('should warn about recipient of native transfer recipients', async () => { - const recipient = toBeHex('0x01', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - }) - - it('should not warn if the address(s) is/are Safe(s) deployed on the current network', async () => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.resolve({} as SafeInfo)) - - const recipient = toBeHex('0x01', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - describe('it should warn if the address(es) is/are Safe(s) deployed on mainnet but not the current network', () => { - beforeEach(() => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.resolve({} as SafeInfo)) - }) - - // ERC-20 - it('should warn about recipient of ERC-20 transfer recipients', async () => { - const erc20 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc20TransferCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc20, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - // ERC-721 - it('should warn about recipient of ERC-721 transferFrom recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721TransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256,bytes) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromWithBytesCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - // multiSend - it('should warn about recipient(s) of multiSend recipients', async () => { - const multiSend = toBeHex('0x01', 20) - - const recipient1 = toBeHex('0x02', 20) - const recipient2 = toBeHex('0x03', 20) - - const data = getMockMultiSendCalldata([recipient1, recipient2]) - - const safeTransaction = createMockSafeTransaction({ - to: multiSend, - data, - operation: OperationType.DelegateCall, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(2) - expect(mockGetBalance).toHaveBeenCalledTimes(2) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(2) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient1, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient1, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - { - severity: 1, - address: recipient2, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient2, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - // Other - it('should warn about recipient of native transfer recipients', async () => { - const recipient = toBeHex('0x01', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - }) -}) diff --git a/src/services/security/modules/RecipientAddressModule/index.ts b/src/services/security/modules/RecipientAddressModule/index.ts deleted file mode 100644 index 77d4f898a6..0000000000 --- a/src/services/security/modules/RecipientAddressModule/index.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import type { JsonRpcProvider } from 'ethers' - -import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { isSmartContract } from '@/utils/wallets' -import { sameAddress } from '@/utils/addresses' -import { getTransactionRecipients } from '@/utils/transaction-calldata' -import { SecuritySeverity } from '../types' -import type { SecurityResponse, SecurityModule } from '../types' - -type RecipientAddressModuleWarning = { - severity: SecuritySeverity - type: RecipietAddressIssueType - address: string - description: { - short: string - long: string - } -} - -export type RecipientAddressModuleResponse = Array - -export type RecipientAddressModuleRequest = { - knownAddresses: string[] - safeTransaction: SafeTransaction - provider: JsonRpcProvider - chainId: string -} - -export const enum RecipietAddressIssueType { - UNKNOWN_ADDRESS = 'UNKNOWN_ADDRESS', - UNUSED_ADDRESS = 'UNUSED_ADDRESS', - SAFE_ON_WRONG_CHAIN = 'SAFE_ON_WRONG_CHAIN', -} - -const MAINNET_CHAIN_ID = '1' - -export class RecipientAddressModule - implements SecurityModule -{ - private isKnownAddress(knownAddresses: string[], address: string): boolean { - return knownAddresses.some((knownAddress) => sameAddress(knownAddress, address)) - } - - private async shouldWarnOfMainnetSafe(currentChainId: string, address: string): Promise { - // We only check if the address is a Safe on mainnet to reduce the number of requests - if (currentChainId === MAINNET_CHAIN_ID) { - return false - } - - try { - await getSafeInfo(MAINNET_CHAIN_ID, address) - return true - } catch { - return false - } - } - - private async checkAddress( - chainId: string, - knownAddresses: Array, - address: string, - provider: JsonRpcProvider, - ): Promise> { - const warnings: Array = [] - - if (this.isKnownAddress(knownAddresses, address)) { - return warnings - } - - if (await isSmartContract(address)) { - return warnings - } - - warnings.push({ - severity: SecuritySeverity.LOW, - address, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: RecipietAddressIssueType.UNKNOWN_ADDRESS, - }) - - const [balance, shouldWarnOfMainnetSafe] = await Promise.all([ - provider.getBalance(address), - this.shouldWarnOfMainnetSafe(chainId, address), - ]) - - if (balance === 0n) { - warnings.push({ - severity: SecuritySeverity.LOW, - address, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: RecipietAddressIssueType.UNUSED_ADDRESS, - }) - } - - if (shouldWarnOfMainnetSafe) { - warnings.push({ - severity: SecuritySeverity.HIGH, - address, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: RecipietAddressIssueType.SAFE_ON_WRONG_CHAIN, - }) - } - - return warnings - } - - async scanTransaction( - request: RecipientAddressModuleRequest, - ): Promise> { - const { safeTransaction, provider, chainId, knownAddresses } = request - - const uniqueRecipients = Array.from(new Set(getTransactionRecipients(safeTransaction.data))) - - const warnings = ( - await Promise.all( - uniqueRecipients.map((address) => this.checkAddress(chainId, knownAddresses, address, provider)), - ) - ).flat() - - if (warnings.length === 0) { - return { - severity: SecuritySeverity.NONE, - } - } - - const severity = Math.max(...warnings.map((warning) => warning.severity)) - - return { - severity, - payload: warnings, - } - } -} diff --git a/src/services/tx/tx-sender/create.ts b/src/services/tx/tx-sender/create.ts index 4d348a0356..7152601bde 100644 --- a/src/services/tx/tx-sender/create.ts +++ b/src/services/tx/tx-sender/create.ts @@ -25,6 +25,17 @@ export const createMultiSendCallOnlyTx = async (txParams: MetaTransactionData[]) return safeSDK.createTransaction({ transactions: txParams, onlyCalls: true }) } +/** + * Create a multiSend transaction from an array of MetaTransactionData and options + * If only one tx is passed it will be created without multiSend and without onlyCalls. + * + * This function can create delegateCalls, which is usually not necessary + */ +export const __unsafe_createMultiSendTx = async (txParams: MetaTransactionData[]): Promise => { + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createTransaction({ transactions: txParams, onlyCalls: false }) +} + export const createRemoveOwnerTx = async (txParams: RemoveOwnerTxParams): Promise => { const safeSDK = getAndValidateSafeSDK() return safeSDK.createRemoveOwnerTx(txParams) diff --git a/src/store/__tests__/addressBookSlice.test.ts b/src/store/__tests__/addressBookSlice.test.ts index d72cc69d2b..5c1be3a222 100644 --- a/src/store/__tests__/addressBookSlice.test.ts +++ b/src/store/__tests__/addressBookSlice.test.ts @@ -1,9 +1,10 @@ +import { faker } from '@faker-js/faker' import { addressBookSlice, setAddressBook, - upsertAddressBookEntry, removeAddressBookEntry, selectAddressBookByChain, + upsertAddressBookEntries, } from '../addressBookSlice' const initialState = { @@ -20,8 +21,8 @@ describe('addressBookSlice', () => { it('should insert an entry in the address book', () => { const state = addressBookSlice.reducer( initialState, - upsertAddressBookEntry({ - chainId: '1', + upsertAddressBookEntries({ + chainIds: ['1'], address: '0x2', name: 'Fred', }), @@ -35,8 +36,8 @@ describe('addressBookSlice', () => { it('should ignore empty names in the address book', () => { const state = addressBookSlice.reducer( initialState, - upsertAddressBookEntry({ - chainId: '1', + upsertAddressBookEntries({ + chainIds: ['1'], address: '0x2', name: '', }), @@ -47,8 +48,8 @@ describe('addressBookSlice', () => { it('should edit an entry in the address book', () => { const state = addressBookSlice.reducer( initialState, - upsertAddressBookEntry({ - chainId: '1', + upsertAddressBookEntries({ + chainIds: ['1'], address: '0x0', name: 'Alice in Wonderland', }), @@ -59,6 +60,38 @@ describe('addressBookSlice', () => { }) }) + it('should insert an multichain entry in the address book', () => { + const address = faker.finance.ethereumAddress() + const state = addressBookSlice.reducer( + initialState, + upsertAddressBookEntries({ + chainIds: ['1', '10', '100', '137'], + address, + name: 'Max', + }), + ) + expect(state).toEqual({ + '1': { '0x0': 'Alice', '0x1': 'Bob', [address]: 'Max' }, + '4': { '0x0': 'Charlie', '0x1': 'Dave' }, + '10': { [address]: 'Max' }, + '100': { [address]: 'Max' }, + '137': { [address]: 'Max' }, + }) + }) + + it('should ignore empty names for multichain entries', () => { + const address = faker.finance.ethereumAddress() + const state = addressBookSlice.reducer( + initialState, + upsertAddressBookEntries({ + chainIds: ['1', '10', '100', '137'], + address, + name: '', + }), + ) + expect(state).toEqual(initialState) + }) + it('should remove an entry from the address book', () => { const stateB = addressBookSlice.reducer( initialState, diff --git a/src/store/__tests__/safeOverviews.test.ts b/src/store/__tests__/safeOverviews.test.ts new file mode 100644 index 0000000000..45baf52bfe --- /dev/null +++ b/src/store/__tests__/safeOverviews.test.ts @@ -0,0 +1,356 @@ +import { renderHook, waitFor } from '@/tests/test-utils' +import { useGetMultipleSafeOverviewsQuery, useGetSafeOverviewQuery } from '../api/gateway' +import { faker } from '@faker-js/faker' +import { getSafeOverviews } from '@safe-global/safe-gateway-typescript-sdk' + +jest.mock('@safe-global/safe-gateway-typescript-sdk') + +describe('safeOverviews', () => { + const mockedGetSafeOverviews = getSafeOverviews as jest.MockedFunction + + beforeEach(() => { + jest.resetAllMocks() + }) + + describe('useGetSafeOverviewQuery', () => { + it('should return null for empty safe Address', async () => { + const request = { chainId: '1', safeAddress: '' } + const { result } = renderHook(() => useGetSafeOverviewQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeUndefined() + expect(result.current.data).toBeNull() + }) + + expect(mockedGetSafeOverviews).not.toHaveBeenCalled() + }) + + it('should return an error if fetching fails', async () => { + const request = { chainId: '1', safeAddress: faker.finance.ethereumAddress() } + mockedGetSafeOverviews.mockRejectedValueOnce('Service unavailable') + + const { result } = renderHook(() => useGetSafeOverviewQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeDefined() + expect(result.current.data).toBeUndefined() + }) + }) + + it('should return null if safeOverview is not found for a given Safe', async () => { + const request = { chainId: '1', safeAddress: faker.finance.ethereumAddress() } + mockedGetSafeOverviews.mockResolvedValueOnce([]) + + const { result } = renderHook(() => useGetSafeOverviewQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await Promise.resolve() + + await waitFor(() => { + expect(mockedGetSafeOverviews).toHaveBeenCalled() + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeUndefined() + expect(result.current.data).toEqual(null) + }) + }) + + it('should return the Safe overview if fetching is successful', async () => { + const request = { chainId: '1', safeAddress: faker.finance.ethereumAddress() } + + const mockOverview = { + address: { value: request.safeAddress }, + chainId: '1', + awaitingConfirmation: null, + fiatTotal: '100', + owners: [{ value: faker.finance.ethereumAddress() }], + threshold: 1, + queued: 0, + } + mockedGetSafeOverviews.mockResolvedValueOnce([mockOverview]) + + const { result } = renderHook(() => useGetSafeOverviewQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await Promise.resolve() + + await waitFor(() => { + expect(mockedGetSafeOverviews).toHaveBeenCalled() + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeUndefined() + expect(result.current.data).toEqual(mockOverview) + }) + }) + + it('should immediately process queue if BATCH SIZE elements are queued', async () => { + const fakeSafeAddress = faker.finance.ethereumAddress() + const requests = [ + { chainId: '1', safeAddress: fakeSafeAddress }, + { chainId: '2', safeAddress: fakeSafeAddress }, + { chainId: '3', safeAddress: fakeSafeAddress }, + { chainId: '4', safeAddress: fakeSafeAddress }, + { chainId: '5', safeAddress: fakeSafeAddress }, + { chainId: '6', safeAddress: fakeSafeAddress }, + { chainId: '7', safeAddress: fakeSafeAddress }, + { chainId: '8', safeAddress: fakeSafeAddress }, + { chainId: '9', safeAddress: fakeSafeAddress }, + { chainId: '10', safeAddress: fakeSafeAddress }, + ] + + const mockOverviews = requests.map((request, idx) => ({ + address: { value: request.safeAddress }, + chainId: (idx + 1).toString(), + awaitingConfirmation: null, + fiatTotal: '100', + owners: [{ value: faker.finance.ethereumAddress() }], + threshold: 1, + queued: 0, + })) + + mockedGetSafeOverviews.mockResolvedValueOnce(mockOverviews) + + const { result: result0 } = renderHook(() => useGetSafeOverviewQuery(requests[0])) + const { result: result1 } = renderHook(() => useGetSafeOverviewQuery(requests[1])) + const { result: result2 } = renderHook(() => useGetSafeOverviewQuery(requests[2])) + const { result: result3 } = renderHook(() => useGetSafeOverviewQuery(requests[3])) + const { result: result4 } = renderHook(() => useGetSafeOverviewQuery(requests[4])) + const { result: result5 } = renderHook(() => useGetSafeOverviewQuery(requests[5])) + const { result: result6 } = renderHook(() => useGetSafeOverviewQuery(requests[6])) + const { result: result7 } = renderHook(() => useGetSafeOverviewQuery(requests[7])) + const { result: result8 } = renderHook(() => useGetSafeOverviewQuery(requests[8])) + + // After 9 requests they should all be loading + expect(result0.current.isLoading).toBeTruthy() + expect(result1.current.isLoading).toBeTruthy() + expect(result2.current.isLoading).toBeTruthy() + expect(result3.current.isLoading).toBeTruthy() + expect(result4.current.isLoading).toBeTruthy() + expect(result5.current.isLoading).toBeTruthy() + expect(result6.current.isLoading).toBeTruthy() + expect(result7.current.isLoading).toBeTruthy() + expect(result8.current.isLoading).toBeTruthy() + + expect(mockedGetSafeOverviews).not.toHaveBeenCalled() + + // Trigger the 10th hook - causing all values to load + const { result: result9 } = renderHook(() => useGetSafeOverviewQuery(requests[9])) + + await waitFor(() => { + // Wait until they all resolve + expect(result0.current.isLoading).toBeFalsy() + expect(result1.current.isLoading).toBeFalsy() + expect(result2.current.isLoading).toBeFalsy() + expect(result3.current.isLoading).toBeFalsy() + expect(result4.current.isLoading).toBeFalsy() + expect(result5.current.isLoading).toBeFalsy() + expect(result6.current.isLoading).toBeFalsy() + expect(result7.current.isLoading).toBeFalsy() + expect(result8.current.isLoading).toBeFalsy() + expect(result9.current.isLoading).toBeFalsy() + + // One request that batched all requests together should have happened + expect(mockedGetSafeOverviews).toHaveBeenCalledWith( + [ + `1:${fakeSafeAddress}`, + `2:${fakeSafeAddress}`, + `3:${fakeSafeAddress}`, + `4:${fakeSafeAddress}`, + `5:${fakeSafeAddress}`, + `6:${fakeSafeAddress}`, + `7:${fakeSafeAddress}`, + `8:${fakeSafeAddress}`, + `9:${fakeSafeAddress}`, + `10:${fakeSafeAddress}`, + ], + { + currency: 'usd', + trusted: true, + exclude_spam: true, + }, + ) + + expect(result0.current.data).toEqual(mockOverviews[0]) + expect(result1.current.data).toEqual(mockOverviews[1]) + expect(result2.current.data).toEqual(mockOverviews[2]) + expect(result3.current.data).toEqual(mockOverviews[3]) + expect(result4.current.data).toEqual(mockOverviews[4]) + expect(result5.current.data).toEqual(mockOverviews[5]) + expect(result6.current.data).toEqual(mockOverviews[6]) + expect(result7.current.data).toEqual(mockOverviews[7]) + expect(result8.current.data).toEqual(mockOverviews[8]) + expect(result9.current.data).toEqual(mockOverviews[9]) + }) + }) + }) + + describe('useGetMultipleSafeOverviewsQuery', () => { + it('Should return empty list for empty list of Safes', async () => { + const request = { currency: 'usd', safes: [] } + + const { result } = renderHook(() => useGetMultipleSafeOverviewsQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await Promise.resolve() + await Promise.resolve() + + await Promise.resolve() + await Promise.resolve() + + await waitFor(() => { + expect(result.current.error).toBeUndefined() + expect(result.current.data).toEqual([]) + expect(result.current.isLoading).toBeFalsy() + }) + }) + + it('Should return a response for non-empty list', async () => { + const request = { + currency: 'usd', + safes: [ + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '10', isWatchlist: false }, + ], + } + + const mockOverview1 = { + address: { value: request.safes[0].address }, + chainId: '1', + awaitingConfirmation: null, + fiatTotal: '100', + owners: [{ value: faker.finance.ethereumAddress() }], + threshold: 1, + queued: 0, + } + + const mockOverview2 = { + address: { value: request.safes[1].address }, + chainId: '10', + awaitingConfirmation: null, + fiatTotal: '200', + owners: [{ value: faker.finance.ethereumAddress() }], + threshold: 1, + queued: 4, + } + + mockedGetSafeOverviews.mockResolvedValueOnce([mockOverview1, mockOverview2]) + + const { result } = renderHook(() => useGetMultipleSafeOverviewsQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeUndefined() + expect(result.current.data).toEqual([mockOverview1, mockOverview2]) + }) + }) + + it('Should return an error if fetching fails', async () => { + const request = { + currency: 'usd', + safes: [ + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '10', isWatchlist: false }, + ], + } + + mockedGetSafeOverviews.mockRejectedValueOnce('Not available') + + const { result } = renderHook(() => useGetMultipleSafeOverviewsQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await waitFor(async () => { + await Promise.resolve() + expect(result.current.error).toBeDefined() + expect(result.current.data).toBeUndefined() + expect(result.current.isLoading).toBeFalsy() + }) + }) + + it('Should split big batches into multiple requests', async () => { + // Requests overviews for 15 Safes at once + const request = { + currency: 'usd', + safes: [ + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + { address: faker.finance.ethereumAddress(), chainId: '1', isWatchlist: false }, + ], + } + + const firstBatchOverviews = request.safes.slice(0, 10).map((safe) => ({ + address: { value: safe.address }, + chainId: '1', + awaitingConfirmation: null, + fiatTotal: faker.string.numeric({ length: { min: 1, max: 6 } }), + owners: [{ value: faker.finance.ethereumAddress() }], + threshold: 1, + queued: 0, + })) + + const secondBatchOverviews = request.safes.slice(10).map((safe) => ({ + address: { value: safe.address }, + chainId: '1', + awaitingConfirmation: null, + fiatTotal: faker.string.numeric({ length: { min: 1, max: 6 } }), + owners: [{ value: faker.finance.ethereumAddress() }], + threshold: 1, + queued: 0, + })) + + // Mock two fetch requests for the 2 batches + mockedGetSafeOverviews.mockResolvedValueOnce(firstBatchOverviews).mockResolvedValueOnce(secondBatchOverviews) + + const { result } = renderHook(() => useGetMultipleSafeOverviewsQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeUndefined() + expect(result.current.data).toEqual([...firstBatchOverviews, ...secondBatchOverviews]) + }) + + // Expect that the correct requests were sent + expect(mockedGetSafeOverviews).toHaveBeenCalledTimes(2) + expect(mockedGetSafeOverviews).toHaveBeenCalledWith( + request.safes.slice(0, 10).map((safe) => `1:${safe.address}`), + { currency: 'usd', exclude_spam: true, trusted: true }, + ) + + expect(mockedGetSafeOverviews).toHaveBeenCalledWith( + request.safes.slice(10).map((safe) => `1:${safe.address}`), + { currency: 'usd', exclude_spam: true, trusted: true }, + ) + }) + }) +}) diff --git a/src/store/addedSafesSlice.ts b/src/store/addedSafesSlice.ts index 94d97c290a..eefde3d421 100644 --- a/src/store/addedSafesSlice.ts +++ b/src/store/addedSafesSlice.ts @@ -1,7 +1,6 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import type { AddressEx, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import type { RootState } from '.' -import { safeInfoSlice } from '@/store/safeInfoSlice' export type AddedSafesOnChain = { [safeAddress: string]: { @@ -17,10 +16,6 @@ export type AddedSafesState = { const initialState: AddedSafesState = {} -const isAddedSafe = (state: AddedSafesState, chainId: string, safeAddress: string) => { - return !!state[chainId]?.[safeAddress] -} - export const addedSafesSlice = createSlice({ name: 'addedSafes', initialState, @@ -55,22 +50,6 @@ export const addedSafesSlice = createSlice({ } }, }, - extraReducers(builder) { - builder.addCase(safeInfoSlice.actions.set, (state, { payload }) => { - if (!payload.data) { - return - } - - const { chainId, address } = payload.data - - if (isAddedSafe(state, chainId, address.value)) { - addedSafesSlice.caseReducers.addOrUpdateSafe(state, { - type: addOrUpdateSafe.type, - payload: { safe: payload.data }, - }) - } - }) - }, }) export const { addOrUpdateSafe, removeSafe } = addedSafesSlice.actions diff --git a/src/store/addressBookSlice.ts b/src/store/addressBookSlice.ts index 0dc2081e94..72c3a975d5 100644 --- a/src/store/addressBookSlice.ts +++ b/src/store/addressBookSlice.ts @@ -24,13 +24,15 @@ export const addressBookSlice = createSlice({ return action.payload }, - upsertAddressBookEntry: (state, action: PayloadAction<{ chainId: string; address: string; name: string }>) => { - const { chainId, address, name } = action.payload + upsertAddressBookEntries: (state, action: PayloadAction<{ chainIds: string[]; address: string; name: string }>) => { + const { chainIds, address, name } = action.payload if (name.trim() === '') { return } - if (!state[chainId]) state[chainId] = {} - state[chainId][address] = name + chainIds.forEach((chainId) => { + if (!state[chainId]) state[chainId] = {} + state[chainId][address] = name + }) }, removeAddressBookEntry: (state, action: PayloadAction<{ chainId: string; address: string }>) => { @@ -43,7 +45,7 @@ export const addressBookSlice = createSlice({ }, }) -export const { setAddressBook, upsertAddressBookEntry, removeAddressBookEntry } = addressBookSlice.actions +export const { setAddressBook, upsertAddressBookEntries, removeAddressBookEntry } = addressBookSlice.actions export const selectAllAddressBooks = (state: RootState): AddressBookState => { return state[addressBookSlice.name] diff --git a/src/store/gateway.ts b/src/store/api/gateway/index.ts similarity index 91% rename from src/store/gateway.ts rename to src/store/api/gateway/index.ts index acbfa95c8e..63fc8a4aba 100644 --- a/src/store/gateway.ts +++ b/src/store/api/gateway/index.ts @@ -4,6 +4,7 @@ import { getTransactionDetails, type TransactionDetails } from '@safe-global/saf import { asError } from '@/services/exceptions/utils' import { getDelegates } from '@safe-global/safe-gateway-typescript-sdk' import type { DelegateResponse } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' +import { safeOverviewEndpoints } from './safeOverviews' async function buildQueryFn(fn: () => Promise) { try { @@ -32,6 +33,7 @@ export const gatewayApi = createApi({ return buildQueryFn(() => getDelegates(chainId, { safe: safeAddress })) }, }), + ...safeOverviewEndpoints(builder), }), }) @@ -40,4 +42,6 @@ export const { useGetMultipleTransactionDetailsQuery, useLazyGetTransactionDetailsQuery, useGetDelegatesQuery, + useGetSafeOverviewQuery, + useGetMultipleSafeOverviewsQuery, } = gatewayApi diff --git a/src/store/api/gateway/safeOverviews.ts b/src/store/api/gateway/safeOverviews.ts new file mode 100644 index 0000000000..23eaf2d4ca --- /dev/null +++ b/src/store/api/gateway/safeOverviews.ts @@ -0,0 +1,156 @@ +import { type EndpointBuilder } from '@reduxjs/toolkit/query/react' + +import { type SafeOverview, getSafeOverviews } from '@safe-global/safe-gateway-typescript-sdk' +import { sameAddress } from '@/utils/addresses' +import type { RootState } from '../..' +import { selectCurrency } from '../../settingsSlice' +import { type SafeItem } from '@/components/welcome/MyAccounts/useAllSafes' +import { asError } from '@/services/exceptions/utils' + +type SafeOverviewQueueItem = { + safeAddress: string + walletAddress?: string + chainId: string + currency: string + callback: (result: { data: SafeOverview | undefined; error?: never } | { data?: never; error: string }) => void +} + +const _BATCH_SIZE = 10 +const _FETCH_TIMEOUT = 50 + +const makeSafeId = (chainId: string, address: string) => `${chainId}:${address}` as `${number}:0x${string}` + +class SafeOverviewFetcher { + private requestQueue: SafeOverviewQueueItem[] = [] + + private fetchTimeout: NodeJS.Timeout | null = null + + private async fetchSafeOverviews({ + safeIds, + walletAddress, + currency, + }: { + safeIds: `${number}:0x${string}`[] + walletAddress?: string + currency: string + }) { + return await getSafeOverviews(safeIds, { + trusted: true, + exclude_spam: true, + currency, + wallet_address: walletAddress, + }) + } + + private async processQueuedItems() { + // Dequeue the first BATCH_SIZE items + const nextBatch = this.requestQueue.slice(0, _BATCH_SIZE) + this.requestQueue = this.requestQueue.slice(_BATCH_SIZE) + + let overviews: SafeOverview[] + try { + this.fetchTimeout && clearTimeout(this.fetchTimeout) + this.fetchTimeout = null + + if (nextBatch.length === 0) { + // Nothing to process + return + } + + const safeIds = nextBatch.map((request) => makeSafeId(request.chainId, request.safeAddress)) + const { walletAddress, currency } = nextBatch[0] + overviews = await this.fetchSafeOverviews({ safeIds, currency, walletAddress }) + } catch (err) { + // Overviews could not be fetched + nextBatch.forEach((item) => item.callback({ error: 'Could not fetch Safe overview' })) + return + } + + nextBatch.forEach((item) => { + const overview = overviews.find( + (entry) => sameAddress(entry.address.value, item.safeAddress) && entry.chainId === item.chainId, + ) + + item.callback({ data: overview }) + }) + } + + private enqueueRequest(item: SafeOverviewQueueItem) { + this.requestQueue.push(item) + + if (this.requestQueue.length >= _BATCH_SIZE) { + this.processQueuedItems() + } + + // If no timer is running start a timer + if (this.fetchTimeout === null) { + this.fetchTimeout = setTimeout(() => { + this.processQueuedItems() + }, _FETCH_TIMEOUT) + } + } + + async getOverview(item: Omit) { + return new Promise((resolve, reject) => { + this.enqueueRequest({ + ...item, + callback: (result) => { + if ('data' in result) { + resolve(result.data) + } + reject(result.error) + }, + }) + }) + } +} + +const batchedFetcher = new SafeOverviewFetcher() + +type MultiOverviewQueryParams = { + currency: string + walletAddress?: string + safes: SafeItem[] +} + +export const safeOverviewEndpoints = (builder: EndpointBuilder) => ({ + getSafeOverview: builder.query( + { + async queryFn({ safeAddress, walletAddress, chainId }, { getState }) { + const state = getState() as RootState + const currency = selectCurrency(state) + + if (!safeAddress) { + return { data: null } + } + + try { + const safeOverview = await batchedFetcher.getOverview({ chainId, currency, walletAddress, safeAddress }) + return { data: safeOverview ?? null } + } catch (error) { + return { error: { status: 'CUSTOM_ERROR', error: asError(error).message } } + } + }, + }, + ), + getMultipleSafeOverviews: builder.query({ + async queryFn(params) { + const { safes, walletAddress, currency } = params + + try { + const promisedSafeOverviews = safes.map((safe) => + batchedFetcher.getOverview({ + chainId: safe.chainId, + safeAddress: safe.address, + currency, + walletAddress, + }), + ) + const safeOverviews = await Promise.all(promisedSafeOverviews) + return { data: safeOverviews.filter(Boolean) as SafeOverview[] } + } catch (error) { + return { error: { status: 'CUSTOM_ERROR', error: (error as Error).message } } + } + }, + }), +}) diff --git a/src/store/ofac.ts b/src/store/api/ofac.ts similarity index 98% rename from src/store/ofac.ts rename to src/store/api/ofac.ts index 9e2cd9e32b..057151ad4d 100644 --- a/src/store/ofac.ts +++ b/src/store/api/ofac.ts @@ -2,7 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react' import { selectChainById } from '@/store/chainsSlice' import { Contract } from 'ethers' import { createWeb3ReadOnly } from '@/hooks/wallets/web3' -import type { RootState } from '.' +import type { RootState } from '..' import { CHAINALYSIS_OFAC_CONTRACT } from '@/config/constants' import chains from '@/config/chains' diff --git a/src/store/safePass.ts b/src/store/api/safePass.ts similarity index 100% rename from src/store/safePass.ts rename to src/store/api/safePass.ts diff --git a/src/store/common.ts b/src/store/common.ts index e2b9776276..514882315c 100644 --- a/src/store/common.ts +++ b/src/store/common.ts @@ -39,3 +39,9 @@ export const selectChainIdAndSafeAddress = createSelector( [(_: RootState, chainId: string) => chainId, (_: RootState, _chainId: string, safeAddress: string) => safeAddress], (chainId, safeAddress) => [chainId, safeAddress] as const, ) + +// Memoized selector for safeAddress +export const selectSafeAddress = createSelector( + [(_: RootState, safeAddress: string) => safeAddress], + (safeAddress) => [safeAddress] as const, +) diff --git a/src/store/index.ts b/src/store/index.ts index 70097eec6e..1dcd5903fb 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -24,8 +24,8 @@ import { } from './slices' import * as slices from './slices' import * as hydrate from './useHydrateStore' -import { ofacApi } from '@/store/ofac' -import { safePassApi } from './safePass' +import { ofacApi } from '@/store/api/ofac' +import { safePassApi } from './api/safePass' import { metadata } from '@/markdown/terms/terms.md' const rootReducer = combineReducers({ diff --git a/src/store/slices.ts b/src/store/slices.ts index c0dce32a5a..a36e3c1806 100644 --- a/src/store/slices.ts +++ b/src/store/slices.ts @@ -19,4 +19,5 @@ export * from './batchSlice' export * from '@/features/counterfactual/store/undeployedSafesSlice' export * from '@/features/swap/store/swapParamsSlice' export * from './swapOrderSlice' -export * from './gateway' +export * from './api/gateway' +export * from './api/gateway/safeOverviews' diff --git a/src/tests/builders/safeTx.ts b/src/tests/builders/safeTx.ts index dda345fbb3..1e8d4eb2ca 100644 --- a/src/tests/builders/safeTx.ts +++ b/src/tests/builders/safeTx.ts @@ -1,6 +1,6 @@ import { Builder, type IBuilder } from '@/tests/Builder' import { faker } from '@faker-js/faker' -import type { SafeSignature, SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { type SafeTransactionData, type SafeSignature, type SafeTransaction } from '@safe-global/safe-core-sdk-types' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' // TODO: Convert to builder @@ -29,18 +29,7 @@ export const createSafeTx = (data = '0x'): SafeTransaction => { export function safeTxBuilder(): IBuilder { return Builder.new().with({ - data: { - to: faker.finance.ethereumAddress(), - value: '0x0', - data: faker.string.hexadecimal({ length: faker.number.int({ max: 500 }) }), - operation: 0, - nonce: faker.number.int(), - safeTxGas: faker.number.toString(), - gasPrice: faker.number.toString(), - gasToken: ZERO_ADDRESS, - baseGas: faker.number.toString(), - refundReceiver: faker.finance.ethereumAddress(), - }, + data: safeTxDataBuilder().build(), signatures: new Map([]), addSignature: function (sig: SafeSignature): void { this.signatures!.set(sig.signer, sig) @@ -55,6 +44,21 @@ export function safeTxBuilder(): IBuilder { }) } +export function safeTxDataBuilder(): IBuilder { + return Builder.new().with({ + to: faker.finance.ethereumAddress(), + value: '0x0', + data: faker.string.hexadecimal({ length: faker.number.int({ max: 500 }) }), + operation: 0, + nonce: faker.number.int(), + safeTxGas: faker.number.toString(), + gasPrice: faker.number.toString(), + gasToken: ZERO_ADDRESS, + baseGas: faker.number.toString(), + refundReceiver: faker.finance.ethereumAddress(), + }) +} + export function safeSignatureBuilder(): IBuilder { return Builder.new().with({ signer: faker.finance.ethereumAddress(), diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx index 81a95277fc..95fc419813 100644 --- a/src/tests/test-utils.tsx +++ b/src/tests/test-utils.tsx @@ -10,6 +10,8 @@ import * as web3 from '@/hooks/wallets/web3' import { type JsonRpcProvider, AbiCoder } from 'ethers' import { id } from 'ethers' import { Provider } from 'react-redux' +import { checksumAddress } from '@/utils/addresses' +import { faker } from '@faker-js/faker' const mockRouter = (props: Partial = {}): NextRouter => ({ asPath: '/', @@ -134,6 +136,8 @@ const mockWeb3Provider = ( return mockWeb3ReadOnly } +export const fakerChecksummedAddress = () => checksumAddress(faker.finance.ethereumAddress()) + // re-export everything export * from '@testing-library/react' diff --git a/src/utils/__tests__/transactions.test.ts b/src/utils/__tests__/transactions.test.ts index 2fa9ae7559..68ca1f22c2 100644 --- a/src/utils/__tests__/transactions.test.ts +++ b/src/utils/__tests__/transactions.test.ts @@ -5,11 +5,43 @@ import type { SafeAppData, Transaction, } from '@safe-global/safe-gateway-typescript-sdk' -import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionInfoType, ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' import { isMultiSendTxInfo } from '../transaction-guards' -import { getQueuedTransactionCount, getTxOrigin } from '../transactions' +import { + extractMigrationL2MasterCopyAddress, + getQueuedTransactionCount, + getTxOrigin, + prependSafeToL2Migration, +} from '../transactions' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import { chainBuilder } from '@/tests/builders/chains' +import { safeSignatureBuilder, safeTxBuilder, safeTxDataBuilder } from '@/tests/builders/safeTx' +import { + getMultiSendCallOnlyDeployment, + getMultiSendDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, + getSafeToL2MigrationDeployment, +} from '@safe-global/safe-deployments' +import type Safe from '@safe-global/protocol-kit' +import { encodeMultiSendData } from '@safe-global/protocol-kit' +import { Multi_send__factory, Safe_to_l2_migration__factory } from '@/types/contracts' +import { faker } from '@faker-js/faker' +import { getAndValidateSafeSDK } from '@/services/tx/tx-sender/sdk' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' +import { checksumAddress } from '../addresses' + +jest.mock('@/services/tx/tx-sender/sdk') + +const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() +const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress +const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() + +const multisendInterface = Multi_send__factory.createInterface() describe('transactions', () => { + const mockGetAndValidateSdk = getAndValidateSafeSDK as jest.MockedFunction + describe('getQueuedTransactionCount', () => { it('should return 0 if no txPage is provided', () => { expect(getQueuedTransactionCount()).toBe('0') @@ -194,4 +226,297 @@ describe('transactions', () => { ).toBe(false) }) }) + + describe('prependSafeToL2Migration', () => { + beforeEach(() => { + // Mock create Tx + mockGetAndValidateSdk.mockReturnValue({ + createTransaction: ({ transactions, onlyCalls }) => { + return Promise.resolve( + safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + to: onlyCalls + ? getMultiSendCallOnlyDeployment()?.defaultAddress ?? faker.finance.ethereumAddress() + : getMultiSendDeployment()?.defaultAddress ?? faker.finance.ethereumAddress(), + value: '0', + data: Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ + encodeMultiSendData(transactions), + ]), + nonce: 0, + operation: 1, + }) + .build(), + }) + .build(), + ) + }, + } as Safe) + }) + + it('should return undefined for undefined safeTx', () => { + expect( + prependSafeToL2Migration(undefined, extendedSafeInfoBuilder().build(), chainBuilder().build()), + ).resolves.toBeUndefined() + }) + + it('should throw if chain is undefined', () => { + expect(() => prependSafeToL2Migration(undefined, extendedSafeInfoBuilder().build(), undefined)).toThrowError() + }) + + it('should not modify tx if the chain is L1', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) + .build() + + expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: false }).build())).resolves.toEqual( + safeTx, + ) + }) + + it('should not modify tx if the nonce is > 0', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 1 }).build() }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) + .build() + + expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( + safeTx, + ) + }) + + it('should not modify tx if implementationState is correct', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UP_TO_DATE }) + .build() + expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( + safeTx, + ) + }) + + it('should not modify tx if the tx is already signed', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) + .build() + + safeTx.addSignature(safeSignatureBuilder().build()) + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) + .build() + + expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( + safeTx, + ) + }) + + it('should not modify tx if the chain has no migration lib deployed', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) + .build() + + expect( + prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '69420' }).build()), + ).resolves.toEqual(safeTx) + }) + + it('should not modify tx if the tx already migrates', () => { + const safeL2SingletonDeployment = getSafeL2SingletonDeployment()?.defaultAddress + + const safeTx = safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + nonce: 0, + to: safeToL2MigrationAddress, + data: + safeL2SingletonDeployment && + safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), + }) + .build(), + }) + .build() + const safeInfo = extendedSafeInfoBuilder() + .with({ + implementationVersionState: ImplementationVersionState.UNKNOWN, + implementation: { + name: '1.3.0', + value: getSafeSingletonDeployment()?.defaultAddress ?? faker.finance.ethereumAddress(), + }, + }) + .build() + expect( + prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '10' }).build()), + ).resolves.toEqual(safeTx) + const multiSendSafeTx = safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + nonce: 0, + to: getMultiSendDeployment()?.defaultAddress, + data: + safeToL2MigrationAddress && + safeL2SingletonDeployment && + Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ + encodeMultiSendData([ + { + value: '0', + operation: 1, + to: safeToL2MigrationAddress, + data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), + }, + ]), + ]), + }) + .build(), + }) + .build() + expect( + prependSafeToL2Migration(multiSendSafeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '10' }).build()), + ).resolves.toEqual(multiSendSafeTx) + }) + + it('should modify single txs if applicable', async () => { + const safeTx = safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + nonce: 0, + to: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ length: 10 }), + value: '0', + }) + .build(), + }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ + implementationVersionState: ImplementationVersionState.UNKNOWN, + implementation: { + name: '1.3.0', + value: getSafeSingletonDeployment()?.defaultAddress ?? faker.finance.ethereumAddress(), + }, + }) + .build() + + const modifiedTx = await prependSafeToL2Migration( + safeTx, + safeInfo, + chainBuilder().with({ l2: true, chainId: '10' }).build(), + ) + + expect(modifiedTx).not.toEqual(safeTx) + expect(modifiedTx?.data.to).toEqual(getMultiSendDeployment()?.defaultAddress) + const decodedMultiSend = decodeMultiSendData(modifiedTx!.data.data) + expect(decodedMultiSend).toHaveLength(2) + const safeL2SingletonDeployment = getSafeL2SingletonDeployment()?.defaultAddress + + expect(decodedMultiSend).toEqual([ + { + to: safeToL2MigrationAddress, + value: '0', + operation: 1, + data: + safeL2SingletonDeployment && + safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), + }, + { + to: checksumAddress(safeTx.data.to), + value: safeTx.data.value, + operation: safeTx.data.operation, + data: safeTx.data.data.toLowerCase(), + }, + ]) + }) + }) + + describe('extractMigrationL2MasterCopyAddress', () => { + it('should return undefined for undefined safeTx', () => { + expect(extractMigrationL2MasterCopyAddress(undefined)).toBeUndefined() + }) + + it('should return undefined for non multisend safeTx', () => { + expect(extractMigrationL2MasterCopyAddress(safeTxBuilder().build())).toBeUndefined() + }) + + it('should return undefined for multisend without migration', () => { + expect( + extractMigrationL2MasterCopyAddress( + safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + data: multisendInterface.encodeFunctionData('multiSend', [ + encodeMultiSendData([ + { + to: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ length: 64 }), + value: '0', + operation: 0, + }, + { + to: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ length: 64 }), + value: '0', + operation: 0, + }, + ]), + ]), + }) + .build(), + }) + .build(), + ), + ).toBeUndefined() + }) + + it('should return migration address for multisend with migration as first tx', () => { + const l2SingletonAddress = getSafeL2SingletonDeployment()?.defaultAddress! + expect( + extractMigrationL2MasterCopyAddress( + safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + data: multisendInterface.encodeFunctionData('multiSend', [ + encodeMultiSendData([ + { + to: safeToL2MigrationAddress!, + data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [l2SingletonAddress]), + value: '0', + operation: 1, + }, + { + to: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ length: 64 }), + value: '0', + operation: 0, + }, + ]), + ]), + }) + .build(), + }) + .build(), + ), + ).toEqual(l2SingletonAddress) + }) + }) }) diff --git a/src/utils/chains.ts b/src/utils/chains.ts index 9887934f20..6a6f17d276 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -34,6 +34,8 @@ export enum FEATURES { ZODIAC_ROLES = 'ZODIAC_ROLES', SAFE_141 = 'SAFE_141', STAKING = 'STAKING', + MULTI_CHAIN_SAFE_CREATION = 'MULTI_CHAIN_SAFE_CREATION', + MULTI_CHAIN_SAFE_ADD_NETWORK = 'MULTI_CHAIN_SAFE_ADD_NETWORK', } export const FeatureRoutes = { diff --git a/src/utils/transaction-calldata.ts b/src/utils/transaction-calldata.ts index 303cccb48a..2d91e6250d 100644 --- a/src/utils/transaction-calldata.ts +++ b/src/utils/transaction-calldata.ts @@ -5,8 +5,8 @@ import type { BaseTransaction } from '@safe-global/safe-apps-sdk' import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' import { ERC20__factory } from '@/types/contracts/factories/@openzeppelin/contracts/build/contracts/ERC20__factory' import { ERC721__factory } from '@/types/contracts/factories/@openzeppelin/contracts/build/contracts/ERC721__factory' -import { decodeMultiSendTxs } from '@/utils/transactions' import { Safe__factory } from '@/types/contracts' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' export const isCalldata = (data: string, fragment: FunctionFragment): boolean => { const signature = fragment.format() @@ -93,7 +93,7 @@ export const getTransactionRecipients = ({ data, to }: BaseTransaction): Array { return [TransactionStatus.AWAITING_CONFIRMATIONS, TransactionStatus.AWAITING_EXECUTION].includes(value) @@ -84,6 +91,50 @@ export const isModuleDetailedExecutionInfo = (value?: DetailedExecutionInfo): va return value?.type === DetailedExecutionInfoType.MODULE } +const isMigrateToL2CallData = (value: { + to: string + data: string | undefined + operation?: OperationType | undefined +}) => { + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress + const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() + + if (value.operation === OperationType.DelegateCall && sameAddress(value.to, safeToL2MigrationAddress)) { + const migrateToL2Selector = safeToL2MigrationInterface?.getFunction('migrateToL2')?.selector + return migrateToL2Selector && value.data ? value.data.startsWith(migrateToL2Selector) : false + } + return false +} + +export const isMigrateToL2TxData = (value: TransactionData | undefined, chainId: string | undefined): boolean => { + if (!value) { + return false + } + + if ( + chainId && + value?.hexData && + isMultiSendCalldata(value?.hexData) && + hasMatchingDeployment(getMultiSendDeployments, value.to.value, chainId, ['1.3.0', '1.4.1']) + ) { + // Its a multiSend to the MultiSend contract (not CallOnly) + const decodedMultiSend = decodeMultiSendData(value.hexData) + const firstTx = decodedMultiSend[0] + + // We only trust the tx if the first tx is the only delegateCall + const hasMoreDelegateCalls = decodedMultiSend + .slice(1) + .some((value) => value.operation === OperationType.DelegateCall) + + if (!hasMoreDelegateCalls && firstTx && isMigrateToL2CallData(firstTx)) { + return true + } + } + + return isMigrateToL2CallData({ to: value.to.value, data: value.hexData, operation: value.operation as 0 | 1 }) +} + // TransactionInfo type guards export const isTransferTxInfo = (value: TransactionInfo): value is Transfer => { return value.type === TransactionInfoType.TRANSFER || isSwapTransferOrderTxInfo(value) @@ -119,6 +170,13 @@ export const isOrderTxInfo = (value: TransactionInfo): value is Order => { return isSwapOrderTxInfo(value) || isTwapOrderTxInfo(value) } +export const isMigrateToL2TxInfo = (value: TransactionInfo): value is Custom => { + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress + + return isCustomTxInfo(value) && sameAddress(value.to.value, safeToL2MigrationAddress) +} + export const isSwapOrderTxInfo = (value: TransactionInfo): value is SwapOrder => { return value.type === TransactionInfoType.SWAP_ORDER } diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index cf2ff95c8b..688cacefda 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -21,19 +21,24 @@ import { } from './transaction-guards' import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types/dist/src/types' import { OperationType } from '@safe-global/safe-core-sdk-types/dist/src/types' -import { getReadOnlyGnosisSafeContract } from '@/services/contracts/safeContracts' +import { getReadOnlyGnosisSafeContract, isValidMasterCopy } from '@/services/contracts/safeContracts' import extractTxInfo from '@/services/tx/extractTxInfo' import type { AdvancedParameters } from '@/components/tx/AdvancedParams' import type { SafeTransaction, TransactionOptions } from '@safe-global/safe-core-sdk-types' import { FEATURES, hasFeature } from '@/utils/chains' import uniqBy from 'lodash/uniqBy' import { Errors, logError } from '@/services/exceptions' -import { Multi_send__factory } from '@/types/contracts' -import { toBeHex, AbiCoder } from 'ethers' +import { Safe_to_l2_migration__factory } from '@/types/contracts' import { type BaseTransaction } from '@safe-global/safe-apps-sdk' -import { id } from 'ethers' import { isEmptyHexData } from '@/utils/hex' +import { type ExtendedSafeInfo } from '@/store/safeInfoSlice' +import { getSafeContractDeployment } from '@/services/contracts/deployments' +import { sameAddress } from './addresses' +import { isMultiSendCalldata } from './transaction-calldata' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' +import { __unsafe_createMultiSendTx } from '@/services/tx/tx-sender' import { getOriginPath } from './url' +import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' export const makeTxFromDetails = (txDetails: TransactionDetails): Transaction => { const getMissingSigners = ({ @@ -199,17 +204,11 @@ export const getTxOrigin = (app?: Partial): string | undefined => { return origin } -const multiSendInterface = Multi_send__factory.createInterface() - -const multiSendFragment = multiSendInterface.getFunction('multiSend') - -const MULTISEND_SIGNATURE_HASH = id('multiSend(bytes)').slice(0, 10) - export const decodeSafeTxToBaseTransactions = (safeTx: SafeTransaction): BaseTransaction[] => { const txs: BaseTransaction[] = [] const safeTxData = safeTx.data.data - if (safeTxData.startsWith(MULTISEND_SIGNATURE_HASH)) { - txs.push(...decodeMultiSendTxs(safeTxData)) + if (isMultiSendCalldata(safeTxData)) { + txs.push(...decodeMultiSendData(safeTxData)) } else { txs.push({ data: safeTxData, @@ -220,62 +219,6 @@ export const decodeSafeTxToBaseTransactions = (safeTx: SafeTransaction): BaseTra return txs } -/** - * TODO: Use core-sdk - * Decodes the transactions contained in `multiSend` call data - * - * @param encodedMultiSendData `multiSend` call data - * @returns array of individual transaction data - */ -export const decodeMultiSendTxs = (encodedMultiSendData: string): BaseTransaction[] => { - // uint8 operation, address to, uint256 value, uint256 dataLength - const INDIVIDUAL_TX_DATA_LENGTH = 2 + 40 + 64 + 64 - - const [decodedMultiSendData] = multiSendInterface.decodeFunctionData(multiSendFragment, encodedMultiSendData) - - const txs: BaseTransaction[] = [] - - // Decode after 0x - let index = 2 - - while (index < decodedMultiSendData.length) { - const txDataEncoded = `0x${decodedMultiSendData.slice( - index, - // Traverse next transaction - (index += INDIVIDUAL_TX_DATA_LENGTH), - )}` - - // Decode operation, to, value, dataLength - let txTo, txValue, txDataBytesLength - try { - ;[, txTo, txValue, txDataBytesLength] = AbiCoder.defaultAbiCoder().decode( - ['uint8', 'address', 'uint256', 'uint256'], - toBeHex(txDataEncoded, 32 * 4), - ) - } catch (e) { - logError(Errors._809, e) - continue - } - - // Each byte is represented by two characters - const dataLength = Number(txDataBytesLength) * 2 - - const txData = `0x${decodedMultiSendData.slice( - index, - // Traverse data length - (index += dataLength), - )}` - - txs.push({ - to: txTo, - value: txValue.toString(), - data: txData, - }) - } - - return txs -} - export const isRejectionTx = (tx?: SafeTransaction) => { return !!tx && !!tx.data.data && isEmptyHexData(tx.data.data) && tx.data.value === '0' } @@ -294,6 +237,120 @@ export const isImitation = ({ txInfo }: TransactionSummary): boolean => { return isTransferTxInfo(txInfo) && isERC20Transfer(txInfo.transferInfo) && Boolean(txInfo.transferInfo.imitation) } +/** + * + * If the Safe is using a invalid masterCopy this function will modify the passed in `safeTx` by making it a MultiSend that migrates the Safe to L2 as the first action. + * + * This only happens under the conditions that + * - The Safe's nonce is 0 + * - The SafeTx's nonce is 0 + * - The Safe is using an invalid masterCopy + * - The SafeTx is not already including a Migration + * + * @param safeTx original SafeTx + * @param safe + * @param chain + * @returns + */ +export const prependSafeToL2Migration = ( + safeTx: SafeTransaction | undefined, + safe: ExtendedSafeInfo, + chain: ChainInfo | undefined, +): Promise => { + if (!chain) { + throw new Error('No Network information available') + } + + const safeL2Deployment = getSafeContractDeployment(chain, safe.version) + const safeL2DeploymentAddress = safeL2Deployment?.networkAddresses[chain.chainId] + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment({ network: chain.chainId }) + const safeToL2MigrationAddress = safeToL2MigrationDeployment?.networkAddresses[chain.chainId] + + if ( + !safeTx || + safeTx.signatures.size > 0 || + !chain.l2 || + safeTx.data.nonce > 0 || + isValidMasterCopy(safe.implementationVersionState) || + !safeToL2MigrationAddress || + !safeL2DeploymentAddress + ) { + // We do not migrate on L1s + // We cannot migrate if the nonce is > 0 + // We do not modify already signed txs + // We do not modify supported masterCopies + // We cannot migrate if no migration contract or L2 contract exists + return Promise.resolve(safeTx) + } + + const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() + + if (sameAddress(safe.implementation.value, safeL2DeploymentAddress)) { + // Safe already has the correct L2 masterCopy + // This should in theory never happen if the implementationState is valid + return Promise.resolve(safeTx) + } + + // If the Safe is a L1 masterCopy on a L2 network and still has nonce 0, we prepend a call to the migration contract to the safeTx. + const txData = safeTx.data.data + + let internalTxs: MetaTransactionData[] + if (isMultiSendCalldata(txData)) { + // Check if the first tx is already a call to the migration contract + internalTxs = decodeMultiSendData(txData) + } else { + internalTxs = [{ to: safeTx.data.to, operation: safeTx.data.operation, value: safeTx.data.value, data: txData }] + } + + if (sameAddress(internalTxs[0]?.to, safeToL2MigrationAddress)) { + // We already migrate. Nothing to do. + return Promise.resolve(safeTx) + } + + // Prepend the migration tx + const newTxs: MetaTransactionData[] = [ + { + operation: 1, // DELEGATE CALL REQUIRED + data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2DeploymentAddress]), + to: safeToL2MigrationAddress, + value: '0', + }, + ...internalTxs, + ] + + return __unsafe_createMultiSendTx(newTxs) +} + +export const extractMigrationL2MasterCopyAddress = (safeTx: SafeTransaction | undefined): string | undefined => { + if (!safeTx) { + return undefined + } + + if (!isMultiSendCalldata(safeTx.data.data)) { + return undefined + } + + const innerTxs = decodeMultiSendData(safeTx.data.data) + const firstInnerTx = innerTxs[0] + if (!firstInnerTx) { + return undefined + } + + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress + const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() + + if ( + firstInnerTx.data.startsWith(safeToL2MigrationInterface.getFunction('migrateToL2').selector) && + sameAddress(firstInnerTx.to, safeToL2MigrationAddress) + ) { + const callParams = safeToL2MigrationInterface.decodeFunctionData('migrateToL2', firstInnerTx.data) + return callParams[0] + } + + return undefined +} + export const getSafeTransaction = async (safeTxHash: string, chainId: string, safeAddress: string) => { const txId = `multisig_${safeAddress}_${safeTxHash}` diff --git a/src/utils/wallets.ts b/src/utils/wallets.ts index ccfd007ee4..9596de55d9 100644 --- a/src/utils/wallets.ts +++ b/src/utils/wallets.ts @@ -5,6 +5,7 @@ import { WALLET_KEYS } from '@/hooks/wallets/consts' import { EMPTY_DATA } from '@safe-global/protocol-kit/dist/src/utils/constants' import memoize from 'lodash/memoize' import { PRIVATE_KEY_MODULE_LABEL } from '@/services/private-key-module' +import { type JsonRpcProvider } from 'ethers' const WALLETCONNECT = 'WalletConnect' @@ -34,14 +35,14 @@ export const isHardwareWallet = (wallet: ConnectedWallet): boolean => { ) } -export const isSmartContract = async (address: string): Promise => { - const provider = getWeb3ReadOnly() +export const isSmartContract = async (address: string, provider?: JsonRpcProvider): Promise => { + const web3 = provider ?? getWeb3ReadOnly() - if (!provider) { + if (!web3) { throw new Error('Provider not found') } - const code = await provider.getCode(address) + const code = await web3.getCode(address) return code !== EMPTY_DATA } diff --git a/yarn.lock b/yarn.lock index 46ed461b4d..c149a12884 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4247,14 +4247,7 @@ dependencies: abitype "^1.0.2" -"@safe-global/safe-deployments@^1.37.8": - version "1.37.8" - resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.37.8.tgz#5d51a57e4c3a9274ce09d8fe7fbe1265a1aaf4c4" - integrity sha512-BT34eqSJ1K+4xJgJVY3/Yxg8TRTEvFppkt4wcirIPGCgR4/j06HptHPyDdmmqTuvih8wi8OpFHi0ncP+cGlXWA== - dependencies: - semver "^7.6.2" - -"@safe-global/safe-deployments@^1.37.9": +"@safe-global/safe-deployments@^1.37.10", "@safe-global/safe-deployments@^1.37.9": version "1.37.10" resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.37.10.tgz#2f61a25bd479332821ba2e91a575237d77406ec3" integrity sha512-lcxX9CV+xdcLs4dF6Cx18zDww5JyqaX6RdcvU0o/34IgJ4Wjo3J/RNzJAoMhurCAfTGr+0vyJ9V13Qo50AR6JA== @@ -14913,6 +14906,18 @@ open@^8.0.4: is-docker "^2.1.1" is-wsl "^2.2.0" +openapi-fetch@^0.10.5: + version "0.10.6" + resolved "https://registry.yarnpkg.com/openapi-fetch/-/openapi-fetch-0.10.6.tgz#255017e3e609c5e7be16bc1ed7a973977c085cdc" + integrity sha512-6xXfvIEL/POtLGOaFPsp3O+pDe+J3DZYxbD9BrsQHXOTeNK8z/gsWHT6adUy1KcpQOhmkerMzlQrJM6DbN55dQ== + dependencies: + openapi-typescript-helpers "^0.0.11" + +openapi-typescript-helpers@^0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.11.tgz#d05e88216b8f3771d5df41c863ebc5c9d10e2954" + integrity sha512-xofUHlVFq+BMquf3nh9I8N2guHckW6mrDO/F3kaFgrL7MGbjldDnQ9TIT+rkH/+H0LiuO+RuZLnNmsJwsjwUKg== + opener@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -16610,6 +16615,12 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +"safe-client-gateway-sdk@git+https://github.com/safe-global/safe-client-gateway-sdk.git#v1.53.0-next-7344903": + version "1.53.0-next-7344903" + resolved "git+https://github.com/safe-global/safe-client-gateway-sdk.git#b0b91319b8753b41edd117bb6425729634250e15" + dependencies: + openapi-fetch "^0.10.5" + safe-regex-test@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377"