diff --git a/.env.example b/.env.example index 121efd942e..69e9230b30 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,16 @@ NEXT_PUBLIC_INFURA_TOKEN= NEXT_PUBLIC_SAFE_APPS_INFURA_TOKEN= +# WalletConnect +NEXT_PUBLIC_WC_PROJECT_ID= + ## CGW NEXT_PUBLIC_GATEWAY_URL_PRODUCTION= NEXT_PUBLIC_GATEWAY_URL_STAGING= +# Blockaid +NEXT_PUBLIC_BLOCKAID_CLIENT_ID= + # Transaction simulation NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL= NEXT_PUBLIC_TENDERLY_PROJECT_NAME= @@ -17,15 +23,11 @@ NEXT_PUBLIC_IS_PRODUCTION= # Latest supported safe version, used for upgrade prompts NEXT_PUBLIC_SAFE_VERSION= -# Access keys +# Sentry NEXT_PUBLIC_SENTRY_DSN= -NEXT_PUBLIC_BEAMER_ID= - -# Wallet-specific variables -NEXT_PUBLIC_WC_PROJECT_ID= -# E2E tests -NEXT_PUBLIC_CYPRESS_MNEMONIC= +# Beamer +NEXT_PUBLIC_BEAMER_ID= # Safe Gelato relay service NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_PRODUCTION= @@ -34,19 +36,11 @@ NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_STAGING= # Firebase Cloud Messaging NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION= NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION= - NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING= NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING= -# Blockaid -NEXT_PUBLIC_BLOCKAID_CLIENT_ID - -# Social Login -NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_STAGING= -NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_PRODUCTION= - # Cypress wallet private keys CYPRESS_WALLET_CREDENTIALS= # [optional] Beamer keys for e2e tests -BEAMER_DATA_E2E= \ No newline at end of file +BEAMER_DATA_E2E= diff --git a/cypress.config.js b/cypress.config.js index 9db6791a57..429e69f07e 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -67,7 +67,7 @@ export default defineConfig({ hideXHR: true, defaultCommandTimeout: 10000, pageLoadTimeout: 60000, - numTestsKeptInMemory: 0, + numTestsKeptInMemory: 20, }, chromeWebSecurity: false, diff --git a/cypress/e2e/pages/sidebar.pages.js b/cypress/e2e/pages/sidebar.pages.js index eaf38c3635..a2bd1c0ad7 100644 --- a/cypress/e2e/pages/sidebar.pages.js +++ b/cypress/e2e/pages/sidebar.pages.js @@ -59,6 +59,9 @@ const emptyPinnedList = '[data-testid="empty-pinned-list"]' const boomarkIcon = '[data-testid="bookmark-icon"]' const emptyAccountList = '[data-testid="empty-account-list"]' const searchInput = '[id="search-by-name"]' +const accountsList = '[data-testid="accounts-list"]' +const sortbyBtn = '[data-testid="sortby-button"]' + export const importBtnStr = 'Import' export const exportBtnStr = 'Export' export const undeployedSafe = 'Undeployed Sepolia' @@ -80,11 +83,13 @@ export const sideBarListItems = ['Home', 'Assets', 'Transactions', 'Address book export const sideBarSafes = { safe1: '0xBb26E3717172d5000F87DeFd391994f789D80aEB', safe2: '0x905934aA8758c06B2422F0C90D97d2fbb6677811', + safe3: '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B', safe1short: '0xBb26...0aEB', safe1short_: '0xBb26', safe2short: '0x9059...7811', safe3short: '0x86Cb...2C27', safe4short: '0x9261...7E00', + multichain_short_: '0xC96e', } // 0x926186108f74dB20BFeb2b6c888E523C78cb7E00 @@ -96,10 +101,19 @@ export const testSafeHeaderDetails = ['2/2', safes.SEP_STATIC_SAFE_9_SHORT] const receiveAssetsStr = 'Receive assets' const emptyPinnedListStr = 'Watch any Safe Account to keep an eye on its activity' const emptySafeListStr = "You don't have any safes yet" -const accountsStr = 'Accounts' +const accountsRegex = /(My accounts|Accounts) \((\d+)\)/ const confirmTxStr = (number) => `${number} to confirm` const pedningTxStr = (n) => `${n} pending` export const confirmGenStr = 'to confirm' +const searchResults = (number) => `Found ${number} result${number === 1 ? '' : 's'}` + +export const sortOptions = { + lastVisited: '[data-testid="last-visited-option"]', + name: '[data-testid="name-option"]', +} +export function checkSearchResults(number) { + cy.contains(searchResults(number)).should('exist') +} export const multichainSafes = { polygon: 'Multichain polygon', @@ -110,6 +124,18 @@ export function searchSafe(safe) { cy.get(searchInput).clear().type(safe) } +export function openSortOptionsMenu() { + cy.get(sortbyBtn).click() +} + +export function selectSortOption(option) { + cy.get(option).click() +} + +export function clearSearchInput() { + cy.get(searchInput).scrollIntoView().clear({ force: true }) +} + export function verifySearchInputPosition() { cy.get(searchInput).then(($searchInput) => { cy.get(pinnedAccountsContainer).then(($pinnedList) => { @@ -232,6 +258,12 @@ export function verifySafeCount(count) { main.verifyMinimumElementsCount(sideSafeListItem, count) } +export function verifyAccountListSafeCount(count) { + cy.get(accountsList).within(() => { + cy.get(sideSafeListItem).should('have.length', count) + }) +} + export function clickOnOpenSidebarBtn() { cy.get(openSafesIcon).click() } @@ -248,9 +280,12 @@ export function verifyAddedSafesExist(safes) { main.verifyValuesExist(sideSafeListItem, safes) } +export function verifySafesDoNotExist(safes) { + main.verifyValuesDoNotExist(sidebarSafeContainer, safes) +} + export function verifyAddedSafesExistByIndex(index, safe) { cy.get(sideSafeListItem).eq(index).should('contain', safe) - cy.get(sideSafeListItem).eq(index).should('contain', 'sep:') } export function verifySafesByNetwork(netwrok, safes) { @@ -266,7 +301,7 @@ export function verifySafesByNetwork(netwrok, safes) { } function getSafeByName(safe) { - return cy.get(sidebarSafeContainer).find(sideSafeListItem).contains(safe).parents('span').parent() + return cy.get(sidebarSafeContainer).find(sideSafeListItem).contains(safe).parents('span').parent().should('exist') } function getSafeItemOptions(name) { @@ -295,13 +330,16 @@ export function clickOnSafeItemOptionsBtnByIndex(index) { cy.get(safeItemOptionsBtn).eq(index).click() } +export function expandGroupSafes(index) { + cy.get(multichainItemSummary).eq(index).click() +} + export function clickOnMultichainItemOptionsBtn(index) { cy.get(multichainItemSummary).eq(index).find(safeItemOptionsBtn).click() } export function checkMultichainTooltipExists(index) { cy.get(multichainItemSummary).eq(index).find(chainLogo).eq(0).trigger('mouseover', { force: true }) - cy.get(multichainTooltip).should('exist') } @@ -340,6 +378,10 @@ export function checkUndeployedSafeExists(index) { return getSubAccountContainer(index).contains(notActivatedStr).should('exist') } +export function checkMultichainSubSafeExists(safes) { + main.verifyValuesExist(subAccountContainer, safes) +} + export function checkAddNetworkBtnPosition(index) { cy.get(multichainItemSummary) .eq(index) @@ -464,7 +506,14 @@ export function verifySafeGiveNameOptionExists(index) { } export function checkAccountsCounter(value) { - cy.contains(accountsStr).should('contain', value) + cy.get(sidebarSafeContainer) + .should('exist') + .then(($el) => { + const text = $el.text() + const match = text.match(accountsRegex) + expect(match).not.to.be.null + expect(match[0]).to.exist + }) } export function checkTxToConfirm(numberOfTx) { diff --git a/cypress/e2e/prodhealthcheck/sidebar_3.cy.js b/cypress/e2e/prodhealthcheck/sidebar_3.cy.js index c1c3711862..01ea2fc20e 100644 --- a/cypress/e2e/prodhealthcheck/sidebar_3.cy.js +++ b/cypress/e2e/prodhealthcheck/sidebar_3.cy.js @@ -22,7 +22,7 @@ describe('[PROD] Sidebar tests 3', () => { }) wallet.connectSigner(signer) sideBar.openSidebar() - sideBar.checkAccountsCounter(2) + sideBar.checkAccountsCounter('2') }) it('Verify pending signature is displayed in sidebar for unsigned tx', () => { diff --git a/cypress/e2e/regression/sidebar_3.cy.js b/cypress/e2e/regression/sidebar_3.cy.js index cee5e6899a..f60cdaa1f6 100644 --- a/cypress/e2e/regression/sidebar_3.cy.js +++ b/cypress/e2e/regression/sidebar_3.cy.js @@ -85,7 +85,7 @@ describe('Sidebar tests 3', () => { }) wallet.connectSigner(signer) sideBar.openSidebar() - sideBar.checkAccountsCounter(2) + sideBar.checkAccountsCounter('2') }) it('Verify that safes the user do not owns show in the watchlist after adding them', () => { diff --git a/cypress/e2e/regression/sidebar_5.cy.js b/cypress/e2e/regression/sidebar_5.cy.js index e638afb254..3f2e7ddc12 100644 --- a/cypress/e2e/regression/sidebar_5.cy.js +++ b/cypress/e2e/regression/sidebar_5.cy.js @@ -4,9 +4,6 @@ import * as sideBar from '../pages/sidebar.pages.js' import * as ls from '../../support/localstorage_data.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' -import * as create_wallet from '../pages/create_wallet.pages.js' -import * as navigation from '../pages/navigation.page.js' -import * as owner from '../pages/owners.pages.js' let staticSafes = [] const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) @@ -36,4 +33,60 @@ describe('Sidebar search tests', () => { sideBar.verifyAddedSafesExist([sideBar.sideBarSafes.safe1short]) sideBar.verifySafeCount(1) }) + + it("Verify searching for a safe name filters out those who don't match", () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.searchSafe(sideBar.sideBarSafes.safe1short_) + sideBar.verifyAddedSafesExist([sideBar.sideBarSafes.safe1short]) + sideBar.verifySafesDoNotExist([sideBar.sideBarSafes.safe2short]) + }) + + it('Verify searching for a safe also finds safes in different networks', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe3], + }) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) + wallet.connectSigner(signer) + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe(sideBar.sideBarSafes.multichain_short_) + sideBar.checkMultichainSubSafeExists([ + constants.networks.gnosis, + constants.networks.ethereum, + constants.networks.sepolia, + ]) + }) + + it('Verify search shows number of results found', () => { + const safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'eth') + cy.visit(constants.BALANCE_URL + safe) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2, sideBar.sideBarSafes.safe3], + }) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) + wallet.connectSigner(signer) + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('0x') + sideBar.checkSearchResults(3) + }) + + it('Verify clearing the search input returns back to the previous lists', () => { + const safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'eth') + cy.visit(constants.BALANCE_URL + safe) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2, sideBar.sideBarSafes.safe3], + }) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) + wallet.connectSigner(signer) + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('0xC') + sideBar.checkSearchResults(1) + sideBar.clearSearchInput() + sideBar.verifyAccountListSafeCount(6) + }) }) diff --git a/cypress/e2e/regression/sidebar_6.cy.js b/cypress/e2e/regression/sidebar_6.cy.js new file mode 100644 index 0000000000..872dfd8cbd --- /dev/null +++ b/cypress/e2e/regression/sidebar_6.cy.js @@ -0,0 +1,86 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as sideBar from '../pages/sidebar.pages.js' +import * as ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +const aSafe = 'Safe A' +const bSafe = 'Safe B' +const safe14 = 'Safe 14' +const safe15 = 'Safe 15' + +describe('Sidebar sorting tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify the same safe of the different networks is ordered by most recent', () => { + let safe_eth = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'eth') + let safe_gno = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'gno') + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) + wallet.connectSigner(signer) + cy.visit(constants.BALANCE_URL + safe_eth) + cy.visit(constants.BALANCE_URL + safe_gno) + + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('96') + sideBar.checkSearchResults(1) + sideBar.verifySafeCount(3) + sideBar.verifyAddedSafesExistByIndex(1, constants.networks.gnosis) + sideBar.verifyAddedSafesExistByIndex(2, constants.networks.ethereum) + }) + + it('Verify the same safe of the different networks is ordered by name', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.undeployedSet) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + wallet.connectSigner(signer) + + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('96') + sideBar.verifySafeCount(3) + sideBar.expandGroupSafes(0) + sideBar.openSortOptionsMenu() + sideBar.selectSortOption(sideBar.sortOptions.name) + sideBar.verifyAddedSafesExistByIndex(1, aSafe) + sideBar.verifyAddedSafesExistByIndex(2, bSafe) + }) + + it('Verify that a pinned safe can be sorted by name and last visited', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.pagination) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__visitedSafes, ls.visitedSafes.set1) + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('15') + cy.wait(1000) + sideBar.clickOnBookmarkBtn(sideBar.sideBarSafes.safe2short) + sideBar.clearSearchInput() + sideBar.searchSafe('14') + cy.wait(1000) + sideBar.clickOnBookmarkBtn(sideBar.sideBarSafes.safe1short) + sideBar.clearSearchInput() + + sideBar.verifyPinnedSafe(sideBar.sideBarSafes.safe2short) + sideBar.verifyPinnedSafe(sideBar.sideBarSafes.safe1short) + + sideBar.openSortOptionsMenu() + sideBar.selectSortOption(sideBar.sortOptions.name) + sideBar.verifyAddedSafesExistByIndex(0, safe14) + sideBar.verifyAddedSafesExistByIndex(1, safe15) + sideBar.selectSortOption(sideBar.sortOptions.lastVisited) + sideBar.verifyAddedSafesExistByIndex(0, safe15) + sideBar.verifyAddedSafesExistByIndex(1, safe14) + }) +}) diff --git a/cypress/e2e/safe-apps/drain_account.spec.cy.js b/cypress/e2e/safe-apps/drain_account.spec.cy.js index 6604ac409e..6f81403565 100644 --- a/cypress/e2e/safe-apps/drain_account.spec.cy.js +++ b/cypress/e2e/safe-apps/drain_account.spec.cy.js @@ -13,7 +13,7 @@ let iframeSelector const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) const signer = walletCredentials.OWNER_4_PRIVATE_KEY -describe('Drain Account tests', () => { +describe('Drain Account tests', { defaultCommandTimeout: 40000 }, () => { before(async () => { safeAppSafes = await getSafes(CATEGORIES.safeapps) }) diff --git a/cypress/support/constants.js b/cypress/support/constants.js index f7cf8aead9..4f3b928082 100644 --- a/cypress/support/constants.js +++ b/cypress/support/constants.js @@ -255,4 +255,5 @@ export const localStorageKeys = { SAFE_v2__SafeApps__infoModal: 'SAFE_v2__SafeApps__infoModal', SAFE_v2__undeployedSafes: 'SAFE_v2__undeployedSafes', SAFE_v2__batch: 'SAFE_v2__batch', + SAFE_v2__visitedSafes: 'SAFE_v2__visitedSafes', } diff --git a/cypress/support/localstorage_data.js b/cypress/support/localstorage_data.js index b4e236383d..feeefe5db4 100644 --- a/cypress/support/localstorage_data.js +++ b/cypress/support/localstorage_data.js @@ -279,6 +279,15 @@ export const batchData = { }, }, } +export const visitedSafes = { + set1: { + 11155111: { + '0x905934aA8758c06B2422F0C90D97d2fbb6677811': { + lastVisited: 1732794651004, + }, + }, + }, +} export const addressBookData = { proposers: { 11155111: { '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED': 'AD Proposer1' }, @@ -356,6 +365,14 @@ export const addressBookData = { '0x926186108f74dB20BFeb2b6c888E523C78cb7E00': 'Undeployed Sepolia', }, }, + undeployedSet: { + 100: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': 'Safe A', + }, + 1: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': 'Safe B', + }, + }, undeployedEth: { 1: { '0x926186108f74dB20BFeb2b6c888E523C78cb7E00': 'Undeployed Sepolia', @@ -772,4 +789,44 @@ export const undeployedSafe = { }, }, }, + safes2: { + 1: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': { + props: { + safeAccountConfig: { + threshold: 1, + owners: ['0x3ba5d9a6d6169429Adb278768D9681A125C01Af6'], + fallbackHandler: '0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99', + }, + safeDeploymentConfig: { + saltNonce: '0', + safeVersion: '1.4.1', + }, + }, + status: { + status: 'AWAITING_EXECUTION', + type: 'PayLater', + }, + }, + }, + 100: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': { + props: { + safeAccountConfig: { + threshold: 1, + owners: ['0x3ba5d9a6d6169429Adb278768D9681A125C01Af6'], + fallbackHandler: '0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99', + }, + safeDeploymentConfig: { + saltNonce: '0', + safeVersion: '1.4.1', + }, + }, + status: { + status: 'AWAITING_EXECUTION', + type: 'PayLater', + }, + }, + }, + }, } diff --git a/package.json b/package.json index 43dddde452..9964334f1f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "safe-wallet-web", "homepage": "https://github.com/safe-global/safe-wallet-web", "license": "GPL-3.0", - "version": "1.47.1", + "version": "1.47.2", "type": "module", "scripts": { "dev": "next dev", diff --git a/public/images/common/arrow-down.svg b/public/images/common/arrow-down.svg new file mode 100644 index 0000000000..8e0969f577 --- /dev/null +++ b/public/images/common/arrow-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/common/bridge.svg b/public/images/common/bridge.svg new file mode 100644 index 0000000000..db47ec7f69 --- /dev/null +++ b/public/images/common/bridge.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/images/common/zkemail-logo.svg b/public/images/common/zkemail-logo.svg new file mode 100644 index 0000000000..8b30a8693e --- /dev/null +++ b/public/images/common/zkemail-logo.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/public/images/transactions/signature.svg b/public/images/transactions/signature.svg new file mode 100644 index 0000000000..79aa58d5a3 --- /dev/null +++ b/public/images/transactions/signature.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/common/CheckWallet/index.test.tsx b/src/components/common/CheckWallet/index.test.tsx index f5730c7c20..56e66dfe31 100644 --- a/src/components/common/CheckWallet/index.test.tsx +++ b/src/components/common/CheckWallet/index.test.tsx @@ -10,12 +10,15 @@ import { useIsWalletProposer } from '@/hooks/useProposers' import { faker } from '@faker-js/faker' import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import useSafeInfo from '@/hooks/useSafeInfo' +import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners' +import type Safe from '@safe-global/protocol-kit' +const mockWalletAddress = faker.finance.ethereumAddress() // mock useWallet jest.mock('@/hooks/wallets/useWallet', () => ({ __esModule: true, default: jest.fn(() => ({ - address: '0x1234567890', + address: mockWalletAddress, })), })) @@ -62,10 +65,11 @@ jest.mock('@/hooks/useSafeInfo', () => ({ }), })) -jest.mock('@/hooks/coreSDK/safeCoreSDK', () => ({ - __esModule: true, - useSafeSDK: jest.fn(() => ({})), -})) +jest.mock('@/hooks/useNestedSafeOwners') +const mockUseNestedSafeOwners = useNestedSafeOwners as jest.MockedFunction + +jest.mock('@/hooks/coreSDK/safeCoreSDK') +const mockUseSafeSdk = useSafeSDK as jest.MockedFunction const renderButton = () => render({(isOk) => }) @@ -73,6 +77,8 @@ const renderButton = () => describe('CheckWallet', () => { beforeEach(() => { jest.clearAllMocks() + mockUseSafeSdk.mockReturnValue({} as unknown as Safe) + mockUseNestedSafeOwners.mockReturnValue([]) }) it('renders correctly when the wallet is connected to the right chain and is an owner', () => { @@ -226,7 +232,7 @@ describe('CheckWallet', () => { }) it('should disable the button if SDK is not initialized', () => { - ;(useSafeSDK as jest.MockedFunction).mockReturnValue(undefined) + mockUseSafeSdk.mockReturnValue(undefined) const { getByText, getByLabelText } = render( {(isOk) => }, @@ -235,4 +241,12 @@ describe('CheckWallet', () => { expect(getByText('Continue')).toBeDisabled() expect(getByLabelText('SDK is not initialized yet')) }) + it('should allow nested Safe owners', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + mockUseNestedSafeOwners.mockReturnValue([faker.finance.ethereumAddress()]) + + const { container } = render({(isOk) => }) + console.log(container.innerHTML) + expect(container.querySelector('button')).not.toBeDisabled() + }) }) diff --git a/src/components/common/CheckWallet/index.tsx b/src/components/common/CheckWallet/index.tsx index 298b0fc62b..dcf1b786de 100644 --- a/src/components/common/CheckWallet/index.tsx +++ b/src/components/common/CheckWallet/index.tsx @@ -8,6 +8,7 @@ import useConnectWallet from '../ConnectWallet/useConnectWallet' import useIsWrongChain from '@/hooks/useIsWrongChain' import { Tooltip } from '@mui/material' import useSafeInfo from '@/hooks/useSafeInfo' +import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner' type CheckWalletProps = { children: (ok: boolean) => ReactElement @@ -45,6 +46,8 @@ const CheckWallet = ({ const { safe } = useSafeInfo() + const isNestedSafeOwner = useIsNestedSafeOwner() + const isUndeployedSafe = !safe.deployed const message = useMemo(() => { @@ -59,7 +62,13 @@ const CheckWallet = ({ return Message.SafeNotActivated } - if (!allowNonOwner && !isSafeOwner && !isProposer && (!isOnlySpendingLimit || !allowSpendingLimit)) { + if ( + !allowNonOwner && + !isSafeOwner && + !isProposer && + !isNestedSafeOwner && + (!isOnlySpendingLimit || !allowSpendingLimit) + ) { return Message.NotSafeOwner } @@ -72,6 +81,7 @@ const CheckWallet = ({ allowSpendingLimit, allowUndeployedSafe, isProposer, + isNestedSafeOwner, isOnlySpendingLimit, isSafeOwner, isUndeployedSafe, diff --git a/src/components/common/ExternalLink/index.tsx b/src/components/common/ExternalLink/index.tsx index 9090c38177..b70a61b347 100644 --- a/src/components/common/ExternalLink/index.tsx +++ b/src/components/common/ExternalLink/index.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react' import { OpenInNewRounded } from '@mui/icons-material' -import { Box, Link, type LinkProps } from '@mui/material' +import { Box, Button, Link, type LinkProps } from '@mui/material' /** * Renders an external Link which always sets the noopener and noreferrer rel attribute and the target to _blank. @@ -9,24 +9,33 @@ import { Box, Link, type LinkProps } from '@mui/material' const ExternalLink = ({ noIcon = false, children, + href, + mode = 'link', ...props -}: Omit & { noIcon?: boolean }): ReactElement => { - if (!props.href) return <>{children} +}: Omit & { noIcon?: boolean; mode?: 'button' | 'link' }): ReactElement => { + if (!href) return <>{children} - return ( - - - {children} - {!noIcon && } - + const linkContent = ( + + {children} + {!noIcon && } + + ) + return mode === 'link' ? ( + + {linkContent} + ) : ( + ) } diff --git a/src/components/common/Navigate/index.test.tsx b/src/components/common/Navigate/index.test.tsx new file mode 100644 index 0000000000..c8b14db7c0 --- /dev/null +++ b/src/components/common/Navigate/index.test.tsx @@ -0,0 +1,29 @@ +import { render } from '@testing-library/react' +import type { NextRouter } from 'next/router' + +import { Navigate } from '@/components/common/Navigate' + +const mockRouter = { + replace: jest.fn(), + push: jest.fn(), +} as jest.MockedObjectDeep + +describe('Navigate', () => { + beforeEach(() => { + jest.resetAllMocks() + + jest.spyOn(require('next/navigation'), 'useRouter').mockReturnValue(mockRouter) + }) + + it('should navigate to the specified route', () => { + render() + + expect(mockRouter.push).toHaveBeenCalledWith('/test') + }) + + it('should replace the current route', () => { + render() + + expect(mockRouter.replace).toHaveBeenCalledWith('/test') + }) +}) diff --git a/src/components/common/Navigate/index.tsx b/src/components/common/Navigate/index.tsx new file mode 100644 index 0000000000..a5cf8f27eb --- /dev/null +++ b/src/components/common/Navigate/index.tsx @@ -0,0 +1,16 @@ +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' + +export function Navigate({ to, replace = false }: { to: string; replace?: boolean }): null { + const router = useRouter() + + useEffect(() => { + if (replace) { + router.replace(to) + } else { + router.push(to) + } + }, [replace, router, to]) + + return null +} diff --git a/src/components/common/WalletProvider/index.tsx b/src/components/common/WalletProvider/index.tsx index 6e14266d1a..cf13559e5f 100644 --- a/src/components/common/WalletProvider/index.tsx +++ b/src/components/common/WalletProvider/index.tsx @@ -1,13 +1,45 @@ -import { createContext, type ReactElement, type ReactNode, useEffect, useState } from 'react' +import { createContext, type ReactElement, type ReactNode, useEffect, useState, useMemo } from 'react' import useOnboard, { type ConnectedWallet, getConnectedWallet } from '@/hooks/wallets/useOnboard' +import useAsync from '@/hooks/useAsync' +import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import { useCurrentChain } from '@/hooks/useChains' +import { useRouter } from 'next/router' +import { type Eip1193Provider } from 'ethers' +import { getNestedWallet } from '@/utils/nested-safe-wallet' +import { sameAddress } from '@/utils/addresses' -export const WalletContext = createContext(null) +export type SignerWallet = { + provider: Eip1193Provider | null + address: string + chainId: string + isSafe?: boolean +} + +export type WalletContextType = { + connectedWallet: ConnectedWallet | null + signer: SignerWallet | null + setSignerAddress: (address: string | undefined) => void +} + +export const WalletContext = createContext(null) const WalletProvider = ({ children }: { children: ReactNode }): ReactElement => { const onboard = useOnboard() + const currentChain = useCurrentChain() + const web3ReadOnly = useWeb3ReadOnly() + const router = useRouter() const onboardWallets = onboard?.state.get().wallets || [] const [wallet, setWallet] = useState(getConnectedWallet(onboardWallets)) + const [signerAddress, setSignerAddress] = useState() + + const [nestedSafeInfo] = useAsync(() => { + if (signerAddress && !sameAddress(signerAddress, wallet?.address) && currentChain) { + return getSafeInfo(currentChain.chainId, signerAddress) + } + }, [currentChain, signerAddress, wallet?.address]) + useEffect(() => { if (!onboard) return @@ -22,7 +54,24 @@ const WalletProvider = ({ children }: { children: ReactNode }): ReactElement => } }, [onboard]) - return {children} + const signer = useMemo(() => { + if (wallet && nestedSafeInfo && web3ReadOnly) { + return getNestedWallet(wallet, nestedSafeInfo, web3ReadOnly, router) + } + return wallet + }, [wallet, nestedSafeInfo, web3ReadOnly, router]) + + return ( + + {children} + + ) } export default WalletProvider diff --git a/src/components/safe-apps/AppFrame/useGetSafeInfo.ts b/src/components/safe-apps/AppFrame/useGetSafeInfo.ts index 1dcb0c6c09..62ddada412 100644 --- a/src/components/safe-apps/AppFrame/useGetSafeInfo.ts +++ b/src/components/safe-apps/AppFrame/useGetSafeInfo.ts @@ -1,24 +1,26 @@ -import { useMemo } from 'react' +import { useCallback } from 'react' import useChainId from '@/hooks/useChainId' import { useCurrentChain } from '@/hooks/useChains' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import useSafeInfo from '@/hooks/useSafeInfo' import { getLegacyChainName } from '../utils' +import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners' const useGetSafeInfo = () => { const { safe, safeAddress } = useSafeInfo() const isOwner = useIsSafeOwner() + const nestedSafeOwners = useNestedSafeOwners() const chainId = useChainId() const chain = useCurrentChain() const chainName = chain?.chainName || '' - return useMemo( - () => () => ({ + return useCallback(() => { + return { safeAddress, chainId: parseInt(chainId, 10), owners: safe.owners.map((owner) => owner.value), threshold: safe.threshold, - isReadOnly: !isOwner, + isReadOnly: !isOwner && (nestedSafeOwners == null || nestedSafeOwners.length === 0), nonce: safe.nonce, implementation: safe.implementation.value, modules: safe.modules ? safe.modules.map((module) => module.value) : null, @@ -26,22 +28,22 @@ const useGetSafeInfo = () => { guard: safe.guard?.value || null, version: safe.version, network: getLegacyChainName(chainName || '', chainId).toUpperCase(), - }), - [ - chainId, - chainName, - isOwner, - safeAddress, - safe.owners, - safe.threshold, - safe.nonce, - safe.implementation, - safe.modules, - safe.fallbackHandler, - safe.guard, - safe.version, - ], - ) + } + }, [ + safeAddress, + chainId, + safe.owners, + safe.threshold, + safe.nonce, + safe.implementation.value, + safe.modules, + safe.fallbackHandler, + safe.guard?.value, + safe.version, + isOwner, + nestedSafeOwners, + chainName, + ]) } export default useGetSafeInfo diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx index 70c741ce09..1bc0133d90 100644 --- a/src/components/sidebar/SidebarNavigation/config.tsx +++ b/src/components/sidebar/SidebarNavigation/config.tsx @@ -7,6 +7,7 @@ import TransactionIcon from '@/public/images/sidebar/transactions.svg' import ABIcon from '@/public/images/sidebar/address-book.svg' import AppsIcon from '@/public/images/apps/apps-icon.svg' import SettingsIcon from '@/public/images/sidebar/settings.svg' +import BridgeIcon from '@/public/images/common/bridge.svg' import SwapIcon from '@/public/images/common/swap.svg' import StakeIcon from '@/public/images/common/stake.svg' import { SvgIcon } from '@mui/material' @@ -31,6 +32,12 @@ export const navItems: NavItem[] = [ icon: , href: AppRoutes.balances.index, }, + { + label: 'Bridge', + icon: , + href: AppRoutes.bridge, + tag: , + }, { label: 'Swap', icon: , @@ -40,7 +47,6 @@ export const navItems: NavItem[] = [ label: 'Stake', icon: , href: AppRoutes.stake, - tag: , }, { label: 'Transactions', diff --git a/src/components/sidebar/SidebarNavigation/index.tsx b/src/components/sidebar/SidebarNavigation/index.tsx index a14541f566..d41a50a084 100644 --- a/src/components/sidebar/SidebarNavigation/index.tsx +++ b/src/components/sidebar/SidebarNavigation/index.tsx @@ -21,16 +21,18 @@ 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' +import { BRIDGE_EVENTS, BRIDGE_LABELS } from '@/services/analytics/events/bridge' const getSubdirectory = (pathname: string): string => { return pathname.split('/')[1] } -const geoBlockedRoutes = [AppRoutes.swap, AppRoutes.stake] +const geoBlockedRoutes = [AppRoutes.bridge, AppRoutes.swap, AppRoutes.stake] -const undeployedSafeBlockedRoutes = [AppRoutes.swap, AppRoutes.stake, AppRoutes.apps.index] +const undeployedSafeBlockedRoutes = [AppRoutes.bridge, AppRoutes.swap, AppRoutes.stake, AppRoutes.apps.index] const customSidebarEvents: { [key: string]: { event: any; label: string } } = { + [AppRoutes.bridge]: { event: BRIDGE_EVENTS.OPEN_BRIDGE, label: BRIDGE_LABELS.sidebar }, [AppRoutes.swap]: { event: SWAP_EVENTS.OPEN_SWAPS, label: SWAP_LABELS.sidebar }, [AppRoutes.stake]: { event: STAKE_EVENTS.OPEN_STAKE, label: STAKE_LABELS.sidebar }, } diff --git a/src/components/transactions/SignTxButton/index.test.tsx b/src/components/transactions/SignTxButton/index.test.tsx new file mode 100644 index 0000000000..1ec35f5b95 --- /dev/null +++ b/src/components/transactions/SignTxButton/index.test.tsx @@ -0,0 +1,104 @@ +import { render, waitFor } from '@/tests/test-utils' +import SignTxButton from '.' +import { executionInfoBuilder, safeTxSummaryBuilder } from '@/tests/builders/safeTx' +import { type AddressEx, DetailedExecutionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import { faker } from '@faker-js/faker' +import useWallet, { useSigner } from '@/hooks/wallets/useWallet' +import { MockEip1193Provider } from '@/tests/mocks/providers' +import { setSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import type Safe from '@safe-global/protocol-kit' +import useSafeInfo from '@/hooks/useSafeInfo' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' + +jest.mock('@/hooks/wallets/useWallet') +jest.mock('@/hooks/useSafeInfo') +jest.mock('@/hooks/useIsSafeOwner') + +describe('SignTxButton', () => { + const mockUseWallet = useWallet as jest.MockedFunction + const mockUseSigner = useSigner as jest.MockedFunction + const mockUseSafeInfo = useSafeInfo as jest.MockedFunction + const mockUseIsSafeOwner = useIsSafeOwner as jest.MockedFunction + + const testMissingSigners: AddressEx[] = [ + { + value: faker.finance.ethereumAddress(), + }, + { + value: faker.finance.ethereumAddress(), + }, + ] + const txSummary = safeTxSummaryBuilder() + .with({ + executionInfo: executionInfoBuilder() + .with({ + type: DetailedExecutionInfoType.MULTISIG, + confirmationsRequired: 3, + confirmationsSubmitted: 1, + missingSigners: testMissingSigners, + }) + .build(), + }) + .build() + + beforeEach(() => { + jest.clearAllMocks() + + const safeAddress = faker.finance.ethereumAddress() + mockUseSafeInfo.mockReturnValue({ + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .build(), + safeLoaded: true, + safeLoading: false, + }) + }) + + it('should be disabled without any wallet connected', () => { + const result = render() + expect(result.getByRole('button')).toBeDisabled() + }) + it('should be disabled with non-owner connected', () => { + mockUseWallet.mockReturnValue({ + address: faker.finance.ethereumAddress(), + chainId: '1', + label: 'MetaMask', + provider: MockEip1193Provider, + }) + + mockUseSigner.mockReturnValue({ + address: faker.finance.ethereumAddress(), + chainId: '1', + provider: MockEip1193Provider, + }) + + mockUseIsSafeOwner.mockReturnValue(false) + + const result = render() + + expect(result.getByRole('button')).toBeDisabled() + }) + + it('should be enabled with missing signer connected', async () => { + mockUseWallet.mockReturnValue({ + address: testMissingSigners[0].value, + chainId: '1', + label: 'MetaMask', + provider: MockEip1193Provider, + }) + + mockUseSigner.mockReturnValue({ + address: testMissingSigners[0].value, + chainId: '1', + provider: MockEip1193Provider, + }) + mockUseIsSafeOwner.mockReturnValue(true) + setSafeSDK({} as unknown as Safe) + const result = render() + await waitFor(() => { + expect(result.getByRole('button')).toBeEnabled() + }) + }) +}) diff --git a/src/components/transactions/SignTxButton/index.tsx b/src/components/transactions/SignTxButton/index.tsx index f1fbdeb8a7..9e10e08829 100644 --- a/src/components/transactions/SignTxButton/index.tsx +++ b/src/components/transactions/SignTxButton/index.tsx @@ -13,6 +13,7 @@ import CheckWallet from '@/components/common/CheckWallet' import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import { TxModalContext } from '@/components/tx-flow' import { ConfirmTxFlow } from '@/components/tx-flow/flows' +import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners' const SignTxButton = ({ txSummary, @@ -23,8 +24,10 @@ const SignTxButton = ({ }): ReactElement => { const { setTxFlow } = useContext(TxModalContext) const wallet = useWallet() + const nestedOwners = useNestedSafeOwners() const isSafeOwner = useIsSafeOwner() - const isSignable = isSignableBy(txSummary, wallet?.address || '') + const isSignable = + isSignableBy(txSummary, wallet?.address || '') || nestedOwners?.some((owner) => isSignableBy(txSummary, owner)) const safeSDK = useSafeSDK() const expiredSwap = useIsExpiredSwap(txSummary.txInfo) const isDisabled = !isSignable || !safeSDK || expiredSwap diff --git a/src/components/transactions/TxDetails/Summary/index.tsx b/src/components/transactions/TxDetails/Summary/index.tsx index 32bd2c1132..980b750cdf 100644 --- a/src/components/transactions/TxDetails/Summary/index.tsx +++ b/src/components/transactions/TxDetails/Summary/index.tsx @@ -13,6 +13,7 @@ import DecodedData from '../TxData/DecodedData' import { calculateSafeTransactionHash } from '@safe-global/protocol-kit/dist/src/utils' import useSafeInfo from '@/hooks/useSafeInfo' import { SafeTxHashDataRow } from './SafeTxHashDataRow' +import { logError, Errors } from '@/services/exceptions' interface Props { txDetails: TransactionDetails @@ -147,7 +148,12 @@ export const PartialSummary = ({ safeTx }: { safeTx: SafeTransaction }) => { const txData = safeTx.data const { safeAddress, safe } = useSafeInfo() const safeTxHash = useMemo(() => { - return safe.version && calculateSafeTransactionHash(safeAddress, safeTx.data, safe.version, BigInt(safe.chainId)) + if (!safe.version) return + try { + return calculateSafeTransactionHash(safeAddress, safeTx.data, safe.version, BigInt(safe.chainId)) + } catch (e) { + logError(Errors._809, e) + } }, [safe.chainId, safe.version, safeAddress, safeTx.data]) return ( <> diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/index.tsx index 8bc19b7cf5..9ddfd67723 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/index.tsx @@ -35,24 +35,26 @@ export const Value = ({ type, value, ...props }: ValueArrayProps): ReactElement return ( [ -
- {parsedValue.map((address, index) => { - const key = `${props.key || props.method}-${index}` - if (Array.isArray(address)) { - const newProps = { - type, - ...props, - value: address, + {parsedValue.length > 0 && ( +
+ {parsedValue.map((address, index) => { + const key = `${props.key || props.method}-${index}` + if (Array.isArray(address)) { + const newProps = { + type, + ...props, + value: address, + } + return } - return - } - return ( -
- -
- ) - })} -
+ return ( +
+ +
+ ) + })} +
+ )} ]
) diff --git a/src/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation/index.tsx b/src/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation/index.tsx index a29c794f73..c27134f612 100644 --- a/src/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation/index.tsx +++ b/src/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation/index.tsx @@ -16,6 +16,8 @@ import TxData from '../..' import { isMultiSendTxInfo, isOrderTxInfo } from '@/utils/transaction-guards' import { ErrorBoundary } from '@sentry/react' import Multisend from '../../DecodedData/Multisend' +import { MODALS_EVENTS } from '@/services/analytics' +import Track from '@/components/common/Track' const safeInterface = Safe__factory.createInterface() @@ -59,19 +61,21 @@ export const OnChainConfirmation = ({ )} {chain && data && ( - - Open nested transaction - + + + Open nested transaction + + )} ) : txDetailsError ? ( diff --git a/src/components/tx-flow/SafeTxProvider.tsx b/src/components/tx-flow/SafeTxProvider.tsx index 31e2070403..e46ec0a846 100644 --- a/src/components/tx-flow/SafeTxProvider.tsx +++ b/src/components/tx-flow/SafeTxProvider.tsx @@ -8,6 +8,7 @@ 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' +import { useSelectAvailableSigner } from '@/hooks/wallets/useSelectAvailableSigner' export type SafeTxContextParams = { safeTx?: SafeTransaction @@ -49,6 +50,7 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => const { safe } = useSafeInfo() const chain = useCurrentChain() + const selectAvailableSigner = useSelectAvailableSigner() const setAndMigrateSafeTx: Dispatch> = useCallback( ( @@ -62,8 +64,11 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => } prependSafeToL2Migration(safeTx, safe, chain).then(setSafeTx) + + // Select a matching signer when we update the transaction + selectAvailableSigner(safeTx, safe) }, - [chain, safe], + [chain, safe, selectAvailableSigner], ) // Signed txs cannot be updated @@ -86,7 +91,6 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => createTx({ ...safeTx.data, safeTxGas: String(finalSafeTxGas) }, finalNonce) .then((tx) => { - console.log('SafeTxProvider: Updated tx with nonce and safeTxGas', tx) setSafeTx(tx) }) .catch(setSafeTxError) diff --git a/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx b/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx index 9451036981..391e2c77ba 100644 --- a/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx +++ b/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx @@ -3,7 +3,7 @@ import { Typography } from '@mui/material' import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' import useSafeInfo from '@/hooks/useSafeInfo' import { useChainId } from '@/hooks/useChainId' -import useWallet from '@/hooks/wallets/useWallet' +import { useSigner } from '@/hooks/wallets/useWallet' import { isExecutable, isMultisigExecutionInfo, isSignableBy } from '@/utils/transaction-guards' import { createExistingTx } from '@/services/tx/tx-sender' import { SafeTxContext } from '../../SafeTxProvider' @@ -18,15 +18,15 @@ const EXECUTE_TEXT = 'Submit the form to execute this transaction.' const SIGN_EXECUTE_TEXT = 'Sign or immediately execute this transaction.' const ConfirmProposedTx = ({ txSummary }: ConfirmProposedTxProps): ReactElement => { - const wallet = useWallet() + const signer = useSigner() const { safe, safeAddress } = useSafeInfo() const chainId = useChainId() const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext) const txId = txSummary.id const txNonce = isMultisigExecutionInfo(txSummary.executionInfo) ? txSummary.executionInfo.nonce : undefined - const canExecute = isExecutable(txSummary, wallet?.address || '', safe) - const canSign = isSignableBy(txSummary, wallet?.address || '') + const canExecute = isExecutable(txSummary, signer?.address || '', safe) + const canSign = isSignableBy(txSummary, signer?.address || '') useEffect(() => { txNonce !== undefined && setNonce(txNonce) diff --git a/src/components/tx-flow/flows/NestedTxSuccessScreen/index.tsx b/src/components/tx-flow/flows/NestedTxSuccessScreen/index.tsx new file mode 100644 index 0000000000..0e7113fe5c --- /dev/null +++ b/src/components/tx-flow/flows/NestedTxSuccessScreen/index.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect } from 'react' +import { Box, Container, Paper, Stack, SvgIcon, Typography } from '@mui/material' +import { PendingStatus, selectPendingTxById } from '@/store/pendingTxsSlice' +import EthHashInfo from '@/components/common/EthHashInfo' +import ErrorMessage from '@/components/tx/ErrorMessage' +import useAddressBook from '@/hooks/useAddressBook' +import NestedSafeIcon from '@/public/images/transactions/nestedTx.svg' +import ArrowDownIcon from '@/public/images/common/arrow-down.svg' + +import css from './styles.module.css' +import Link from 'next/link' +import { AppRoutes } from '@/config/routes' +import { useAppSelector } from '@/store' +import ExternalLink from '@/components/common/ExternalLink' +import { MODALS_EVENTS } from '@/services/analytics' +import Track from '@/components/common/Track' +import useAsync from '@/hooks/useAsync' +import { getSafeTransaction } from '@/utils/transactions' +import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' + +type Props = { + txId: string +} +const NestedTxSuccessScreen = ({ txId }: Props) => { + const addressBook = useAddressBook() + + // _pendingTx eventually clears from the store, so we need to cache it + const _pendingTx = useAppSelector((state) => (txId ? selectPendingTxById(state, txId) : undefined)) + const [cachedPendingTx, setCachedPendingTx] = useState(_pendingTx) + useEffect(() => { + if (_pendingTx) { + setCachedPendingTx(_pendingTx) + } + }, [_pendingTx]) + + const [safeTx] = useAsync(() => { + if (cachedPendingTx?.status == PendingStatus.NESTED_SIGNING) { + return getSafeTransaction( + cachedPendingTx.txHashOrParentSafeTxHash, + cachedPendingTx.chainId, + cachedPendingTx.signerAddress, + ) + } + }, [cachedPendingTx]) + const isSafeTxHash = + cachedPendingTx?.status == PendingStatus.NESTED_SIGNING && + !!safeTx && + isMultisigDetailedExecutionInfo(safeTx.detailedExecutionInfo) && + safeTx.detailedExecutionInfo.safeTxHash === cachedPendingTx.txHashOrParentSafeTxHash + + if (cachedPendingTx?.status !== PendingStatus.NESTED_SIGNING) { + return No transaction data found + } + + const currentSafeAddress = addressBook[cachedPendingTx.safeAddress] + const parentSafeAddress = addressBook[cachedPendingTx.signerAddress] + + return ( + + + + + + + A nested transaction was created + + + Once confirmed and executed this signer transaction will confirm the child Safe's transaction. + + + + + Parent Safe + + + + + + + approveHash + + + + + Current Safe + + + + + + + Open the transaction + + + + + ) +} + +export default NestedTxSuccessScreen diff --git a/src/components/tx-flow/flows/NestedTxSuccessScreen/styles.module.css b/src/components/tx-flow/flows/NestedTxSuccessScreen/styles.module.css new file mode 100644 index 0000000000..0b3beb0b53 --- /dev/null +++ b/src/components/tx-flow/flows/NestedTxSuccessScreen/styles.module.css @@ -0,0 +1,7 @@ +.icon { + border-radius: 100%; + background-color: var(--color-background-light); + height: 100px; + width: 100px; + padding-top: 30px; +} diff --git a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx index 13101e0591..a48d0cb15b 100644 --- a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx +++ b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx @@ -6,8 +6,10 @@ import * as execThroughRoleHooks from '@/components/tx/SignOrExecuteForm/Execute import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk' import ReviewSignMessageOnChain from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain' -import { JsonRpcProvider, zeroPadValue } from 'ethers' -import { act } from 'react' +import { JsonRpcProvider } from 'ethers' +import { act } from '@testing-library/react' +import { faker } from '@faker-js/faker' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import type { SafeTxContextParams } from '../../SafeTxProvider' import { SafeTxContext } from '../../SafeTxProvider' import { createSafeTx } from '@/tests/builders/safeTx' @@ -24,17 +26,15 @@ describe('ReviewSignMessageOnChain', () => { false, ]) jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => new JsonRpcProvider()) - jest.spyOn(useSafeInfo, 'default').mockImplementation( - () => - ({ - safe: { - address: { - value: zeroPadValue('0x01', 20), - }, - version: '1.3.0', - } as ReturnType['safe'], - }) as ReturnType, - ) + const safeAddress = faker.finance.ethereumAddress() + jest.spyOn(useSafeInfo, 'default').mockReturnValue({ + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .build(), + safeLoaded: true, + safeLoading: false, + }) await act(async () => { render( diff --git a/src/components/tx-flow/flows/index.ts b/src/components/tx-flow/flows/index.ts index 48335b9bb8..b842818db7 100644 --- a/src/components/tx-flow/flows/index.ts +++ b/src/components/tx-flow/flows/index.ts @@ -22,6 +22,7 @@ export const SafeAppsTxFlow = dynamic(() => import('./SafeAppsTx')) export const SignMessageFlow = dynamic(() => import('./SignMessage')) export const SignMessageOnChainFlow = dynamic(() => import('./SignMessageOnChain')) export const SuccessScreenFlow = dynamic(() => import('./SuccessScreen')) +export const NestedTxSuccessScreenFlow = dynamic(() => import('./NestedTxSuccessScreen')) export const TokenTransferFlow = dynamic(() => import('./TokenTransfer')) export const UpdateSafeFlow = dynamic(() => import('./UpdateSafe')) export const UpsertRecoveryFlow = dynamic(() => import('./UpsertRecovery')) diff --git a/src/components/tx-flow/index.tsx b/src/components/tx-flow/index.tsx index f1895a350a..be1f3d66c4 100644 --- a/src/components/tx-flow/index.tsx +++ b/src/components/tx-flow/index.tsx @@ -1,9 +1,10 @@ import { createContext, type ReactElement, type ReactNode, useState, useEffect, useCallback, useRef } from 'react' import { usePathname } from 'next/navigation' import TxModalDialog from '@/components/common/TxModalDialog' -import { SuccessScreenFlow } from './flows' +import { SuccessScreenFlow, NestedTxSuccessScreenFlow } from './flows' import useSafeAddress from '@/hooks/useSafeAddress' import useChainId from '@/hooks/useChainId' +import { useWalletContext } from '@/hooks/wallets/useWallet' const noop = () => {} @@ -33,6 +34,7 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle const prevSafeId = useRef(safeId ?? '') const pathname = usePathname() const prevPathname = useRef(pathname) + const { setSignerAddress } = useWalletContext() ?? {} const handleModalClose = useCallback(() => { if (shouldWarn.current && !confirmClose()) { @@ -41,7 +43,9 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle onClose.current() onClose.current = noop setFlow(undefined) - }, []) + + setSignerAddress?.(undefined) + }, [setSignerAddress]) // Open a new tx flow, close the previous one if any const setTxFlow = useCallback( @@ -50,7 +54,7 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle if (prev === newTxFlow) return prev // If a new flow is triggered, close the current one - if (prev && newTxFlow && newTxFlow.type !== SuccessScreenFlow) { + if (prev && newTxFlow && newTxFlow.type !== SuccessScreenFlow && newTxFlow.type !== NestedTxSuccessScreenFlow) { if (shouldWarn.current && !confirmClose()) { return prev } diff --git a/src/components/tx/DecodedTx/index.tsx b/src/components/tx/DecodedTx/index.tsx index 9841f48a62..39a04fcbf3 100644 --- a/src/components/tx/DecodedTx/index.tsx +++ b/src/components/tx/DecodedTx/index.tsx @@ -1,4 +1,5 @@ import { type SyntheticEvent, type ReactElement, memo } from 'react' +import { ErrorBoundary } from '@sentry/react' import { isCustomTxInfo, isMultisigDetailedExecutionInfo, @@ -130,7 +131,11 @@ const DecodedTx = ({ hideDecodedData={isMethodCallInAdvanced && !!decodedData?.method} /> ) : ( - tx && + tx && ( + + + + ) )} diff --git a/src/components/tx/SignOrExecuteForm/SignForm.tsx b/src/components/tx/SignOrExecuteForm/SignForm.tsx index 6e45dd7a29..8673d781cc 100644 --- a/src/components/tx/SignOrExecuteForm/SignForm.tsx +++ b/src/components/tx/SignOrExecuteForm/SignForm.tsx @@ -17,6 +17,8 @@ import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletReject import BatchButton from './BatchButton' import { asError } from '@/services/exceptions/utils' import { isWalletRejection } from '@/utils/wallets' +import { useSigner } from '@/hooks/wallets/useWallet' +import { NestedTxSuccessScreenFlow } from '@/components/tx-flow/flows' export const SignForm = ({ safeTx, @@ -47,6 +49,7 @@ export const SignForm = ({ const { setTxFlow } = useContext(TxModalContext) const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = txSecurity const hasSigned = useAlreadySigned(safeTx) + const signer = useSigner() // On modal submit const handleSubmit = async (e: SyntheticEvent, isAddingToBatch = false) => { @@ -83,7 +86,11 @@ export const SignForm = ({ onSubmit?.(resultTxId) } - setTxFlow(undefined) + if (signer?.isSafe) { + setTxFlow(, undefined, false) + } else { + setTxFlow(undefined) + } } const onBatchClick = (e: SyntheticEvent) => { diff --git a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx index 1627da354c..f5944f1a52 100644 --- a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx +++ b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx @@ -38,6 +38,9 @@ import { useApprovalInfos } from '../ApprovalEditor/hooks/useApprovalInfos' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import ConfirmationView from '../confirmation-views' +import { SignerForm } from './SignerForm' +import { useSigner } from '@/hooks/wallets/useWallet' +import { isNestedConfirmationTxInfo } from '@/utils/transaction-guards' export type SubmitCallback = (txId: string, isExecuted?: boolean) => void @@ -61,15 +64,26 @@ const trackTxEvents = ( isExecuted: boolean, isRoleExecution: boolean, isProposerCreation: boolean, + isParentSigner: boolean, + origin?: string, ) => { - const creationEvent = isRoleExecution - ? TX_EVENTS.CREATE_VIA_ROLE - : isProposerCreation - ? TX_EVENTS.CREATE_VIA_PROPOSER - : TX_EVENTS.CREATE - const executionEvent = isRoleExecution ? TX_EVENTS.EXECUTE_VIA_ROLE : TX_EVENTS.EXECUTE - const event = isCreation ? creationEvent : isExecuted ? executionEvent : TX_EVENTS.CONFIRM - const txType = getTransactionTrackingType(details) + const isNestedConfirmation = !!details && isNestedConfirmationTxInfo(details.txInfo) + + const creationEvent = getCreationEvent({ isParentSigner, isRoleExecution, isProposerCreation }) + const confirmationEvent = getConfirmationEvent({ isParentSigner, isNestedConfirmation }) + const executionEvent = getExecutionEvent({ isParentSigner, isNestedConfirmation, isRoleExecution }) + + const event = (() => { + if (isCreation) { + return creationEvent + } + if (isExecuted) { + return executionEvent + } + return confirmationEvent + })() + + const txType = getTransactionTrackingType(details, origin) trackEvent({ ...event, label: txType }) // Immediate execution on creation @@ -78,6 +92,42 @@ const trackTxEvents = ( } } +function getCreationEvent(args: { isParentSigner: boolean; isRoleExecution: boolean; isProposerCreation: boolean }) { + if (args.isParentSigner) { + return TX_EVENTS.CREATE_VIA_PARENT + } + if (args.isRoleExecution) { + return TX_EVENTS.CREATE_VIA_ROLE + } + if (args.isProposerCreation) { + return TX_EVENTS.CREATE_VIA_PROPOSER + } + return TX_EVENTS.CREATE +} + +function getConfirmationEvent(args: { isParentSigner: boolean; isNestedConfirmation: boolean }) { + if (args.isParentSigner) { + return TX_EVENTS.CONFIRM_VIA_PARENT + } + if (args.isNestedConfirmation) { + return TX_EVENTS.CONFIRM_IN_PARENT + } + return TX_EVENTS.CONFIRM +} + +function getExecutionEvent(args: { isParentSigner: boolean; isNestedConfirmation: boolean; isRoleExecution: boolean }) { + if (args.isParentSigner) { + return TX_EVENTS.EXECUTE_VIA_PARENT + } + if (args.isNestedConfirmation) { + return TX_EVENTS.EXECUTE_IN_PARENT + } + if (args.isRoleExecution) { + return TX_EVENTS.EXECUTE_VIA_ROLE + } + return TX_EVENTS.EXECUTE +} + export const SignOrExecuteForm = ({ chainId, safeTx, @@ -103,6 +153,7 @@ export const SignOrExecuteForm = ({ const isApproval = readableApprovals && readableApprovals.length > 0 const { safe } = useSafeInfo() const isSafeOwner = useIsSafeOwner() + const signer = useSigner() const isProposer = useIsWalletProposer() const isProposing = isProposer && !isSafeOwner && isCreation const isCounterfactualSafe = !safe.deployed @@ -130,9 +181,17 @@ export const SignOrExecuteForm = ({ const { data: details } = await trigger({ chainId, txId }) // Track tx event - trackTxEvents(details, !!isCreation, isExecuted, isRoleExecution, isProposerCreation) + trackTxEvents( + details, + !!isCreation, + isExecuted, + isRoleExecution, + isProposerCreation, + !!signer?.isSafe, + props.origin, + ) }, - [chainId, isCreation, onSubmit, trigger], + [chainId, isCreation, onSubmit, trigger, signer?.isSafe, props.origin], ) const onRoleExecutionSubmit = useCallback( @@ -171,6 +230,8 @@ export const SignOrExecuteForm = ({ {!isCounterfactualSafe && !props.isRejection && } + + { + const { signer, setSignerAddress, connectedWallet: wallet } = useWalletContext() ?? {} + const nestedSafeOwners = useNestedSafeOwners() + const signerAddress = signer?.address + const { safe } = useSafeInfo() + const { safeTx } = useContext(SafeTxContext) + const isNestedOwner = useIsNestedSafeOwner() + + const onChange = (event: SelectChangeEvent) => { + trackEvent(MODALS_EVENTS.CHANGE_SIGNER) + setSignerAddress?.(event.target.value) + } + + const isOptionEnabled = useCallback( + (address: string) => { + if (!safeTx) { + return true + } + + if (safeTx.signatures.size < safe.threshold) { + const signers = Array.from(safeTx.signatures.keys()) + return !signers.some((key) => sameAddress(key, address)) + } + + return true + }, + [safeTx, safe.threshold], + ) + + const options = useMemo(() => { + if (!wallet) { + return [] + } + const owners = new Set(nestedSafeOwners ?? []) + + if (willExecute || safe.owners.some((owner) => sameAddress(owner.value, wallet.address))) { + owners.add(wallet.address) + } + + return Array.from(owners) + }, [nestedSafeOwners, safe.owners, wallet, willExecute]) + + if (!wallet || !isNestedOwner) { + return null + } + + return ( + + + + {willExecute ? 'Execute' : 'Sign'} with + + + + + + + + Signer Account + + + + + ) +} diff --git a/src/components/tx/SignOrExecuteForm/SignerForm/styles.module.css b/src/components/tx/SignOrExecuteForm/SignerForm/styles.module.css new file mode 100644 index 0000000000..c4ebf3be80 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/SignerForm/styles.module.css @@ -0,0 +1,10 @@ +.signerForm :global .MuiOutlinedInput-notchedOutline { + border: 1px solid var(--color-border-light) !important; +} + +.disabledPill { + background-color: var(--color-border-light); + border-radius: 4px; + color: var(--color-text-primary); + padding: 4px 8px; +} diff --git a/src/components/tx/SignOrExecuteForm/__tests__/SignerForm.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/SignerForm.test.tsx new file mode 100644 index 0000000000..c96501f4e7 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/__tests__/SignerForm.test.tsx @@ -0,0 +1,131 @@ +import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners' +import useSafeInfo from '@/hooks/useSafeInfo' +import { render } from '@/tests/test-utils' +import { SignerForm } from '../SignerForm' +import { faker } from '@faker-js/faker' +import { extendedSafeInfoBuilder, addressExBuilder } from '@/tests/builders/safe' +import { generateRandomArray } from '@/tests/builders/utils' +import { type Eip1193Provider } from 'ethers' +import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' +import { type ReactElement, useState } from 'react' +import { WalletContext } from '@/components/common/WalletProvider' + +jest.mock('@/hooks/useNestedSafeOwners') +jest.mock('@/hooks/useSafeInfo') + +const TestWalletContextProvider = ({ + connectedWallet, + children, +}: { + connectedWallet: ConnectedWallet | null + children: ReactElement +}) => { + const [signerAddress, setSignerAddress] = useState() + + return ( + + {children} + + ) +} + +describe('SignerForm', () => { + const mockUseSafeInfo = useSafeInfo as jest.MockedFunction + const mockUseNestedSafeOwners = useNestedSafeOwners as jest.MockedFunction + + const safeAddress = faker.finance.ethereumAddress() + // Safe with 3 owners + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ chainId: '1' }) + .with({ owners: generateRandomArray(() => addressExBuilder().build(), { min: 3, max: 3 }) }) + .build(), + safeLoaded: true, + safeLoading: false, + } + + const mockOwners = mockSafeInfo.safe.owners + + beforeAll(() => { + mockUseSafeInfo.mockReturnValue(mockSafeInfo) + }) + + it('should not render anything if no wallet is connected', () => { + const result = render( + + + , + ) + expect(result.queryByText('Sign with')).toBeNull() + }) + + it('should not render if there are no nested Safes', () => { + mockUseNestedSafeOwners.mockReturnValue([]) + + const result = render( + + + , + ) + + expect(result.queryByText('Sign with')).toBeNull() + }) + + it('should render sign form if there are nested Safes', () => { + mockUseNestedSafeOwners.mockReturnValue([mockOwners[0].value]) + const result = render( + + + , + ) + expect(result.queryByText('Sign with')).toBeVisible() + }) + + it('should render execution form if there are nested Safes', () => { + mockUseNestedSafeOwners.mockReturnValue([mockOwners[0].value]) + const result = render( + + + , + ) + expect(result.queryByText('Execute with')).toBeVisible() + }) +}) diff --git a/src/components/tx/SignOrExecuteForm/hooks.test.ts b/src/components/tx/SignOrExecuteForm/__tests__/hooks.test.ts similarity index 97% rename from src/components/tx/SignOrExecuteForm/hooks.test.ts rename to src/components/tx/SignOrExecuteForm/__tests__/hooks.test.ts index b6099d9877..c0e3f5cdff 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.test.ts +++ b/src/components/tx/SignOrExecuteForm/__tests__/hooks.test.ts @@ -17,11 +17,14 @@ import { useRecommendedNonce, useTxActions, useValidateNonce, -} from './hooks' +} from '../hooks' import * as recommendedNonce from '@/services/tx/tx-sender/recommendedNonce' import { defaultSafeInfo } from '@/store/safeInfoSlice' import { chainBuilder } from '@/tests/builders/chains' import * as useChains from '@/hooks/useChains' +import { MockEip1193Provider } from '@/tests/mocks/providers' +import { type SignerWallet } from '@/components/common/WalletProvider' +import { type NestedWallet } from '@/utils/nested-safe-wallet' const chainInfo = chainBuilder().with({ chainId: '1' }).build() @@ -49,11 +52,11 @@ describe('SignOrExecute hooks', () => { } as unknown as OnboardAPI) // Wallet - jest.spyOn(wallet, 'default').mockReturnValue({ + jest.spyOn(wallet, 'useSigner').mockReturnValue({ chainId: '1', - label: 'MetaMask', address: '0x1234567890000000000000000000000000000000', - } as unknown as ConnectedWallet) + provider: MockEip1193Provider, + } as unknown as NestedWallet) jest.spyOn(useChains, 'useCurrentChain').mockReturnValue(chainInfo) }) @@ -564,11 +567,11 @@ describe('SignOrExecute hooks', () => { describe('useAlreadySigned', () => { it('should return true if wallet already signed a tx', () => { // Wallet - jest.spyOn(wallet, 'default').mockReturnValue({ + jest.spyOn(wallet, 'useSigner').mockReturnValue({ chainId: '1', - label: 'MetaMask', address: '0x1234567890000000000000000000000000000000', - } as unknown as ConnectedWallet) + provider: MockEip1193Provider, + } as SignerWallet) const tx = createSafeTx() tx.addSignature({ @@ -584,11 +587,11 @@ describe('SignOrExecute hooks', () => { it('should return false if wallet has not signed a tx yet', () => { // Wallet - jest.spyOn(wallet, 'default').mockReturnValue({ + jest.spyOn(wallet, 'useSigner').mockReturnValue({ chainId: '1', - label: 'MetaMask', address: '0x1234567890000000000000000000000000000000', - } as unknown as ConnectedWallet) + provider: MockEip1193Provider, + } as SignerWallet) const tx = createSafeTx() tx.addSignature({ diff --git a/src/components/tx/SignOrExecuteForm/hooks.ts b/src/components/tx/SignOrExecuteForm/hooks.ts index 70df0774be..2474b104c6 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.ts @@ -1,9 +1,9 @@ -import { assertTx, assertWallet, assertOnboard, assertChainInfo } from '@/utils/helpers' +import { assertTx, assertOnboard, assertChainInfo, assertProvider } from '@/utils/helpers' import { useMemo } from 'react' import { type TransactionOptions, type SafeTransaction } from '@safe-global/safe-core-sdk-types' import { sameString } from '@safe-global/protocol-kit/dist/src/utils' import useSafeInfo from '@/hooks/useSafeInfo' -import useWallet from '@/hooks/wallets/useWallet' +import useWallet, { useSigner } from '@/hooks/wallets/useWallet' import useOnboard from '@/hooks/wallets/useOnboard' import { isSmartContractWallet } from '@/utils/wallets' import { @@ -42,8 +42,8 @@ type txDetails = AsyncResult export const useProposeTx = (safeTx?: SafeTransaction, txId?: string, origin?: string): txDetails => { const { safe } = useSafeInfo() - const wallet = useWallet() - const sender = wallet?.address || safe.owners?.[0]?.value + const signer = useSigner() + const sender = signer?.address || safe.owners?.[0]?.value return useAsync( async () => { @@ -61,6 +61,7 @@ export const useProposeTx = (safeTx?: SafeTransaction, txId?: string, origin?: s export const useTxActions = (): TxActions => { const { safe } = useSafeInfo() const onboard = useOnboard() + const signer = useSigner() const wallet = useWallet() const [addTxToBatch] = useUpdateBatch() const chain = useCurrentChain() @@ -87,48 +88,56 @@ export const useTxActions = (): TxActions => { const addToBatch: TxActions['addToBatch'] = async (safeTx, origin) => { assertTx(safeTx) - assertWallet(wallet) + assertProvider(signer?.provider) - const tx = await _propose(wallet.address, safeTx, undefined, origin) + const tx = await _propose(signer.address, safeTx, undefined, origin) await addTxToBatch(tx) return tx.txId } const signRelayedTx = async (safeTx: SafeTransaction, txId?: string): Promise => { assertTx(safeTx) - assertWallet(wallet) + assertProvider(signer?.provider) // Smart contracts cannot sign transactions off-chain - if (await isSmartContractWallet(wallet.chainId, wallet.address)) { + if (await isSmartContractWallet(signer.chainId, signer.address)) { throw new Error('Cannot relay an unsigned transaction from a smart contract wallet') } - return await dispatchTxSigning(safeTx, version, wallet.provider, txId) + return await dispatchTxSigning(safeTx, version, signer.provider, txId) } const signTx: TxActions['signTx'] = async (safeTx, txId, origin) => { assertTx(safeTx) - assertWallet(wallet) + assertProvider(signer?.provider) assertOnboard(onboard) // Smart contract wallets must sign via an on-chain tx - if (await isSmartContractWallet(wallet.chainId, wallet.address)) { + if (signer.isSafe || (await isSmartContractWallet(signer.chainId, signer.address))) { // If the first signature is a smart contract wallet, we have to propose w/o signatures // Otherwise the backend won't pick up the tx // The signature will be added once the on-chain signature is indexed - const id = txId || (await _propose(wallet.address, safeTx, txId, origin)).txId - await dispatchOnChainSigning(safeTx, id, wallet.provider, chainId, wallet.address, safeAddress) + const id = txId || (await _propose(signer.address, safeTx, txId, origin)).txId + await dispatchOnChainSigning( + safeTx, + id, + signer.provider, + chainId, + signer.address, + safeAddress, + Boolean(signer.isSafe), + ) return id } // Otherwise, sign off-chain - const signedTx = await dispatchTxSigning(safeTx, version, wallet.provider, txId) - const tx = await _propose(wallet.address, signedTx, txId, origin) + const signedTx = await dispatchTxSigning(safeTx, version, signer.provider, txId) + const tx = await _propose(signer.address, signedTx, txId, origin) return tx.txId } const signProposerTx: TxActions['signProposerTx'] = async (safeTx) => { assertTx(safeTx) - assertWallet(wallet) + assertProvider(wallet?.provider) assertOnboard(onboard) const signedTx = await dispatchProposerTxSigning(safeTx, wallet) @@ -139,7 +148,7 @@ export const useTxActions = (): TxActions => { const executeTx: TxActions['executeTx'] = async (txOptions, safeTx, txId, origin, isRelayed) => { assertTx(safeTx) - assertWallet(wallet) + assertProvider(signer?.provider) assertOnboard(onboard) assertChainInfo(chain) @@ -153,7 +162,7 @@ export const useTxActions = (): TxActions => { // Propose the tx if there's no id yet ("immediate execution") if (!txId || rePropose) { - tx = await _propose(wallet.address, safeTx, txId, origin) + tx = await _propose(signer.address, safeTx, txId, origin) txId = tx.txId } @@ -161,16 +170,15 @@ export const useTxActions = (): TxActions => { if (isRelayed) { await dispatchTxRelay(safeTx, safe, txId, chain, txOptions.gasLimit) } else { - const isSmartAccount = await isSmartContractWallet(wallet.chainId, wallet.address) - - await dispatchTxExecution(safeTx, txOptions, txId, wallet.provider, wallet.address, safeAddress, isSmartAccount) + const isSmartAccount = await isSmartContractWallet(signer.chainId, signer.address) + await dispatchTxExecution(safeTx, txOptions, txId, signer.provider, signer.address, safeAddress, isSmartAccount) } return txId } return { addToBatch, signTx, executeTx, signProposerTx, proposeTx } - }, [safe, wallet, addTxToBatch, onboard, chain]) + }, [safe, wallet, signer?.provider, signer?.address, signer?.chainId, signer?.isSafe, addTxToBatch, onboard, chain]) } export const useValidateNonce = (safeTx: SafeTransaction | undefined): boolean => { @@ -236,7 +244,7 @@ export const useSafeTxGas = (safeTx: SafeTransaction | undefined): string | unde } export const useAlreadySigned = (safeTx: SafeTransaction | undefined): boolean => { - const wallet = useWallet() + const wallet = useSigner() const hasSigned = safeTx && wallet && (safeTx.signatures.has(wallet.address.toLowerCase()) || safeTx.signatures.has(wallet.address)) return Boolean(hasSigned) diff --git a/src/components/tx/security/blockaid/__tests__/useBlockaid.test.ts b/src/components/tx/security/blockaid/__tests__/useBlockaid.test.ts index 0043375c3f..f8b3fc81d1 100644 --- a/src/components/tx/security/blockaid/__tests__/useBlockaid.test.ts +++ b/src/components/tx/security/blockaid/__tests__/useBlockaid.test.ts @@ -1,5 +1,4 @@ import * as useChains from '@/hooks/useChains' -import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import * as useWallet from '@/hooks/wallets/useWallet' import { SecuritySeverity } from '@/services/security/modules/types' import { eip712TypedDataBuilder } from '@/tests/builders/messages' @@ -12,6 +11,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { safeInfoBuilder } from '@/tests/builders/safe' import { CLASSIFICATION_MAPPING, REASON_MAPPING } from '..' import { renderHook, waitFor } from '@/tests/test-utils' +import { type SignerWallet } from '@/components/common/WalletProvider' const setupFetchStub = (data: any) => () => { return Promise.resolve({ @@ -37,7 +37,7 @@ jest.mock('@/hooks/useSafeInfo') const mockUseSafeInfo = useSafeInfo as jest.MockedFunction describe.each([TEST_CASES.MESSAGE, TEST_CASES.TRANSACTION])('useBlockaid for %s', (testCase) => { - let mockUseWallet: jest.SpyInstance + let mockUseSigner: jest.SpyInstance const mockPayload = testCase === TEST_CASES.TRANSACTION ? safeTxBuilder().build() : eip712TypedDataBuilder().build() @@ -46,8 +46,8 @@ describe.each([TEST_CASES.MESSAGE, TEST_CASES.TRANSACTION])('useBlockaid for %s' beforeEach(() => { jest.resetAllMocks() jest.useFakeTimers() - mockUseWallet = jest.spyOn(useWallet, 'default') - mockUseWallet.mockImplementation(() => null) + mockUseSigner = jest.spyOn(useWallet, 'useSigner') + mockUseSigner.mockImplementation(() => null) mockUseSafeInfo.mockReturnValue({ safe: { ...mockSafeInfo, deployed: true }, safeAddress: mockSafeInfo.address.value, @@ -81,7 +81,7 @@ describe.each([TEST_CASES.MESSAGE, TEST_CASES.TRANSACTION])('useBlockaid for %s' it('should return undefined without feature enabled', async () => { const walletAddress = toBeHex('0x1', 20) - mockUseWallet.mockImplementation(() => ({ + mockUseSigner.mockImplementation(() => ({ address: walletAddress, chainId: '1', label: 'Testwallet', @@ -102,7 +102,7 @@ describe.each([TEST_CASES.MESSAGE, TEST_CASES.TRANSACTION])('useBlockaid for %s' it('should handle request errors', async () => { const walletAddress = toBeHex('0x1', 20) - mockUseWallet.mockImplementation(() => ({ + mockUseSigner.mockImplementation(() => ({ address: walletAddress, chainId: '1', label: 'Testwallet', @@ -126,7 +126,7 @@ describe.each([TEST_CASES.MESSAGE, TEST_CASES.TRANSACTION])('useBlockaid for %s' it('should handle failed simulations', async () => { const walletAddress = toBeHex('0x1', 20) - mockUseWallet.mockImplementation(() => ({ + mockUseSigner.mockImplementation(() => ({ address: walletAddress, chainId: '1', label: 'Testwallet', @@ -216,7 +216,7 @@ describe.each([TEST_CASES.MESSAGE, TEST_CASES.TRANSACTION])('useBlockaid for %s' }, } - mockUseWallet.mockImplementation(() => ({ + mockUseSigner.mockImplementation(() => ({ address: walletAddress, chainId: '1', label: 'Testwallet', diff --git a/src/components/tx/security/blockaid/useBlockaid.ts b/src/components/tx/security/blockaid/useBlockaid.ts index c328e97a92..f09a02fc0f 100644 --- a/src/components/tx/security/blockaid/useBlockaid.ts +++ b/src/components/tx/security/blockaid/useBlockaid.ts @@ -1,7 +1,7 @@ import useAsync, { type AsyncResult } from '@/hooks/useAsync' import { useHasFeature } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' -import useWallet from '@/hooks/wallets/useWallet' +import { useSigner } from '@/hooks/wallets/useWallet' import { MODALS_EVENTS, trackEvent } from '@/services/analytics' import type { SecurityResponse } from '@/services/security/modules/types' import { FEATURES } from '@/utils/chains' @@ -19,12 +19,12 @@ export const useBlockaid = ( data: SafeTransaction | EIP712TypedData | undefined, ): AsyncResult> => { const { safe, safeAddress } = useSafeInfo() - const wallet = useWallet() + const signer = useSigner() const isFeatureEnabled = useHasFeature(FEATURES.RISK_MITIGATION) const [blockaidPayload, blockaidErrors, blockaidLoading] = useAsync>( () => { - if (!isFeatureEnabled || !data || !wallet?.address) { + if (!isFeatureEnabled || !data || !signer?.address) { return } @@ -32,12 +32,11 @@ export const useBlockaid = ( chainId: Number(safe.chainId), data, safeAddress, - walletAddress: wallet.address, + walletAddress: signer.address, threshold: safe.threshold, }) }, - - [safe.chainId, safe.threshold, safeAddress, data, wallet?.address, isFeatureEnabled], + [safe.chainId, safe.threshold, safeAddress, data, signer?.address, isFeatureEnabled], false, ) diff --git a/src/components/tx/security/tenderly/index.tsx b/src/components/tx/security/tenderly/index.tsx index 7022849d28..9ad2f4c504 100644 --- a/src/components/tx/security/tenderly/index.tsx +++ b/src/components/tx/security/tenderly/index.tsx @@ -4,7 +4,7 @@ import { useContext, useEffect } from 'react' import type { ReactElement } from 'react' import useSafeInfo from '@/hooks/useSafeInfo' -import useWallet from '@/hooks/wallets/useWallet' +import { useSigner } from '@/hooks/wallets/useWallet' import CheckIcon from '@/public/images/common/check.svg' import CloseIcon from '@/public/images/common/close.svg' import { useDarkMode } from '@/hooks/useDarkMode' @@ -34,7 +34,7 @@ export type TxSimulationProps = { // TODO: Test this component const TxSimulationBlock = ({ transactions, disabled, gasLimit, executionOwner }: TxSimulationProps): ReactElement => { const { safe } = useSafeInfo() - const wallet = useWallet() + const signer = useSigner() const isSafeOwner = useIsSafeOwner() const isDarkMode = useDarkMode() const { safeTx } = useContext(SafeTxContext) @@ -44,14 +44,14 @@ const TxSimulationBlock = ({ transactions, disabled, gasLimit, executionOwner }: } = useContext(TxInfoContext) const handleSimulation = async () => { - if (!wallet) { + if (!signer) { return } simulateTransaction({ safe, // fall back to the first owner of the safe in case the transaction is created by a proposer - executionOwner: (executionOwner ?? isSafeOwner) ? wallet.address : safe.owners[0].value, + executionOwner: (executionOwner ?? isSafeOwner) ? signer.address : safe.owners[0].value, transactions, gasLimit, } as SimulationTxParams) diff --git a/src/components/wrappers/DisclaimerWrapper/index.test.tsx b/src/components/wrappers/DisclaimerWrapper/index.test.tsx new file mode 100644 index 0000000000..875ac8ccc2 --- /dev/null +++ b/src/components/wrappers/DisclaimerWrapper/index.test.tsx @@ -0,0 +1,40 @@ +import { _DisclaimerWrapper, DisclaimerWrapper } from '@/components/wrappers/DisclaimerWrapper' +import { act, render } from '@/tests/test-utils' + +describe('DisclaimerWrapper', () => { + it('should render children if consent is given', () => { + const { queryByText } = render( + <_DisclaimerWrapper localStorageKey="key" widgetName="name" getLocalStorage={() => [true as any, () => {}]}> + <>Consent given + , + ) + + expect(queryByText('Consent given')).toBeTruthy() + }) + + it('should not render children if consent is not given', () => { + const { queryByText } = render( + <_DisclaimerWrapper localStorageKey="key" widgetName="name" getLocalStorage={() => [false as any, () => {}]}> + <>Consent given + , + ) + + expect(queryByText('Consent given')).toBeFalsy() + }) + + it('should render children if disclaimer is accepted', () => { + const { getByText, queryByText } = render( + + <>Consent given + , + ) + + expect(queryByText('Consent given')).toBeFalsy() + + act(() => { + getByText('Continue').click() + }) + + expect(queryByText('Consent given')).toBeTruthy() + }) +}) diff --git a/src/components/wrappers/DisclaimerWrapper/index.tsx b/src/components/wrappers/DisclaimerWrapper/index.tsx new file mode 100644 index 0000000000..12d180b13b --- /dev/null +++ b/src/components/wrappers/DisclaimerWrapper/index.tsx @@ -0,0 +1,45 @@ +import { Stack } from '@mui/material' +import type { ReactElement } from 'react' + +import Disclaimer from '@/components/common/Disclaimer' +import WidgetDisclaimer from '@/components/common/WidgetDisclaimer' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import madProps from '@/utils/mad-props' + +// TODO: Use with swaps/staking +export function _DisclaimerWrapper({ + children, + localStorageKey, + widgetName, + getLocalStorage, +}: { + children: ReactElement + localStorageKey: string + widgetName: string + getLocalStorage: typeof useLocalStorage +}): ReactElement | null { + const [hasConsented = false, setHasConsented] = getLocalStorage(localStorageKey) + + const onAccept = () => { + setHasConsented(true) + } + + if (!hasConsented) { + return ( + + } + onAccept={onAccept} + buttonText="Continue" + /> + + ) + } + + return children +} + +export const DisclaimerWrapper = madProps(_DisclaimerWrapper, { + getLocalStorage: () => useLocalStorage, +}) diff --git a/src/components/wrappers/FeatureWrapper/index.test.tsx b/src/components/wrappers/FeatureWrapper/index.test.tsx new file mode 100644 index 0000000000..5cb50165ad --- /dev/null +++ b/src/components/wrappers/FeatureWrapper/index.test.tsx @@ -0,0 +1,73 @@ +import { faker } from '@faker-js/faker' +import type { NextRouter } from 'next/router' + +import { render } from '@/tests/test-utils' +import { _FeatureWrapper } from '@/components/wrappers/FeatureWrapper' +import { FEATURES } from '@/utils/chains' +import type * as useChains from '@/hooks/useChains' + +const mockRouter = { + replace: jest.fn(), +} as jest.MockedObjectDeep +const mockUseHasFeature: jest.MockedFn<(typeof useChains)['useHasFeature']> = jest.fn() + +describe('FeatureWrapper', () => { + beforeEach(() => { + jest.resetAllMocks() + + jest.spyOn(require('next/navigation'), 'useRouter').mockReturnValue(mockRouter) + jest.spyOn(require('@/hooks/useChains'), 'default').mockReturnValue(mockUseHasFeature) + }) + + it('should render the children if the feature is enabled', () => { + mockUseHasFeature.mockReturnValue(true) + + const { queryByText } = render( + <_FeatureWrapper + feature={faker.helpers.objectValue(FEATURES)} + fallbackRoute="/test" + isFeatureEnabled={mockUseHasFeature} + > + <>Feature enabled + , + ) + + expect(queryByText('Feature enabled')).toBeTruthy() + expect(mockRouter.replace).not.toHaveBeenCalled() + }) + + it('should replace the current route if the feature is disabled', () => { + const route = '/test' + mockUseHasFeature.mockReturnValue(false) + + const { queryByText } = render( + <_FeatureWrapper + feature={faker.helpers.objectValue(FEATURES)} + fallbackRoute={route} + isFeatureEnabled={mockUseHasFeature} + > + <>Feature enabled + , + ) + + expect(queryByText('Feature enabled')).toBeNull() + expect(mockRouter.replace).toHaveBeenCalledWith(route) + }) + + it('should not render anything if the enabled features are loading', () => { + mockUseHasFeature.mockReturnValue(undefined) + + const { queryByText } = render( + <_FeatureWrapper + feature={faker.helpers.objectValue(FEATURES)} + fallbackRoute="/test" + isFeatureEnabled={mockUseHasFeature} + > + <>Feature enabled + , + ) + + expect(queryByText('Feature enabled')).toBeNull() + expect(mockRouter.replace).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/wrappers/FeatureWrapper/index.tsx b/src/components/wrappers/FeatureWrapper/index.tsx new file mode 100644 index 0000000000..806a250aea --- /dev/null +++ b/src/components/wrappers/FeatureWrapper/index.tsx @@ -0,0 +1,35 @@ +import type { ReactElement } from 'react' + +import { Navigate } from '@/components/common/Navigate' +import { useHasFeature } from '@/hooks/useChains' +import madProps from '@/utils/mad-props' +import type { FEATURES } from '@/utils/chains' + +// TODO: Use with swaps/staking +export function _FeatureWrapper({ + children, + feature, + fallbackRoute, + isFeatureEnabled, +}: { + children: ReactElement + feature: FEATURES + fallbackRoute: string + isFeatureEnabled: typeof useHasFeature +}): ReactElement | null { + const isEnabled = isFeatureEnabled(feature) + + if (isEnabled === undefined) { + return null + } + + if (isEnabled === false) { + return + } + + return children +} + +export const FeatureWrapper = madProps(_FeatureWrapper, { + isFeatureEnabled: () => useHasFeature, +}) diff --git a/src/components/wrappers/SanctionWrapper/index.test.tsx b/src/components/wrappers/SanctionWrapper/index.test.tsx new file mode 100644 index 0000000000..655334a343 --- /dev/null +++ b/src/components/wrappers/SanctionWrapper/index.test.tsx @@ -0,0 +1,109 @@ +import { faker } from '@faker-js/faker' + +import { render } from '@/tests/test-utils' +import { _SanctionWrapper } from '@/components/wrappers/SanctionWrapper' +import type { useGetIsSanctionedQuery } from '@/store/api/ofac' +import type useSafeInfo from '@/hooks/useSafeInfo' +import type useWallet from '@/hooks/wallets/useWallet' + +describe('SanctionWrapper', () => { + it('should render the children if neither the signer or Safe is sanctioned', () => { + const safe = faker.finance.ethereumAddress() + const wallet = faker.finance.ethereumAddress() + + const getSafeInfo = (() => { + return { safeAddress: safe } + }) as typeof useSafeInfo + + const getWallet = (() => { + return { address: wallet } + }) as typeof useWallet + + const isSanctioned = (() => { + return { data: false } + }) as typeof useGetIsSanctionedQuery + + const { queryByText } = render( + <_SanctionWrapper featureTitle="test" getSafeInfo={getSafeInfo} getWallet={getWallet} isSanctioned={isSanctioned}> + <>Not sanctioned + , + ) + + expect(queryByText('Not sanctioned')).toBeTruthy() + }) + + it('should render the disclaimer if the signer is sanctioned', () => { + const safe = faker.finance.ethereumAddress() + const wallet = faker.finance.ethereumAddress() + + const getSafeInfo = (() => { + return { safeAddress: safe } + }) as typeof useSafeInfo + + const getWallet = (() => { + return { address: wallet } + }) as typeof useWallet + + const isSanctioned = ((address: string) => { + return { data: address === wallet } + }) as typeof useGetIsSanctionedQuery + + const { queryByText } = render( + <_SanctionWrapper featureTitle="test" getSafeInfo={getSafeInfo} getWallet={getWallet} isSanctioned={isSanctioned}> + <>Not sanctioned + , + ) + + expect(queryByText('Not sanctioned')).toBeFalsy() + }) + + it('should render the disclaimer if the Safe is sanctioned', () => { + const safe = faker.finance.ethereumAddress() + const wallet = faker.finance.ethereumAddress() + + const getSafeInfo = (() => { + return { safeAddress: safe } + }) as typeof useSafeInfo + + const getWallet = (() => { + return { address: wallet } + }) as typeof useWallet + + const isSanctioned = ((address: string) => { + return { data: address === safe } + }) as typeof useGetIsSanctionedQuery + + const { queryByText } = render( + <_SanctionWrapper featureTitle="test" getSafeInfo={getSafeInfo} getWallet={getWallet} isSanctioned={isSanctioned}> + <>Not sanctioned + , + ) + + expect(queryByText('Blocked address')).toBeTruthy() + }) + + it('should render if the sanction list is loading', () => { + const safe = faker.finance.ethereumAddress() + const wallet = faker.finance.ethereumAddress() + + const getSafeInfo = (() => { + return { safeAddress: safe } + }) as typeof useSafeInfo + + const getWallet = (() => { + return { address: wallet } + }) as typeof useWallet + + const isSanctioned = (() => { + return { data: undefined } + }) as typeof useGetIsSanctionedQuery + + const { queryByText } = render( + <_SanctionWrapper featureTitle="test" getSafeInfo={getSafeInfo} getWallet={getWallet} isSanctioned={isSanctioned}> + <>Not sanctioned + , + ) + + expect(queryByText('Not sanctioned')).toBeTruthy() + }) +}) diff --git a/src/components/wrappers/SanctionWrapper/index.tsx b/src/components/wrappers/SanctionWrapper/index.tsx new file mode 100644 index 0000000000..12465eed4b --- /dev/null +++ b/src/components/wrappers/SanctionWrapper/index.tsx @@ -0,0 +1,52 @@ +import { Stack } from '@mui/material' +import { skipToken } from '@reduxjs/toolkit/query' +import type { ReactElement } from 'react' + +import BlockedAddress from '@/components/common/BlockedAddress' +import useSafeInfo from '@/hooks/useSafeInfo' +import useWallet from '@/hooks/wallets/useWallet' +import { useGetIsSanctionedQuery } from '@/store/api/ofac' +import { getKeyWithTrueValue } from '@/utils/helpers' +import madProps from '@/utils/mad-props' + +// TODO: Use with swaps/staking +export function _SanctionWrapper({ + children, + featureTitle, + getSafeInfo, + getWallet, + isSanctioned, +}: { + children: ReactElement + featureTitle: string + getSafeInfo: typeof useSafeInfo + getWallet: typeof useWallet + isSanctioned: typeof useGetIsSanctionedQuery +}): ReactElement | null { + const { safeAddress } = getSafeInfo() + const wallet = getWallet() + + const { data: isSafeAddressBlocked = false } = isSanctioned(safeAddress || skipToken) + const { data: isWalletAddressBlocked = false } = isSanctioned(wallet?.address || skipToken) + + const blockedAddress = getKeyWithTrueValue({ + [safeAddress]: !!isSafeAddressBlocked, + [wallet?.address || '']: !!isWalletAddressBlocked, + }) + + if (blockedAddress) { + return ( + + + + ) + } + + return children +} + +export const SanctionWrapper = madProps(_SanctionWrapper, { + getWallet: () => useWallet, + getSafeInfo: () => useSafeInfo, + isSanctioned: () => useGetIsSanctionedQuery, +}) diff --git a/src/config/routes.ts b/src/config/routes.ts index 8f65fa5bb7..9002355243 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -11,6 +11,7 @@ export const AppRoutes = { imprint: '/imprint', home: '/home', cookie: '/cookie', + bridge: '/bridge', addressBook: '/address-book', addOwner: '/addOwner', _offline: '/_offline', diff --git a/src/features/bridge/components/Bridge/index.tsx b/src/features/bridge/components/Bridge/index.tsx new file mode 100644 index 0000000000..3d503b3cfd --- /dev/null +++ b/src/features/bridge/components/Bridge/index.tsx @@ -0,0 +1,28 @@ +import dynamic from 'next/dynamic' + +import { AppRoutes } from '@/config/routes' +import { FEATURES } from '@/utils/chains' +import { FeatureWrapper } from '@/components/wrappers/FeatureWrapper' +import { SanctionWrapper } from '@/components/wrappers/SanctionWrapper' +import { DisclaimerWrapper } from '@/components/wrappers/DisclaimerWrapper' + +const LOCAL_STORAGE_CONSENT_KEY = 'bridgeConsent' + +const BridgeWidget = dynamic( + () => import('@/features/bridge/components/BridgeWidget').then((module) => module.BridgeWidget), + { + ssr: false, + }, +) + +export function Bridge() { + return ( + + + + + + + + ) +} diff --git a/src/features/bridge/components/BridgeWidget/index.test.tsx b/src/features/bridge/components/BridgeWidget/index.test.tsx new file mode 100644 index 0000000000..09d19c1361 --- /dev/null +++ b/src/features/bridge/components/BridgeWidget/index.test.tsx @@ -0,0 +1,82 @@ +import { faker } from '@faker-js/faker' + +import { _getAppData } from '@/features/bridge/components/BridgeWidget' +import { chainBuilder } from '@/tests/builders/chains' +import { FEATURES } from '@/utils/chains' + +describe('BridgeWidget', () => { + describe('getAppData', () => { + it('should return the correct SafeAppDataWithPermissions', () => { + const result = _getAppData(false) + + expect(result).toStrictEqual({ + accessControl: { + type: 'NO_RESTRICTIONS', + }, + chainIds: [], + description: '', + developerWebsite: '', + features: [], + iconUrl: '/images/common/bridge.svg', + id: expect.any(Number), + name: 'Bridge', + safeAppsPermissions: [], + socialProfiles: [], + tags: [], + url: 'https://iframe.jumper.exchange/?theme=light', + }) + }) + + it('should return the correct SafeAppDataWithPermissions with dark mode', () => { + const result = _getAppData(true) + + expect(result).toStrictEqual({ + accessControl: { + type: 'NO_RESTRICTIONS', + }, + chainIds: [], + description: '', + developerWebsite: '', + features: [], + iconUrl: '/images/common/bridge.svg', + id: expect.any(Number), + name: 'Bridge', + safeAppsPermissions: [], + socialProfiles: [], + tags: [], + url: 'https://iframe.jumper.exchange/?theme=dark', + }) + }) + + it('should return the correct SafeAppDataWithPermissions with chains', () => { + const chains = Array.from({ length: faker.number.int({ min: 1, max: 10 }) }, (_, i) => { + return ( + chainBuilder() + // @ts-expect-error + .with({ features: i % 2 ? [FEATURES.BRIDGE] : [] }) + .build() + ) + }) + + const result = _getAppData(false, chains) + + expect(result).toStrictEqual({ + accessControl: { + type: 'NO_RESTRICTIONS', + }, + // @ts-expect-error + chainIds: chains.filter((chain) => chain.features.includes(FEATURES.BRIDGE)).map((chain) => chain.chainId), + description: '', + developerWebsite: '', + features: [], + iconUrl: '/images/common/bridge.svg', + id: expect.any(Number), + name: 'Bridge', + safeAppsPermissions: [], + socialProfiles: [], + tags: [], + url: 'https://iframe.jumper.exchange/?theme=light', + }) + }) + }) +}) diff --git a/src/features/bridge/components/BridgeWidget/index.tsx b/src/features/bridge/components/BridgeWidget/index.tsx new file mode 100644 index 0000000000..cf36700fdf --- /dev/null +++ b/src/features/bridge/components/BridgeWidget/index.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react' +import type { ReactElement } from 'react' + +import AppFrame from '@/components/safe-apps/AppFrame' +import { getEmptySafeApp } from '@/components/safe-apps/utils' +import useChains from '@/hooks/useChains' +import { FEATURES, hasFeature } from '@/utils/chains' +import { useDarkMode } from '@/hooks/useDarkMode' +import type { SafeAppDataWithPermissions } from '@/components/safe-apps/types' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +export const BRIDGE_WIDGET_URL = 'https://iframe.jumper.exchange' + +export function BridgeWidget(): ReactElement { + const isDarkMode = useDarkMode() + const { configs } = useChains() + + const appData = useMemo((): SafeAppDataWithPermissions => { + return _getAppData(isDarkMode, configs) + }, [configs, isDarkMode]) + + return ( + + ) +} + +export function _getAppData(isDarkMode: boolean, chains?: Array): SafeAppDataWithPermissions { + const theme = isDarkMode ? 'dark' : 'light' + return { + ...getEmptySafeApp(), + name: 'Bridge', + iconUrl: '/images/common/bridge.svg', + chainIds: getChainIds(chains), + url: `${BRIDGE_WIDGET_URL}/?theme=${theme}`, + } +} + +function getChainIds(chains?: Array): Array { + if (!chains) { + return [] + } + return chains.reduce>((acc, cur) => { + if (hasFeature(cur, FEATURES.BRIDGE)) { + acc.push(cur.chainId) + } + return acc + }, []) +} diff --git a/src/features/bridge/hooks/useIsBridgeFeatureEnabled.ts b/src/features/bridge/hooks/useIsBridgeFeatureEnabled.ts new file mode 100644 index 0000000000..2f3a718483 --- /dev/null +++ b/src/features/bridge/hooks/useIsBridgeFeatureEnabled.ts @@ -0,0 +1,6 @@ +import { useIsGeoblockedFeatureEnabled } from '@/hooks/useIsGeoblockedFeatureEnabled' +import { FEATURES } from '@/utils/chains' + +export function useIsBridgeFeatureEnabled() { + return useIsGeoblockedFeatureEnabled(FEATURES.BRIDGE) +} diff --git a/src/features/myAccounts/components/OrderByButton/index.tsx b/src/features/myAccounts/components/OrderByButton/index.tsx index 5a07a4b4cd..0944fee6d1 100644 --- a/src/features/myAccounts/components/OrderByButton/index.tsx +++ b/src/features/myAccounts/components/OrderByButton/index.tsx @@ -36,6 +36,7 @@ const OrderByButton = ({ orderBy: orderBy, onOrderByChange: onOrderByChange }: O return ( - - ) : ( - - - - + Self Custodial Recovery + + + + + Fully own your recovery setup + + + + Nominate anyone including friends, family or yourself + + + + No additional cost and ensured privacy + + + + } + /> + + } + disabled={!hasSygnumApp} + label={ +
+ + + + Sygnum Web3 Recovery + + + + + Your key. Your crypto. Your recovery + + + + Account recovery by your identity + + + + Regulated Swiss digital asset bank + + + + {!hasSygnumApp && ( + + Not available on this network + + )} +
+ } + /> + + } + label={ +
+ + + + Email Recovery + + + + + Free: no fees, no gas cost + + + + Easy to use: just respond to an email to recover Safe + + + + Anonymous: thanks to zero knowledge tech the email is never exposed + + +
+ } + /> + + )} + /> + + + Unhappy with the provided options?{' '} + + Give us feedback - )} -
- - + + + {[RecoveryMethod.SelfCustody, RecoveryMethod.ZkEmail].includes(currentType) ? ( + + + + ) : ( + + + + + + )} + + + + + setOpenZkEmailModal(false)} /> + ) } diff --git a/src/features/recovery/components/RecoverySettings/ZkEmailFakeDoorModal.tsx b/src/features/recovery/components/RecoverySettings/ZkEmailFakeDoorModal.tsx new file mode 100644 index 0000000000..63a2758dfe --- /dev/null +++ b/src/features/recovery/components/RecoverySettings/ZkEmailFakeDoorModal.tsx @@ -0,0 +1,33 @@ +import { type ReactElement } from 'react' +import { Dialog, DialogContent, IconButton, Stack, Typography } from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' + +import RecoveryZkEmailIcon from '@/public/images/common/zkemail-logo.svg' +import css from './styles.module.css' + +export function ZkEmailFakeDoorModal({ open, onClose }: { open: boolean; onClose: () => void }): ReactElement { + return ( + + + + + + + + + + + Feature is coming soon + + + Thanks for showing interest in email recovery. We are currently measuring demand for it, so your click on + this option made the release one step closer. + + + Stay tuned! + + + + + ) +} diff --git a/src/features/recovery/hooks/__tests__/useIsValidExecution.test.ts b/src/features/recovery/hooks/__tests__/useIsValidExecution.test.ts index 4fb0e7c5f0..6ca079a063 100644 --- a/src/features/recovery/hooks/__tests__/useIsValidExecution.test.ts +++ b/src/features/recovery/hooks/__tests__/useIsValidExecution.test.ts @@ -52,7 +52,7 @@ describe('useIsValidExecution', () => { jest.resetAllMocks() jest.spyOn(web3, 'useWeb3ReadOnly').mockImplementation(() => mockReadOnlyProvider) - jest.spyOn(useWallet, 'default').mockReturnValue(mockWallet) + jest.spyOn(useWallet, 'useSigner').mockReturnValue(mockWallet) jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) }) diff --git a/src/hooks/__tests__/useGasLimit.test.ts b/src/hooks/__tests__/useGasLimit.test.ts index bfdf20d472..c63d061381 100644 --- a/src/hooks/__tests__/useGasLimit.test.ts +++ b/src/hooks/__tests__/useGasLimit.test.ts @@ -30,7 +30,9 @@ describe('useGasLimit', () => { getContractManager: () => contractManager, } as unknown as Safe) - jest.spyOn(useWallet, 'default').mockReturnValue(connectedWalletBuilder().with({ address: walletAddress }).build()) + jest + .spyOn(useWallet, 'useSigner') + .mockReturnValue(connectedWalletBuilder().with({ address: walletAddress }).build()) jest.spyOn(useSafeInfo, 'default').mockReturnValue({ safe: { ...safeInfo, deployed: true }, safeAddress: safeInfo.address.value, @@ -49,7 +51,7 @@ describe('useGasLimit', () => { }) it('should return undefined if no owner is connected', async () => { - jest.spyOn(useWallet, 'default').mockReturnValue( + jest.spyOn(useWallet, 'useSigner').mockReturnValue( connectedWalletBuilder() .with({ address: undefined, diff --git a/src/hooks/__tests__/useNestedSafeOwners.test.ts b/src/hooks/__tests__/useNestedSafeOwners.test.ts new file mode 100644 index 0000000000..ed3d8b187e --- /dev/null +++ b/src/hooks/__tests__/useNestedSafeOwners.test.ts @@ -0,0 +1,54 @@ +import { useNestedSafeOwners } from '../useNestedSafeOwners' +import useSafeInfo from '@/hooks/useSafeInfo' +import { faker } from '@faker-js/faker' +import { addressExBuilder, extendedSafeInfoBuilder } from '@/tests/builders/safe' +import { renderHook } from '@/tests/test-utils' +import { generateRandomArray } from '@/tests/builders/utils' +import useOwnedSafes from '../useOwnedSafes' + +jest.mock('@/hooks/useOwnedSafes') +jest.mock('@/hooks/useSafeInfo') + +describe('useNestedSafeOwners', () => { + const mockUseSafeInfo = useSafeInfo as jest.MockedFunction + const mockUseOwnedSafes = useOwnedSafes as jest.MockedFunction + + const safeAddress = faker.finance.ethereumAddress() + // Safe with 3 owners + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ chainId: '1' }) + .with({ owners: generateRandomArray(() => addressExBuilder().build(), { min: 3, max: 3 }) }) + .build(), + safeLoaded: true, + safeLoading: false, + } + + const mockOwners = mockSafeInfo.safe.owners + + beforeAll(() => { + mockUseSafeInfo.mockReturnValue(mockSafeInfo) + }) + + it('should return undefined without owned Safes', () => { + mockUseOwnedSafes.mockReturnValue({}) + const { result } = renderHook(() => useNestedSafeOwners()) + expect(result.current).toEqual(undefined) + }) + + it('should return empty list if no owned Safe is in the owners', () => { + mockUseOwnedSafes.mockReturnValue({ '1': [faker.finance.ethereumAddress()] }) + const { result } = renderHook(() => useNestedSafeOwners()) + expect(result.current).toEqual([]) + }) + + it('should return intersection of owners and owned Safes', () => { + mockUseOwnedSafes.mockReturnValue({ + '1': [faker.finance.ethereumAddress(), mockOwners[0].value, mockOwners[1].value, mockOwners[2].value], + }) + const { result } = renderHook(() => useNestedSafeOwners()) + expect(result.current).toEqual([mockOwners[0].value, mockOwners[1].value, mockOwners[2].value]) + }) +}) diff --git a/src/hooks/useGasLimit.ts b/src/hooks/useGasLimit.ts index cd6f1626b5..73fcde6b83 100644 --- a/src/hooks/useGasLimit.ts +++ b/src/hooks/useGasLimit.ts @@ -7,7 +7,7 @@ import useAsync from '@/hooks/useAsync' import useChainId from '@/hooks/useChainId' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import chains from '@/config/chains' -import useWallet from './wallets/useWallet' +import { useSigner } from './wallets/useWallet' import { useSafeSDK } from './coreSDK/safeCoreSDK' import useIsSafeOwner from './useIsSafeOwner' import { Errors, logError } from '@/services/exceptions' @@ -144,7 +144,7 @@ const useGasLimit = ( const { safe } = useSafeInfo() const safeAddress = safe.address.value const threshold = safe.threshold - const wallet = useWallet() + const wallet = useSigner() const walletAddress = wallet?.address const isOwner = useIsSafeOwner() const currentChainId = useChainId() diff --git a/src/hooks/useIsGeoblockedFeatureEnabled.ts b/src/hooks/useIsGeoblockedFeatureEnabled.ts new file mode 100644 index 0000000000..ce97243684 --- /dev/null +++ b/src/hooks/useIsGeoblockedFeatureEnabled.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react' + +import { GeoblockingContext } from '@/components/common/GeoblockingProvider' +import { useHasFeature } from '@/hooks/useChains' +import type { FEATURES } from '@/utils/chains' + +// TODO: Refactor useIsStakingFeatureEnabled/useIsStakingFeatureEnabled to use this +export function useIsGeoblockedFeatureEnabled(feature: FEATURES): boolean | undefined { + const isBlockedCountry = useContext(GeoblockingContext) + return useHasFeature(feature) && !isBlockedCountry +} diff --git a/src/hooks/useIsNestedSafeOwner.ts b/src/hooks/useIsNestedSafeOwner.ts new file mode 100644 index 0000000000..10a2c87fb0 --- /dev/null +++ b/src/hooks/useIsNestedSafeOwner.ts @@ -0,0 +1,7 @@ +import { useMemo } from 'react' +import { useNestedSafeOwners } from './useNestedSafeOwners' + +export const useIsNestedSafeOwner = () => { + const nestedOwners = useNestedSafeOwners() + return useMemo(() => nestedOwners && nestedOwners.length > 0, [nestedOwners]) +} diff --git a/src/hooks/useIsSafeOwner.ts b/src/hooks/useIsSafeOwner.ts index e1118c5818..4e6f73c8bd 100644 --- a/src/hooks/useIsSafeOwner.ts +++ b/src/hooks/useIsSafeOwner.ts @@ -1,12 +1,12 @@ import useSafeInfo from '@/hooks/useSafeInfo' -import useWallet from '@/hooks/wallets/useWallet' import { isOwner } from '@/utils/transaction-guards' +import { useSigner } from './wallets/useWallet' const useIsSafeOwner = () => { const { safe } = useSafeInfo() - const wallet = useWallet() + const signer = useSigner() - return isOwner(safe.owners, wallet?.address) + return isOwner(safe.owners, signer?.address) } export default useIsSafeOwner diff --git a/src/hooks/useIsValidExecution.ts b/src/hooks/useIsValidExecution.ts index e13194b007..0548d88c08 100644 --- a/src/hooks/useIsValidExecution.ts +++ b/src/hooks/useIsValidExecution.ts @@ -9,9 +9,11 @@ import { type JsonRpcProvider } from 'ethers' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import { getCurrentGnosisSafeContract } from '@/services/contracts/safeContracts' import useSafeInfo from '@/hooks/useSafeInfo' -import useWallet from '@/hooks/wallets/useWallet' +import { useSigner } from '@/hooks/wallets/useWallet' import { encodeSignatures } from '@/services/tx/encodeSignatures' import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { type NestedWallet } from '@/utils/nested-safe-wallet' +import { assertProvider } from '@/utils/helpers' const isContractError = (error: EthersError) => { if (!error.reason) return false @@ -22,10 +24,12 @@ const isContractError = (error: EthersError) => { // Monkey patch the signerProvider to proxy requests to the "readonly" provider if on the wrong chain // This is ONLY used to check the validity of a transaction in `useIsValidExecution` export const getPatchedSignerProvider = ( - wallet: ConnectedWallet, + wallet: ConnectedWallet | NestedWallet, chainId: SafeInfo['chainId'], readOnlyProvider: JsonRpcProvider, ) => { + assertProvider(wallet.provider) + const signerProvider = createWeb3(wallet.provider) if (wallet.chainId !== chainId) { @@ -57,7 +61,7 @@ const useIsValidExecution = ( executionValidationError?: Error isValidExecutionLoading: boolean } => { - const wallet = useWallet() + const wallet = useSigner() const { safe } = useSafeInfo() const readOnlyProvider = useWeb3ReadOnly() const isOwner = useIsSafeOwner() diff --git a/src/hooks/useNestedSafeOwners.tsx b/src/hooks/useNestedSafeOwners.tsx new file mode 100644 index 0000000000..efda33c46d --- /dev/null +++ b/src/hooks/useNestedSafeOwners.tsx @@ -0,0 +1,19 @@ +import useSafeInfo from '@/hooks/useSafeInfo' +import { useMemo } from 'react' +import useOwnedSafes from './useOwnedSafes' + +export const useNestedSafeOwners = () => { + const { safe, safeLoaded } = useSafeInfo() + const allOwned = useOwnedSafes() + + const nestedSafeOwner = useMemo(() => { + if (!safeLoaded) return null + + // Find an intersection of owned safes and the owners of the current safe + const ownerAddresses = safe?.owners.map((owner) => owner.value) + + return allOwned[safe.chainId]?.filter((ownedSafe) => ownerAddresses?.includes(ownedSafe)) + }, [allOwned, safe, safeLoaded]) + + return nestedSafeOwner +} diff --git a/src/hooks/useOwnedSafes.ts b/src/hooks/useOwnedSafes.ts index 0463efd4a1..769bfdb507 100644 --- a/src/hooks/useOwnedSafes.ts +++ b/src/hooks/useOwnedSafes.ts @@ -1,12 +1,10 @@ -import { useEffect } from 'react' -import { getOwnedSafes, type OwnedSafes } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo } from 'react' +import { type OwnedSafes } from '@safe-global/safe-gateway-typescript-sdk' -import useLocalStorage from '@/services/local-storage/useLocalStorage' import useWallet from '@/hooks/wallets/useWallet' -import { Errors, logError } from '@/services/exceptions' import useChainId from './useChainId' - -const CACHE_KEY = 'ownedSafes' +import { useGetSafesByOwnerQuery } from '@/store/slices' +import { skipToken } from '@reduxjs/toolkit/query' type OwnedSafesCache = { [walletAddress: string]: { @@ -14,40 +12,17 @@ type OwnedSafesCache = { } } -// TODO: Replace with useGetSafesByOwnerQuery const useOwnedSafes = (): OwnedSafesCache['walletAddress'] => { const chainId = useChainId() const { address: walletAddress } = useWallet() || {} - const [ownedSafesCache, setOwnedSafesCache] = useLocalStorage(CACHE_KEY) - - useEffect(() => { - if (!walletAddress || !chainId) return - let isCurrent = true - /** - * No useAsync in this case to avoid updating - * for a new chainId with stale data see https://github.com/safe-global/safe-wallet-web/pull/1760#discussion_r1133705349 - */ - getOwnedSafes(chainId, walletAddress) - .then( - (ownedSafes) => - isCurrent && - setOwnedSafesCache((prev) => ({ - ...prev, - [walletAddress]: { - ...(prev?.[walletAddress] || {}), - [chainId]: ownedSafes.safes, - }, - })), - ) - .catch((error: Error) => logError(Errors._610, error.message)) + const { data: ownedSafes } = useGetSafesByOwnerQuery( + walletAddress ? { chainId, ownerAddress: walletAddress } : skipToken, + ) - return () => { - isCurrent = false - } - }, [chainId, walletAddress, setOwnedSafesCache]) + const result = useMemo(() => ({ [chainId]: ownedSafes?.safes ?? [] }), [chainId, ownedSafes]) - return ownedSafesCache?.[walletAddress || ''] ?? {} + return result ?? {} } export default useOwnedSafes diff --git a/src/hooks/useTransactionStatus.ts b/src/hooks/useTransactionStatus.ts index ce79a694ae..1e539869d8 100644 --- a/src/hooks/useTransactionStatus.ts +++ b/src/hooks/useTransactionStatus.ts @@ -22,6 +22,7 @@ export const STATUS_LABELS: Record = { [PendingStatus.RELAYING]: 'Relaying', [PendingStatus.INDEXING]: 'Indexing', [PendingStatus.SIGNING]: 'Signing', + [PendingStatus.NESTED_SIGNING]: 'Signing', [ReplacedStatus]: 'Transaction will be replaced', } diff --git a/src/hooks/useTransactionType.tsx b/src/hooks/useTransactionType.tsx index 1955d01e72..fa2f74d48b 100644 --- a/src/hooks/useTransactionType.tsx +++ b/src/hooks/useTransactionType.tsx @@ -14,10 +14,9 @@ import BatchIcon from '@/public/images/common/multisend.svg' import { isCancellationTxInfo, - isExecTxInfo, isModuleExecutionInfo, isMultiSendTxInfo, - isOnChainConfirmationTxInfo, + isNestedConfirmationTxInfo, isOutgoingTransfer, isTxQueued, } from '@/utils/transaction-guards' @@ -133,7 +132,7 @@ export const getTransactionType = (tx: TransactionSummary, addressBook: AddressB } } - if (isOnChainConfirmationTxInfo(tx.txInfo) || isExecTxInfo(tx.txInfo)) { + if (isNestedConfirmationTxInfo(tx.txInfo)) { return { icon: , text: `Nested Safe${addressBookName ? `: ${addressBookName}` : ''}`, diff --git a/src/hooks/useTxPendingStatuses.ts b/src/hooks/useTxPendingStatuses.ts index 776fce6d4f..66897e17b3 100644 --- a/src/hooks/useTxPendingStatuses.ts +++ b/src/hooks/useTxPendingStatuses.ts @@ -233,6 +233,32 @@ const useTxPendingStatuses = (): void => { ) }) + const unsubNestedTx = txSubscribe(TxEvent.NESTED_SAFE_TX_CREATED, (detail) => { + const txId = detail.txId + const nonce = detail.nonce + + if (!txId || nonce === undefined) return + + // If we have future issues with statuses, we should refactor `useTxPendingStatuses` + // @see https://github.com/safe-global/safe-wallet-web/issues/1754 + const isIndexed = historicalTxs.some((tx) => tx.transaction.id === txId) + if (isIndexed) { + return + } + + dispatch( + setPendingTx({ + nonce, + chainId, + safeAddress, + txId, + status: PendingStatus.NESTED_SIGNING, + signerAddress: detail.parentSafeAddress, + txHashOrParentSafeTxHash: detail.txHashOrParentSafeTxHash, + }), + ) + }) + // All final states stop the watcher and clear the pending state const unsubFns = FINAL_PENDING_STATUSES.map((event) => txSubscribe(event, (detail) => { @@ -249,7 +275,14 @@ const useTxPendingStatuses = (): void => { }), ) - unsubFns.push(unsubProcessing, unsubSignatureProposing, unsubExecuting, unsubProcessed, unsubRelaying) + unsubFns.push( + unsubProcessing, + unsubSignatureProposing, + unsubExecuting, + unsubProcessed, + unsubRelaying, + unsubNestedTx, + ) return () => { unsubFns.forEach((unsub) => unsub()) diff --git a/src/hooks/wallets/useSelectAvailableSigner.ts b/src/hooks/wallets/useSelectAvailableSigner.ts new file mode 100644 index 0000000000..feef89fd90 --- /dev/null +++ b/src/hooks/wallets/useSelectAvailableSigner.ts @@ -0,0 +1,24 @@ +import { useCallback } from 'react' +import { useWalletContext } from './useWallet' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { useNestedSafeOwners } from '../useNestedSafeOwners' +import { getAvailableSigners } from '@/utils/signers' + +/** + * + * @returns a function that sets a signer that can sign the given transaction in the given Safe + */ +export const useSelectAvailableSigner = () => { + const { connectedWallet: wallet, setSignerAddress } = useWalletContext() ?? {} + const nestedSafeOwners = useNestedSafeOwners() + + return useCallback( + (tx: SafeTransaction | undefined, safe: SafeInfo) => { + const availableSigners = getAvailableSigners(wallet, nestedSafeOwners, safe, tx) + + setSignerAddress?.(availableSigners[0]) + }, + [setSignerAddress, nestedSafeOwners, wallet], + ) +} diff --git a/src/hooks/wallets/useWallet.ts b/src/hooks/wallets/useWallet.ts index c3b0b3c10c..70e6fc476b 100644 --- a/src/hooks/wallets/useWallet.ts +++ b/src/hooks/wallets/useWallet.ts @@ -3,6 +3,14 @@ import { type ConnectedWallet } from './useOnboard' import { WalletContext } from '@/components/common/WalletProvider' const useWallet = (): ConnectedWallet | null => { + return useContext(WalletContext)?.connectedWallet ?? null +} + +export const useSigner = () => { + return useContext(WalletContext)?.signer ?? null +} + +export const useWalletContext = () => { return useContext(WalletContext) } diff --git a/src/pages/bridge.tsx b/src/pages/bridge.tsx new file mode 100644 index 0000000000..34a04320ce --- /dev/null +++ b/src/pages/bridge.tsx @@ -0,0 +1,17 @@ +import Head from 'next/head' +import type { NextPage } from 'next' + +import { Bridge } from '@/features/bridge/components/Bridge' + +const BridgePage: NextPage = () => { + return ( + <> + + {'Safe{Wallet} – Bridge'} + + + + ) +} + +export default BridgePage diff --git a/src/services/analytics/__tests__/tx-tracking.test.ts b/src/services/analytics/__tests__/tx-tracking.test.ts index 90e5d6492e..063638f7a2 100644 --- a/src/services/analytics/__tests__/tx-tracking.test.ts +++ b/src/services/analytics/__tests__/tx-tracking.test.ts @@ -174,4 +174,15 @@ describe('getTransactionTrackingType', () => { const txType = getTransactionTrackingType(details) expect(txType).toEqual(TX_TYPES.batch) }) + + it('should return native_bridge for native bridge transactions', () => { + const details = { + txInfo: { + type: TransactionInfoType.CUSTOM, + }, + } as unknown as TransactionDetails + const origin = '{"url":"https://iframe.jumper.exchange","name":"Bridge"}' + const txType = getTransactionTrackingType(details, origin) + expect(txType).toEqual(TX_TYPES.native_bridge) + }) }) diff --git a/src/services/analytics/events/bridge.ts b/src/services/analytics/events/bridge.ts new file mode 100644 index 0000000000..74589c50f6 --- /dev/null +++ b/src/services/analytics/events/bridge.ts @@ -0,0 +1,12 @@ +const BRIDGE_CATEGORY = 'bridge' + +export const BRIDGE_EVENTS = { + OPEN_BRIDGE: { + action: 'Open bridge', + category: BRIDGE_CATEGORY, + }, +} + +export enum BRIDGE_LABELS { + sidebar = 'sidebar', +} diff --git a/src/services/analytics/events/modals.ts b/src/services/analytics/events/modals.ts index cba11054ec..eebafc3291 100644 --- a/src/services/analytics/events/modals.ts +++ b/src/services/analytics/events/modals.ts @@ -72,6 +72,21 @@ export const MODALS_EVENTS = { action: 'Swap', category: MODALS_CATEGORY, }, + CHANGE_SIGNER: { + action: 'Change tx signer', + category: MODALS_CATEGORY, + event: EventType.CLICK, + }, + OPEN_PARENT_TX: { + action: 'Open parent transaction', + category: MODALS_CATEGORY, + event: EventType.CLICK, + }, + OPEN_NESTED_TX: { + action: 'Open nested transaction', + category: MODALS_CATEGORY, + event: EventType.CLICK, + }, } export enum MODAL_NAVIGATION { diff --git a/src/services/analytics/events/transactions.ts b/src/services/analytics/events/transactions.ts index 839a8057a0..61b198035c 100644 --- a/src/services/analytics/events/transactions.ts +++ b/src/services/analytics/events/transactions.ts @@ -19,8 +19,10 @@ export enum TX_TYPES { batch = 'batch', rejection = 'rejection', typed_message = 'typed_message', + nested_safe = 'nested_safe', walletconnect = 'walletconnect', custom = 'custom', + native_bridge = 'native_bridge', native_swap = 'native_swap', bulk_execute = 'bulk_execute', @@ -78,4 +80,29 @@ export const TX_EVENTS = { action: 'Execute via role', category: TX_CATEGORY, }, + CREATE_VIA_PARENT: { + event: EventType.TX_CREATED, + action: 'Create via parent', + category: TX_CATEGORY, + }, + CONFIRM_VIA_PARENT: { + event: EventType.TX_CREATED, + action: 'Confirm via parent', + category: TX_CATEGORY, + }, + EXECUTE_VIA_PARENT: { + event: EventType.TX_CREATED, + action: 'Execute via parent', + category: TX_CATEGORY, + }, + CONFIRM_IN_PARENT: { + event: EventType.TX_CONFIRMED, + action: 'Confirm in parent', + category: TX_CATEGORY, + }, + EXECUTE_IN_PARENT: { + event: EventType.TX_EXECUTED, + action: 'Execute in parent', + category: TX_CATEGORY, + }, } diff --git a/src/services/analytics/tx-tracking.ts b/src/services/analytics/tx-tracking.ts index b1397a6649..fa2d20c913 100644 --- a/src/services/analytics/tx-tracking.ts +++ b/src/services/analytics/tx-tracking.ts @@ -9,15 +9,22 @@ import { isCancellationTxInfo, isSwapOrderTxInfo, isAnyStakingTxInfo, + isNestedConfirmationTxInfo, } from '@/utils/transaction-guards' +import { BRIDGE_WIDGET_URL } from '@/features/bridge/components/BridgeWidget' -export const getTransactionTrackingType = (details: TransactionDetails | undefined): string => { +export const getTransactionTrackingType = (details: TransactionDetails | undefined, origin?: string): string => { if (!details) { return TX_TYPES.custom } const { txInfo } = details + const isNativeBridge = origin?.includes(BRIDGE_WIDGET_URL) + if (isNativeBridge) { + return TX_TYPES.native_bridge + } + if (isTransferTxInfo(txInfo)) { if (isERC721Transfer(txInfo.transferInfo)) { return TX_TYPES.transfer_nft @@ -69,6 +76,10 @@ export const getTransactionTrackingType = (details: TransactionDetails | undefin return TX_TYPES.batch } + if (isNestedConfirmationTxInfo(txInfo)) { + return TX_TYPES.nested_safe + } + return TX_TYPES.walletconnect } diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts index 3a6e098b0e..b1767ab521 100644 --- a/src/services/exceptions/ErrorCodes.ts +++ b/src/services/exceptions/ErrorCodes.ts @@ -70,6 +70,7 @@ enum ErrorCodes { _814 = '814: Failed to speed up transaction', _815 = '815: Error executing a transaction through a role', _816 = '816: Error computing replay Safe creation data', + _817 = '817: Error sending a transaction through nested Safe provider', _900 = '900: Error loading Safe App', _901 = '901: Error processing Safe Apps SDK request', diff --git a/src/services/safe-wallet-provider/index.test.ts b/src/services/safe-wallet-provider/index.test.ts index 3252f21e23..cbb86f5c50 100644 --- a/src/services/safe-wallet-provider/index.test.ts +++ b/src/services/safe-wallet-provider/index.test.ts @@ -2,6 +2,8 @@ import { faker } from '@faker-js/faker' import { SafeWalletProvider } from '.' import { ERC20__factory } from '@/types/contracts' +import { numberToHex } from '@/utils/hex' +import type { TransactionReceipt } from 'ethers' const safe = { safeAddress: faker.finance.ethereumAddress(), @@ -617,10 +619,16 @@ describe('SafeWalletProvider', () => { }) describe('wallet_getCallsStatus', () => { - it('should look up a tx by txHash', async () => { + it('should return a confirmed transaction', async () => { + const receipt: Pick = { + logs: [], + blockHash: faker.string.hexadecimal(), + blockNumber: faker.number.int(), + gasUsed: faker.number.bigInt(), + } const sdk = { getBySafeTxHash: jest.fn().mockResolvedValue({ - txStatus: 'AWAITING_EXECUTION', + txStatus: 'SUCCESS', txHash: '0x123', txData: { dataDecoded: { @@ -628,22 +636,45 @@ describe('SafeWalletProvider', () => { }, }, }), - proxy: jest.fn(), + proxy: jest.fn().mockImplementation((method) => { + if (method === 'eth_getTransactionReceipt') { + return Promise.resolve(receipt) + } + return Promise.reject('Unknown method') + }), } const safeWalletProvider = new SafeWalletProvider(safe, sdk as any) const params = ['0x123'] - await safeWalletProvider.request(1, { method: 'wallet_getCallsStatus', params } as any, appInfo) + const status = await safeWalletProvider.request(1, { method: 'wallet_getCallsStatus', params } as any, appInfo) expect(sdk.getBySafeTxHash).toHaveBeenCalledWith(params[0]) expect(sdk.proxy).toHaveBeenCalledWith('eth_getTransactionReceipt', params) + expect(status).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: { + receipts: [ + { + blockHash: receipt.blockHash, + blockNumber: numberToHex(receipt.blockNumber), + chainId: '0x1', + gasUsed: numberToHex(receipt.gasUsed), + logs: receipt.logs, + status: '0x1', + transactionHash: '0x123', + }, + ], + status: 'CONFIRMED', + }, + }) }) it('should return a pending status w/o txHash', async () => { const sdk = { getBySafeTxHash: jest.fn().mockResolvedValue({ - txStatus: 'AWAITING_CONFIRMATION', + txStatus: 'AWAITING_EXECUTION', txData: { dataDecoded: { parameters: [{ valueDecoded: [1] }], @@ -656,10 +687,52 @@ describe('SafeWalletProvider', () => { const params = ['0x123'] - await safeWalletProvider.request(1, { method: 'wallet_getCallsStatus', params } as any, appInfo) + const status = await safeWalletProvider.request(1, { method: 'wallet_getCallsStatus', params } as any, appInfo) expect(sdk.getBySafeTxHash).toHaveBeenCalledWith(params[0]) expect(sdk.proxy).not.toHaveBeenCalled() + expect(status).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: { + status: 'PENDING', + }, + }) + }) + + it('should return a pending status w/o receipt', async () => { + const sdk = { + getBySafeTxHash: jest.fn().mockResolvedValue({ + txStatus: 'AWAITING_EXECUTION', + txHash: '0x123', + txData: { + dataDecoded: { + parameters: [{ valueDecoded: [1] }], + }, + }, + }), + proxy: jest.fn().mockImplementation((method) => { + if (method === 'eth_getTransactionReceipt') { + return Promise.resolve(null) + } + return Promise.reject('Unknown method') + }), + } + const safeWalletProvider = new SafeWalletProvider(safe, sdk as any) + + const params = ['0x123'] + + const status = await safeWalletProvider.request(1, { method: 'wallet_getCallsStatus', params } as any, appInfo) + + expect(sdk.getBySafeTxHash).toHaveBeenCalledWith(params[0]) + expect(sdk.proxy).toHaveBeenCalledWith('eth_getTransactionReceipt', params) + expect(status).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: { + status: 'PENDING', + }, + }) }) }) diff --git a/src/services/safe-wallet-provider/index.ts b/src/services/safe-wallet-provider/index.ts index d44109b528..086d835285 100644 --- a/src/services/safe-wallet-provider/index.ts +++ b/src/services/safe-wallet-provider/index.ts @@ -93,7 +93,8 @@ export class SafeWalletProvider { return this.wallet_switchEthereumChain(...(params as [{ chainId: string }]), appInfo) } - case 'eth_accounts': { + case 'eth_accounts': + case 'eth_requestAccounts': { return this.eth_accounts() } @@ -365,17 +366,15 @@ export class SafeWalletProvider { return safeTxHash } async wallet_getCallsStatus(safeTxHash: string): Promise<{ - calls: Array<{ - status: BundleStatus - receipt: { - success: boolean - blockHash: string - blockNumber: string // hex string - blockTimestamp: string // hex string - gasUsed: string // hex string - transactionHash: string - logs: TransactionReceipt['logs'] - } + status: BundleStatus + receipts?: Array<{ + logs: TransactionReceipt['logs'] + status: `0x${string}` // Hex 1 or 0 for success or failure, respectively + chainId: `0x${string}` + blockHash: `0x${string}` + blockNumber: `0x${string}` + gasUsed: `0x${string}` + transactionHash: `0x${string}` }> }> { let tx: TransactionDetails | undefined @@ -384,33 +383,39 @@ export class SafeWalletProvider { tx = await this.sdk.getBySafeTxHash(safeTxHash) } catch (e) {} - if (!tx || !tx.txData?.dataDecoded) { + if (!tx) { throw new Error('Transaction not found') } - const calls = new Array(tx.txData.dataDecoded.parameters?.[0].valueDecoded?.length ?? 1).fill(null) - const { txStatus, txHash } = tx + const status = BundleTxStatuses[tx.txStatus] - let receipt: TransactionReceipt | undefined - if (txHash) { - receipt = await (this.sdk.proxy('eth_getTransactionReceipt', [txHash]) as Promise) + if (!tx.txHash) { + return { + status, + } } - const callStatus = { - status: BundleTxStatuses[txStatus], - receipt: { - success: txStatus === TransactionStatus.SUCCESS, - blockHash: receipt?.blockHash ?? '', - blockNumber: receipt?.blockNumber.toString() ?? '0x0', - blockTimestamp: numberToHex(tx.executedAt ?? 0), - gasUsed: receipt?.gasUsed.toString() ?? '0x0', - transactionHash: txHash ?? '', - logs: receipt?.logs ?? [], - }, + const receipt = await (this.sdk.proxy('eth_getTransactionReceipt', [ + tx.txHash, + ]) as Promise) + if (!receipt) { + return { status } } + const calls = tx.txData?.dataDecoded?.parameters?.[0].valueDecoded?.length ?? 1 + const receipts = Array.from({ length: calls }, () => ({ + logs: receipt.logs, + status: numberToHex(tx.txStatus === TransactionStatus.SUCCESS ? 1 : 0), + chainId: numberToHex(this.safe.chainId), + blockHash: receipt.blockHash as `0x${string}`, + blockNumber: numberToHex(receipt.blockNumber), + gasUsed: numberToHex(receipt.gasUsed), + transactionHash: tx.txHash as `0x${string}`, + })) + return { - calls: calls.map(() => callStatus), + status, + receipts, } } async wallet_showCallsStatus(txHash: string): Promise { diff --git a/src/services/tx/tx-sender/__tests__/ts-sender.test.ts b/src/services/tx/tx-sender/__tests__/ts-sender.test.ts index 40ce5714e3..190434c93d 100644 --- a/src/services/tx/tx-sender/__tests__/ts-sender.test.ts +++ b/src/services/tx/tx-sender/__tests__/ts-sender.test.ts @@ -43,12 +43,14 @@ const SIGNER_ADDRESS = '0x1234567890123456789012345678901234567890' const TX_HASH = '0x1234567890' // Mock getTransactionDetails jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({ + ...jest.requireActual('@safe-global/safe-gateway-typescript-sdk'), getTransactionDetails: jest.fn(), postSafeGasEstimation: jest.fn(() => Promise.resolve({ safeTxGas: 60000, recommendedNonce: 17 })), Operation: { CALL: 0, }, relayTransaction: jest.fn(() => Promise.resolve({ taskId: '0xdead1' })), + __esModule: true, })) // Mock extractTxInfo diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index a60ce9d11c..15d256440b 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -141,18 +141,22 @@ export const dispatchOnChainSigning = async ( chainId: SafeInfo['chainId'], signerAddress: string, safeAddress: string, + isNestedSafe: boolean, ) => { const sdk = await getSafeSDKWithSigner(provider) const safeTxHash = await sdk.getTransactionHash(safeTx) const eventParams = { txId, nonce: safeTx.data.nonce } const options = chainId === chains.zksync ? { gasLimit: ZK_SYNC_ON_CHAIN_SIGNATURE_GAS_LIMIT } : undefined - + let txHashOrParentSafeTxHash: string try { // TODO: This is a workaround until there is a fix for unchecked transactions in the protocol-kit const encodedApproveHashTx = await prepareApproveTxHash(safeTxHash, provider) - await provider.request({ + // Note: SafeWalletProvider returns transaction hash if it exists, otherwise the safeTxHash + // If the parent immediately executes, this will be the transaction hash of the approveHash + // otherwise the safeTxHash of it + txHashOrParentSafeTxHash = await provider.request({ method: 'eth_sendTransaction', params: [{ from: signerAddress, to: safeAddress, data: encodedApproveHashTx, gas: options?.gasLimit }], }) @@ -165,6 +169,14 @@ export const dispatchOnChainSigning = async ( txDispatch(TxEvent.ONCHAIN_SIGNATURE_SUCCESS, eventParams) + if (isNestedSafe) { + txDispatch(TxEvent.NESTED_SAFE_TX_CREATED, { + ...eventParams, + txHashOrParentSafeTxHash, + parentSafeAddress: signerAddress, + }) + } + // Until the on-chain signature is/has been executed, the safeTx is not // signed so we don't return it } diff --git a/src/services/tx/txEvents.ts b/src/services/tx/txEvents.ts index 562e52135c..9ecdb89778 100644 --- a/src/services/tx/txEvents.ts +++ b/src/services/tx/txEvents.ts @@ -12,6 +12,7 @@ export enum TxEvent { SIGNATURE_INDEXED = 'SIGNATURE_INDEXED', ONCHAIN_SIGNATURE_REQUESTED = 'ONCHAIN_SIGNATURE_REQUESTED', ONCHAIN_SIGNATURE_SUCCESS = 'ONCHAIN_SIGNATURE_SUCCESS', + NESTED_SAFE_TX_CREATED = 'NESTED_SAFE_TX_CREATED', EXECUTING = 'EXECUTING', PROCESSING = 'PROCESSING', PROCESSING_MODULE = 'PROCESSING_MODULE', @@ -38,6 +39,7 @@ interface TxEvents { [TxEvent.SIGNATURE_INDEXED]: { txId: string } [TxEvent.ONCHAIN_SIGNATURE_REQUESTED]: Id [TxEvent.ONCHAIN_SIGNATURE_SUCCESS]: Id + [TxEvent.NESTED_SAFE_TX_CREATED]: Id & { parentSafeAddress: string; txHashOrParentSafeTxHash: string } [TxEvent.EXECUTING]: Id [TxEvent.PROCESSING]: Id & { txHash: string diff --git a/src/store/__tests__/txQueueSlice.test.ts b/src/store/__tests__/txQueueSlice.test.ts index 7c92ce8f99..312bfbd7a3 100644 --- a/src/store/__tests__/txQueueSlice.test.ts +++ b/src/store/__tests__/txQueueSlice.test.ts @@ -17,6 +17,7 @@ import { txQueueListener, txQueueSlice } from '../txQueueSlice' import type { PendingTxsState } from '../pendingTxsSlice' import { PendingStatus } from '../pendingTxsSlice' import type { RootState } from '..' +import { faker } from '@faker-js/faker/.' describe('txQueueSlice', () => { const listenerMiddlewareInstance = createListenerMiddleware() @@ -71,6 +72,48 @@ describe('txQueueSlice', () => { expect(txDispatchSpy).toHaveBeenCalledWith(txEvents.TxEvent.SIGNATURE_INDEXED, { txId: '0x123' }) }) + it('should dispatch SIGNATURE_INDEXED event for Nested Signing state', () => { + const state = { + pendingTxs: { + '0x123': { + nonce: 1, + chainId: '5', + safeAddress: '0x0000000000000000000000000000000000000000', + status: PendingStatus.NESTED_SIGNING, + signerAddress: '0x456', + txHashOrParentSafeTxHash: faker.string.hexadecimal({ length: 64 }), + }, + } as PendingTxsState, + } as RootState + + const listenerApi = { + getState: jest.fn(() => state), + dispatch: jest.fn(), + } + + const transaction = { + type: TransactionListItemType.TRANSACTION, + transaction: { + id: '0x123', + executionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + missingSigners: [], + }, + }, + } as unknown as TransactionListItem + + const action = txQueueSlice.actions.set({ + loading: false, + data: { + results: [transaction], + }, + }) + + listenerMiddlewareInstance.middleware(listenerApi)(jest.fn())(action) + + expect(txDispatchSpy).toHaveBeenCalledWith(txEvents.TxEvent.SIGNATURE_INDEXED, { txId: '0x123' }) + }) + it('should not dispatch an event if the queue slice is cleared', () => { const state = { pendingTxs: { diff --git a/src/store/api/gateway/index.ts b/src/store/api/gateway/index.ts index 769f2ee924..cf87583b2b 100644 --- a/src/store/api/gateway/index.ts +++ b/src/store/api/gateway/index.ts @@ -1,7 +1,12 @@ import { proposerEndpoints } from '@/store/api/gateway/proposers' import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react' -import { getTransactionDetails, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { + type AllOwnedSafes, + getAllOwnedSafes, + getTransactionDetails, + type TransactionDetails, +} from '@safe-global/safe-gateway-typescript-sdk' import { asError } from '@/services/exceptions/utils' import { safeOverviewEndpoints } from './safeOverviews' import { createSubmission, getSafesByOwner, getSubmission } from '@safe-global/safe-client-gateway-sdk' @@ -29,6 +34,19 @@ export const gatewayApi = createApi({ return buildQueryFn(() => Promise.all(txIds.map((txId) => getTransactionDetails(chainId, txId)))) }, }), + getAllOwnedSafes: builder.query({ + queryFn({ walletAddress }) { + return buildQueryFn(() => getAllOwnedSafes(walletAddress)) + }, + }), + getSafesByOwner: builder.query({ + queryFn({ chainId, ownerAddress }) { + return buildQueryFn(() => getSafesByOwner({ params: { path: { chainId, ownerAddress } } })) + }, + providesTags: (_res, _err, { chainId, ownerAddress }) => { + return [{ type: 'OwnedSafes', id: `${chainId}:${ownerAddress}` }] + }, + }), getSubmission: builder.query< getSubmission, { outreachId: number; chainId: string; safeAddress: string; signerAddress: string } @@ -40,14 +58,6 @@ export const gatewayApi = createApi({ }, providesTags: ['Submissions'], }), - getSafesByOwner: builder.query({ - queryFn({ chainId, ownerAddress }) { - return buildQueryFn(() => getSafesByOwner({ params: { path: { chainId, ownerAddress } } })) - }, - providesTags: (_res, _err, { chainId, ownerAddress }) => { - return [{ type: 'OwnedSafes', id: `${chainId}:${ownerAddress}` }] - }, - }), createSubmission: builder.mutation< createSubmission, { outreachId: number; chainId: string; safeAddress: string; signerAddress: string } @@ -81,4 +91,5 @@ export const { useGetSafeOverviewQuery, useGetMultipleSafeOverviewsQuery, useGetSafesByOwnerQuery, + useGetAllOwnedSafesQuery, } = gatewayApi diff --git a/src/store/pendingTxsSlice.ts b/src/store/pendingTxsSlice.ts index 671c550a08..8eee001fdf 100644 --- a/src/store/pendingTxsSlice.ts +++ b/src/store/pendingTxsSlice.ts @@ -6,6 +6,7 @@ import { selectChainIdAndSafeAddress } from '@/store/common' export enum PendingStatus { SIGNING = 'SIGNING', + NESTED_SIGNING = 'NESTED_SIGNING', SUBMITTING = 'SUBMITTING', PROCESSING = 'PROCESSING', RELAYING = 'RELAYING', @@ -67,12 +68,19 @@ type PendingIndexingTx = PendingTxCommonProps & { txHash?: string } +type PendingNestedSigningTx = PendingTxCommonProps & { + signerAddress: string + txHashOrParentSafeTxHash: string + status: PendingStatus.NESTED_SIGNING +} + export type PendingTx = | PendingSigningTx | PendingSubmittingTx | PendingProcessingTx | PendingRelayingTx | PendingIndexingTx + | PendingNestedSigningTx export type PendingTxsState = { [txId: string]: PendingTx diff --git a/src/store/txQueueSlice.ts b/src/store/txQueueSlice.ts index 37ca6c0ae8..f5decbd539 100644 --- a/src/store/txQueueSlice.ts +++ b/src/store/txQueueSlice.ts @@ -8,6 +8,8 @@ import { PendingStatus, selectPendingTxs } from './pendingTxsSlice' import { sameAddress } from '@/utils/addresses' import { txDispatch, TxEvent } from '@/services/tx/txEvents' +const SIGNING_STATES = [PendingStatus.SIGNING, PendingStatus.NESTED_SIGNING] + const { slice, selector } = makeLoadableSlice('txQueue', undefined as TransactionListPage | undefined) export const txQueueSlice = slice @@ -45,7 +47,7 @@ export const txQueueListener = (listenerMiddleware: typeof listenerMiddlewareIns const txId = result.transaction.id const pendingTx = pendingTxs[txId] - if (!pendingTx || pendingTx.status !== PendingStatus.SIGNING) { + if (!pendingTx || !SIGNING_STATES.includes(pendingTx.status) || !('signerAddress' in pendingTx)) { continue } diff --git a/src/tests/builders/safeTx.ts b/src/tests/builders/safeTx.ts index 1e8d4eb2ca..449238592a 100644 --- a/src/tests/builders/safeTx.ts +++ b/src/tests/builders/safeTx.ts @@ -2,6 +2,15 @@ import { Builder, type IBuilder } from '@/tests/Builder' import { faker } from '@faker-js/faker' 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' +import { + type Custom, + DetailedExecutionInfoType, + type MultisigExecutionInfo, + type TransactionInfo, + TransactionInfoType, + type TransactionSummary, +} from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionStatus } from '@safe-global/safe-apps-sdk' // TODO: Convert to builder export const createSafeTx = (data = '0x'): SafeTransaction => { @@ -65,3 +74,40 @@ export function safeSignatureBuilder(): IBuilder { data: faker.string.hexadecimal({ length: faker.number.int({ max: 500 }) }), }) } + +export function safeTxSummaryBuilder(): IBuilder { + return Builder.new().with({ + id: `multisig_${faker.string.hexadecimal({ length: 40 })}_${faker.string.hexadecimal({ length: 64 })}`, + executionInfo: executionInfoBuilder().build(), + txInfo: txInfoBuilder().build(), + txStatus: faker.helpers.enumValue(TransactionStatus), + }) +} + +export function executionInfoBuilder(): IBuilder { + const num1 = faker.number.int({ min: 1, max: 10 }) + const num2 = faker.number.int({ min: 1, max: 10 }) + + return Builder.new().with({ + nonce: faker.number.int(), + type: DetailedExecutionInfoType.MULTISIG, + confirmationsRequired: Math.max(num1, num2), + confirmationsSubmitted: Math.min(num1, num2), + missingSigners: Array.from({ length: Math.min(num1, num2) }).map(() => ({ + value: faker.finance.ethereumAddress(), + })), + }) +} + +export function txInfoBuilder(): IBuilder { + const mockData = faker.string.hexadecimal({ length: { min: 0, max: 128 } }) + return Builder.new().with({ + type: TransactionInfoType.CUSTOM, + actionCount: 1, + dataSize: mockData.length.toString(), + isCancellation: false, + methodName: faker.string.alpha(), + to: { value: faker.finance.ethereumAddress() }, + value: faker.number.bigInt({ min: 0, max: 10n ** 18n }).toString(), + }) +} diff --git a/src/utils/__tests__/signers.test.ts b/src/utils/__tests__/signers.test.ts new file mode 100644 index 0000000000..5ef249b3ed --- /dev/null +++ b/src/utils/__tests__/signers.test.ts @@ -0,0 +1,140 @@ +import { getAvailableSigners } from '../signers' +import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' +import { safeInfoBuilder } from '@/tests/builders/safe' +import { faker } from '@faker-js/faker' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { checksumAddress } from '../addresses' + +describe('getAvailableSigners', () => { + const mockWallet = { + address: checksumAddress(faker.finance.ethereumAddress()), + } as ConnectedWallet + const parentSafe = checksumAddress(faker.finance.ethereumAddress()) + + const mockTx = { + signatures: new Map(), + } as SafeTransaction + + it('should return an empty array if wallet is null', () => { + const mockSafe = safeInfoBuilder() + .with({ owners: [{ value: mockWallet.address }] }) + .build() + + const result = getAvailableSigners(null, ['0xOwner1'], mockSafe, mockTx) + + expect(result).toEqual([]) + }) + + it('should return an empty array if nestedSafeOwners is null', () => { + const mockSafe = safeInfoBuilder() + .with({ owners: [{ value: mockWallet.address }] }) + .build() + const result = getAvailableSigners(mockWallet, null, mockSafe, mockTx) + + expect(result).toEqual([]) + }) + + it('should return an empty array if tx is undefined', () => { + const nestedOwners = [mockWallet.address] + const mockSafe = safeInfoBuilder() + .with({ owners: [{ value: mockWallet.address }, { value: parentSafe }] }) + .build() + + const result = getAvailableSigners(mockWallet, nestedOwners, mockSafe, undefined) + + expect(result).toEqual([]) + }) + + it('should include wallet address if wallet is a direct owner and has not signed', () => { + const nestedOwners = [checksumAddress(faker.finance.ethereumAddress())] + const mockSafe = safeInfoBuilder() + .with({ owners: [{ value: mockWallet.address }, { value: parentSafe }] }) + .build() + + const result = getAvailableSigners(mockWallet, nestedOwners, mockSafe, mockTx) + + expect(result).toEqual([nestedOwners[0], mockWallet.address]) + }) + + it('should not include wallet address if wallet is a direct owner and has already signed', () => { + const nestedOwners = [checksumAddress(faker.finance.ethereumAddress())] + const mockSafe = safeInfoBuilder() + .with({ owners: [{ value: mockWallet.address }, { value: parentSafe }], threshold: 2 }) + .build() + const signedTx = { + ...mockTx, + signatures: new Map([[mockWallet.address, 'mockWallet signature']]), + } as unknown as SafeTransaction + + const result = getAvailableSigners(mockWallet, nestedOwners, mockSafe, signedTx) + + expect(result).toEqual([nestedOwners[0]]) + }) + + it('should return only signers who have not signed if threshold is not met', () => { + const nestedOwners = [ + checksumAddress(faker.finance.ethereumAddress()), + checksumAddress(faker.finance.ethereumAddress()), + ] + const mockSafe = safeInfoBuilder() + .with({ owners: [{ value: mockWallet.address }, { value: parentSafe }] }) + .build() + const signedTx = { + ...mockTx, + signatures: new Map([[nestedOwners[0], 'nestedOwners[0] signature']]), + } as unknown as SafeTransaction + + const result = getAvailableSigners(mockWallet, nestedOwners, mockSafe, signedTx) + + expect(result).toEqual([nestedOwners[1], mockWallet.address]) + }) + + it('should return nestedSafeOwners if wallet is not a direct owner', () => { + const nestedOwners = [ + checksumAddress(faker.finance.ethereumAddress()), + checksumAddress(faker.finance.ethereumAddress()), + ] + const mockSafe = safeInfoBuilder() + .with({ owners: [{ value: parentSafe }] }) + .build() + const result = getAvailableSigners(mockWallet, nestedOwners, mockSafe, mockTx) + + expect(result).toEqual([nestedOwners[0], nestedOwners[1]]) + }) + + it('should return nested signers if the transaction has met the threshold', () => { + const nestedOwners = [checksumAddress(faker.finance.ethereumAddress())] + const mockSafe = safeInfoBuilder() + .with({ owners: [{ value: mockWallet.address }, { value: parentSafe }], threshold: 2 }) + .build() + const fullySignedTx = { + ...mockTx, + signatures: new Map([ + [checksumAddress(mockWallet.address), 'mockWallet signature'], + [checksumAddress(nestedOwners[0]), 'nestedOwners[0] signature'], + ]), + } as unknown as SafeTransaction + + const result = getAvailableSigners(mockWallet, nestedOwners, mockSafe, fullySignedTx) + + expect(result).toEqual([nestedOwners[0]]) + }) + + it('should handle case insensitivity in addresses', () => { + const nonChecksummedMockWallet = { address: mockWallet.address.toLowerCase() } as ConnectedWallet + const nestedOwners = [faker.finance.ethereumAddress().toUpperCase(), faker.finance.ethereumAddress().toLowerCase()] + const mockSafe = safeInfoBuilder() + .with({ + owners: [{ value: mockWallet.address }, { value: parentSafe }], + }) + .build() + + const result = getAvailableSigners(nonChecksummedMockWallet, nestedOwners, mockSafe, mockTx) + + expect(result).toEqual([ + checksumAddress(nestedOwners[0]), + checksumAddress(nestedOwners[1]), + checksumAddress(nonChecksummedMockWallet.address), + ]) + }) +}) diff --git a/src/utils/chains.ts b/src/utils/chains.ts index 329aaee6a7..d1aa5bcf27 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -39,6 +39,7 @@ export enum FEATURES { MULTI_CHAIN_SAFE_ADD_NETWORK = 'MULTI_CHAIN_SAFE_ADD_NETWORK', PROPOSERS = 'PROPOSERS', TARGETED_SURVEY = 'TARGETED_SURVEY', + BRIDGE = 'BRIDGE', } export const FeatureRoutes = { @@ -47,6 +48,7 @@ export const FeatureRoutes = { [AppRoutes.stake]: FEATURES.STAKING, [AppRoutes.balances.nfts]: FEATURES.ERC721, [AppRoutes.settings.notifications]: FEATURES.PUSH_NOTIFICATIONS, + [AppRoutes.bridge]: FEATURES.BRIDGE, } export const hasFeature = (chain: ChainInfo, feature: FEATURES): boolean => { diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index fd9f04b9ca..eac09b1a51 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -29,7 +29,7 @@ export function assertChainInfo(chainInfo: ChainInfo | undefined): asserts chain return invariant(chainInfo, 'No chain config available') } -export function assertProvider(provider: Eip1193Provider | undefined): asserts provider { +export function assertProvider(provider: Eip1193Provider | undefined | null): asserts provider { return invariant(provider, 'Provider not found') } diff --git a/src/utils/hex.ts b/src/utils/hex.ts index 6ce32e4723..7bac3d8481 100644 --- a/src/utils/hex.ts +++ b/src/utils/hex.ts @@ -1,3 +1,3 @@ export const isEmptyHexData = (encodedData: string): boolean => encodedData !== '' && isNaN(parseInt(encodedData, 16)) -export const numberToHex = (value: number) => `0x${value.toString(16)}` +export const numberToHex = (value: number | bigint): `0x${string}` => `0x${value.toString(16)}` diff --git a/src/utils/nested-safe-wallet.ts b/src/utils/nested-safe-wallet.ts new file mode 100644 index 0000000000..efe73dadb1 --- /dev/null +++ b/src/utils/nested-safe-wallet.ts @@ -0,0 +1,164 @@ +import { type Eip1193Provider, getAddress, type JsonRpcProvider } from 'ethers' +import { SafeWalletProvider, type WalletSDK } from '@/services/safe-wallet-provider' +import { getTransactionDetails, type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type NextRouter } from 'next/router' +import { AppRoutes } from '@/config/routes' +import proposeTx from '@/services/tx/proposeTransaction' +import { isSmartContractWallet } from '@/utils/wallets' +import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' +import { initSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import { logError } from '@/services/exceptions' +import ErrorCodes from '@/services/exceptions/ErrorCodes' +import { tryOffChainTxSigning } from '@/services/tx/tx-sender/sdk' +import type { TransactionResult } from '@safe-global/safe-core-sdk-types' + +export type NestedWallet = { + address: string + chainId: string + provider: Eip1193Provider | null + isSafe: true +} + +export const getNestedWallet = ( + actualWallet: ConnectedWallet, + safeInfo: SafeInfo, + web3ReadOnly: JsonRpcProvider, + router: NextRouter, +): NestedWallet => { + let requestId = 0 + const nestedSafeSdk: WalletSDK = { + getBySafeTxHash(safeTxHash) { + return getTransactionDetails(safeInfo.chainId, safeTxHash) + }, + async switchChain() { + return Promise.reject('Switching chains is not supported yet') + }, + getCreateCallTransaction() { + throw new Error('Unsupported method') + }, + + async signMessage(): Promise<{ signature: string }> { + return Promise.reject('signMessage is not supported yet') + }, + + async proxy(method, params) { + return web3ReadOnly?.send(method, params ?? []) + }, + + async send(params) { + const safeCoreSDK = await initSafeSDK({ + provider: web3ReadOnly, + chainId: safeInfo.chainId, + address: safeInfo.address.value, + version: safeInfo.version, + implementationVersionState: safeInfo.implementationVersionState, + implementation: safeInfo.implementation.value, + }) + + const connectedSDK = await safeCoreSDK?.connect({ provider: actualWallet.provider }) + + if (!connectedSDK) { + return Promise.reject('Could not initialize core sdk') + } + + const transactions = params.txs.map(({ to, value, data }: any) => { + return { + to: getAddress(to), + value: BigInt(value).toString(), + data, + operation: 0, + } + }) + + const safeTx = await connectedSDK.createTransaction({ + transactions, + onlyCalls: true, + }) + + const safeTxHash = await connectedSDK.getTransactionHash(safeTx) + + let result: TransactionResult | null = null + + try { + if (await isSmartContractWallet(safeInfo.chainId, actualWallet.address)) { + // With the unchecked signer, the contract call resolves once the tx + // has been submitted in the wallet not when it has been executed + + // First we propose so the backend will pick it up + await proposeTx(safeInfo.chainId, safeInfo.address.value, actualWallet.address, safeTx, safeTxHash) + result = await connectedSDK.approveTransactionHash(safeTxHash) + } else { + // Sign off-chain + if (safeInfo.threshold === 1) { + // Always propose the tx so the resulting link to the parentTx does not error out + await proposeTx(safeInfo.chainId, safeInfo.address.value, actualWallet.address, safeTx, safeTxHash) + + // Directly execute the tx + result = await connectedSDK.executeTransaction(safeTx) + } else { + const signedTx = await tryOffChainTxSigning(safeTx, safeInfo.version, connectedSDK) + await proposeTx(safeInfo.chainId, safeInfo.address.value, actualWallet.address, signedTx, safeTxHash) + } + } + } catch (err) { + logError(ErrorCodes._817, err) + throw err + } + + return { + safeTxHash, + txHash: result?.hash, + } + }, + + setSafeSettings() { + throw new Error('setSafeSettings is not supported yet') + }, + + showTxStatus(safeTxHash) { + router.push({ + pathname: AppRoutes.transactions.tx, + query: { + safe: router.query.safe, + id: safeTxHash, + }, + }) + }, + + async signTypedMessage() { + return Promise.reject('signTypedMessage is not supported yet') + }, + } + + const nestedSafeProvider = new SafeWalletProvider( + { + chainId: Number(safeInfo.chainId), + safeAddress: safeInfo.address.value, + }, + nestedSafeSdk, + ) + + return { + provider: { + async request(request) { + const result = await nestedSafeProvider.request(requestId++, request, { + url: '', + description: '', + iconUrl: '', + name: 'Nested Safe', + }) + + if ('result' in result) { + return result.result + } + + if ('error' in result) { + throw new Error(result.error.message) + } + }, + }, + address: safeInfo.address.value, + chainId: safeInfo.chainId, + isSafe: true, + } +} diff --git a/src/utils/signers.ts b/src/utils/signers.ts new file mode 100644 index 0000000000..a8babd9767 --- /dev/null +++ b/src/utils/signers.ts @@ -0,0 +1,32 @@ +import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { checksumAddress } from './addresses' + +export const getAvailableSigners = ( + wallet: ConnectedWallet | null | undefined, + nestedSafeOwners: string[] | null, + safe: SafeInfo, + tx: SafeTransaction | undefined, +) => { + if (!wallet || !nestedSafeOwners || !tx) { + return [] + } + const walletAddress = checksumAddress(wallet.address) + + const isDirectOwner = safe.owners.map((owner) => checksumAddress(owner.value)).includes(walletAddress) + const isFullySigned = tx.signatures.size >= safe.threshold + const availableSigners = nestedSafeOwners ? nestedSafeOwners.map(checksumAddress) : [] + + const signers = Array.from(tx.signatures.keys()).map(checksumAddress) + + if (isDirectOwner && !signers.includes(walletAddress)) { + availableSigners.push(walletAddress) + } + + if (!isFullySigned) { + // Filter signers that already signed + return availableSigners.filter((signer) => !signers.includes(signer)) + } + return availableSigners +} diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts index 3ee3e88b72..f126ebf5b0 100644 --- a/src/utils/transaction-guards.ts +++ b/src/utils/transaction-guards.ts @@ -443,3 +443,7 @@ export const isExecTxInfo = (info: TransactionInfo): info is Custom => { } return false } + +export const isNestedConfirmationTxInfo = (info: TransactionInfo): boolean => { + return isCustomTxInfo(info) && (isOnChainConfirmationTxInfo(info) || isExecTxInfo(info)) +}