diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index f8681c536b..04286dbde8 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -24,7 +24,7 @@ jobs: # branch should not be protected branch: 'main' # user names of users allowed to contribute without CLA - allowlist: lukasschor,rmeissner,germartinez,Uxio0,dasanra,francovenica,tschubotz,luarx,DaniSomoza,iamacook,yagopv,usame-algan,schmanu,DiogoSoaress,JagoFigueroa,bot* + allowlist: lukasschor,rmeissner,germartinez,Uxio0,dasanra,francovenica,tschubotz,luarx,DaniSomoza,iamacook,yagopv,usame-algan,schmanu,DiogoSoaress,JagoFigueroa,fmrsabino,mike10ca,bot* # the followings are the optional inputs - If the optional inputs are not given, then default values will be taken # enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d978694270..6af7316dbc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,7 +9,7 @@ concurrency: jobs: e2e: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 name: Smoke E2E tests steps: - uses: actions/checkout@v3 diff --git a/.vscode/settings.json b/.vscode/settings.json index fae8e3d8a9..8f39472ed3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true + "typescript.enablePromptUseWorkspaceTsdk": true, + "editor.formatOnSave": true } diff --git a/cypress.config.js b/cypress.config.js index 16a5083407..8c736ea572 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -11,6 +11,7 @@ export default defineConfig({ e2e: { baseUrl: 'http://localhost:3000', + testIsolation: false, }, chromeWebSecurity: false, diff --git a/cypress/e2e/add_owner.cy.js b/cypress/e2e/add_owner.cy.js index d63a568e5f..c354e86a0c 100644 --- a/cypress/e2e/add_owner.cy.js +++ b/cypress/e2e/add_owner.cy.js @@ -1,10 +1,10 @@ -const NEW_OWNER = '0xE297437d6b53890cbf004e401F3acc67c8b39665' -const TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' +import * as constants from '../../support/constants' + const offset = 7 describe('Adding an owner', () => { before(() => { - cy.visit(`/${TEST_SAFE}/settings/setup`) + cy.visit(`/${constants.GOERLI_TEST_SAFE}/settings/setup`) cy.contains('button', 'Accept selection').click() // Advanced Settings page is loaded @@ -21,7 +21,7 @@ describe('Adding an owner', () => { // Fills new owner data cy.get('input[placeholder="New owner"]').type('New Owner Name') - cy.get('input[name="address"]').type(NEW_OWNER) + cy.get('input[name="address"]').type(constants.EOA) // Advances to step 2 cy.contains('Next').click() diff --git a/cypress/e2e/pages/address_book.page.js b/cypress/e2e/pages/address_book.page.js new file mode 100644 index 0000000000..c02b046a9f --- /dev/null +++ b/cypress/e2e/pages/address_book.page.js @@ -0,0 +1,114 @@ +export const acceptSelection = 'Accept selection' +export const addressBook = 'Address book' +const createEntryBtn = 'Create entry' + +const beameriFrameContainer = '#beamerOverlay .iframeCointaner' +const beamerInput = 'input[id="beamer"]' +const nameInput = 'input[name="name"]' +const addressInput = 'input[name="address"]' +const saveBtn = 'Save' +export const editEntryBtn = 'button[aria-label="Edit entry"]' +export const deleteEntryBtn = 'button[aria-label="Delete entry"]' +export const deleteEntryModalBtnSection = '.MuiDialogActions-root' +export const delteEntryModaldeleteBtn = 'Delete' +const exportFileModalBtnSection = '.MuiDialogActions-root' +const exportFileModalExportBtn = 'Export' +const importBtn = 'Import' +const exportBtn = 'Export' +const whatsNewBtnStr = "What's new" +const beamrCookiesStr = 'accept the "Beamer" cookies' + +export function clickOnImportFileBtn() { + cy.contains(importBtn).click() +} + +export function importFile() { + cy.get('[type="file"]').attachFile('../fixtures/address_book_test.csv') + // Import button should be enabled + cy.get('.MuiDialogActions-root').contains('Import').should('not.be.disabled') + cy.get('.MuiDialogActions-root').contains('Import').click() +} + +export function verifyImportModalIsClosed() { + cy.get('Import address book').should('not.exist') +} + +export function verifyDataImported(name, address) { + cy.contains(name).should('exist') + cy.contains(address).should('exist') +} + +export function clickOnExportFileBtn() { + cy.contains(exportBtn).click() +} + +export function confirmExport() { + cy.get(exportFileModalBtnSection).contains(exportFileModalExportBtn).click() +} + +export function clickOnCreateEntryBtn() { + cy.contains(createEntryBtn).click() +} + +export function tyeInName(name) { + cy.get(nameInput).type(name) +} + +export function typeInAddress(address) { + cy.get(addressInput).type(address) +} + +export function clickOnSaveEntryBtn() { + cy.contains('button', saveBtn).click() +} + +export function verifyNewEntryAdded(name, address) { + cy.contains(name).should('exist') + cy.contains(address).should('exist') +} + +export function clickOnEditEntryBtn() { + cy.get(editEntryBtn).click({ force: true }) +} + +export function typeInNameInput(name) { + cy.get(nameInput).clear().type(name).should('have.value', name) +} + +export function clickOnSaveButton() { + cy.contains('button', saveBtn).click() +} + +export function verifyNameWasChanged(name, editedName) { + cy.get(name).should('not.exist') + cy.contains(editedName).should('exist') +} + +export function clickDeleteEntryButton() { + cy.get(deleteEntryBtn).click({ force: true }) +} + +export function clickDeleteEntryModalDeleteButton() { + cy.get(deleteEntryModalBtnSection).contains(delteEntryModaldeleteBtn).click() +} + +export function verifyEditedNameNotExists(name) { + cy.get(name).should('not.exist') +} + +export function clickOnWhatsNewBtn(force = false) { + cy.contains(whatsNewBtnStr).click({ force: force }) +} + +export function acceptBeamerCookies() { + cy.contains(beamrCookiesStr) +} + +export function verifyBeamerIsChecked() { + cy.get(beamerInput).should('be.checked') +} + +export function verifyBeameriFrameExists() { + cy.wait(1000) + cy.get(beameriFrameContainer).should('exist') +} diff --git a/cypress/e2e/pages/balances.pages.js b/cypress/e2e/pages/balances.pages.js new file mode 100644 index 0000000000..4c640272b2 --- /dev/null +++ b/cypress/e2e/pages/balances.pages.js @@ -0,0 +1,158 @@ +const etherscanLink = 'a[aria-label="View on goerli.etherscan.io"]' +export const balanceSingleRow = '[aria-labelledby="tableTitle"] > tbody tr' +const currencyDropdown = '[id="currency"]' +const currencyDropdownList = 'ul[role="listbox"]' +const currencyDropdownListSelected = 'ul[role="listbox"] li[aria-selected="true"]' +const hideAssetBtn = 'button[aria-label="Hide asset"]' +const hiddeTokensBtn = '[data-testid="toggle-hidden-assets"]' +const hiddenTokenCheckbox = 'input[type="checkbox"]' +const paginationPageList = 'ul[role="listbox"]' +const currencyDropDown = 'div[id="currency"]' +const hiddenTokenSaveBtn = 'Save' +const hideTokenDefaultString = 'Hide tokens' + +const pageRowsDefault = '25' +const rowsPerPage10 = '10' +const nextPageBtn = 'button[aria-label="Go to next page"]' +const previousPageBtn = 'button[aria-label="Go to previous page"]' +const tablePageRage21to28 = '21–28 of' +const rowsPerPageString = 'Rows per page:' +const pageCountString1to25 = '1–25 of' +const pageCountString1to10 = '1–10 of' +const pageCountString10to20 = '11–20 of' + +export const currencyEUR = 'EUR' +export const currencyUSD = 'USD' + +export const currencyDai = 'Dai' +export const currencyDaiAlttext = 'DAI' +export const currentcyDaiFormat = '120,496.61 DAI' + +export const currencyEther = 'Wrapped Ether' +export const currencyEtherAlttext = 'WETH' +export const currentcyEtherFormat = '0.05918 WETH' + +export const currencyUSDCoin = 'USD Coin' +export const currencyUSDAlttext = 'USDC' +export const currentcyUSDFormat = '131,363 USDC' + +export const currencyGörliEther = 'Görli Ether' +export const currentcyGörliEtherFormat = '0.14 GOR' + +export const currencyUniswap = 'Uniswap' +export const currentcyUniswapFormat = '0.01828 UNI' + +export const currencyGnosis = 'Gnosis' +export const currentcyGnosisFormat = '< 0.00001 GNO' + +export const currencyOx = /^0x$/ +export const currentcyOxFormat = '1.003 ZRX' + +export function verityTokenAltImageIsVisible(currency, alttext) { + cy.contains(currency) + .parents('tr') + .within(() => { + cy.get(`img[alt=${alttext}]`).should('be.visible') + }) +} + +export function verifyAssetNameHasExplorerLink(currency, columnName) { + cy.contains(currency).parents('tr').find('td').eq(columnName).find(etherscanLink).should('be.visible') +} + +export function verifyBalance(currency, tokenAmountColumn, alttext) { + cy.contains(currency).parents('tr').find('td').eq(tokenAmountColumn).contains(alttext) +} + +export function verifyTokenBalanceFormat(currency, formatString, tokenAmountColumn, fiatAmountColumn, fiatRegex) { + cy.contains(currency) + .parents('tr') + .within(() => { + cy.get('td').eq(tokenAmountColumn).contains(formatString) + cy.get('td').eq(fiatAmountColumn).contains(fiatRegex) + }) +} + +export function verifyFirstRowDoesNotContainCurrency(currency, fiatAmountColumn) { + cy.get(balanceSingleRow).first().find('td').eq(fiatAmountColumn).should('not.contain', currency) +} + +export function verifyFirstRowContainsCurrency(currency, fiatAmountColumn) { + cy.get(balanceSingleRow).first().find('td').eq(fiatAmountColumn).contains(currency) +} + +export function clickOnCurrencyDropdown() { + cy.get(currencyDropdown).click() +} + +export function selectCurrency(currency) { + cy.get(currencyDropdownList).findByText(currency).click({ force: true }) + cy.get(currencyDropdownList) + .findByText(currency) + .click({ force: true }) + .then(() => { + cy.get(currencyDropdownListSelected).should('contain', currency) + }) +} + +export function hideAsset(asset) { + cy.contains(asset).parents('tr').find('button[aria-label="Hide asset"]').click() + cy.wait(350) + cy.contains(asset).should('not.exist') +} + +export function openHideTokenMenu() { + cy.get(hiddeTokensBtn).click() +} + +export function clickOnTokenCheckbox(token) { + cy.contains(token).parents('tr').find(hiddenTokenCheckbox).click() +} + +export function saveHiddenTokenSelection() { + cy.contains(hiddenTokenSaveBtn).click() +} + +export function verifyTokenIsVisible(token) { + cy.contains(token) +} + +export function verifyMenuButtonLabelIsDefault() { + cy.contains(hideTokenDefaultString) +} + +export function verifyInitialTableState() { + cy.contains(rowsPerPageString).next().contains(pageRowsDefault) + cy.contains(pageCountString1to25) + cy.get(balanceSingleRow).should('have.length', 25) +} + +export function changeTo10RowsPerPage() { + cy.contains(rowsPerPageString).next().contains(pageRowsDefault).click({ force: true }) + cy.get(paginationPageList).contains(rowsPerPage10).click() +} + +export function verifyTableHas10Rows() { + cy.contains(rowsPerPageString).next().contains(rowsPerPage10) + cy.contains(pageCountString1to10) + cy.get(balanceSingleRow).should('have.length', 10) +} + +export function navigateToNextPage() { + cy.get(nextPageBtn).click({ force: true }) + cy.get(nextPageBtn).click({ force: true }) +} + +export function verifyTableHasNRows(assetsLength) { + cy.contains(tablePageRage21to28) + cy.get(balanceSingleRow).should('have.length', assetsLength) +} + +export function navigateToPreviousPage() { + cy.get(previousPageBtn).click({ force: true }) +} + +export function verifyTableHas10RowsAgain() { + cy.contains(pageCountString10to20) + cy.get(balanceSingleRow).should('have.length', 10) +} diff --git a/cypress/e2e/pages/batches.pages.js b/cypress/e2e/pages/batches.pages.js new file mode 100644 index 0000000000..1b5631c6fb --- /dev/null +++ b/cypress/e2e/pages/batches.pages.js @@ -0,0 +1,103 @@ +const tokenSelectorText = 'G(ö|oe)rli Ether' +const noLaterString = 'No, later' +const yesExecuteString = 'Yes, execute' +const newTransactionTitle = 'New transaction' +const sendTokensButn = 'Send tokens' +const nextBtn = 'Next' +const executeBtn = 'Execute' +const addToBatchBtn = 'Add to batch' +const confirmBatchBtn = 'Confirm batch' + +export const closeModalBtnBtn = '[data-testid="CloseIcon"]' +export const deleteTransactionbtn = '[title="Delete transaction"]' +export const batchTxTopBar = '[data-track="batching: Batch sidebar open"]' +export const batchTxCounter = '[data-track="batching: Batch sidebar open"] span > span' +export const addNewTxBatch = '[data-track="batching: Add new tx to batch"]' +export const batchedTransactionsStr = 'Batched transactions' +export const addInitialTransactionStr = 'Add an initial transaction to the batch' +export const transactionAddedToBatchStr = 'Transaction is added to batch' +export const addNewStransactionStr = 'Add new transaction' + +const recipientInput = 'input[name="recipient"]' +const tokenAddressInput = 'input[name="tokenAddress"]' +const listBox = 'ul[role="listbox"]' +const amountInput = '[name="amount"]' +const nonceInput = 'input[name="nonce"]' +const executeOptionsContainer = 'div[role="radiogroup"]' + +export function addToBatch(EOA, currentNonce, amount, verify = false) { + fillTransactionData(EOA, amount) + setNonceAndProceed(currentNonce) + // Execute the transaction if verification is required + if (verify) { + executeTransaction() + } + addToBatchButton() +} + +function fillTransactionData(EOA, amount) { + cy.get(recipientInput).type(EOA, { delay: 1 }) + // Click on the Token selector + cy.get(tokenAddressInput).prev().click() + cy.get(listBox).contains(new RegExp(tokenSelectorText)).click() + cy.get(amountInput).type(amount) + cy.contains(nextBtn).click() +} + +function setNonceAndProceed(currentNonce) { + cy.get(nonceInput).clear().type(currentNonce, { force: true }).blur() + cy.contains(executeBtn).scrollIntoView() +} + +function executeTransaction() { + cy.waitForSelector(() => { + return cy.get(executeOptionsContainer).then(() => { + cy.contains(yesExecuteString, { timeout: 4000 }).click() + cy.contains(addToBatchBtn).should('not.exist') + }) + }) +} + +function addToBatchButton() { + cy.contains(noLaterString, { timeout: 4000 }).click() + cy.contains(addToBatchBtn).should('be.visible').and('not.be.disabled').click() +} + +export function openBatchtransactionsModal() { + cy.get(batchTxTopBar).should('be.visible').click() + cy.contains(batchedTransactionsStr).should('be.visible') + cy.contains(addInitialTransactionStr) +} + +export function openNewTransactionModal() { + cy.get(addNewTxBatch).click() + cy.contains('h1', newTransactionTitle).should('be.visible') + cy.contains(sendTokensButn).click() +} + +export function verifyAmountTransactionsInBatch(count) { + cy.contains(batchedTransactionsStr, { timeout: 7000 }) + .should('be.visible') + .parents('aside') + .find('ul > li') + .should('have.length', count) +} + +export function clickOnConfirmBatchBtn() { + cy.contains(confirmBatchBtn).click() +} + +export function verifyBatchTransactionsCount(count) { + cy.contains(`This batch contains ${count} transactions`).should('be.visible') +} + +export function clickOnBatchCounter() { + cy.get(batchTxCounter).click() +} +export function verifyTransactionAdded() { + cy.contains(transactionAddedToBatchStr).should('be.visible') +} + +export function verifyBatchIconCount(count) { + cy.get(batchTxCounter).contains(count) +} diff --git a/cypress/e2e/pages/create_tx.pages.js b/cypress/e2e/pages/create_tx.pages.js new file mode 100644 index 0000000000..11b7bb78e7 --- /dev/null +++ b/cypress/e2e/pages/create_tx.pages.js @@ -0,0 +1,191 @@ +import * as constants from '../../support/constants' + +const newTransactionBtnStr = 'New transaction' +const recepientInput = 'input[name="recipient"]' +const sendTokensBtnStr = 'Send tokens' +const tokenAddressInput = 'input[name="tokenAddress"]' +const amountInput = 'input[name="amount"]' +const nonceInput = 'input[name="nonce"]' +const gasLimitInput = '[name="gasLimit"]' +const rotateLeftIcon = '[data-testid="RotateLeftIcon"]' +const transactionItemExpandable = 'div[id^="transfer"]' + +const viewTransactionBtn = 'View transaction' +const transactionDetailsTitle = 'Transaction details' +const QueueLabel = 'needs to be executed first' +const TransactionSummary = 'Send' + +const maxAmountBtnStr = 'Max' +const nextBtnStr = 'Next' +const nativeTokenTransferStr = 'Native token transfer' +const yesStr = 'Yes, ' +const estimatedFeeStr = 'Estimated fee' +const executeStr = 'Execute' +const transactionsPerHrStr = 'Transactions per hour' +const transactionsPerHr5Of5Str = '5 of 5' +const editBtnStr = 'Edit' +const executionParamsStr = 'Execution parameters' +const noLaterStr = 'No, later' +const signBtnStr = 'Sign' +const expandAllBtnStr = 'Expand all' +const collapseAllBtnStr = 'Collapse all' + +export function clickOnNewtransactionBtn() { + // Assert that "New transaction" button is visible + cy.contains(newTransactionBtnStr, { + timeout: 60_000, // `lastWallet` takes a while initialize in CI + }) + .should('be.visible') + .and('not.be.disabled') + + // Open the new transaction modal + cy.contains(newTransactionBtnStr).click() + cy.contains('h1', newTransactionBtnStr).should('be.visible') +} + +export function typeRecipientAddress(address) { + cy.get(recepientInput).type(address).should('have.value', address) +} +export function clickOnSendTokensBtn() { + cy.contains(sendTokensBtnStr).click() +} + +export function clickOnTokenselectorAndSelectGoerli() { + cy.get(tokenAddressInput).prev().click() + cy.get('ul[role="listbox"]').contains(constants.goerliToken).click() +} + +export function setMaxAmount() { + cy.contains(maxAmountBtnStr).click() +} + +export function verifyMaxAmount(token, tokenAbbreviation) { + cy.get(tokenAddressInput) + .prev() + .find('p') + .contains(token) + .next() + .then((element) => { + const maxBalance = element.text().replace(tokenAbbreviation, '').trim() + cy.get(amountInput).should('have.value', maxBalance) + console.log(maxBalance) + }) +} + +export function setSendValue(value) { + cy.get(amountInput).clear().type(value) +} + +export function clickOnNextBtn() { + cy.contains(nextBtnStr).click() +} + +export function verifySubmitBtnIsEnabled() { + cy.get('button[type="submit"]').should('not.be.disabled') +} + +export function verifyNativeTokenTransfer() { + cy.contains(nativeTokenTransferStr).should('be.visible') +} + +export function changeNonce(value) { + cy.get(nonceInput).clear().type(value, { force: true }).blur() +} + +export function verifyConfirmTransactionData() { + cy.contains(yesStr).should('exist').click() + cy.contains(estimatedFeeStr).should('exist') + + // Asserting the sponsored info is present + cy.contains(executeStr).scrollIntoView().should('be.visible') + + cy.get('span').contains(estimatedFeeStr).next().should('have.css', 'text-decoration-line', 'line-through') + cy.contains(transactionsPerHrStr) + cy.contains(transactionsPerHr5Of5Str) +} + +export function openExecutionParamsModal() { + cy.contains(estimatedFeeStr).click() + cy.contains(editBtnStr).click() +} + +export function verifyAndSubmitExecutionParams() { + cy.contains(executionParamsStr).parents('form').as('Paramsform') + + // Only gaslimit should be editable when the relayer is selected + const arrayNames = ['Wallet nonce', 'Max priority fee (Gwei)', 'Max fee (Gwei)'] + arrayNames.forEach((element) => { + cy.get('@Paramsform').find('label').contains(`${element}`).next().find('input').should('be.disabled') + }) + + cy.get('@Paramsform').find(gasLimitInput).clear().type('300000').invoke('prop', 'value').should('equal', '300000') + cy.get('@Paramsform').find(gasLimitInput).parent('div').find(rotateLeftIcon).click() + cy.get('@Paramsform').submit() +} + +export function clickOnNoLaterOption() { + // Asserts the execute checkbox is uncheckable (???) + cy.contains(noLaterStr).click() +} + +export function clickOnSignTransactionBtn() { + cy.contains(signBtnStr).click() +} + +export function waitForProposeRequest() { + cy.intercept('POST', constants.proposeEndpoint).as('ProposeTx') + cy.wait('@ProposeTx') +} + +export function clickViewTransaction() { + cy.contains(viewTransactionBtn).click() +} + +export function verifySingleTxPage() { + cy.get('h3').contains(transactionDetailsTitle).should('be.visible') +} + +export function verifyQueueLabel() { + cy.contains(QueueLabel).should('be.visible') +} + +export function verifyTransactionSummary(sendValue) { + cy.contains(TransactionSummary + `${sendValue} ${constants.tokenAbbreviation.gor}`).should('exist') +} + +export function verifyDateExists(date) { + cy.contains('div', date).should('exist') +} + +export function verifyImageAltTxt(index, text) { + cy.get('img').eq(index).should('have.attr', 'alt', text).should('be.visible') +} + +export function verifyStatus(status) { + cy.contains('div', status).should('exist') +} + +export function verifyTransactionStrExists(str) { + cy.contains(str).should('exist') +} + +export function verifyTransactionStrNotVible(str) { + cy.contains(str).should('not.be.visible') +} + +export function clickOnTransactionExpandableItem(name, actions) { + cy.contains('div', name) + .next() + .click() + .within(() => { + actions() + }) +} + +export function clickOnExpandAllBtn() { + cy.contains(expandAllBtnStr).click() +} + +export function clickOnCollapseAllBtn() { + cy.contains(collapseAllBtnStr).click() +} diff --git a/cypress/e2e/pages/create_wallet.pages.js b/cypress/e2e/pages/create_wallet.pages.js new file mode 100644 index 0000000000..57ad95b318 --- /dev/null +++ b/cypress/e2e/pages/create_wallet.pages.js @@ -0,0 +1,89 @@ +import * as constants from '../../support/constants' + +const newAccountBtnStr = 'Create new Account' + +const nameInput = 'input[name="name"]' +const selectNetworkBtn = '[data-cy="create-safe-select-network"]' +const ownerInput = 'input[name^="owners"][name$="name"]' +const ownerAddress = 'input[name^="owners"][name$="address"]' +const thresholdInput = 'input[name="threshold"]' +const removeOwnerBtn = 'button[aria-label="Remove owner"]' + +export function clickOnCreateNewAccuntBtn() { + cy.contains(newAccountBtnStr).click() +} + +export function typeWalletName(name) { + cy.get(nameInput).should('have.attr', 'placeholder').should('match', constants.goerlySafeName) + cy.get(nameInput).type(name).should('have.value', name) +} + +export function selectNetwork(network, regex = false) { + cy.get(selectNetworkBtn).click() + cy.contains(network).click() + + if (regex) { + regex = constants.networks.goerli + cy.get(selectNetworkBtn).click().invoke('text').should('match', regex) + } else { + cy.get(selectNetworkBtn).click().should('have.text', network) + } + cy.get('body').click() +} + +export function clickOnNextBtn() { + cy.contains('button', 'Next').click() +} + +export function verifyOwnerName(name, index) { + cy.get(ownerInput).eq(index).should('have.value', name) +} + +export function verifyOwnerAddress(address, index) { + cy.get(ownerAddress).eq(index).should('have.value', address) +} + +export function verifyThreshold(number) { + cy.get(thresholdInput).should('have.value', number) +} + +export function typeOwnerName(name, index) { + cy.get(getOwnerNameInput(index)).type(name).should('have.value', name) +} + +function typeOwnerAddress(address, index) { + cy.get(getOwnerAddressInput(index)).type(address).should('have.value', address) +} + +function clickOnAddNewOwnerBtn() { + cy.contains('button', 'Add new owner').click() +} + +export function addNewOwner(name, address, index) { + clickOnAddNewOwnerBtn() + typeOwnerName(name, index) + typeOwnerAddress(address, index) +} + +export function updateThreshold(number) { + cy.get(thresholdInput).parent().click() + cy.contains('li', number).click() +} + +export function removeOwner(index) { + cy.get(removeOwnerBtn).eq(index).click() +} + +export function verifySummaryData(safeName, ownerAddress, startThreshold, endThreshold) { + cy.contains(safeName) + cy.contains(ownerAddress) + cy.contains(`${startThreshold} out of ${endThreshold}`) +} + +function getOwnerNameInput(index) { + return `input[name="owners.${index}.name"]` +} + +function getOwnerAddressInput(index) { + return `input[name="owners.${index}.address"]` +} diff --git a/cypress/e2e/pages/dashboard.pages.js b/cypress/e2e/pages/dashboard.pages.js new file mode 100644 index 0000000000..d862ab987c --- /dev/null +++ b/cypress/e2e/pages/dashboard.pages.js @@ -0,0 +1,89 @@ +import * as constants from '../../support/constants' + +const connectAndTransactStr = 'Connect & transact' +const transactionQueueStr = 'Pending transactions' +const noTransactionStr = 'This Safe has no queued transactions' +const overviewStr = 'Overview' +const viewAssetsStr = 'View assets' +const tokensStr = 'Tokens' +const nftStr = 'NFTs' +const viewAllStr = 'View all' +const transactionBuilderStr = 'Use Transaction Builder' +const walletConnectStr = 'Use WalletConnect' +const safeAppStr = 'Safe Apps' +const exploreSafeApps = 'Explore Safe Apps' + +const txBuilder = 'a[href*="tx-builder"]' +const walletConnect = 'a[href*="wallet-connect"]' +const safeSpecificLink = 'a[href*="&appUrl=http"]' + +export function verifyConnectTransactStrIsVisible() { + cy.contains(connectAndTransactStr).should('be.visible') +} + +export function verifyOverviewWidgetData() { + // Alias for the Overview section + cy.contains('h2', overviewStr).parents('section').as('overviewSection') + + cy.get('@overviewSection').within(() => { + // Prefix is separated across elements in EthHashInfo + cy.contains(constants.TEST_SAFE).should('exist') + cy.contains('1/2') + cy.get(`a[href="${constants.BALANCE_URL}${encodeURIComponent(constants.TEST_SAFE)}"]`).contains(viewAssetsStr) + // Text next to Tokens contains a number greater than 0 + cy.contains('p', tokensStr).next().contains('1') + cy.contains('p', nftStr).next().contains('0') + }) +} + +export function verifyTxQueueWidget() { + // Alias for the Transaction queue section + cy.contains('h2', transactionQueueStr).parents('section').as('txQueueSection') + + cy.get('@txQueueSection').within(() => { + // There should be queued transactions + cy.contains(noTransactionStr).should('not.exist') + + // Queued txns + cy.contains( + `a[href^="/transactions/tx?id=multisig_0x"]`, + '13' + 'Send' + '0.00002 GOR' + 'to' + 'gor:0xE297...9665' + '1/1', + ).should('exist') + cy.contains(`a[href="${constants.transactionQueueUrl}${encodeURIComponent(constants.TEST_SAFE)}"]`, viewAllStr) + }) +} + +export function verifyFeaturedAppsSection() { + // Alias for the featured Safe Apps section + cy.contains('h2', connectAndTransactStr).parents('section').as('featuredSafeAppsSection') + + // Tx Builder app + cy.get('@featuredSafeAppsSection').within(() => { + // Transaction Builder + cy.contains(transactionBuilderStr) + cy.get(txBuilder).should('exist') + + // WalletConnect app + cy.contains(walletConnectStr) + cy.get(walletConnect).should('exist') + + // Featured apps have a Safe-specific link + cy.get(safeSpecificLink).should('have.length', 2) + }) +} + +export function verifySafeAppsSection() { + // Create an alias for the Safe Apps section + cy.contains('h2', safeAppStr).parents('section').as('safeAppsSection') + + cy.get('@safeAppsSection').contains(exploreSafeApps) + + // Regular safe apps + cy.get('@safeAppsSection').within(() => { + // Find exactly 5 Safe Apps cards inside the Safe Apps section + cy.get(`a[href^="${constants.openAppsUrl}${encodeURIComponent(constants.TEST_SAFE)}&appUrl=http"]`).should( + 'have.length', + 5, + ) + }) +} diff --git a/cypress/e2e/pages/import_export.pages.js b/cypress/e2e/pages/import_export.pages.js new file mode 100644 index 0000000000..2a0c82fd52 --- /dev/null +++ b/cypress/e2e/pages/import_export.pages.js @@ -0,0 +1,104 @@ +import { format } from 'date-fns' +const path = require('path') + +const addressBookBtnStr = 'Address book' +const dataImportModalStr = 'Data import' +const appsBtnStr = 'Apps' +const bookmarkedAppsBtnStr = 'Bookmarked apps' +const settingsBtnStr = 'Settings' +const appearenceTabStr = 'Appearance' +const dataTabStr = 'Data' +const tab = 'div[role="tablist"] a' +export const prependChainPrefixStr = 'Prepend chain prefix to addresses' +export const copyAddressStr = 'Copy addresses with chain prefix' +export const darkModeStr = 'Dark mode' + +export function verifyImportBtnIsVisible() { + cy.contains('button', 'Import').should('be.visible') +} +export function clickOnImportBtn() { + verifyImportBtnIsVisible() + cy.contains('button', 'Import').click() +} + +export function clickOnImportBtnDataImportModal() { + cy.contains(dataImportModalStr).parent().contains('button', 'Import').click() +} + +export function uploadFile(filePath) { + cy.get('[type="file"]').attachFile(filePath) +} + +export function verifyImportModalData() { + //verifies that the modal says the amount of chains/addressbook values it uploaded for file ../fixtures/data_import.json + cy.contains('Added Safe Accounts on 3 chains').should('be.visible') + cy.contains('Address book for 3 chains').should('be.visible') + cy.contains('Settings').should('be.visible') + cy.contains('Bookmarked Safe Apps').should('be.visible') +} + +export function clickOnImportedSafe(safe) { + cy.contains(safe).click() +} + +export function clickOnAddressBookBtn() { + cy.contains(addressBookBtnStr).click() +} + +export function verifyImportedAddressBookData() { + //Verifies imported owners in the Address book for file ../fixtures/data_import.json + cy.get('tbody tr:nth-child(1) td:nth-child(1)').contains('test1') + cy.get('tbody tr:nth-child(1) td:nth-child(2)').contains('0x61a0c717d18232711bC788F19C9Cd56a43cc8872') + cy.get('tbody tr:nth-child(2) td:nth-child(1)').contains('test2') + cy.get('tbody tr:nth-child(2) td:nth-child(2)').contains('0x7724b234c9099C205F03b458944942bcEBA13408') +} + +export function clickOnAppsBtn() { + cy.get('aside').contains('li', appsBtnStr).click() +} + +export function clickOnBookmarkedAppsBtn() { + cy.contains(bookmarkedAppsBtnStr).click() + //Takes a some time to load the apps page, It waits for bookmark to be lighted up + cy.waitForSelector(() => { + return cy + .get('[aria-selected="true"] p') + .invoke('html') + .then((text) => text === 'Bookmarked apps') + }) +} + +export function verifyAppsAreVisible(appNames) { + appNames.forEach((appName) => { + cy.contains(appName).should('be.visible') + }) +} + +export function clickOnSettingsBtn() { + cy.contains(settingsBtnStr).click() +} + +export function clickOnAppearenceBtn() { + cy.contains(tab, appearenceTabStr).click() +} + +export function clickOnDataTab() { + cy.contains(tab, dataTabStr).click() +} + +export function verifyCheckboxes(checkboxes, checked = false) { + checkboxes.forEach((checkbox) => { + cy.contains('label', checkbox) + .find('input[type="checkbox"]') + .should(checked ? 'be.checked' : 'not.be.checked') + }) +} + +export function verifyFileDownload() { + const date = format(new Date(), 'yyyy-MM-dd', { timeZone: 'UTC' }) + const fileName = `safe-${date}.json` + cy.contains('div', fileName).next().click() + const downloadsFolder = Cypress.config('downloadsFolder') + //File reading is failing in the CI. Can be tested locally + cy.readFile(path.join(downloadsFolder, fileName)).should('exist') +} diff --git a/cypress/e2e/pages/load_safe.pages.js b/cypress/e2e/pages/load_safe.pages.js new file mode 100644 index 0000000000..3d36669e48 --- /dev/null +++ b/cypress/e2e/pages/load_safe.pages.js @@ -0,0 +1,116 @@ +import * as constants from '../../support/constants' + +const addExistingAccountBtnStr = 'Add existing Account' +const contactStr = 'Name, address & network' +const invalidAddressFormatErrorMsg = 'Invalid address format' + +const safeDataForm = '[data-testid=load-safe-form]' +const nameInput = 'input[name="name"]' +const addressInput = 'input[name="address"]' +const sideBarIcon = '[data-testid=ChevronRightIcon]' +const sidebarCheckIcon = '[data-testid=CheckIcon]' +const nextBtnStr = 'Next' +const addBtnStr = 'Add' +const settingsBtnStr = 'Settings' +const ownersConfirmationsStr = 'Owners and confirmations' +const transactionStr = 'Transactions' + +export function openLoadSafeForm() { + cy.contains('button', addExistingAccountBtnStr).click() + cy.contains(contactStr) +} + +export function clickNetworkSelector(networkName) { + cy.get(safeDataForm).contains(networkName).click() +} + +export function selectGoerli() { + cy.get('ul li').contains(constants.networks.goerli).click() + cy.contains('span', constants.networks.goerli) +} + +export function selectPolygon() { + cy.get('ul li').contains(constants.networks.polygon).click() + cy.contains('span', constants.networks.polygon) +} + +export function verifyNameInputHasPlceholder() { + cy.get(nameInput).should('have.attr', 'placeholder').should('match', constants.goerlySafeName) +} + +export function inputName(name) { + cy.get(nameInput).type(name).should('have.value', name) +} + +export function verifyIncorrectAddressErrorMessage() { + inputAddress('Random text') + cy.get(addressInput).parent().prev('label').contains(invalidAddressFormatErrorMsg) +} + +export function inputAddress(address) { + cy.get(addressInput).clear().type(address) +} + +export function verifyAddressInputValue() { + // The address field should be filled with the "bare" QR code's address + const [, address] = constants.GOERLI_TEST_SAFE.split(':') + cy.get('input[name="address"]').should('have.value', address) +} + +export function clickOnNextBtn() { + cy.contains(nextBtnStr).click() +} + +export function verifyDataInReviewSection(safeName, ownerName) { + cy.findByText(safeName).should('be.visible') + cy.findByText(ownerName).should('be.visible') +} + +export function clickOnAddBtn() { + cy.contains('button', addBtnStr).click() +} + +export function veriySidebarSafeNameIsVisible(safeName) { + cy.get('aside').contains(safeName).should('be.visible') +} + +export function verifyOwnerNamePresentInSettings(ownername) { + clickOnSettingsBtn() + cy.contains(ownername).should('exist') +} + +function clickOnSettingsBtn() { + cy.get('aside ul').contains(settingsBtnStr).click() +} + +export function verifyOwnersModalIsVisible() { + cy.contains(ownersConfirmationsStr).should('be.visible') +} + +export function openSidebar() { + cy.get('aside').within(() => { + cy.get(sideBarIcon).click({ force: true }) + }) +} + +export function verifyAddressInsidebar(address) { + cy.get('li').within(() => { + cy.contains(address).should('exist') + }) +} + +export function verifySidebarIconNumber(number) { + cy.get(sidebarCheckIcon).next().contains(number).should('exist') +} + +export function clickOnPendingActions() { + cy.get(sidebarCheckIcon).next().click() +} + +export function verifyTransactionSectionIsVisible() { + cy.contains('h3', transactionStr).should('be.visible') +} + +export function verifyNumberOfTransactions(startNumber, endNumber) { + cy.get(`span:contains("${startNumber} out of ${endNumber}")`).should('have.length', 1) +} diff --git a/cypress/e2e/pages/main.page.js b/cypress/e2e/pages/main.page.js new file mode 100644 index 0000000000..aea2e04bd0 --- /dev/null +++ b/cypress/e2e/pages/main.page.js @@ -0,0 +1,27 @@ +import * as constants from '../../support/constants' + +const acceptSelection = 'Accept selection' + +export function acceptCookies() { + cy.contains(acceptSelection).click() + cy.contains(acceptSelection).should('not.exist') + cy.wait(500) +} + +export function verifyGoerliWalletHeader() { + cy.contains(constants.goerlyE2EWallet) +} + +export function verifyHomeSafeUrl(safe) { + cy.location('href', { timeout: 10000 }).should('include', constants.homeUrl + safe) +} + +export function checkTextsExistWithinElement(element, texts) { + texts.forEach((text) => { + cy.wrap(element).findByText(text).should('exist') + }) +} + +export function verifyCheckboxeState(element, index, state) { + cy.get(element).eq(index).should(state) +} diff --git a/cypress/e2e/pages/nfts.pages.js b/cypress/e2e/pages/nfts.pages.js new file mode 100644 index 0000000000..185224b70f --- /dev/null +++ b/cypress/e2e/pages/nfts.pages.js @@ -0,0 +1,116 @@ +import * as constants from '../../support/constants' + +const nftModal = 'div[role="dialog"]' +const nftModalCloseBtn = 'button[aria-label="close"]' +const recipientInput = 'input[name="recipient"]' + +const noneNFTSelected = '0 NFTs selected' +const sendNFTStr = 'Send NFTs' +const recipientAddressStr = 'Recipient address or ENS' +const selectedNFTStr = 'Selected NFTs' +const executeBtnStr = 'Execute' +const nextBtnStr = 'Next' +const sendStr = 'Send' +const toStr = 'To' +const transferFromStr = 'safeTransferFrom' + +function verifyTableRows(number) { + cy.get('tbody tr').should('have.length', number) +} + +export function verifyNFTNumber(number) { + verifyTableRows(number) +} + +export function verifyDataInTable(name, address, tokenID, link) { + cy.get('tbody tr:first-child').contains('td:first-child', name) + cy.get('tbody tr:first-child').contains('td:first-child', address) + cy.get('tbody tr:first-child').contains('td:nth-child(2)', tokenID) + cy.get(`tbody tr:first-child td:nth-child(3) a[href="${link}"]`) +} + +export function openFirstNFT() { + cy.get('tbody tr:first-child td:nth-child(2)').click() +} + +export function verifyNameInNFTModal(name) { + cy.get(nftModal).contains(name) +} + +export function preventBaseMainnetGoerliFromBeingSelected() { + cy.get(nftModal).contains(constants.networks.goerli) +} + +export function verifyNFTModalLink(link) { + cy.get(nftModal).contains(`a[href="${link}"]`, 'View on OpenSea') +} + +export function closeNFTModal() { + cy.get(nftModalCloseBtn).click() + cy.get(nftModal).should('not.exist') +} + +export function clickOnThirdNFT() { + cy.get('tbody tr:nth-child(3) td:nth-child(2)').click() +} +export function verifyNFTModalDoesNotExist() { + cy.get(nftModal).should('not.exist') +} + +export function selectNFTs(numberOfNFTs) { + for (let i = 1; i <= numberOfNFTs; i++) { + cy.get(`tbody tr:nth-child(${i}) input[type="checkbox"]`).click() + cy.contains(`${i} NFT${i > 1 ? 's' : ''} selected`) + } + cy.contains('button', `Send ${numberOfNFTs} NFT${numberOfNFTs > 1 ? 's' : ''}`) +} + +export function deselectNFTs(checkboxIndexes, checkedItems) { + let total = checkedItems - checkboxIndexes.length + + checkboxIndexes.forEach((index) => { + cy.get(`tbody tr:nth-child(${index}) input[type="checkbox"]`).uncheck() + }) + + cy.contains(`${total} NFT${total !== 1 ? 's' : ''} selected`) + if (total === 0) { + verifyInitialNFTData() + } +} + +export function verifyInitialNFTData() { + cy.contains(noneNFTSelected) + cy.contains('button[disabled]', 'Send') +} + +export function sendNFT(numberOfCheckedNFTs) { + cy.contains('button', `Send ${numberOfCheckedNFTs} NFT${numberOfCheckedNFTs !== 1 ? 's' : ''}`).click() +} + +export function verifyNFTModalData() { + cy.contains(sendNFTStr) + cy.contains(recipientAddressStr) + cy.contains(selectedNFTStr) +} + +export function typeRecipientAddress(address) { + cy.get(recipientInput).type(address) +} + +export function clikOnNextBtn() { + cy.contains('button', nextBtnStr).click() +} + +export function verifyReviewModalData(NFTcount) { + cy.contains(sendStr) + cy.contains(toStr) + cy.wait(1000) + cy.get(`b:contains(${transferFromStr})`).should('have.length', NFTcount) + cy.contains('button:not([disabled])', executeBtnStr) + if (NFTcount > 1) { + const numbersArr = Array.from({ length: NFTcount }, (_, index) => index + 1) + numbersArr.forEach((number) => { + cy.contains(number.toString()).should('be.visible') + }) + } +} diff --git a/cypress/e2e/pages/safeapps.pages.js b/cypress/e2e/pages/safeapps.pages.js new file mode 100644 index 0000000000..4700348f6b --- /dev/null +++ b/cypress/e2e/pages/safeapps.pages.js @@ -0,0 +1,193 @@ +import * as constants from '../../support/constants' + +const searchAppInput = 'input[id="search-by-name"]' +const appUrlInput = 'input[name="appUrl"]' +const closePreviewWindowBtn = 'button[aria-label*="Close"][aria-label*="preview"]' + +const addBtnStr = /add/i +const noAppsStr = /no Safe Apps found/i +const bookmarkedAppsStr = /bookmarked Apps/i +const customAppsStr = /my custom Apps/i +const addCustomAppBtnStr = /add custom Safe App/i +const openSafeAppBtnStr = /open Safe App/i +const disclaimerTtle = /disclaimer/i +const continueBtnStr = /continue/i +const cameraCheckBoxStr = /camera/i +const microfoneCheckBoxStr = /microphone/i +const permissionRequestStr = /permissions request/i +const accessToAddressBookStr = /access to your address book/i +const acceptBtnStr = /accept/i +const clearAllBtnStr = /clear all/i +const allowAllPermissions = /allow all/i + +const appNotSupportedMsg = "The app doesn't support Safe App functionality" + +export const pinWalletConnectStr = /pin walletconnect/i +export const transactionBuilderStr = /pin transaction builder/i +export const logoWalletConnect = /logo.*walletconnect/i +export const walletConnectHeadlinePreview = /walletconnect/i +export const availableNetworksPreview = /available networks/i +export const connecttextPreview = 'Connect your Safe to any dApp that supports WalletConnect' +export const localStorageItem = + '{"https://safe-test-app.com":[{"feature":"camera","status":"granted"},{"feature":"microphone","status":"denied"}]}' +export const gridItem = 'main .MuiPaper-root > .MuiGrid-item' +export const linkNames = { + logo: /logo/i, +} + +export const permissionCheckboxes = { + camera: 'input[name="camera"]', + addressbook: 'input[name="requestAddressBook"]', + microphone: 'input[name="microphone"]', + geolocation: 'input[name="geolocation"]', + fullscreen: 'input[name="fullscreen"]', +} + +export const permissionCheckboxNames = { + camera: 'Camera', + addressbook: 'Address Book', + microphone: 'Microphone', + geolocation: 'Geolocation', + fullscreen: 'Fullscreen', +} +export function typeAppName(name) { + cy.get(searchAppInput).clear().type(name) +} + +export function clearSearchAppInput() { + cy.get(searchAppInput).clear() +} + +export function verifyLinkName(name) { + cy.findAllByRole('link', { name: name }).should('have.length', 1) +} + +export function clickOnApp(app) { + cy.findByRole('link', { name: app }).click() +} + +export function verifyNoAppsTextPresent() { + cy.contains(noAppsStr).should('exist') +} + +export function pinApp(app, pin = true) { + let str = 'Unpin' + if (!pin) str = 'Pin' + cy.findByLabelText(app) + .click() + .should(($el) => { + const ariaLabel = $el.attr('aria-label') + expect(ariaLabel).to.include(str) + }) +} + +export function clickOnBookmarkedAppsTab() { + cy.findByText(bookmarkedAppsStr).click() +} + +export function verifyAppCount(count) { + cy.findByText(`ALL (${count})`).should('be.visible') +} + +export function clickOnCustomAppsTab() { + cy.findByText(customAppsStr).click() +} + +export function clickOnAddCustomApp() { + cy.findByText(addCustomAppBtnStr).click() +} + +export function typeCustomAppUrl(url) { + cy.get(appUrlInput).clear().type(url) +} + +export function verifyAppNotSupportedMsg() { + cy.contains(appNotSupportedMsg).should('be.visible') +} + +export function verifyAppTitle(title) { + cy.findByRole('heading', { name: title }).should('exist') +} + +export function acceptTC() { + cy.findByRole('checkbox').click() +} + +export function clickOnAddBtn() { + cy.findByRole('button', { name: addBtnStr }).click() +} + +export function verifyAppDescription(descr) { + cy.findByText(descr).should('exist') +} + +export function clickOnOpenSafeAppBtn() { + cy.findByRole('link', { name: openSafeAppBtnStr }).click() + cy.wait(500) + verifyDisclaimerIsVisible() + cy.wait(500) +} + +function verifyDisclaimerIsVisible() { + cy.findByRole('heading', { name: disclaimerTtle }).should('be.visible') +} + +export function clickOnContinueBtn() { + return cy.findByRole('button', { name: continueBtnStr }).click() +} + +export function verifyCameraCheckBoxExists() { + cy.findByRole('checkbox', { name: cameraCheckBoxStr }).should('exist') +} + +export function verifyMicrofoneCheckBoxExists() { + return cy.findByRole('checkbox', { name: microfoneCheckBoxStr }).should('exist') +} + +export function storeAndVerifyPermissions() { + cy.waitForSelector(() => { + return cy + .findByRole('button', { name: continueBtnStr }) + .click() + .wait(500) + .should(() => { + const storedBrowserPermissions = JSON.parse(localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY)) + const browserPermissions = Object.values(storedBrowserPermissions)[0][0] + const storedInfoModal = JSON.parse(localStorage.getItem(constants.INFO_MODAL_KEY)) + + expect(browserPermissions.feature).to.eq('camera') + expect(browserPermissions.status).to.eq('granted') + expect(storedInfoModal['5'].consentsAccepted).to.eq(true) + }) + }) +} + +export function verifyPreviewWindow(str1, str2, str3) { + cy.findByRole('heading', { name: str1 }).should('exist') + cy.findByText(str2).should('exist') + cy.findByText(str3).should('exist') +} + +export function closePreviewWindow() { + cy.get(closePreviewWindowBtn).click() +} + +export function verifyPermissionsRequestExists() { + cy.findByRole('heading', { name: permissionRequestStr }).should('exist') +} + +export function verifyAccessToAddressBookExists() { + cy.findByText(accessToAddressBookStr).should('exist') +} + +export function clickOnAcceptBtn() { + cy.findByRole('button', { name: acceptBtnStr }).click() +} + +export function uncheckAllPermissions(element) { + cy.wrap(element).findByText(clearAllBtnStr).click() +} + +export function checkAllPermissions(element) { + cy.wrap(element).findByText(allowAllPermissions).click() +} diff --git a/cypress/e2e/safe-apps/apps_list.cy.js b/cypress/e2e/safe-apps/apps_list.cy.js index 87ae81ffd2..a6db25138e 100644 --- a/cypress/e2e/safe-apps/apps_list.cy.js +++ b/cypress/e2e/safe-apps/apps_list.cy.js @@ -1,79 +1,77 @@ -import { TEST_SAFE } from './constants' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' + +const myCustomAppTitle = 'Cypress Test App' +const myCustomAppDescrAdded = 'Cypress Test App Description' describe('The Safe Apps list', () => { before(() => { - cy.visit(`/${TEST_SAFE}/apps`, { failOnStatusCode: false }) - cy.findByText(/accept selection/i).click() + cy.clearLocalStorage() + cy.visit(constants.TEST_SAFE_2 + constants.appsUrl, { failOnStatusCode: false }) + main.acceptCookies() }) describe('When searching apps', () => { it('should filter the list by app name', () => { // Wait for /safe-apps response - cy.intercept('GET', '/**/safe-apps').then(() => { - cy.findByRole('textbox').type('walletconnect') - cy.findAllByRole('link', { name: /logo/i }).should('have.length', 1) + cy.intercept('GET', constants.appsEndpoint).then(() => { + safeapps.typeAppName(constants.appNames.walletConnect) + safeapps.verifyLinkName(safeapps.linkNames.logo) }) }) it('should filter the list by app description', () => { - cy.findByRole('textbox').clear().type('compose custom contract') - cy.findAllByRole('link', { name: /logo/i }).should('have.length', 1) + safeapps.typeAppName(constants.appNames.customContract) + safeapps.verifyLinkName(safeapps.linkNames.logo) }) it('should show a not found text when no match', () => { - cy.findByRole('textbox').clear().type('atextwithoutresults') - cy.findByText(/no Safe Apps found/i).should('exist') + safeapps.typeAppName(constants.appNames.noResults) + safeapps.verifyNoAppsTextPresent() }) }) describe('When browsing the apps list', () => { it('should allow to pin apps', () => { - cy.findByRole('textbox').clear() - cy.findByLabelText(/pin walletconnect/i).click() - cy.findByLabelText(/pin transaction builder/i).click() - cy.findByText(/bookmarked Apps/i).click() - cy.findByText('ALL (2)').should('exist') + safeapps.clearSearchAppInput() + safeapps.pinApp(safeapps.pinWalletConnectStr) + safeapps.pinApp(safeapps.transactionBuilderStr) + safeapps.clickOnBookmarkedAppsTab() + safeapps.verifyAppCount(2) }) it('should allow to unpin apps', () => { - cy.findAllByLabelText(/unpin walletConnect/i) - .first() - .click() - cy.findAllByLabelText(/unpin transaction builder/i) - .first() - .click() - cy.findByText('ALL (0)').should('exist') + safeapps.pinApp(safeapps.pinWalletConnectStr) + safeapps.pinApp(safeapps.transactionBuilderStr) + safeapps.verifyAppCount(0) }) }) describe('When adding a custom app', () => { it('should show an error when the app manifest is invalid', () => { - cy.intercept('GET', 'https://my-invalid-custom-app.com/manifest.json', { - name: 'My Custom App', + cy.intercept('GET', constants.invalidAppUrl, { + name: constants.testAppData.name, }) - cy.findByText(/my custom Apps/i).click() - cy.findByText(/add custom Safe App/i).click({ force: true }) - cy.findByLabelText(/Safe App url/i) - .clear() - .type('https://my-invalid-custom-app.com') - cy.contains("The app doesn't support Safe App functionality").should('exist') + safeapps.clickOnCustomAppsTab() + safeapps.clickOnAddCustomApp() + safeapps.typeCustomAppUrl(constants.invalidAppUrl) + safeapps.verifyAppNotSupportedMsg() }) it('should be added to the list within the custom apps section', () => { - cy.intercept('GET', 'https://my-valid-custom-app.com/manifest.json', { - name: 'My Custom App', - description: 'My Custom App Description', + cy.intercept('GET', constants.validAppUrlJson, { + name: constants.testAppData.name, + description: constants.testAppData.descr, icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], }) - cy.findByLabelText(/Safe App url/i) - .clear() - .type('https://my-valid-custom-app.com') - cy.findByRole('heading', { name: /my custom app/i }).should('exist') - cy.findByRole('checkbox').click() - cy.findByRole('button', { name: /add/i }).click() - cy.findByText('ALL (1)').should('exist') - cy.findByText(/my custom app description/i).should('exist') + safeapps.typeCustomAppUrl(constants.validAppUrl) + safeapps.verifyAppTitle(myCustomAppTitle) + safeapps.acceptTC() + safeapps.clickOnAddBtn() + safeapps.verifyAppCount(1) + safeapps.verifyAppDescription(myCustomAppDescrAdded) }) }) }) diff --git a/cypress/e2e/safe-apps/browser_permissions.cy.js b/cypress/e2e/safe-apps/browser_permissions.cy.js index 16f0b8efc8..fc11c368c6 100644 --- a/cypress/e2e/safe-apps/browser_permissions.cy.js +++ b/cypress/e2e/safe-apps/browser_permissions.cy.js @@ -1,15 +1,16 @@ -import { BROWSER_PERMISSIONS_KEY } from './constants' - -const appUrl = 'https://safe-test-app.com' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' describe('The Browser permissions system', () => { describe('When the safe app requires permissions', () => { beforeEach(() => { + cy.clearLocalStorage() cy.fixture('safe-app').then((html) => { - cy.intercept('GET', `${appUrl}/*`, html) + cy.intercept('GET', `${constants.testAppUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { - name: 'Cypress Test App', - description: 'Cypress Test App Description', + name: constants.testAppData.name, + description: constants.testAppData.descr, icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], safe_apps_permissions: ['camera', 'microphone'], }) @@ -17,23 +18,18 @@ describe('The Browser permissions system', () => { }) it('should show a permissions slide to the user', () => { - cy.visitSafeApp(`${appUrl}/app`) - - cy.findByRole('checkbox', { name: /camera/i }).should('exist') - cy.findByRole('checkbox', { name: /microphone/i }).should('exist') + cy.visitSafeApp(`${constants.testAppUrl}/app`) + safeapps.verifyCameraCheckBoxExists() + safeapps.verifyMicrofoneCheckBoxExists() }) it('should allow to change, accept and store the selection', () => { - cy.findByText(/accept selection/i).click() + main.acceptCookies() + safeapps.verifyMicrofoneCheckBoxExists().click() - cy.findByRole('checkbox', { name: /microphone/i }).click() - cy.findByRole('button', { name: /continue/i }) - .click() - .should(() => { - expect(window.localStorage.getItem(BROWSER_PERMISSIONS_KEY)).to.eq( - '{"https://safe-test-app.com":[{"feature":"camera","status":"granted"},{"feature":"microphone","status":"denied"}]}', - ) - }) + safeapps.clickOnContinueBtn().should(() => { + expect(window.localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY)).to.eq(safeapps.localStorageItem) + }) }) }) }) diff --git a/cypress/e2e/safe-apps/info_modal.cy.js b/cypress/e2e/safe-apps/info_modal.cy.js index 5f6ea1e14f..3ea79a148c 100644 --- a/cypress/e2e/safe-apps/info_modal.cy.js +++ b/cypress/e2e/safe-apps/info_modal.cy.js @@ -1,36 +1,28 @@ -import { TEST_SAFE, BROWSER_PERMISSIONS_KEY, INFO_MODAL_KEY } from './constants' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' describe('The Safe Apps info modal', () => { before(() => { - cy.visit(`/${TEST_SAFE}/apps`, { failOnStatusCode: false }) - cy.findByText(/accept selection/i).click() + cy.clearLocalStorage() + cy.visit(constants.TEST_SAFE_2 + constants.appsUrl, { failOnStatusCode: false }) + main.acceptCookies() }) describe('when opening a Safe App', () => { it('should show the disclaimer', () => { - cy.findByRole('link', { name: /logo.*walletconnect/i }).click() - cy.findByRole('link', { name: /open Safe App/i }).click() - cy.findByRole('heading', { name: /disclaimer/i }).should('exist') + safeapps.clickOnApp(safeapps.logoWalletConnect) + safeapps.clickOnOpenSafeAppBtn() }) it('should show the permissions slide if the app require permissions', () => { - cy.findByRole('button', { name: /continue/i }).click() + safeapps.clickOnContinueBtn() cy.wait(500) // wait for the animation to finish - cy.findByRole('checkbox', { name: /camera/i }).should('exist') + safeapps.verifyCameraCheckBoxExists() }) it('should store the permissions and consents decision when accepted', () => { - cy.findByRole('button', { name: /continue/i }) - .click() - .should(() => { - const storedBrowserPermissions = JSON.parse(localStorage.getItem(BROWSER_PERMISSIONS_KEY)) - const browserPermissions = Object.values(storedBrowserPermissions)[0][0] - const storedInfoModal = JSON.parse(localStorage.getItem(INFO_MODAL_KEY)) - - expect(browserPermissions.feature).to.eq('camera') - expect(browserPermissions.status).to.eq('granted') - expect(storedInfoModal['5'].consentsAccepted).to.eq(true) - }) + safeapps.storeAndVerifyPermissions() }) }) }) diff --git a/cypress/e2e/safe-apps/permissions_settings.cy.js b/cypress/e2e/safe-apps/permissions_settings.cy.js index 7093480e95..629f117dc3 100644 --- a/cypress/e2e/safe-apps/permissions_settings.cy.js +++ b/cypress/e2e/safe-apps/permissions_settings.cy.js @@ -1,37 +1,41 @@ -import { BROWSER_PERMISSIONS_KEY, SAFE_PERMISSIONS_KEY } from './constants' -import { TEST_SAFE } from './constants' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' let $dapps = [] +const app1 = 'https://app1.com' +const app3 = 'https://app3.com' describe('The Safe Apps permissions settings section', () => { before(() => { + cy.clearLocalStorage() cy.on('window:before:load', (window) => { window.localStorage.setItem( - BROWSER_PERMISSIONS_KEY, + constants.BROWSER_PERMISSIONS_KEY, JSON.stringify({ - 'https://app1.com': [ + app1: [ { feature: 'camera', status: 'granted' }, { feature: 'fullscreen', status: 'granted' }, { feature: 'geolocation', status: 'granted' }, ], - 'https://app2.com': [{ feature: 'microphone', status: 'granted' }], - 'https://app3.com': [{ feature: 'camera', status: 'denied' }], + app2: [{ feature: 'microphone', status: 'granted' }], + app3: [{ feature: 'camera', status: 'denied' }], }), ) window.localStorage.setItem( - SAFE_PERMISSIONS_KEY, + constants.SAFE_PERMISSIONS_KEY, JSON.stringify({ - 'https://app2.com': [ + app2: [ { - invoker: 'https://app1.com', + invoker: app1, parentCapability: 'requestAddressBook', date: 1666103778276, caveats: [], }, ], - 'https://app4.com': [ + app4: [ { - invoker: 'https://app3.com', + invoker: app3, parentCapability: 'requestAddressBook', date: 1666103787026, caveats: [], @@ -41,8 +45,8 @@ describe('The Safe Apps permissions settings section', () => { ) }) - cy.visit(`${TEST_SAFE}/settings/safe-apps`, { failOnStatusCode: false }) - cy.findByText(/accept selection/i).click() + cy.visit(constants.TEST_SAFE_2 + constants.appSettingsUrl, { failOnStatusCode: false }) + main.acceptCookies() }) it('should show the permissions configuration for each stored app', () => { @@ -51,78 +55,59 @@ describe('The Safe Apps permissions settings section', () => { describe('For each app', () => { before(() => { - cy.get('main .MuiPaper-root > .MuiGrid-item').then((items) => { + cy.get(safeapps.gridItem).then((items) => { $dapps = items }) }) it('app1 should have camera, full screen and geo permissions', () => { - cy.wrap($dapps[0]) - .findByText(/https:\/\/app1.com/i) - .should('exist') - cy.wrap($dapps[0]) - .findByText(/camera/i) - .should('exist') - cy.wrap($dapps[0]) - .findByText(/fullscreen/i) - .should('exist') - cy.wrap($dapps[0]) - .findByText(/geolocation/i) - .should('exist') - - cy.wrap($dapps[0]).findAllByRole('checkbox').should('have.checked') + const app1Data = [ + 'app1', + safeapps.permissionCheckboxNames.camera, + safeapps.permissionCheckboxNames.fullscreen, + safeapps.permissionCheckboxNames.geolocation, + ] + + main.checkTextsExistWithinElement($dapps[0], app1Data) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.camera, 0, constants.checkboxStates.checked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.geolocation, 0, constants.checkboxStates.checked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.fullscreen, 0, constants.checkboxStates.checked) }) it('app2 should have address book and microphone permissions', () => { - cy.wrap($dapps[1]) - .findByText(/https:\/\/app2.com/i) - .should('exist') - cy.wrap($dapps[1]) - .findByText(/address book/i) - .should('exist') - cy.wrap($dapps[1]) - .findByText(/microphone/i) - .should('exist') - - cy.wrap($dapps[1]).findAllByRole('checkbox').should('have.checked') + const app2Data = [ + 'app2', + safeapps.permissionCheckboxNames.addressbook, + safeapps.permissionCheckboxNames.microphone, + ] + + main.checkTextsExistWithinElement($dapps[1], app2Data) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.microphone, 0, constants.checkboxStates.checked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.addressbook, 0, constants.checkboxStates.checked) }) it('app3 should have camera permissions', () => { - cy.wrap($dapps[2]) - .findByText(/https:\/\/app3.com/i) - .should('exist') - cy.wrap($dapps[2]) - .findByText(/camera/i) - .should('exist') + const app3Data = ['app3', safeapps.permissionCheckboxNames.camera] - cy.wrap($dapps[2]) - .findByLabelText(/camera/i) - .should('have.not.checked') + main.checkTextsExistWithinElement($dapps[2], app3Data) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.camera, 1, constants.checkboxStates.unchecked) }) it('app4 should have address book permissions', () => { - cy.wrap($dapps[3]) - .findByText(/https:\/\/app4.com/i) - .should('exist') - cy.wrap($dapps[3]) - .findByText(/address book/i) - .should('exist') - - cy.wrap($dapps[3]) - .findByLabelText(/address book/i) - .should('have.checked') + const app4Data = ['app4', safeapps.permissionCheckboxNames.addressbook] + + main.checkTextsExistWithinElement($dapps[3], app4Data) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.addressbook, 1, constants.checkboxStates.checked) }) it('should allow to allow all or clear all the checkboxes at once', () => { - cy.wrap($dapps[1]) - .findByText(/clear all/i) - .click() - cy.wrap($dapps[1]).findAllByRole('checkbox').should('have.not.checked') + safeapps.uncheckAllPermissions($dapps[1]) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.addressbook, 0, constants.checkboxStates.unchecked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.microphone, 0, constants.checkboxStates.unchecked) - cy.wrap($dapps[1]) - .findByText(/allow all/i) - .click() - cy.wrap($dapps[1]).findAllByRole('checkbox').should('have.checked') + safeapps.checkAllPermissions($dapps[1]) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.addressbook, 0, constants.checkboxStates.checked) + main.verifyCheckboxeState(safeapps.permissionCheckboxes.microphone, 0, constants.checkboxStates.checked) }) it('should allow to remove apps and reflect it in the localStorage', () => { @@ -132,14 +117,14 @@ describe('The Safe Apps permissions settings section', () => { .last() .click() .should(() => { - const storedBrowserPermissions = JSON.parse(localStorage.getItem(BROWSER_PERMISSIONS_KEY)) + const storedBrowserPermissions = JSON.parse(localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY)) const browserPermissions = Object.values(storedBrowserPermissions) expect(browserPermissions).to.have.length(1) expect(browserPermissions[0][0].feature).to.eq('microphone') expect(browserPermissions[0][0].status).to.eq('granted') - const storedSafePermissions = JSON.parse(localStorage.getItem(SAFE_PERMISSIONS_KEY)) + const storedSafePermissions = JSON.parse(localStorage.getItem(constants.SAFE_PERMISSIONS_KEY)) const safePermissions = Object.values(storedSafePermissions) expect(safePermissions).to.have.length(2) diff --git a/cypress/e2e/safe-apps/preview_drawer.cy.js b/cypress/e2e/safe-apps/preview_drawer.cy.js index bd45193c92..ed0b366935 100644 --- a/cypress/e2e/safe-apps/preview_drawer.cy.js +++ b/cypress/e2e/safe-apps/preview_drawer.cy.js @@ -1,24 +1,27 @@ -import { TEST_SAFE } from './constants' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' describe('The Safe Apps info modal', () => { before(() => { - cy.visit(`/${TEST_SAFE}/apps`, { failOnStatusCode: false }) - cy.findByText(/accept selection/i).click() + cy.clearLocalStorage() + cy.visit(`/${constants.TEST_SAFE_2}/apps`, { failOnStatusCode: false }) + main.acceptCookies() }) describe('when opening a Safe App from the app list', () => { it('should show the preview drawer', () => { - cy.findByRole('link', { name: /logo.*walletconnect/i }).click() - cy.findByRole('presentation').within((presentation) => { - cy.findByRole('heading', { name: /walletconnect/i }).should('exist') - cy.findByText('Connect your Safe to any dApp that supports WalletConnect').should('exist') - cy.findByText(/available networks/i).should('exist') - cy.findByLabelText(/pin walletconnect/i).click() - cy.findByLabelText(/unpin walletconnect/i) - .should('exist') - .click() - cy.findByLabelText(/pin walletconnect/i).should('exist') - cy.findByLabelText(/close walletconnect preview/i).click() + safeapps.clickOnApp(safeapps.logoWalletConnect) + + cy.findByRole('presentation').within(() => { + safeapps.verifyPreviewWindow( + safeapps.walletConnectHeadlinePreview, + safeapps.connecttextPreview, + safeapps.availableNetworksPreview, + ) + safeapps.pinApp(safeapps.pinWalletConnectStr) + safeapps.pinApp(safeapps.pinWalletConnectStr, false) + safeapps.closePreviewWindow() }) cy.findByRole('presentation').should('not.exist') }) diff --git a/cypress/e2e/safe-apps/safe_permissions.cy.js b/cypress/e2e/safe-apps/safe_permissions.cy.js index 0f188854a3..33e4f74c49 100644 --- a/cypress/e2e/safe-apps/safe_permissions.cy.js +++ b/cypress/e2e/safe-apps/safe_permissions.cy.js @@ -1,13 +1,16 @@ -import { SAFE_PERMISSIONS_KEY } from './constants' -const appUrl = 'https://safe-test-app.com' +import * as constants from '../../support/constants' +import * as safeapps from '../pages/safeapps.pages' describe('The Safe permissions system', () => { + before(() => { + cy.clearLocalStorage() + }) beforeEach(() => { cy.fixture('safe-app').then((html) => { - cy.intercept('GET', `${appUrl}/*`, html) + cy.intercept('GET', `${constants.testAppUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { - name: 'Cypress Test App', - description: 'Cypress Test App Description', + name: constants.testAppData.name, + description: constants.testAppData.descr, icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], }) }) @@ -15,17 +18,15 @@ describe('The Safe permissions system', () => { describe('When requesting permissions with wallet_requestPermissions', () => { it('should show the permissions prompt and return the permissions on accept', () => { - cy.visitSafeApp(`${appUrl}/request-permissions`) - - cy.findByRole('heading', { name: /permissions request/i }).should('exist') - cy.findByText(/access to your address book/i).should('exist') - - cy.findByRole('button', { name: /accept/i }).click() + cy.visitSafeApp(constants.testAppUrl + constants.requestPermissionsUrl) + safeapps.verifyPermissionsRequestExists() + safeapps.verifyAccessToAddressBookExists() + safeapps.clickOnAcceptBtn() cy.get('@safeAppsMessage').should('have.been.calledWithMatch', { data: [ { - invoker: 'https://safe-test-app.com', + invoker: constants.testAppUrl, parentCapability: 'requestAddressBook', date: Cypress.sinon.match.number, caveats: [], @@ -39,11 +40,11 @@ describe('The Safe permissions system', () => { it('should return the current permissions', () => { cy.on('window:before:load', (window) => { window.localStorage.setItem( - SAFE_PERMISSIONS_KEY, + constants.SAFE_PERMISSIONS_KEY, JSON.stringify({ - [appUrl]: [ + [constants.testAppUrl]: [ { - invoker: appUrl, + invoker: constants.testAppUrl, parentCapability: 'requestAddressBook', date: 1111111111111, caveats: [], @@ -53,12 +54,12 @@ describe('The Safe permissions system', () => { ) }) - cy.visitSafeApp(`${appUrl}/get-permissions`) + cy.visitSafeApp(constants.testAppUrl + constants.getPermissionsUrl) cy.get('@safeAppsMessage').should('have.been.calledWithMatch', { data: [ { - invoker: appUrl, + invoker: constants.testAppUrl, parentCapability: 'requestAddressBook', date: Cypress.sinon.match.number, caveats: [], diff --git a/cypress/e2e/safe-apps/tx_modal.cy.js b/cypress/e2e/safe-apps/tx_modal.cy.js index bf36767fc2..abbaced9c2 100644 --- a/cypress/e2e/safe-apps/tx_modal.cy.js +++ b/cypress/e2e/safe-apps/tx_modal.cy.js @@ -1,12 +1,19 @@ -const appUrl = 'https://safe-test-app.com' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' + +const testAppName = 'Cypress Test App' +const testAppDescr = 'Cypress Test App Description' describe('The transaction modal', () => { + before(() => { + cy.clearLocalStorage() + }) beforeEach(() => { cy.fixture('safe-app').then((html) => { - cy.intercept('GET', `${appUrl}/*`, html) + cy.intercept('GET', `${constants.testAppUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { - name: 'Cypress Test App', - description: 'Cypress Test App Description', + name: testAppName, + description: testAppDescr, icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], }) }) @@ -14,11 +21,11 @@ describe('The transaction modal', () => { describe('When sending a transaction from an app', () => { it('should show the transaction popup', { defaultCommandTimeout: 12000 }, () => { - cy.visitSafeApp(`${appUrl}/dummy`) + cy.visitSafeApp(`${constants.testAppUrl}/dummy`) - cy.findByText(/accept selection/i).click() + main.acceptCookies() cy.findByRole('dialog').within(() => { - cy.findByText(/Cypress Test App/i) + cy.findByText(testAppName) }) }) }) diff --git a/cypress/e2e/smoke/address_book.cy.js b/cypress/e2e/smoke/address_book.cy.js index 3e365bd69a..92d12e606d 100644 --- a/cypress/e2e/smoke/address_book.cy.js +++ b/cypress/e2e/smoke/address_book.cy.js @@ -1,79 +1,50 @@ import 'cypress-file-upload' const path = require('path') import { format } from 'date-fns' +import * as constants from '../../support/constants' +import * as addressBook from '../../e2e/pages/address_book.page' +import * as main from '../../e2e/pages/main.page' const NAME = 'Owner1' const EDITED_NAME = 'Edited Owner1' -const ADDRESS = '0x6a5602335a878ADDCa4BF63a050E34946B56B5bC' -const GOERLI_TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' -const GNO_TEST_SAFE = 'gno:0xB8d760a90a5ed54D3c2b3EFC231277e99188642A' -const GOERLI_CSV_ENTRY = { - name: 'goerli user 1', - address: '0x730F87dA2A3C6721e2196DFB990759e9bdfc5083', -} -const GNO_CSV_ENTRY = { - name: 'gno user 1', - address: '0x61a0c717d18232711bC788F19C9Cd56a43cc8872', -} -describe('Address book', () => { +describe('Address book tests', () => { before(() => { - cy.visit(`/address-book?safe=${GOERLI_TEST_SAFE}`) - cy.contains('Accept selection').click() - // Waits for the Address Book table to be in the page - cy.contains('p', 'Address book').should('be.visible') + cy.clearLocalStorage() + cy.visit(constants.addressBookUrl + constants.GOERLI_TEST_SAFE) + main.acceptCookies() }) describe('should add remove and edit entries in the address book', () => { it('should add an entry', () => { - // Add a new entry manually - cy.contains('Create entry').click() - cy.get('input[name="name"]').type(NAME) - cy.get('input[name="address"]').type(ADDRESS) - - // Save the entry - cy.contains('button', 'Save').click() - - // The new entry is visible - cy.contains(NAME).should('exist') - cy.contains(ADDRESS).should('exist') + addressBook.clickOnCreateEntryBtn() + addressBook.tyeInName(NAME) + addressBook.typeInAddress(constants.RECIPIENT_ADDRESS) + addressBook.clickOnSaveEntryBtn() + addressBook.verifyNewEntryAdded(NAME, constants.RECIPIENT_ADDRESS) }) it('should save an edited entry name', () => { - // Click the edit button in the first entry - cy.get('button[aria-label="Edit entry"]').click({ force: true }) - - // Give the entry a new name - cy.get('input[name="name"]').clear().type(EDITED_NAME) - cy.contains('button', 'Save').click() - - // Previous name should have been replaced by the edited one - cy.get(NAME).should('not.exist') - cy.contains(EDITED_NAME).should('exist') + addressBook.clickOnEditEntryBtn() + addressBook.typeInNameInput(EDITED_NAME) + addressBook.clickOnSaveButton() + addressBook.verifyNameWasChanged(NAME, EDITED_NAME) }) it('should delete an entry', () => { // Click the delete button in the first entry - cy.get('button[aria-label="Delete entry"]').click({ force: true }) - - cy.get('.MuiDialogActions-root').contains('Delete').click() - cy.get(EDITED_NAME).should('not.exist') + addressBook.clickDeleteEntryButton() + addressBook.clickDeleteEntryModalDeleteButton() + addressBook.verifyEditedNameNotExists(EDITED_NAME) }) }) describe('should import and export address book files', () => { it('should import an address book csv file', () => { - cy.contains('Import').click() - cy.get('[type="file"]').attachFile('../fixtures/address_book_test.csv') - - // Import button should be enabled - cy.get('.MuiDialogActions-root').contains('Import').should('not.be.disabled') - cy.get('.MuiDialogActions-root').contains('Import').click() - - // The import modal should be closed - cy.get('Import address book').should('not.exist') - cy.contains(GOERLI_CSV_ENTRY.name).should('exist') - cy.contains(GOERLI_CSV_ENTRY.address).should('exist') + addressBook.clickOnImportFileBtn() + addressBook.importFile() + addressBook.verifyImportModalIsClosed() + addressBook.verifyDataImported(constants.GOERLI_CSV_ENTRY.name, constants.GOERLI_CSV_ENTRY.address) }) it.skip('should find Gnosis Chain imported address', () => { @@ -84,14 +55,14 @@ describe('Address book', () => { cy.contains('Gnosis Chain').click() // Navigate to the Address Book page - cy.visit(`/address-book?safe=${GNO_TEST_SAFE}`) + cy.visit(`/address-book?safe=${constants.GNO_TEST_SAFE}`) // Waits for the Address Book table to be in the page cy.contains('p', 'Address book').should('be.visible') // Finds the imported Gnosis Chain address - cy.contains(GNO_CSV_ENTRY.name).should('exist') - cy.contains(GNO_CSV_ENTRY.address).should('exist') + cy.contains(constants.GNO_CSV_ENTRY.name).should('exist') + cy.contains(constants.GNO_CSV_ENTRY.address).should('exist') }) it('should download correctly the address book file', () => { @@ -99,10 +70,10 @@ describe('Address book', () => { const date = format(new Date(), 'yyyy-MM-dd', { timeZone: 'UTC' }) const fileName = `safe-address-book-${date}.csv` //name that is given to the file automatically - cy.contains('Export').click() + addressBook.clickOnExportFileBtn() //This is the submit button for the Export modal. It requires an actuall class or testId to differentiate //from the Export button at the top of the AB table - cy.get('.MuiDialogActions-root').contains('Export').click() + addressBook.confirmExport() const downloadsFolder = Cypress.config('downloadsFolder') //File reading is failing in the CI. Can be tested locally diff --git a/cypress/e2e/smoke/balances.cy.js b/cypress/e2e/smoke/balances.cy.js index 3d244dee8a..bc6d0468b7 100644 --- a/cypress/e2e/smoke/balances.cy.js +++ b/cypress/e2e/smoke/balances.cy.js @@ -1,8 +1,7 @@ -const assetsTable = '[aria-labelledby="tableTitle"] > tbody' -const balanceSingleRow = '[aria-labelledby="tableTitle"] > tbody tr' +import * as constants from '../../support/constants' +import * as balances from '../pages/balances.pages' +import * as main from '../../e2e/pages/main.page' -const TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' -const PAGINATION_TEST_SAFE = 'gor:0x850493a15914aAC05a821A3FAb973b4598889A7b' const ASSETS_LENGTH = 8 const ASSET_NAME_COLUMN = 0 const TOKEN_AMOUNT_COLUMN = 1 @@ -13,229 +12,127 @@ describe('Assets > Coins', () => { const fiatRegex = new RegExp(`([0-9]{1,3},)*[0-9]{1,3}.[0-9]{2}`) before(() => { + cy.clearLocalStorage() // Open the Safe used for testing - cy.visit(`/balances?safe=${TEST_SAFE}`) - cy.contains('button', 'Accept selection').click() + cy.visit(constants.BALANCE_URL + constants.GOERLI_TEST_SAFE) + main.acceptCookies() // Table is loaded cy.contains('Görli Ether') cy.contains('button', 'Got it').click() - cy.get(balanceSingleRow).should('have.length.lessThan', ASSETS_LENGTH) + cy.get(balances.balanceSingleRow).should('have.length.lessThan', ASSETS_LENGTH) cy.contains('div', 'Default tokens').click() cy.wait(100) cy.contains('div', 'All tokens').click() - cy.get(balanceSingleRow).should('have.length', ASSETS_LENGTH) + cy.get(balances.balanceSingleRow).should('have.length', ASSETS_LENGTH) }) describe('should have different tokens', () => { it('should have Dai', () => { - // Row should have an image with alt text "Dai" - cy.contains('Dai') - .parents('tr') - .within(() => { - cy.get('img[alt="DAI"]').should('be.visible') - }) - - // Asset name column contains link to block explorer - cy.contains('Dai') - .parents('tr') - .find('td') - .eq(ASSET_NAME_COLUMN) - .get('a[aria-label="View on goerli.etherscan.io"]') - .should('be.visible') - - // Balance should contain DAI - cy.contains('Dai').parents('tr').find('td').eq(TOKEN_AMOUNT_COLUMN).contains('DAI') + balances.verityTokenAltImageIsVisible(balances.currencyDai, balances.currencyDaiAlttext) + balances.verifyAssetNameHasExplorerLink(balances.currencyDai, ASSET_NAME_COLUMN) + balances.verifyBalance(balances.currencyDai, TOKEN_AMOUNT_COLUMN, balances.currencyDaiAlttext) }) it('should have Wrapped Ether', () => { - // Row should have an image with alt text "Wrapped Ether" - cy.contains('Wrapped Ether') - .parents('tr') - .within(() => { - cy.get('img[alt="WETH"]').should('be.visible') - }) - - // Asset name column contains link to block explorer - cy.contains('Wrapped Ether') - .parents('tr') - .find('td') - .eq(ASSET_NAME_COLUMN) - .get('a[aria-label="View on goerli.etherscan.io"]') - .should('be.visible') - - // Balance should contain WETH - cy.contains('Wrapped Ether').parents('tr').find('td').eq(TOKEN_AMOUNT_COLUMN).contains('WETH') + balances.verityTokenAltImageIsVisible(balances.currencyEther, balances.currencyEtherAlttext) + balances.verifyAssetNameHasExplorerLink(balances.currencyEther, ASSET_NAME_COLUMN) + balances.verifyBalance(balances.currencyEther, TOKEN_AMOUNT_COLUMN, balances.currencyEtherAlttext) }) it('should have USD Coin', () => { - // Row should have an image with alt text "USD Coin" - cy.contains('USD Coin') - .parents('tr') - .within(() => { - cy.get('img[alt="USDC"]').should('be.visible') - }) - - // Asset name column contains link to block explorer - cy.contains('USD Coin') - .parents('tr') - .find('td') - .eq(ASSET_NAME_COLUMN) - .get('a[aria-label="View on goerli.etherscan.io"]') - .should('be.visible') - - // Balance should contain USDT - cy.contains('USD Coin').parents('tr').find('td').eq(TOKEN_AMOUNT_COLUMN).contains('USDC') + balances.verityTokenAltImageIsVisible(balances.currencyUSDCoin, balances.currencyUSDAlttext) + balances.verifyAssetNameHasExplorerLink(balances.currencyUSDCoin, ASSET_NAME_COLUMN) + balances.verifyBalance(balances.currencyUSDCoin, TOKEN_AMOUNT_COLUMN, balances.currencyUSDAlttext) }) }) describe('values should be formatted as per locale', () => { it('should have Token and Fiat balances formated as per specification', () => { - cy.contains('Dai') - .parents('tr') - .within(() => { - // cy.get('td').eq(TOKEN_AMOUNT_COLUMN).contains('803.292M DAI') - cy.get('td').eq(TOKEN_AMOUNT_COLUMN).contains('120,496.61 DAI') - cy.get('td').eq(FIAT_AMOUNT_COLUMN).contains(fiatRegex) - }) - - cy.contains('Wrapped Ether') - .parents('tr') - .within(() => { - cy.get('td').eq(TOKEN_AMOUNT_COLUMN).contains('0.05918 WETH') - cy.get('td').eq(FIAT_AMOUNT_COLUMN).contains(fiatRegex) - }) - - // Strict match because other tokens contain "Ether" in their name - cy.contains('Görli Ether') - .parents('tr') - .within(() => { - cy.get('td').eq(TOKEN_AMOUNT_COLUMN).contains('0.14 GOR') - cy.get('td').eq(FIAT_AMOUNT_COLUMN).contains(fiatRegex) - }) - - cy.contains('Uniswap') - .parents('tr') - .within(() => { - cy.get('td').eq(TOKEN_AMOUNT_COLUMN).contains('0.01828 UNI') - cy.get('td').eq(FIAT_AMOUNT_COLUMN).contains(fiatRegex) - }) - - cy.contains('USD Coin') - .parents('tr') - .within(() => { - // cy.get('td').eq(TOKEN_AMOUNT_COLUMN).contains('13,636,504 USDC') - cy.get('td').eq(TOKEN_AMOUNT_COLUMN).contains('131,363 USDC') - cy.get('td').eq(FIAT_AMOUNT_COLUMN).contains(fiatRegex) - }) - - cy.contains('Gnosis') - .parents('tr') - .within(() => { - cy.get('td').eq(TOKEN_AMOUNT_COLUMN).contains('< 0.00001 GNO') - cy.get('td').eq(FIAT_AMOUNT_COLUMN).contains(fiatRegex) - }) - - cy.contains(/^0x$/) - .parents('tr') - .within(() => { - cy.get('td').eq(TOKEN_AMOUNT_COLUMN).contains('1.003 ZRX') - cy.get('td').eq(FIAT_AMOUNT_COLUMN).contains(fiatRegex) - }) + balances.verifyTokenBalanceFormat( + balances.currencyDai, + balances.currentcyDaiFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + balances.verifyTokenBalanceFormat( + balances.currencyEther, + balances.currentcyEtherFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + balances.verifyTokenBalanceFormat( + balances.currencyGörliEther, + balances.currentcyGörliEtherFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + balances.verifyTokenBalanceFormat( + balances.currencyUniswap, + balances.currentcyUniswapFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + balances.verifyTokenBalanceFormat( + balances.currencyUSDCoin, + balances.currentcyUSDFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + balances.verifyTokenBalanceFormat( + balances.currencyGnosis, + balances.currentcyGnosisFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + balances.verifyTokenBalanceFormat( + balances.currencyOx, + balances.currentcyOxFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) }) }) describe('fiat currency can be changed', () => { it('should have USD as default currency', () => { - // First row Fiat balance should not contain EUR - cy.get(balanceSingleRow).first().find('td').eq(FIAT_AMOUNT_COLUMN).should('not.contain', 'EUR') - // First row Fiat balance should contain USD - cy.get(balanceSingleRow).first().find('td').eq(FIAT_AMOUNT_COLUMN).contains('USD') + balances.verifyFirstRowDoesNotContainCurrency(balances.currencyEUR, FIAT_AMOUNT_COLUMN) + balances.verifyFirstRowContainsCurrency(balances.currencyUSD, FIAT_AMOUNT_COLUMN) }) it('should allow changing the currency to EUR', () => { - // Click on balances currency dropdown - cy.get('[id="currency"]').click() - - // Select EUR - cy.get('ul[role="listbox"]').findByText('EUR').click({ force: true }) - - // First row Fiat balance should not contain USD - cy.get(balanceSingleRow).first().find('td').eq(FIAT_AMOUNT_COLUMN).should('not.contain', 'USD') - // First row Fiat balance should contain EUR - cy.get(balanceSingleRow).first().find('td').eq(FIAT_AMOUNT_COLUMN).should('contain', 'EUR') + balances.clickOnCurrencyDropdown() + balances.selectCurrency(balances.currencyEUR) + balances.verifyFirstRowDoesNotContainCurrency(balances.currencyUSD, FIAT_AMOUNT_COLUMN) + balances.verifyFirstRowContainsCurrency(balances.currencyEUR, FIAT_AMOUNT_COLUMN) }) }) describe('tokens can be manually hidden', () => { it('hide single token', () => { - // Click hide Dai - cy.contains('Dai').parents('tr').find('button[aria-label="Hide asset"]').click() - // time to hide the asset - cy.wait(350) - cy.contains('Dai').should('not.exist') + balances.hideAsset(balances.currencyDai) }) it('unhide hidden token', () => { - // Open hide token menu - cy.contains('1 hidden token').click() - // uncheck dai token - cy.contains('Dai').parents('tr').find('input[type="checkbox"]').click() - // apply changes - cy.contains('Save').click() - // Dai token is visible again - cy.contains('Dai') - // The menu button shows "Hide tokens" label again - cy.contains('Hide tokens') - }) - }) - - describe('pagination should work', () => { - before(() => { - // Open the Safe used for testing pagination - cy.visit(`/balances?safe=${PAGINATION_TEST_SAFE}`) - cy.contains('button', 'Accept selection').click() - - // Table is loaded - cy.contains('Görli Ether') - cy.contains('button', 'Got it').click() - // Enable all tokens - cy.contains('div', 'Default tokens').click() - cy.wait(100) - cy.contains('div', 'All tokens').click() - }) - - it('should allow changing rows per page and navigate to next and previous page', () => { - // Table should have 25 rows inittially - cy.contains('Rows per page:').next().contains('25') - cy.contains('1–25 of') - cy.get(balanceSingleRow).should('have.length', 25) - - // Change to 10 rows per page - // force click because the center of the element is hidden from the view - cy.contains('Rows per page:').next().contains('25').click({ force: true }) - cy.get('ul[role="listbox"]').contains('10').click() - - // Table should have 10 rows - cy.contains('Rows per page:').next().contains('10') - cy.contains('1–10 of') - cy.get(balanceSingleRow).should('have.length', 10) - - // Click on the next page button - cy.get('button[aria-label="Go to next page"]').click({ force: true }) - cy.get('button[aria-label="Go to next page"]').click({ force: true }) - - // Table should have N rows - cy.contains('21–28 of') - cy.get(balanceSingleRow).should('have.length', ASSETS_LENGTH) - - // Click on the previous page button - cy.get('button[aria-label="Go to previous page"]').click({ force: true }) - - // Table should have 10 rows - cy.contains('11–20 of') - cy.get(balanceSingleRow).should('have.length', 10) + balances.openHideTokenMenu() + balances.clickOnTokenCheckbox(balances.currencyDai) + balances.saveHiddenTokenSelection() + balances.verifyTokenIsVisible(balances.currencyDai) + balances.verifyMenuButtonLabelIsDefault() }) }) }) diff --git a/cypress/e2e/smoke/balances_pagination.cy.js b/cypress/e2e/smoke/balances_pagination.cy.js new file mode 100644 index 0000000000..63202a9bd4 --- /dev/null +++ b/cypress/e2e/smoke/balances_pagination.cy.js @@ -0,0 +1,30 @@ +import * as constants from '../../support/constants' +import * as balances from '../pages/balances.pages' + +const ASSETS_LENGTH = 8 + +describe('Balance pagination tests', () => { + before(() => { + cy.clearLocalStorage() + // Open the Safe used for testing + cy.visit(constants.BALANCE_URL + constants.PAGINATION_TEST_SAFE) + cy.contains('button', 'Accept selection').click() + // Table is loaded + cy.contains('Görli Ether') + + cy.contains('button', 'Got it').click() + cy.contains('div', 'Default tokens').click() + cy.wait(100) + cy.contains('div', 'All tokens').click() + }) + + it('should allow changing rows per page and navigate to next and previous page', () => { + balances.verifyInitialTableState() + balances.changeTo10RowsPerPage() + balances.verifyTableHas10Rows() + balances.navigateToNextPage() + balances.verifyTableHasNRows(ASSETS_LENGTH) + balances.navigateToPreviousPage() + balances.verifyTableHas10RowsAgain() + }) +}) diff --git a/cypress/e2e/smoke/batch_tx.cy.js b/cypress/e2e/smoke/batch_tx.cy.js index 579e8145ea..ef9baa461b 100644 --- a/cypress/e2e/smoke/batch_tx.cy.js +++ b/cypress/e2e/smoke/batch_tx.cy.js @@ -1,46 +1,43 @@ -const SAFE = 'gor:0x04f8b1EA3cBB315b87ced0E32deb5a43cC151a91' -const EOA = '0xE297437d6b53890cbf004e401F3acc67c8b39665' +import * as batch from '../pages/batches.pages' +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' const currentNonce = 3 -const BATCH_TX_TOPBAR = '[data-track="batching: Batch sidebar open"]' -const BATCH_TX_COUNTER = '[data-track="batching: Batch sidebar open"] span > span' -const ADD_NEW_TX_BATCH = '[data-track="batching: Add new tx to batch"]' const funds_first_tx = '0.001' const funds_second_tx = '0.002' -var transactionsInBatchList = 0 describe('Create batch transaction', () => { before(() => { - cy.visit(`/home?safe=${SAFE}`) - cy.contains('Accept selection').click() - - cy.contains(/E2E Wallet @ G(ö|oe)rli/) + cy.clearLocalStorage() + cy.visit(constants.homeUrl + constants.TEST_SAFE) + main.acceptCookies() + cy.contains(constants.goerlyE2EWallet, { timeout: 10000 }) }) it('Should open an empty batch list', () => { - cy.get(BATCH_TX_TOPBAR).should('be.visible').click() - cy.contains('Batched transactions').should('be.visible') - cy.contains('Add an initial transaction to the batch') - cy.get(ADD_NEW_TX_BATCH).click() + batch.openBatchtransactionsModal() + batch.openNewTransactionModal() }) it('Should see the add batch button in a transaction form', () => { //The "true" is to validate that the add to batch button is not visible if "Yes, execute" is selected - addToBatch(EOA, currentNonce, funds_first_tx, true) + batch.addToBatch(constants.EOA, currentNonce, funds_first_tx, true) }) it('Should see the transaction being added to the batch', () => { - cy.contains('Transaction is added to batch').should('be.visible') + cy.contains(batch.transactionAddedToBatchStr).should('be.visible') //The batch button in the header shows the transaction count - cy.get(BATCH_TX_COUNTER).contains('1').click() - amountTransactionsInBatch(1) + batch.verifyBatchIconCount(1) + batch.clickOnBatchCounter() + batch.verifyAmountTransactionsInBatch(1) }) it('Should add a second transaction to the batch', () => { - cy.contains('Add new transaction').click() - addToBatch(EOA, currentNonce, funds_second_tx) - cy.get(BATCH_TX_COUNTER).contains('2').click() - amountTransactionsInBatch(2) + batch.openNewTransactionModal() + batch.addToBatch(constants.EOA, currentNonce, funds_second_tx) + batch.verifyBatchIconCount(2) + batch.clickOnBatchCounter() + batch.verifyAmountTransactionsInBatch(2) }) it.skip('Should swap transactions order', () => { @@ -48,53 +45,18 @@ describe('Create batch transaction', () => { }) it('Should confirm the batch and see 2 transactions in the form', () => { - cy.contains('Confirm batch').click() - cy.contains(`This batch contains ${transactionsInBatchList} transactions`).should('be.visible') + batch.clickOnConfirmBatchBtn() + batch.verifyBatchTransactionsCount(2) cy.contains(funds_first_tx).parents('ul').as('TransactionList') cy.get('@TransactionList').find('li').eq(0).find('span').eq(0).contains(funds_first_tx) cy.get('@TransactionList').find('li').eq(1).find('span').eq(0).contains(funds_second_tx) }) it('Should remove a transaction from the batch', () => { - cy.get(BATCH_TX_COUNTER).click() - cy.contains('Batched transactions').should('be.visible').parents('aside').find('ul > li').as('BatchList') - cy.get('@BatchList').find('[title="Delete transaction"]').eq(0).click() + batch.clickOnBatchCounter() + cy.contains(batch.batchedTransactionsStr).should('be.visible').parents('aside').find('ul > li').as('BatchList') + cy.get('@BatchList').find(batch.deleteTransactionbtn).eq(0).click() cy.get('@BatchList').should('have.length', 1) cy.get('@BatchList').contains(funds_first_tx).should('not.exist') }) }) - -const amountTransactionsInBatch = (count) => { - cy.contains('Batched transactions', { timeout: 7000 }) - .should('be.visible') - .parents('aside') - .find('ul > li') - .should('have.length', count) - transactionsInBatchList = count -} - -const addToBatch = (EOA, currentNonce, amount, verify = false) => { - // Modal is open - cy.contains('h1', 'New transaction').should('be.visible') - cy.contains('Send tokens').click() - - // Fill transaction data - cy.get('input[name="recipient"]').type(EOA, { delay: 1 }) - // Click on the Token selector - cy.get('input[name="tokenAddress"]').prev().click() - cy.get('ul[role="listbox"]') - - .contains(/G(ö|oe)rli Ether/) - .click() - cy.get('[name="amount"]').type(amount) - cy.contains('Next').click() - cy.get('input[name="nonce"]').clear().type(currentNonce, { force: true }).type('{enter}', { force: true }) - cy.contains('Execute').scrollIntoView() - //Only validates the button not showing once in the entire run - if (verify) { - cy.contains('Yes, execute', { timeout: 4000 }).click() - cy.contains('Add to batch').should('not.exist') - } - cy.contains('No, later', { timeout: 4000 }).click() - cy.contains('Add to batch').should('be.visible').and('not.be.disabled').click() -} diff --git a/cypress/e2e/smoke/beamer.cy.js b/cypress/e2e/smoke/beamer.cy.js index 6267ba9487..412a8b5d8a 100644 --- a/cypress/e2e/smoke/beamer.cy.js +++ b/cypress/e2e/smoke/beamer.cy.js @@ -1,31 +1,22 @@ -const TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' +import * as constants from '../../support/constants' +import * as addressbook from '../pages/address_book.page' +import * as main from '../../e2e/pages/main.page' describe('Beamer', () => { - it('should require accept "Updates" cookies to display Beamer', () => { - // Disable PWA, otherwise it will throw a security error - cy.visit(`/address-book?safe=${TEST_SAFE}`) - - // Way to select the cookies banner without an id - cy.contains('Accept selection').click() - - // Open What's new - cy.contains("What's new").click() - - // Tells that the user has to accept "Beamer" cookies - cy.contains('accept the "Beamer" cookies') - - // "Beamer" is checked when the banner opens - cy.get('input[id="beamer"]').should('be.checked') - // Accept "Updates & Feedback" cookies - cy.contains('Accept selection').click() - cy.contains('Accept selection').should('not.exist') + before(() => { + cy.clearLocalStorage() + cy.visit(constants.addressBookUrl + constants.GOERLI_TEST_SAFE) + main.acceptCookies() + }) + it.skip('should require accept "Updates" cookies to display Beamer', () => { + addressbook.clickOnWhatsNewBtn() + addressbook.acceptBeamerCookies() + addressbook.verifyBeamerIsChecked() + main.acceptCookies() // wait for Beamer cookies to be set - cy.wait(600) - - // Open What's new - cy.contains("What's new").click({ force: true }) // clicks through the "lastPostTitle" - - cy.get('#beamerOverlay .iframeCointaner').should('exist') + cy.wait(1000) + addressbook.clickOnWhatsNewBtn(true) // clicks through the "lastPostTitle" + addressbook.verifyBeameriFrameExists() }) }) diff --git a/cypress/e2e/smoke/create_safe_simple.cy.js b/cypress/e2e/smoke/create_safe_simple.cy.js index 1cd65e41f8..7d7b78dfe4 100644 --- a/cypress/e2e/smoke/create_safe_simple.cy.js +++ b/cypress/e2e/smoke/create_safe_simple.cy.js @@ -1,82 +1,54 @@ -const DEFAULT_OWNER_ADDRESS = '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED' -const OWNER_ADDRESS = '0xE297437d6b53890cbf004e401F3acc67c8b39665' +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as createwallet from '../pages/create_wallet.pages' + +const safeName = 'Test safe name' +const ownerName = 'Test Owner Name' +const ownerName2 = 'Test Owner Name 2' describe('Create Safe form', () => { it('should navigate to the form', () => { - cy.visit('/welcome') - - // Close cookie banner - cy.contains('button', 'Accept selection').click() - - // Ensure wallet is connected to correct chain via header - cy.contains(/E2E Wallet @ G(ö|oe)rli/) - - cy.contains('Create new Account').click() + cy.clearLocalStorage() + cy.visit(constants.welcomeUrl) + main.acceptCookies() + main.verifyGoerliWalletHeader() + createwallet.clickOnCreateNewAccuntBtn() }) it('should allow setting a name', () => { - // Name input should have a placeholder ending in 'goerli-safe' - cy.get('input[name="name"]') - .should('have.attr', 'placeholder') - .should('match', /g(ö|oe)rli-safe/) - - // Input a custom name - cy.get('input[name="name"]').type('Test safe name').should('have.value', 'Test safe name') + createwallet.typeWalletName(safeName) }) it('should allow changing the network', () => { - // Switch to a different network - cy.get('[data-cy="create-safe-select-network"]').click() - cy.contains('Ethereum').click() - - // Switch back to Görli - cy.get('[data-cy="create-safe-select-network"]').click() - - // Prevent Base Mainnet Goerli from being selected - cy.contains('li span', /^G(ö|oe)rli$/).click() - - cy.contains('button', 'Next').click() + createwallet.selectNetwork(constants.networks.ethereum) + createwallet.selectNetwork(constants.networks.goerli, true) + createwallet.clickOnNextBtn() }) it('should display a default owner and threshold', () => { - // Default owner - cy.get('input[name="owners.0.address"]').should('have.value', DEFAULT_OWNER_ADDRESS) - - // Default threshold - cy.get('input[name="threshold"]').should('have.value', 1) + createwallet.verifyOwnerAddress(constants.DEFAULT_OWNER_ADDRESS, 0) + createwallet.verifyThreshold(1) }) it('should allow changing the owner name', () => { - cy.get('input[name="owners.0.name"]').type('Test Owner Name') + createwallet.typeOwnerName(ownerName, 0) cy.contains('button', 'Back').click() cy.contains('button', 'Next').click() - cy.get('input[name="owners.0.name"]').should('have.value', 'Test Owner Name') + createwallet.verifyOwnerName(ownerName, 0) }) it('should add a new owner and update threshold', () => { - // Add new owner - cy.contains('button', 'Add new owner').click() - cy.get('input[name="owners.1.address"]').should('exist') - cy.get('input[name="owners.1.address"]').type(OWNER_ADDRESS) - - // Update threshold - cy.get('input[name="threshold"]').parent().click() - cy.contains('li', '2').click() + createwallet.addNewOwner(ownerName2, constants.EOA, 1) + createwallet.updateThreshold(2) }) it('should remove an owner and update threshold', () => { - // Remove owner - cy.get('button[aria-label="Remove owner"]').click() - - // Threshold should change back to 1 - cy.get('input[name="threshold"]').should('have.value', 1) - - cy.contains('button', 'Next').click() + createwallet.removeOwner(0) + createwallet.verifyThreshold(1) + createwallet.clickOnNextBtn() }) it('should display summary on review page', () => { - cy.contains('Test safe name') - cy.contains(DEFAULT_OWNER_ADDRESS) - cy.contains('1 out of 1') + createwallet.verifySummaryData(safeName, constants.DEFAULT_OWNER_ADDRESS, 1, 1) }) }) diff --git a/cypress/e2e/smoke/create_tx.cy.js b/cypress/e2e/smoke/create_tx.cy.js index 5dcddadf8e..0e1dab05cb 100644 --- a/cypress/e2e/smoke/create_tx.cy.js +++ b/cypress/e2e/smoke/create_tx.cy.js @@ -1,127 +1,46 @@ -const SAFE = 'gor:0x04f8b1EA3cBB315b87ced0E32deb5a43cC151a91' -const EOA = '0xE297437d6b53890cbf004e401F3acc67c8b39665' +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as createtx from '../../e2e/pages/create_tx.pages' const sendValue = 0.00002 const currentNonce = 3 describe('Queue a transaction on 1/N', () => { before(() => { - cy.visit(`/home?safe=${SAFE}`) - - cy.contains('Accept selection').click() + cy.clearLocalStorage() + cy.visit(constants.homeUrl + constants.TEST_SAFE) + main.acceptCookies() }) - it('should create and queue a transaction', () => { - // Assert that "New transaction" button is visible - cy.contains('New transaction', { - timeout: 60_000, // `lastWallet` takes a while initialize in CI - }) - .should('be.visible') - .and('not.be.disabled') - - // Open the new transaction modal - cy.contains('New transaction').click() - - // Modal is open - cy.contains('h1', 'New transaction').should('be.visible') - cy.contains('Send tokens').click() - - // Fill transaction data - cy.get('input[name="recipient"]').type(EOA) - // Click on the Token selector - cy.get('input[name="tokenAddress"]').prev().click() - cy.get('ul[role="listbox"]') - .contains(/G(ö|oe)rli Ether/) - .click() - - // Insert max amount - cy.contains('Max').click() - - // Validates the "Max" button action, then clears and sets the actual sendValue - cy.get('input[name="tokenAddress"]') - .prev() - .find('p') - .contains(/G(ö|oe)rli Ether/) - .next() - .then((element) => { - const maxBalance = element.text().replace(' GOR', '').trim() - cy.get('input[name="amount"]').should('have.value', maxBalance) - }) - - cy.get('input[name="amount"]').clear().type(sendValue) - - cy.contains('Next').click() + it('should create a new send token transaction', () => { + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + createtx.typeRecipientAddress(constants.EOA) + createtx.clickOnTokenselectorAndSelectGoerli() + createtx.setMaxAmount() + createtx.verifyMaxAmount(constants.goerliToken, constants.tokenAbbreviation.gor) + createtx.setSendValue(sendValue) + createtx.clickOnNextBtn() }) - it('should create a queued transaction', () => { - cy.get('button[type="submit"]').should('not.be.disabled') - + it('should review, edit and submit the tx', () => { + createtx.verifySubmitBtnIsEnabled() cy.wait(1000) - - cy.contains('Native token transfer').should('be.visible') - - // Changes nonce to next one - cy.get('input[name="nonce"]').clear().type(currentNonce, { force: true }).type('{enter}', { force: true }) - - // Execution - cy.contains('Yes, ').should('exist') - cy.contains('Estimated fee').should('exist') - - // Asserting the sponsored info is present - cy.contains('Execute').scrollIntoView().should('be.visible') - - cy.get('span').contains('Estimated fee').next().should('have.css', 'text-decoration-line', 'line-through') - cy.contains('Transactions per hour') - cy.contains('5 of 5') - - cy.contains('Estimated fee').click() - cy.contains('Edit').click() - cy.contains('Execution parameters').parents('form').as('Paramsform') - - // Only gaslimit should be editable when the relayer is selected - const arrayNames = ['Wallet nonce', 'Max priority fee (Gwei)', 'Max fee (Gwei)'] - arrayNames.forEach((element) => { - cy.get('@Paramsform').find('label').contains(`${element}`).next().find('input').should('be.disabled') - }) - - cy.get('@Paramsform') - .find('[name="gasLimit"]') - .clear() - .type('300000') - .invoke('prop', 'value') - .should('equal', '300000') - cy.get('@Paramsform').find('[name="gasLimit"]').parent('div').find('[data-testid="RotateLeftIcon"]').click() - - cy.get('@Paramsform').submit() - - // Asserts the execute checkbox is uncheckable - cy.contains('No, later').click() - - cy.get('input[name="nonce"]') - .clear({ force: true }) - .type(currentNonce + 10, { force: true }) - .type('{enter}', { force: true }) - - cy.contains('Sign').click() + createtx.verifyNativeTokenTransfer() + createtx.changeNonce(currentNonce) + createtx.verifyConfirmTransactionData() + createtx.openExecutionParamsModal() + createtx.verifyAndSubmitExecutionParams() + createtx.clickOnNoLaterOption() + createtx.changeNonce(13) + createtx.clickOnSignTransactionBtn() }) - it('should click the notification and see the transaction queued', () => { - // Wait for the /propose request - cy.intercept('POST', '/**/propose').as('ProposeTx') - cy.wait('@ProposeTx') - - // Click on the notification - cy.contains('View transaction').click() - - //cy.contains('Queue').click() - - // Single Tx page - cy.contains('h3', 'Transaction details').should('be.visible') - - // Queue label - cy.contains(`needs to be executed first`).should('be.visible') - - // Transaction summary - cy.contains('Send' + '-' + `${sendValue} GOR`).should('exist') + it('should click on the notification and see the transaction queued', () => { + createtx.waitForProposeRequest() + createtx.clickViewTransaction() + createtx.verifySingleTxPage() + createtx.verifyQueueLabel() + createtx.verifyTransactionSummary(sendValue) }) }) diff --git a/cypress/e2e/smoke/dashboard.cy.js b/cypress/e2e/smoke/dashboard.cy.js index d42604fe5b..f4ce1b6ec1 100644 --- a/cypress/e2e/smoke/dashboard.cy.js +++ b/cypress/e2e/smoke/dashboard.cy.js @@ -1,75 +1,28 @@ -const SAFE = 'gor:0x04f8b1EA3cBB315b87ced0E32deb5a43cC151a91' +import * as constants from '../../support/constants' +import * as dashboard from '../pages/dashboard.pages' +import * as main from '../pages/main.page' describe('Dashboard', () => { before(() => { - // Go to the test Safe home page - cy.visit(`/home?safe=${SAFE}`) - - cy.contains('button', 'Accept selection').click() - - // Wait for dashboard to initialize - cy.contains('Connect & transact') + cy.clearLocalStorage() + cy.visit(constants.homeUrl + constants.TEST_SAFE) + main.acceptCookies() + dashboard.verifyConnectTransactStrIsVisible() }) it('should display the overview widget', () => { - // Alias for the Overview section - cy.contains('h2', 'Overview').parents('section').as('overviewSection') - - cy.get('@overviewSection').within(() => { - // Prefix is separated across elements in EthHashInfo - cy.contains(SAFE).should('exist') - cy.contains('1/2') - cy.get(`a[href="/balances?safe=${encodeURIComponent(SAFE)}"]`).contains('View assets') - // Text next to Tokens contains a number greater than 0 - cy.contains('p', 'Tokens').next().contains('1') - cy.contains('p', 'NFTs').next().contains('0') - }) + dashboard.verifyOverviewWidgetData() }) it('should display the tx queue widget', () => { - // Alias for the Transaction queue section - cy.contains('h2', 'Transaction queue').parents('section').as('txQueueSection') - - cy.get('@txQueueSection').within(() => { - // There should be queued transactions - cy.contains('This Safe has no queued transactions').should('not.exist') - - // Queued txns - cy.contains(`a[href^="/transactions/tx?id=multisig_0x"]`, '13' + 'Send' + '-0.00002 GOR' + '1/1').should('exist') - - cy.contains(`a[href="/transactions/queue?safe=${encodeURIComponent(SAFE)}"]`, 'View all') - }) + dashboard.verifyTxQueueWidget() }) it('should display the featured Safe Apps', () => { - // Alias for the featured Safe Apps section - cy.contains('h2', 'Connect & transact').parents('section').as('featuredSafeAppsSection') - - // Tx Builder app - cy.get('@featuredSafeAppsSection').within(() => { - // Transaction Builder - cy.contains('Use Transaction Builder') - cy.get(`a[href*='tx-builder']`).should('exist') - - // WalletConnect app - cy.contains('Use WalletConnect') - cy.get(`a[href*='wallet-connect']`).should('exist') - - // Featured apps have a Safe-specific link - cy.get(`a[href*="&appUrl=http"]`).should('have.length', 2) - }) + dashboard.verifyFeaturedAppsSection() }) it('should show the Safe Apps Section', () => { - // Create an alias for the Safe Apps section - cy.contains('h2', 'Safe Apps').parents('section').as('safeAppsSection') - - cy.get('@safeAppsSection').contains('Explore Safe Apps') - - // Regular safe apps - cy.get('@safeAppsSection').within(() => { - // Find exactly 5 Safe Apps cards inside the Safe Apps section - cy.get(`a[href^="/apps/open?safe=${encodeURIComponent(SAFE)}&appUrl=http"]`).should('have.length', 5) - }) + dashboard.verifySafeAppsSection() }) }) diff --git a/cypress/e2e/smoke/import_export_data.cy.js b/cypress/e2e/smoke/import_export_data.cy.js index f35de9f962..165ee847e6 100644 --- a/cypress/e2e/smoke/import_export_data.cy.js +++ b/cypress/e2e/smoke/import_export_data.cy.js @@ -1,71 +1,52 @@ import 'cypress-file-upload' -const path = require('path') -import { format } from 'date-fns' +import * as file from '../pages/import_export.pages' +import * as main from '../pages/main.page' +import * as constants from '../../support/constants' describe('Import Export Data', () => { before(() => { - cy.visit(`/welcome`) - cy.contains('Accept selection').click() - // Waits for the Import button to be visible - cy.contains('button', 'Import').should('be.visible') + cy.clearLocalStorage() + cy.visit(constants.welcomeUrl) + main.acceptCookies() + file.verifyImportBtnIsVisible() }) it('Uploads test file and access safe', () => { - cy.contains('button', 'Import').click() - //Uploads the file - cy.get('[type="file"]').attachFile('../fixtures/data_import.json') - //verifies that the modal says the amount of chains/addressbook values it uploaded - cy.contains('Added Safe Accounts on 3 chains').should('be.visible') - cy.contains('Address book for 3 chains').should('be.visible') - cy.contains('Settings').should('be.visible') - cy.contains('Bookmarked Safe Apps').should('be.visible') - cy.contains('Data import').parent().contains('button', 'Import').click() - //Click in one of the imported safes - cy.contains('safe 1 goerli').click() + const filePath = '../fixtures/data_import.json' + const safe = 'safe 1 goerli' + + file.clickOnImportBtn() + file.uploadFile(filePath) + file.verifyImportModalData() + file.clickOnImportBtnDataImportModal() + file.clickOnImportedSafe(safe) }) it("Verify safe's address book imported data", () => { - //Verifies imported owners in the Address book - cy.contains('Address book').click() - cy.get('tbody tr:nth-child(1) td:nth-child(1)').contains('test1') - cy.get('tbody tr:nth-child(1) td:nth-child(2)').contains('0x61a0c717d18232711bC788F19C9Cd56a43cc8872') - cy.get('tbody tr:nth-child(2) td:nth-child(1)').contains('test2') - cy.get('tbody tr:nth-child(2) td:nth-child(2)').contains('0x7724b234c9099C205F03b458944942bcEBA13408') + file.clickOnAddressBookBtn() + file.verifyImportedAddressBookData() }) it('Verify pinned apps', () => { - cy.get('aside').contains('li', 'Apps').click() - cy.contains('Bookmarked apps').click() - //Takes a some time to load the apps page, It waits for bookmark to be lighted up - cy.waitForSelector(() => { - return cy - .get('[aria-selected="true"] p') - .invoke('html') - .then((text) => text === 'Bookmarked apps') - }) - cy.contains('Drain Account').should('be.visible') - cy.contains('Transaction Builder').should('be.visible') + const appNames = ['Drain Account', 'Transaction Builder'] + + file.clickOnAppsBtn() + file.clickOnBookmarkedAppsBtn() + file.verifyAppsAreVisible(appNames) }) it('Verify imported data in settings', () => { - //In the settings checks the checkboxes and darkmode enabled - cy.contains('Settings').click() - cy.contains('Appearance').click() - cy.contains('label', 'Prepend chain prefix to addresses').find('input[type="checkbox"]').should('not.be.checked') - cy.contains('label', 'Copy addresses with chain prefix').find('input[type="checkbox"]').should('not.be.checked') - cy.get('main').contains('label', 'Dark mode').find('input[type="checkbox"]').should('be.checked') + const unchecked = [file.prependChainPrefixStr, file.copyAddressStr] + const checked = [file.darkModeStr] + file.clickOnSettingsBtn() + file.clickOnAppearenceBtn() + file.verifyCheckboxes(unchecked) + file.verifyCheckboxes(checked, true) }) it('Verifies data for export in Data tab', () => { - cy.contains('div[role="tablist"] a', 'Data').click() - cy.contains('Added Safe Accounts on 3 chains').should('be.visible') - cy.contains('Address book for 3 chains').should('be.visible') - cy.contains('Bookmarked Safe Apps').should('be.visible') - const date = format(new Date(), 'yyyy-MM-dd', { timeZone: 'UTC' }) - const fileName = `safe-${date}.json` - cy.contains('div', fileName).next().click() - const downloadsFolder = Cypress.config('downloadsFolder') - //File reading is failing in the CI. Can be tested locally - cy.readFile(path.join(downloadsFolder, fileName)).should('exist') + file.clickOnDataTab() + file.verifyImportModalData() + file.verifyFileDownload() }) }) diff --git a/cypress/e2e/smoke/landing.cy.js b/cypress/e2e/smoke/landing.cy.js index 4dd60b90d1..751f1e6cfa 100644 --- a/cypress/e2e/smoke/landing.cy.js +++ b/cypress/e2e/smoke/landing.cy.js @@ -1,7 +1,8 @@ +import * as constants from '../../support/constants' describe('Landing page', () => { it('redirects to welcome page', () => { + cy.clearLocalStorage() cy.visit('/') - - cy.url().should('include', '/welcome') + cy.url().should('include', constants.welcomeUrl) }) }) diff --git a/cypress/e2e/smoke/load_safe.cy.js b/cypress/e2e/smoke/load_safe.cy.js index 404a296e8d..b346898ccb 100644 --- a/cypress/e2e/smoke/load_safe.cy.js +++ b/cypress/e2e/smoke/load_safe.cy.js @@ -1,56 +1,48 @@ import 'cypress-file-upload' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safe from '../pages/load_safe.pages' +import * as createwallet from '../pages/create_wallet.pages' +const testSafeName = 'Test safe name' +const testOwnerName = 'Test Owner Name' // TODO const SAFE_ENS_NAME = 'test20.eth' -const SAFE_ENS_NAME_TRANSLATED = '0xE297437d6b53890cbf004e401F3acc67c8b39665' +const SAFE_ENS_NAME_TRANSLATED = constants.EOA -const SAFE_QR_CODE_ADDRESS = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' -const EOA_ADDRESS = '0xE297437d6b53890cbf004e401F3acc67c8b39665' +const EOA_ADDRESS = constants.EOA -const INVALID_INPUT_ERROR_MSG = 'Invalid address format' const INVALID_ADDRESS_ERROR_MSG = 'Address given is not a valid Safe address' // TODO const OWNER_ENS_DEFAULT_NAME = 'test20.eth' -const OWNER_ADDRESS = '0xE297437d6b53890cbf004e401F3acc67c8b39665' +const OWNER_ADDRESS = constants.EOA describe('Load existing Safe', () => { before(() => { - cy.visit('/welcome?chain=matic') - cy.contains('Accept selection').click() - - // Enters Loading Safe form - cy.contains('button', 'Add existing Account').click() - cy.contains('Name, address & network') + cy.clearLocalStorage() + cy.visit(constants.welcomeUrl) + main.acceptCookies() + safe.openLoadSafeForm() + cy.wait(2000) }) it('should allow choosing the network where the Safe exists', () => { - // Click the network selector inside the Stepper content - cy.get('[data-testid=load-safe-form]').contains('Polygon').click() - - // Selects Goerli - cy.get('ul li') - .contains(/^G(ö|oe)rli$/) - .click() - cy.contains('span', /^G(ö|oe)rli$/) + safe.clickNetworkSelector(constants.networks.goerli) + safe.selectPolygon() + cy.wait(2000) + safe.clickNetworkSelector(constants.networks.polygon) + safe.selectGoerli() }) it('should accept name the Safe', () => { // alias the address input label cy.get('input[name="address"]').parent().prev('label').as('addressLabel') - // Name input should have a placeholder ending in 'goerli-safe' - cy.get('input[name="name"]') - .should('have.attr', 'placeholder') - .should('match', /g(ö|oe)rli-safe/) - // Input a custom name - cy.get('input[name="name"]').type('Test safe name').should('have.value', 'Test safe name') - - // Input incorrect Safe address - cy.get('input[name="address"]').type('RandomText') - cy.get('@addressLabel').contains(INVALID_INPUT_ERROR_MSG) - - cy.get('input[name="address"]').clear().type(SAFE_QR_CODE_ADDRESS) + safe.verifyNameInputHasPlceholder(testSafeName) + safe.inputName(testSafeName) + safe.verifyIncorrectAddressErrorMessage() + safe.inputAddress(constants.GOERLI_TEST_SAFE) // Type an invalid address // cy.get('input[name="address"]').clear().type(EOA_ADDRESS) @@ -68,11 +60,8 @@ describe('Load existing Safe', () => { // cy.contains('Upload an image').click() // cy.get('[type="file"]').attachFile('../fixtures/goerli_safe_QR.png') - // The address field should be filled with the "bare" QR code's address - const [, address] = SAFE_QR_CODE_ADDRESS.split(':') - cy.get('input[name="address"]').should('have.value', address) - - cy.contains('Next').click() + safe.verifyAddressInputValue() + safe.clickOnNextBtn() }) // TODO: register the goerli ENS for the Safe owner when possible @@ -85,29 +74,18 @@ describe('Load existing Safe', () => { }) it('should set custom name in the first owner', () => { - // Sets a custom name for the first owner - cy.get('input[name="owners.0.name"]').type('Test Owner Name').should('have.value', 'Test Owner Name') - cy.contains('Next').click() + createwallet.typeOwnerName(testOwnerName, 0) + safe.clickOnNextBtn() }) it('should have Safe and owner names in the Review step', () => { - // Finds Safe name - cy.findByText('Test safe name').should('exist') - // Finds custom owner name - cy.findByText('Test Owner Name').should('exist') - - cy.contains('button', 'Add').click() + safe.verifyDataInReviewSection(testSafeName, testOwnerName) + safe.clickOnAddBtn() }) it('should load successfully the custom Safe name', () => { - // Safe loaded - cy.location('href', { timeout: 10000 }).should('include', `/home?safe=${SAFE_QR_CODE_ADDRESS}`) - - // Finds Safe name in the sidebar - cy.get('aside').contains('Test safe name') - - // Safe name is present in Settings - cy.get('aside ul').contains('Settings').click() - cy.contains('Test Owner Name').should('exist') + main.verifyHomeSafeUrl(constants.GOERLI_TEST_SAFE) + safe.veriySidebarSafeNameIsVisible(testSafeName) + safe.verifyOwnerNamePresentInSettings(testOwnerName) }) }) diff --git a/cypress/e2e/smoke/nfts.cy.js b/cypress/e2e/smoke/nfts.cy.js index a1613d9376..55205fadb6 100644 --- a/cypress/e2e/smoke/nfts.cy.js +++ b/cypress/e2e/smoke/nfts.cy.js @@ -1,88 +1,51 @@ -const TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as nfts from '../pages/nfts.pages' + +const nftsName = 'BillyNFT721' +const nftsAddress = '0x0000...816D' +const nftsTokenID = 'Kitaro World #261' +const nftsLink = 'https://testnets.opensea.io/assets/0x000000000faE8c6069596c9C805A1975C657816D/443' describe('Assets > NFTs', () => { before(() => { - cy.visit(`/balances/nfts?safe=${TEST_SAFE}`) - cy.contains('button', 'Accept selection').click() - cy.contains(/E2E Wallet @ G(ö|oe)rli/) + cy.clearLocalStorage() + cy.visit(constants.balanceNftsUrl + constants.GOERLI_TEST_SAFE) + main.acceptCookies() + cy.contains(constants.goerlyE2EWallet) }) describe('should have NFTs', () => { it('should have NFTs in the table', () => { - cy.get('tbody tr').should('have.length', 5) + nfts.verifyNFTNumber(5) }) it('should have info in the NFT row', () => { - cy.get('tbody tr:first-child').contains('td:first-child', 'BillyNFT721') - cy.get('tbody tr:first-child').contains('td:first-child', '0x0000...816D') - - cy.get('tbody tr:first-child').contains('td:nth-child(2)', 'Kitaro World #261') - - cy.get( - 'tbody tr:first-child td:nth-child(3) a[href="https://testnets.opensea.io/assets/0x000000000faE8c6069596c9C805A1975C657816D/443"]', - ) + nfts.verifyDataInTable(nftsName, nftsAddress, nftsTokenID, nftsLink) }) it('should open an NFT preview', () => { - // Preview the first NFT - cy.get('tbody tr:first-child td:nth-child(2)').click() - - // Modal - cy.get('div[role="dialog"]').contains('Kitaro World #261') - - // Prevent Base Mainnet Goerli from being selected - cy.get('div[role="dialog"]').contains(/^G(ö|oe)rli$/) - cy.get('div[role="dialog"]').contains( - 'a[href="https://testnets.opensea.io/assets/0x000000000faE8c6069596c9C805A1975C657816D/443"]', - 'View on OpenSea', - ) - - // Close the modal - cy.get('div[role="dialog"] button').click() - cy.get('div[role="dialog"]').should('not.exist') + nfts.openFirstNFT() + nfts.verifyNameInNFTModal(nftsTokenID) + nfts.preventBaseMainnetGoerliFromBeingSelected() + nfts.verifyNFTModalLink(nftsLink) + nfts.closeNFTModal() }) it('should not open an NFT preview for NFTs without one', () => { - // Click on the third NFT - cy.get('tbody tr:nth-child(3) td:nth-child(2)').click() - cy.get('div[role="dialog"]').should('not.exist') + nfts.clickOnThirdNFT() + nfts.verifyNFTModalDoesNotExist() }) it('should select and send multiple NFTs', () => { - // Select three NFTs - cy.contains('0 NFTs selected') - cy.contains('button[disabled]', 'Send') - cy.get('tbody tr:first-child input[type="checkbox"]').click() - cy.contains('1 NFT selected') - cy.contains('button:not([disabled])', 'Send 1 NFT') - cy.get('tbody tr:nth-child(2) input[type="checkbox"]').click() - cy.contains('2 NFTs selected') - cy.contains('button', 'Send 2 NFTs') - cy.get('tbody tr:last-child input[type="checkbox"]').click() - cy.contains('3 NFTs selected') - - // Deselect one NFT - cy.get('tbody tr:nth-child(2) input[type="checkbox"]').click() - cy.contains('2 NFTs selected') - - // Send NFTs - cy.contains('button', 'Send 2 NFTs').click() - - // Modal appears - cy.contains('Send NFTs') - cy.contains('Recipient address or ENS') - cy.contains('Selected NFTs') - cy.get('input[name="recipient"]').type('0x97d314157727D517A706B5D08507A1f9B44AaaE9') - cy.contains('button', 'Next').click() - - // Review modal appears - cy.contains('Send') - cy.contains('To') - cy.wait(1000) - cy.contains('1') - cy.contains('2') - cy.get('b:contains("safeTransferFrom")').should('have.length', 2) - cy.contains('button:not([disabled])', 'Execute') + nfts.verifyInitialNFTData() + nfts.selectNFTs(3) + nfts.deselectNFTs([2], 3) + nfts.sendNFT(2) + nfts.verifyNFTModalData() + nfts.typeRecipientAddress(constants.GOERLI_TEST_SAFE) + nfts.clikOnNextBtn() + nfts.verifyReviewModalData(2) }) }) }) diff --git a/cypress/e2e/smoke/pending_actions.cy.js b/cypress/e2e/smoke/pending_actions.cy.js index 2ebbea1fe6..6f8a9edc81 100644 --- a/cypress/e2e/smoke/pending_actions.cy.js +++ b/cypress/e2e/smoke/pending_actions.cy.js @@ -1,11 +1,14 @@ -const SAFE = 'gor:0x04f8b1EA3cBB315b87ced0E32deb5a43cC151a91' +import * as constants from '../../support/constants' +import * as safe from '../pages/load_safe.pages' describe('Pending actions', () => { before(() => { - cy.visit(`/welcome`) - cy.contains('button', 'Accept selection').click() + cy.visit(constants.welcomeUrl) + // main.acceptCookies() }) + //TODO: Discuss test logic + beforeEach(() => { // Uses the previously saved local storage // to preserve the wallet connection between tests @@ -16,42 +19,25 @@ describe('Pending actions', () => { cy.saveLocalStorageCache() }) - it('should add the Safe with the pending actions', () => { - // Enters Loading Safe form - cy.contains('button', 'Add').click() - cy.contains('Name, address & network') - - // Inputs the Safe address - cy.get('input[name="address"]').type(SAFE) - cy.contains('Next').click() - - cy.contains('Owners and confirmations') - cy.contains('Next').click() - - cy.contains('Add').click() + it.skip('should add the Safe with the pending actions', () => { + safe.openLoadSafeForm() + safe.inputAddress(constants.TEST_SAFE) + safe.clickOnNextBtn() + safe.verifyOwnersModalIsVisible() + safe.clickOnNextBtn() + safe.clickOnAddBtn() }) - it('should display the pending actions in the Safe list sidebar', () => { - cy.get('aside').within(() => { - cy.get('[data-testid=ChevronRightIcon]').click({ force: true }) - }) - - cy.get('li').within(() => { - cy.contains('0x04f8...1a91').should('exist') - - //cy.get('img[alt="E2E Wallet logo"]').next().contains('2').should('exist') - cy.get('[data-testid=CheckIcon]').next().contains('1').should('exist') - - // click on the pending actions - cy.get('[data-testid=CheckIcon]').next().click() - }) + it.skip('should display the pending actions in the Safe list sidebar', () => { + safe.openSidebar() + safe.verifyAddressInsidebar(constants.SIDEBAR_ADDRESS) + safe.verifySidebarIconNumber(1) + safe.clickOnPendingActions() + //cy.get('img[alt="E2E Wallet logo"]').next().contains('2').should('exist') }) - it('should have the right number of queued and signable transactions', () => { - // Navigates to the tx queue - cy.contains('h3', 'Transactions').should('be.visible') - - // contains 1 queued transaction - cy.get('span:contains("1 out of 1")').should('have.length', 1) + it.skip('should have the right number of queued and signable transactions', () => { + safe.verifyTransactionSectionIsVisible() + safe.verifyNumberOfTransactions(1, 1) }) }) diff --git a/cypress/e2e/smoke/tx_history.cy.js b/cypress/e2e/smoke/tx_history.cy.js index 84278a7e94..482840fc1e 100644 --- a/cypress/e2e/smoke/tx_history.cy.js +++ b/cypress/e2e/smoke/tx_history.cy.js @@ -1,25 +1,37 @@ -const SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as createTx from '../pages/create_tx.pages' const INCOMING = 'Received' const OUTGOING = 'Sent' const CONTRACT_INTERACTION = 'Contract interaction' +const str1 = 'True' +const str2 = '1337' +const str3 = '5688' + describe('Transaction history', () => { before(() => { + cy.clearLocalStorage() // Go to the test Safe transaction history - cy.visit(`/transactions/history?safe=${SAFE}`) - cy.contains('button', 'Accept selection').click() + cy.visit(constants.transactionsHistoryUrl + constants.GOERLI_TEST_SAFE) + main.acceptCookies() }) it('should display October 9th transactions', () => { const DATE = 'Oct 9, 2022' const NEXT_DATE_LABEL = 'Feb 8, 2022' - - // Date label - cy.contains('div', DATE).should('exist') - - // Next date label - cy.contains('div', NEXT_DATE_LABEL).scrollIntoView() + const amount = '0.25 GOR' + const amount2 = '0.11 WETH' + const amount3 = '120,497.61 DAI' + const time = '4:56 PM' + const time2 = '4:59 PM' + const time3 = '5:00 PM' + const time4 = '5:01 PM' + const success = 'Success' + + createTx.verifyDateExists(DATE) + createTx.verifyDateExists(NEXT_DATE_LABEL) // Transaction summaries from October 9th const rows = cy.contains('div', DATE).nextUntil(`div:contains(${NEXT_DATE_LABEL})`) @@ -31,128 +43,77 @@ describe('Transaction history', () => { .last() .within(() => { // Type - cy.get('img').should('have.attr', 'alt', INCOMING) - cy.contains('div', 'Received').should('exist') + createTx.verifyImageAltTxt(0, INCOMING) + createTx.verifyStatus(constants.transactionStatus.received) // Info - cy.get('img[alt="GOR"]').should('be.visible') - cy.contains('span', '0.25 GOR').should('exist') - - // Time - cy.contains('span', '4:56 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyImageAltTxt(1, constants.tokenAbbreviation.gor) + createTx.verifyTransactionStrExists(amount) + createTx.verifyTransactionStrExists(time) + createTx.verifyTransactionStrExists(success) }) // CowSwap deposit of Wrapped Ether .prev() .within(() => { - // Nonce - cy.contains('0') - - // Type + createTx.verifyTransactionStrExists('0') // TODO: update next line after fixing the logo // cy.find('img').should('have.attr', 'src').should('include', WRAPPED_ETH) - cy.contains('div', 'Wrapped Ether').should('exist') - - // Info - cy.contains('div', 'deposit').should('exist') - - // Time - cy.contains('span', '4:59 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyTransactionStrExists(constants.tokenNames.wrappedEther) + createTx.verifyTransactionStrExists(constants.transactionStatus.deposit) + createTx.verifyTransactionStrExists(time2) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) // CowSwap approval of Wrapped Ether .prev() .within(() => { - // Nonce - cy.contains('1') - + createTx.verifyTransactionStrExists('1') // Type // TODO: update next line after fixing the logo // cy.find('img').should('have.attr', 'src').should('include', WRAPPED_ETH) - cy.contains('div', 'Wrapped Ether').should('exist') - - // Info - cy.contains('div', 'approve').should('exist') - - // Time - cy.contains('span', '5:00 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyTransactionStrExists(constants.transactionStatus.approve) + createTx.verifyTransactionStrExists(time3) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) // Contract interaction .prev() .within(() => { - // Nonce - cy.contains('2') - - // Type - cy.contains('div', 'Contract interaction').should('exist') - - // Time - cy.contains('span', '5:01 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyTransactionStrExists('2') + createTx.verifyTransactionStrExists(constants.transactionStatus.interaction) + createTx.verifyTransactionStrExists(time4) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) // Send 0.11 WETH .prev() .within(() => { - // Type - cy.get('img').should('have.attr', 'alt', OUTGOING) - cy.contains('div', 'Sent').should('exist') - - // Info - cy.contains('span', '-0.11 WETH').should('exist') - - // Time - cy.contains('span', '5:01 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyImageAltTxt(0, OUTGOING) + createTx.verifyTransactionStrExists(constants.transactionStatus.sent) + createTx.verifyTransactionStrExists(amount2) + createTx.verifyTransactionStrExists(time4) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) // Receive 120 DAI .prev() .within(() => { - // Type - cy.contains('div', 'Received').should('exist') - - // Info - cy.contains('span', '120,497.61 DAI').should('exist') - - // Time - cy.contains('span', '5:01 PM').should('exist') - - // Status - cy.contains('span', 'Success').should('exist') + createTx.verifyTransactionStrExists(constants.transactionStatus.received) + createTx.verifyTransactionStrExists(amount3) + createTx.verifyTransactionStrExists(time4) + createTx.verifyTransactionStrExists(constants.transactionStatus.success) }) }) it('should expand/collapse all actions', () => { - // Open the tx details - cy.contains('div', 'Mar 24, 2023') - .next() - .click() - .within(() => { - cy.contains('True').should('not.be.visible') - cy.contains('1337').should('not.be.visible') - cy.contains('5688').should('not.be.visible') - cy.contains('Expand all').click() - - // All the values in the actions must be visible - cy.contains('True').should('exist') - cy.contains('1337').should('exist') - cy.contains('5688').should('exist') - - // After collapse all the same values should not be visible - cy.contains('Collapse all').click() - cy.contains('True').should('not.be.visible') - cy.contains('1337').should('not.be.visible') - cy.contains('5688').should('not.be.visible') - }) + createTx.clickOnTransactionExpandableItem('Mar 24, 2023', () => { + createTx.verifyTransactionStrNotVible(str1) + createTx.verifyTransactionStrNotVible(str2) + createTx.verifyTransactionStrNotVible(str3) + createTx.clickOnExpandAllBtn() + createTx.verifyTransactionStrExists(str1) + createTx.verifyTransactionStrExists(str2) + createTx.verifyTransactionStrExists(str3) + createTx.clickOnCollapseAllBtn() + createTx.verifyTransactionStrNotVible(str1) + createTx.verifyTransactionStrNotVible(str2) + createTx.verifyTransactionStrNotVible(str3) + }) }) }) diff --git a/cypress/e2e/tx_modal.cy.js b/cypress/e2e/tx_modal.cy.js index 89b92256d4..f416144b47 100644 --- a/cypress/e2e/tx_modal.cy.js +++ b/cypress/e2e/tx_modal.cy.js @@ -1,6 +1,7 @@ +import * as constants from '../support/constants' + const TEST_SAFE = 'rin:0x11Df0fa87b30080d59eba632570f620e37f2a8f7' const RECIPIENT_ENS = 'diogo.eth' -const RECIPIENT_ADDRESS = '0x6a5602335a878ADDCa4BF63a050E34946B56B5bC' const SAFE_NONCE = '6' describe('Tx Modal', () => { @@ -56,7 +57,7 @@ describe('Tx Modal', () => { cy.get('label[for="address-book-input"]').next().type(RECIPIENT_ENS) // Waits for resolving the ENS - cy.contains(RECIPIENT_ADDRESS).should('be.visible') + cy.contains(constants.RECIPIENT_ADDRESS).should('be.visible') }) it('should have all tokens available in the token selector', () => { @@ -127,7 +128,7 @@ describe('Tx Modal', () => { // Sender cy.contains('Sending from').parent().next().contains(TEST_SAFE) // Recipient - cy.contains('Recipient').parent().next().contains(RECIPIENT_ADDRESS) + cy.contains('Recipient').parent().next().contains(constants.RECIPIENT_ADDRESS) // Token value cy.contains('0.000004 GNO') diff --git a/cypress/e2e/tx_simulation.cy.js b/cypress/e2e/tx_simulation.cy.js index 885fc17b33..4f219d41c3 100644 --- a/cypress/e2e/tx_simulation.cy.js +++ b/cypress/e2e/tx_simulation.cy.js @@ -1,5 +1,6 @@ +import * as constants from '../support/constants' + const TEST_SAFE = 'rin:0x11Df0fa87b30080d59eba632570f620e37f2a8f7' -const RECIPIENT_ADDRESS = '0x6a5602335a878ADDCa4BF63a050E34946B56B5bC' describe('Tx Simulation', () => { before(() => { @@ -13,7 +14,7 @@ describe('Tx Simulation', () => { // Choose recipient cy.get('input[name="recipient"]').should('be.visible') - cy.get('input[name="recipient"]').type(RECIPIENT_ADDRESS, { force: true }) + cy.get('input[name="recipient"]').type(constants.RECIPIENT_ADDRESS, { force: true }) // Select asset and amount cy.get('input[name="tokenAddress"]').parent().click() diff --git a/cypress/support/constants.js b/cypress/support/constants.js new file mode 100644 index 0000000000..77e3994062 --- /dev/null +++ b/cypress/support/constants.js @@ -0,0 +1,88 @@ +import { LS_NAMESPACE } from '../../src/config/constants' +export const RECIPIENT_ADDRESS = '0x6a5602335a878ADDCa4BF63a050E34946B56B5bC' +export const GOERLI_TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' +export const GNO_TEST_SAFE = 'gno:0xB8d760a90a5ed54D3c2b3EFC231277e99188642A' +export const PAGINATION_TEST_SAFE = 'gor:0x850493a15914aAC05a821A3FAb973b4598889A7b' +export const TEST_SAFE = 'gor:0x04f8b1EA3cBB315b87ced0E32deb5a43cC151a91' +export const EOA = '0xE297437d6b53890cbf004e401F3acc67c8b39665' +export const DEFAULT_OWNER_ADDRESS = '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED' +export const TEST_SAFE_2 = 'gor:0xE96C43C54B08eC528e9e815fC3D02Ea94A320505' +export const SIDEBAR_ADDRESS = '0x04f8...1a91' + +export const BROWSER_PERMISSIONS_KEY = `${LS_NAMESPACE}SafeApps__browserPermissions` +export const SAFE_PERMISSIONS_KEY = `${LS_NAMESPACE}SafeApps__safePermissions` +export const INFO_MODAL_KEY = `${LS_NAMESPACE}SafeApps__infoModal` + +export const goerlyE2EWallet = /E2E Wallet @ G(ö|oe)rli/ +export const goerlySafeName = /g(ö|oe)rli-safe/ +export const goerliToken = /G(ö|oe)rli Ether/ + +export const testAppUrl = 'https://safe-test-app.com' +export const addressBookUrl = '/address-book?safe=' +export const BALANCE_URL = '/balances?safe=' +export const balanceNftsUrl = '/balances/nfts?safe=' +export const transactionQueueUrl = '/transactions/queue?safe=' +export const transactionsHistoryUrl = '/transactions/history?safe=' +export const openAppsUrl = '/apps/open?safe=' +export const homeUrl = '/home?safe=' +export const welcomeUrl = '/welcome' +export const chainMaticUrl = '/welcome?chain=matic' +export const appsUrl = '/apps' +export const requestPermissionsUrl = '/request-permissions' +export const getPermissionsUrl = '/get-permissions' +export const appSettingsUrl = '/settings/safe-apps' +export const invalidAppUrl = 'https://my-invalid-custom-app.com/manifest.json' +export const validAppUrlJson = 'https://my-valid-custom-app.com/manifest.json' +export const validAppUrl = 'https://my-valid-custom-app.com' + +export const proposeEndpoint = '/**/propose' +export const appsEndpoint = '/**/safe-apps' + +export const GOERLI_CSV_ENTRY = { + name: 'goerli user 1', + address: '0x730F87dA2A3C6721e2196DFB990759e9bdfc5083', +} +export const GNO_CSV_ENTRY = { + name: 'gno user 1', + address: '0x61a0c717d18232711bC788F19C9Cd56a43cc8872', +} + +export const networks = { + ethereum: 'Ethereum', + goerli: /^G(ö|oe)rli$/, + sepolia: 'Sepolia', + polygon: 'Polygon', +} + +export const tokenAbbreviation = { + gor: 'GOR', +} + +export const appNames = { + walletConnect: 'walletconnect', + customContract: 'compose custom contract', + noResults: 'atextwithoutresults', +} + +export const testAppData = { + name: 'Cypress Test App', + descr: 'Cypress Test App Description', +} + +export const checkboxStates = { + unchecked: 'not.be.checked', + checked: 'be.checked', +} + +export const transactionStatus = { + received: 'Receive', + sent: 'Send', + deposit: 'deposit', + approve: 'Approve', + success: 'Success', + interaction: 'Contract interaction', +} + +export const tokenNames = { + wrappedEther: 'Wrapped Ether', +} diff --git a/jest.config.cjs b/jest.config.cjs index 76962d43aa..bd350fb152 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -15,6 +15,9 @@ const customJestConfig = { }, testEnvironment: 'jest-environment-jsdom', testEnvironmentOptions: { url: 'http://localhost/balances?safe=rin:0xb3b83bf204C458B461de9B0CD2739DB152b4fa5A' }, + globals: { + fetch: global.fetch + } } // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/package.json b/package.json index 2b62ff0bf7..7b86ad63ca 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "@types/semver": "^7.3.10", "@typescript-eslint/eslint-plugin": "^5.47.1", "cross-env": "^7.0.3", - "cypress": "^11.1.0", + "cypress": "^12.15.0", "cypress-file-upload": "^5.0.8", "eslint": "8.31.0", "eslint-config-next": "13.1.1", diff --git a/src/components/common/Notifications/index.tsx b/src/components/common/Notifications/index.tsx index 53c6eebb46..fc714a8d0b 100644 --- a/src/components/common/Notifications/index.tsx +++ b/src/components/common/Notifications/index.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '@/store' import type { Notification } from '@/store/notificationsSlice' import { closeNotification, readNotification, selectNotifications } from '@/store/notificationsSlice' import type { AlertColor, SnackbarCloseReason } from '@mui/material' -import { Alert, Link, Snackbar } from '@mui/material' +import { Alert, Link, Snackbar, Typography } from '@mui/material' import css from './styles.module.css' import NextLink from 'next/link' import ChevronRightIcon from '@mui/icons-material/ChevronRight' @@ -45,6 +45,7 @@ export const NotificationLink = ({ } const Toast = ({ + title, message, detailedMessage, variant, @@ -73,6 +74,12 @@ const Toast = ({ return ( + {title && ( + + {title} + + )} + {message} {detailedMessage && ( diff --git a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx index 2c57b9e188..9a873a8178 100644 --- a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx +++ b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx @@ -6,7 +6,7 @@ import { BigNumber } from 'ethers' import SafeTokenWidget from '..' import { hexZeroPad } from 'ethers/lib/utils' import { AppRoutes } from '@/config/routes' -import useSafeTokenAllocation from '@/hooks/useSafeTokenAllocation' +import useSafeTokenAllocation, { useSafeVotingPower } from '@/hooks/useSafeTokenAllocation' const MOCK_GOVERNANCE_APP_URL = 'https://mock.governance.safe.global' @@ -52,21 +52,24 @@ describe('SafeTokenWidget', () => { it('Should render nothing for unsupported chains', () => { ;(useChainId as jest.Mock).mockImplementationOnce(jest.fn(() => '100')) - ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from(0), false]) + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(0), , false]) const result = render() expect(result.baseElement).toContainHTML('
') }) it('Should display 0 if Safe has no SAFE token', async () => { - ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from(0), false]) + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(0), , false]) const result = render() await waitFor(() => expect(result.baseElement).toHaveTextContent('0')) }) it('Should display the value formatted correctly', async () => { - ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from('472238796133701648384'), false]) + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from('472238796133701648384'), , false]) // to avoid failing tests in some environments const NumberFormat = Intl.NumberFormat @@ -82,7 +85,8 @@ describe('SafeTokenWidget', () => { }) it('Should render a link to the governance app', async () => { - ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from(420000), false]) + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(420000), , false]) const result = render() await waitFor(() => { @@ -91,4 +95,14 @@ describe('SafeTokenWidget', () => { ) }) }) + + it('Should render a claim button for SEP5 qualification', async () => { + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[{ tag: 'user_v2' }], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(420000), , false]) + + const result = render() + await waitFor(() => { + expect(result.baseElement).toContainHTML('New allocation') + }) + }) }) diff --git a/src/components/common/SafeTokenWidget/index.tsx b/src/components/common/SafeTokenWidget/index.tsx index 921f792232..3b1b189566 100644 --- a/src/components/common/SafeTokenWidget/index.tsx +++ b/src/components/common/SafeTokenWidget/index.tsx @@ -2,10 +2,10 @@ import { SafeAppsTag, SAFE_TOKEN_ADDRESSES } from '@/config/constants' import { AppRoutes } from '@/config/routes' import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' import useChainId from '@/hooks/useChainId' -import useSafeTokenAllocation from '@/hooks/useSafeTokenAllocation' +import useSafeTokenAllocation, { useSafeVotingPower, type Vesting } from '@/hooks/useSafeTokenAllocation' import { OVERVIEW_EVENTS } from '@/services/analytics' import { formatVisualAmount } from '@/utils/formatters' -import { Box, ButtonBase, Skeleton, Tooltip, Typography } from '@mui/material' +import { Box, Button, ButtonBase, Skeleton, Tooltip, Typography } from '@mui/material' import { BigNumber } from 'ethers' import Link from 'next/link' import { useRouter } from 'next/router' @@ -13,6 +13,8 @@ import type { UrlObject } from 'url' import Track from '../Track' import SafeTokenIcon from '@/public/images/common/safe-token.svg' import css from './styles.module.css' +import UnreadBadge from '../UnreadBadge' +import classnames from 'classnames' const TOKEN_DECIMALS = 18 @@ -20,13 +22,26 @@ export const getSafeTokenAddress = (chainId: string): string => { return SAFE_TOKEN_ADDRESSES[chainId] } +const canRedeemSep5Airdrop = (allocation?: Vesting[]): boolean => { + const sep5Allocation = allocation?.find(({ tag }) => tag === 'user_v2') + + if (!sep5Allocation) { + return false + } + + return !sep5Allocation.isRedeemed && !sep5Allocation.isExpired +} + +const SEP5_DEADLINE = '27.10' + const SafeTokenWidget = () => { const chainId = useChainId() const router = useRouter() const [apps] = useRemoteSafeApps(SafeAppsTag.SAFE_GOVERNANCE_APP) const governanceApp = apps?.[0] - const [allocation, allocationLoading] = useSafeTokenAllocation() + const [allocationData, , allocationDataLoading] = useSafeTokenAllocation() + const [allocation, , allocationLoading] = useSafeVotingPower(allocationData) const tokenAddress = getSafeTokenAddress(chainId) if (!tokenAddress) { @@ -40,24 +55,57 @@ const SafeTokenWidget = () => { } : undefined + const canRedeemSep5 = canRedeemSep5Airdrop(allocationData) const flooredSafeBalance = formatVisualAmount(allocation || BigNumber.from(0), TOKEN_DECIMALS, 2) return ( - + - - {allocationLoading ? : flooredSafeBalance} + + + {allocationDataLoading || allocationLoading ? ( + + ) : ( + flooredSafeBalance + )} + + {canRedeemSep5 && ( + + + + )} diff --git a/src/components/common/SafeTokenWidget/styles.module.css b/src/components/common/SafeTokenWidget/styles.module.css index 515d2fcd31..b81af87f3d 100644 --- a/src/components/common/SafeTokenWidget/styles.module.css +++ b/src/components/common/SafeTokenWidget/styles.module.css @@ -19,4 +19,18 @@ gap: var(--space-1); margin-left: 0; margin-right: 0; + align-self: stretch; +} + +.sep5 { + height: 42px; +} + +[data-theme='dark'] .allocationBadge :global .MuiBadge-dot { + background-color: var(--color-primary-main); +} + +.redeemButton { + margin-left: var(--space-1); + padding: calc(var(--space-1) / 2) var(--space-1); } diff --git a/src/components/common/Track/index.tsx b/src/components/common/Track/index.tsx index f2df5be2f0..09a2832940 100644 --- a/src/components/common/Track/index.tsx +++ b/src/components/common/Track/index.tsx @@ -10,6 +10,11 @@ type Props = { label?: EventLabel } +const shouldTrack = (el: HTMLDivElement) => { + const disabledChildren = el.querySelectorAll('*[disabled]') + return disabledChildren.length === 0 +} + const Track = ({ children, as: Wrapper = 'span', ...trackData }: Props): typeof children => { const el = useRef(null) @@ -21,7 +26,9 @@ const Track = ({ children, as: Wrapper = 'span', ...trackData }: Props): typeof const trackEl = el.current const handleClick = () => { - trackEvent(trackData) + if (shouldTrack(trackEl)) { + trackEvent(trackData) + } } // We cannot use onClick as events in children do not always bubble up diff --git a/src/components/dashboard/PendingTxs/PendingTxListItem.tsx b/src/components/dashboard/PendingTxs/PendingTxListItem.tsx index b4219796d0..1ac2855c73 100644 --- a/src/components/dashboard/PendingTxs/PendingTxListItem.tsx +++ b/src/components/dashboard/PendingTxs/PendingTxListItem.tsx @@ -2,16 +2,20 @@ import NextLink from 'next/link' import { useRouter } from 'next/router' import type { ReactElement } from 'react' import { useMemo } from 'react' +import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' import ChevronRight from '@mui/icons-material/ChevronRight' import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' import { Box, SvgIcon, Typography } from '@mui/material' -import { isMultisigExecutionInfo } from '@/utils/transaction-guards' +import { isExecutable, isMultisigExecutionInfo, isSignableBy } from '@/utils/transaction-guards' import TxInfo from '@/components/transactions/TxInfo' import TxType from '@/components/transactions/TxType' import css from './styles.module.css' -import classNames from 'classnames' import OwnersIcon from '@/public/images/common/owners.svg' import { AppRoutes } from '@/config/routes' +import useSafeInfo from '@/hooks/useSafeInfo' +import useWallet from '@/hooks/wallets/useWallet' +import SignTxButton from '@/components/transactions/SignTxButton' +import ExecuteTxButton from '@/components/transactions/ExecuteTxButton' type PendingTxType = { transaction: TransactionSummary @@ -20,6 +24,10 @@ type PendingTxType = { const PendingTx = ({ transaction }: PendingTxType): ReactElement => { const router = useRouter() const { id } = transaction + const { safe } = useSafeInfo() + const wallet = useWallet() + const canSign = wallet ? isSignableBy(transaction, wallet.address) : false + const canExecute = wallet ? isExecutable(transaction, wallet?.address, safe) : false const url = useMemo( () => ({ @@ -32,37 +40,41 @@ const PendingTx = ({ transaction }: PendingTxType): ReactElement => { [router, id], ) + const displayInfo = !transaction.txInfo.richDecodedInfo && transaction.txInfo.type !== TransactionInfoType.TRANSFER + return ( - - - {isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce} - + + {isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce} - + - - - + {displayInfo && ( + + + + )} - - {isMultisigExecutionInfo(transaction.executionInfo) ? ( - - - - {`${transaction.executionInfo.confirmationsSubmitted}/${transaction.executionInfo.confirmationsRequired}`} - - - ) : ( - - )} - + {isMultisigExecutionInfo(transaction.executionInfo) ? ( + + + + {`${transaction.executionInfo.confirmationsSubmitted}/${transaction.executionInfo.confirmationsRequired}`} + + + ) : ( + + )} - + {canExecute ? ( + + ) : canSign ? ( + + ) : ( - + )} ) diff --git a/src/components/dashboard/PendingTxs/PendingTxsList.tsx b/src/components/dashboard/PendingTxs/PendingTxsList.tsx index 28cf208d0f..263b5a726b 100644 --- a/src/components/dashboard/PendingTxs/PendingTxsList.tsx +++ b/src/components/dashboard/PendingTxs/PendingTxsList.tsx @@ -2,31 +2,18 @@ import type { ReactElement } from 'react' import { useMemo } from 'react' import { useRouter } from 'next/router' import { getLatestTransactions } from '@/utils/tx-list' -import styled from '@emotion/styled' import { Box, Skeleton, Typography } from '@mui/material' import { Card, ViewAllLink, WidgetBody, WidgetContainer } from '../styled' import PendingTxListItem from './PendingTxListItem' import useTxQueue from '@/hooks/useTxQueue' import { AppRoutes } from '@/config/routes' import NoTransactionsIcon from '@/public/images/transactions/no-transactions.svg' -import { getQueuedTransactionCount } from '@/utils/transactions' +import css from './styles.module.css' +import { isSignableBy, isExecutable } from '@/utils/transaction-guards' +import useWallet from '@/hooks/wallets/useWallet' +import useSafeInfo from '@/hooks/useSafeInfo' -const SkeletonWrapper = styled.div` - border-radius: 8px; - overflow: hidden; -` - -const StyledList = styled.div` - display: flex; - flex-direction: column; - gap: var(--space-1); - width: 100%; -` - -const StyledWidgetTitle = styled.div` - display: flex; - justify-content: space-between; -` +const MAX_TXS = 4 const EmptyState = () => { return ( @@ -42,60 +29,63 @@ const EmptyState = () => { ) } -const PendingTxsList = ({ size = 4 }: { size?: number }): ReactElement | null => { +const LoadingState = () => ( +
+ {Array.from(Array(MAX_TXS).keys()).map((key) => ( + + ))} +
+) + +const PendingTxsList = (): ReactElement | null => { + const router = useRouter() const { page, loading } = useTxQueue() + const { safe } = useSafeInfo() + const wallet = useWallet() const queuedTxns = useMemo(() => getLatestTransactions(page?.results), [page?.results]) - const queuedTxsToDisplay = queuedTxns.slice(0, size) - const totalQueuedTxs = getQueuedTransactionCount(page) - const router = useRouter() + + const actionableTxs = useMemo(() => { + return wallet + ? queuedTxns.filter( + (tx) => isSignableBy(tx.transaction, wallet.address) || isExecutable(tx.transaction, wallet.address, safe), + ) + : queuedTxns + }, [wallet, queuedTxns, safe]) + + const txs = actionableTxs.length ? actionableTxs : queuedTxns + const txsToDisplay = txs.slice(0, MAX_TXS) const queueUrl = useMemo( () => ({ pathname: AppRoutes.transactions.queue, query: { safe: router.query.safe }, }), - [router], + [router.query.safe], ) - const LoadingState = useMemo( - () => ( - - {Array.from(Array(size).keys()).map((key) => ( - - - - ))} - - ), - [size], - ) - - const ResultState = useMemo( - () => ( - - {queuedTxsToDisplay.map((transaction) => ( - - ))} - - ), - [queuedTxsToDisplay], - ) - - const getWidgetBody = () => { - if (loading) return LoadingState - if (!queuedTxsToDisplay.length) return - return ResultState - } - return ( - +
- Transaction queue {totalQueuedTxs ? ` (${totalQueuedTxs})` : ''} + Pending transactions + {queuedTxns.length > 0 && } - - {getWidgetBody()} +
+ + + {loading ? ( + + ) : queuedTxns.length ? ( +
+ {txsToDisplay.map((tx) => ( + + ))} +
+ ) : ( + + )} +
) } diff --git a/src/components/dashboard/PendingTxs/styles.module.css b/src/components/dashboard/PendingTxs/styles.module.css index f5dc2103d5..ad2a6fe17e 100644 --- a/src/components/dashboard/PendingTxs/styles.module.css +++ b/src/components/dashboard/PendingTxs/styles.module.css @@ -1,19 +1,37 @@ -.gridContainer { +.container { width: 100%; - display: grid; - grid-gap: 4px; - align-items: center; padding: 8px 16px; background-color: var(--color-background-paper); border: 1px solid var(--color-border-light); border-radius: 8px; + flex-wrap: wrap; + display: flex; + align-items: center; + gap: var(--space-2); } -.gridContainer:hover { +.container:hover { background-color: var(--color-background-light); border-color: var(--color-secondary-light); } +.list { + display: flex; + flex-direction: column; + gap: var(--space-1); + width: 100%; +} + +.skeleton { + border-radius: 8px; + overflow: hidden; +} + +.title { + display: flex; + justify-content: space-between; +} + .confirmationsCount { display: flex; align-items: center; @@ -27,24 +45,11 @@ text-align: center; } -.columnTemplate { - grid-template-columns: repeat(12, 1fr); - grid-template-areas: 'nonce type type type type type type info info info confirmations icon'; -} - -.columnWrap { - white-space: normal; - word-break: break-word; -} - @media (max-width: 599.95px) { - .confirmationsCount { - padding: 4px var(--space-1); - } - - .columnTemplate { - grid-template-areas: - 'nonce type type type type type type type type type confirmations icon' - 'empty info info info info info info info info info info info'; + .txInfo { + width: 100%; + order: 1; + flex: auto; + margin-top: calc(var(--space-1) * -1); } } diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 713abbe444..497aed2b7e 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -25,7 +25,7 @@ const Dashboard = (): ReactElement => { - + diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index d4ed0eca20..f78414284b 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -23,6 +23,7 @@ import { getReadOnlyGnosisSafeContract, getReadOnlyProxyFactoryContract, } from '@/services/contracts/safeContracts' +import { LATEST_SAFE_VERSION } from '@/config/constants' const provider = new JsonRpcProvider(undefined, { name: 'rinkeby', chainId: 4 }) @@ -230,8 +231,8 @@ describe('createNewSafeViaRelayer', () => { const expectedSaltNonce = 69 const expectedThreshold = 1 - const proxyFactoryAddress = getReadOnlyProxyFactoryContract('5').getAddress() - const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract('5') + const proxyFactoryAddress = getReadOnlyProxyFactoryContract('5', LATEST_SAFE_VERSION).getAddress() + const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract('5', LATEST_SAFE_VERSION) const safeContractAddress = getReadOnlyGnosisSafeContract(mockChainInfo).getAddress() const expectedInitializer = Gnosis_safe__factory.createInterface().encodeFunctionData('setup', [ diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index 6bb49eaece..4f5d397686 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -9,7 +9,7 @@ import type { ConnectedWallet } from '@/services/onboard' import { BigNumber } from '@ethersproject/bignumber' import { SafeCreationStatus } from '@/components/new-safe/create/steps/StatusStep/useSafeCreation' import { didRevert, type EthersError } from '@/utils/ethers-utils' -import { Errors, logError } from '@/services/exceptions' +import { Errors, trackError } from '@/services/exceptions' import { ErrorCode } from '@ethersproject/logger' import { isWalletRejection } from '@/utils/wallets' import type { PendingSafeTx } from '@/components/new-safe/create/types' @@ -44,7 +44,7 @@ export const getSafeDeployProps = ( callback: (txHash: string) => void, chainId: string, ): PredictSafeProps & { callback: DeploySafeProps['callback'] } => { - const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract(chainId) + const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract(chainId, LATEST_SAFE_VERSION) return { safeAccountConfig: { @@ -90,8 +90,8 @@ export const encodeSafeCreationTx = ({ chain, }: SafeCreationProps & { chain: ChainInfo }) => { const readOnlySafeContract = getReadOnlyGnosisSafeContract(chain, LATEST_SAFE_VERSION) - const readOnlyProxyContract = getReadOnlyProxyFactoryContract(chain.chainId) - const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract(chain.chainId) + const readOnlyProxyContract = getReadOnlyProxyFactoryContract(chain.chainId, LATEST_SAFE_VERSION) + const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract(chain.chainId, LATEST_SAFE_VERSION) const setupData = readOnlySafeContract.encode('setup', [ owners, @@ -118,7 +118,7 @@ export const getSafeCreationTxInfo = async ( chain: ChainInfo, wallet: ConnectedWallet, ): Promise => { - const readOnlyProxyContract = getReadOnlyProxyFactoryContract(chain.chainId) + const readOnlyProxyContract = getReadOnlyProxyFactoryContract(chain.chainId, LATEST_SAFE_VERSION) const data = encodeSafeCreationTx({ owners: owners.map((owner) => owner.address), @@ -143,7 +143,7 @@ export const estimateSafeCreationGas = async ( from: string, safeParams: SafeCreationProps, ): Promise => { - const readOnlyProxyFactoryContract = getReadOnlyProxyFactoryContract(chain.chainId) + const readOnlyProxyFactoryContract = getReadOnlyProxyFactoryContract(chain.chainId, LATEST_SAFE_VERSION) const encodedSafeCreationTx = encodeSafeCreationTx({ ...safeParams, chain }) return provider.estimateGas({ @@ -167,7 +167,7 @@ export const pollSafeInfo = async (chainId: string, safeAddress: string): Promis } export const handleSafeCreationError = (error: EthersError) => { - logError(Errors._800, error.message) + trackError(Errors._800, error.message) if (isWalletRejection(error)) { return SafeCreationStatus.WALLET_REJECTED @@ -270,9 +270,9 @@ export const getRedirect = ( } export const relaySafeCreation = async (chain: ChainInfo, owners: string[], threshold: number, saltNonce: number) => { - const readOnlyProxyFactoryContract = getReadOnlyProxyFactoryContract(chain.chainId) + const readOnlyProxyFactoryContract = getReadOnlyProxyFactoryContract(chain.chainId, LATEST_SAFE_VERSION) const proxyFactoryAddress = readOnlyProxyFactoryContract.getAddress() - const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract(chain.chainId) + const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract(chain.chainId, LATEST_SAFE_VERSION) const fallbackHandlerAddress = readOnlyFallbackHandlerContract.getAddress() const readOnlySafeContract = getReadOnlyGnosisSafeContract(chain) const safeContractAddress = readOnlySafeContract.getAddress() diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 7ec15641ff..0699dc6ae1 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -26,6 +26,7 @@ import classnames from 'classnames' import { hasRemainingRelays } from '@/utils/relaying' import { BigNumber } from 'ethers' import { usePendingSafe } from '../StatusStep/usePendingSafe' +import { LATEST_SAFE_VERSION } from '@/config/constants' const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps) => { const isWrongChain = useIsWrongChain() @@ -78,7 +79,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { if (!wallet || !provider || !chain) return - const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract(chain.chainId) + const readOnlyFallbackHandlerContract = getReadOnlyFallbackHandlerContract(chain.chainId, LATEST_SAFE_VERSION) const props = { safeAccountConfig: { diff --git a/src/components/new-safe/load/index.tsx b/src/components/new-safe/load/index.tsx index 791557e218..727d39a840 100644 --- a/src/components/new-safe/load/index.tsx +++ b/src/components/new-safe/load/index.tsx @@ -20,8 +20,8 @@ export const LoadSafeSteps: TxStepperProps['steps'] = [ { title: 'Name, address & network', subtitle: 'Paste the address of the Safe Account you want to add, select the network and choose a name.', - render: (_, onSubmit, onBack, setStep) => ( - + render: (data, onSubmit, onBack, setStep) => ( + ), }, { @@ -61,6 +61,8 @@ const LoadSafe = ({ initialData }: { initialData?: TxStepperProps void }): ReactElement => { const requiresAction = !isRead && !!link @@ -45,6 +47,13 @@ const NotificationCenterItem = ({
) + const primaryText = ( + <> + {title && {title}} + {message} + + ) + return ( @@ -58,7 +67,7 @@ const NotificationCenterItem = ({ {getNotificationIcon(variant)} - + ) } diff --git a/src/components/settings/FallbackHandler/index.tsx b/src/components/settings/FallbackHandler/index.tsx index 69fd0a4653..59b5a4e557 100644 --- a/src/components/settings/FallbackHandler/index.tsx +++ b/src/components/settings/FallbackHandler/index.tsx @@ -7,8 +7,8 @@ import type { ReactElement } from 'react' import EthHashInfo from '@/components/common/EthHashInfo' import AlertIcon from '@/public/images/common/alert.svg' import useSafeInfo from '@/hooks/useSafeInfo' -import { getFallbackHandlerDeployment } from '@safe-global/safe-deployments' -import { HelpCenterArticle, LATEST_SAFE_VERSION } from '@/config/constants' +import { getFallbackHandlerContractDeployment } from '@/services/contracts/deployments' +import { HelpCenterArticle } from '@/config/constants' import ExternalLink from '@/components/common/ExternalLink' import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp' @@ -23,10 +23,7 @@ export const FallbackHandler = (): ReactElement | null => { const supportsFallbackHandler = !!safe.version && semverSatisfies(safe.version, FALLBACK_HANDLER_VERSION) const fallbackHandlerDeployment = useMemo(() => { - return getFallbackHandlerDeployment({ - version: safe.version || LATEST_SAFE_VERSION, - network: safe.chainId, - }) + return getFallbackHandlerContractDeployment(safe.chainId, safe.version) }, [safe.version, safe.chainId]) if (!supportsFallbackHandler) { diff --git a/src/components/transactions/BatchExecuteButton/index.tsx b/src/components/transactions/BatchExecuteButton/index.tsx index 301fb0b9b3..98365d7525 100644 --- a/src/components/transactions/BatchExecuteButton/index.tsx +++ b/src/components/transactions/BatchExecuteButton/index.tsx @@ -60,7 +60,7 @@ const BatchExecuteButton = () => { disabled={isDisabled} onClick={handleOpenModal} > - Execute batch{isBatchable && ` (${batchableTransactions.length})`} + Bulk execute{isBatchable && ` ${batchableTransactions.length} transactions`}
diff --git a/src/components/transactions/ExecuteTxButton/index.tsx b/src/components/transactions/ExecuteTxButton/index.tsx index 1cc4d8e60a..1791c43394 100644 --- a/src/components/transactions/ExecuteTxButton/index.tsx +++ b/src/components/transactions/ExecuteTxButton/index.tsx @@ -38,6 +38,7 @@ const ExecuteTxButton = ({ const onClick = (e: SyntheticEvent) => { e.stopPropagation() + e.preventDefault() setTxFlow(, undefined, false) } diff --git a/src/components/transactions/HumanDescription/index.tsx b/src/components/transactions/HumanDescription/index.tsx new file mode 100644 index 0000000000..ff6feee1d9 --- /dev/null +++ b/src/components/transactions/HumanDescription/index.tsx @@ -0,0 +1,73 @@ +import { + type RichAddressFragment, + type RichDecodedInfo, + type RichTokenValueFragment, + RichFragmentType, +} from '@safe-global/safe-gateway-typescript-sdk/dist/types/human-description' +import EthHashInfo from '@/components/common/EthHashInfo' +import css from './styles.module.css' +import useAddressBook from '@/hooks/useAddressBook' +import TokenAmount from '@/components/common/TokenAmount' +import React from 'react' +import { type Transfer } from '@safe-global/safe-gateway-typescript-sdk' +import { TransferTx } from '@/components/transactions/TxInfo' +import { formatAmount } from '@/utils/formatNumber' + +const AddressFragment = ({ fragment }: { fragment: RichAddressFragment }) => { + const addressBook = useAddressBook() + + return ( +
+ +
+ ) +} + +const TokenValueFragment = ({ fragment }: { fragment: RichTokenValueFragment }) => { + const isUnlimitedApproval = fragment.value === 'unlimited' + + return ( + + ) +} + +export const TransferDescription = ({ txInfo, isSendTx }: { txInfo: Transfer; isSendTx: boolean }) => { + const action = isSendTx ? 'Send' : 'Receive' + const direction = isSendTx ? 'to' : 'from' + const address = isSendTx ? txInfo.recipient.value : txInfo.sender.value + const name = isSendTx ? txInfo.recipient.name : txInfo.sender.name + + return ( + <> + {action} + + {direction} +
+ +
+ + ) +} + +export const HumanDescription = ({ fragments }: RichDecodedInfo) => { + return ( + <> + {fragments.map((fragment) => { + switch (fragment.type) { + case RichFragmentType.Text: + return {fragment.value} + case RichFragmentType.Address: + return + case RichFragmentType.TokenValue: + return + } + })} + + ) +} diff --git a/src/components/transactions/HumanDescription/styles.module.css b/src/components/transactions/HumanDescription/styles.module.css new file mode 100644 index 0000000000..bc6ec61307 --- /dev/null +++ b/src/components/transactions/HumanDescription/styles.module.css @@ -0,0 +1,30 @@ +.summary { + display: flex; + gap: 8px; + align-items: center; + width: 100%; +} + +/* TODO: This is a workaround to hide address in case there is a title */ +.address div[title] + div { + display: none; +} + +.value { + display: flex; + align-items: center; + font-weight: bold; + gap: 4px; +} + +.method { + display: inline-flex; + align-items: center; + gap: 0.5em; +} + +.wrapper { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/src/components/transactions/SignTxButton/index.tsx b/src/components/transactions/SignTxButton/index.tsx index fbd35d7901..f05705ddf4 100644 --- a/src/components/transactions/SignTxButton/index.tsx +++ b/src/components/transactions/SignTxButton/index.tsx @@ -35,6 +35,7 @@ const SignTxButton = ({ const onClick = (e: SyntheticEvent) => { e.stopPropagation() + e.preventDefault() setTxFlow(, undefined, false) } diff --git a/src/components/transactions/TxSummary/index.tsx b/src/components/transactions/TxSummary/index.tsx index e516d6ca67..fb640452c6 100644 --- a/src/components/transactions/TxSummary/index.tsx +++ b/src/components/transactions/TxSummary/index.tsx @@ -1,7 +1,7 @@ import type { Palette } from '@mui/material' import { Box, CircularProgress, Typography } from '@mui/material' import type { ReactElement } from 'react' -import { type Transaction, TransactionStatus } from '@safe-global/safe-gateway-typescript-sdk' +import { type Transaction, TransactionInfoType, TransactionStatus } from '@safe-global/safe-gateway-typescript-sdk' import DateTime from '@/components/common/DateTime' import TxInfo from '@/components/transactions/TxInfo' @@ -52,15 +52,18 @@ const TxSummary = ({ item, isGrouped }: TxSummaryProps): ReactElement => { : undefined const displayConfirmations = isQueue && !!submittedConfirmations && !!requiredConfirmations + const displayInfo = !tx.txInfo.richDecodedInfo && tx.txInfo.type !== TransactionInfoType.TRANSFER return ( @@ -70,9 +73,11 @@ const TxSummary = ({ item, isGrouped }: TxSummaryProps): ReactElement => { - - - + {displayInfo && ( + + + + )} diff --git a/src/components/transactions/TxSummary/styles.module.css b/src/components/transactions/TxSummary/styles.module.css index 044a9ea0e5..c8e4d787e6 100644 --- a/src/components/transactions/TxSummary/styles.module.css +++ b/src/components/transactions/TxSummary/styles.module.css @@ -7,29 +7,30 @@ } .columnTemplate { - grid-template-columns: minmax(50px, 0.25fr) minmax(150px, 2fr) minmax(150px, 2fr) minmax(200px, 2fr) 1fr 1fr minmax( - 170px, - 1fr - ); + grid-template-columns: + minmax(50px, 0.25fr) minmax(150px, 2fr) minmax(150px, 2fr) minmax(200px, 2fr) 1fr minmax(60px, 0.5fr) + minmax(170px, 1fr); grid-template-areas: 'nonce type info date confirmations actions status'; } -.columnTemplateWithoutNonce { - grid-template-columns: minmax(50px, 0.25fr) minmax(150px, 2fr) minmax(150px, 2fr) minmax(200px, 2fr) 1fr 1fr minmax( +.columnTemplateShort { + grid-template-columns: minmax(50px, 0.25fr) minmax(150px, 4fr) minmax(200px, 2fr) 1fr minmax(60px, 0.5fr) minmax( 170px, 1fr ); - grid-template-areas: 'nonce type info date confirmations actions status'; + grid-template-areas: 'nonce type date confirmations actions status'; } .columnTemplateTxHistory { - grid-template-columns: minmax(50px, 0.25fr) minmax(150px, 2fr) minmax(150px, 2fr) minmax(100px, 1fr) minmax( - 170px, - 1fr - ); + grid-template-columns: minmax(50px, 0.25fr) minmax(150px, 3fr) minmax(150px, 3fr) 0.75fr 0.5fr; grid-template-areas: 'nonce type info date status'; } +.columnTemplateTxHistoryShort { + grid-template-columns: minmax(50px, 0.25fr) 6fr 0.75fr 0.5fr; + grid-template-areas: 'nonce type date status'; +} + .columnWrap { white-space: normal; } @@ -43,7 +44,8 @@ gap: var(--space-1); } - .columnTemplate { + .columnTemplate, + .columnTemplateShort { grid-template-columns: repeat(12, auto); grid-template-areas: 'nonce type type type type type type type type type type type' @@ -54,18 +56,8 @@ 'empty actions actions actions actions actions actions actions actions actions actions actions'; } - .columnTemplateWithoutNonce { - grid-template-columns: repeat(12, 1fr); - grid-template-areas: - 'nonce type type type type type type type type type type type' - 'empty info info info info info info info info info info info' - 'empty date date date date date date date date date date date' - 'empty confirmations confirmations confirmations confirmations confirmations confirmations confirmations confirmations confirmations confirmations confirmations' - 'empty status status status status status status status status status status status' - 'empty actions actions actions actions actions actions actions actions actions actions actions'; - } - - .columnTemplateTxHistory { + .columnTemplateTxHistory, + .columnTemplateTxHistoryShort { grid-template-columns: repeat(12, 1fr); grid-template-areas: 'nonce type type type type type type type type type type type' diff --git a/src/components/transactions/TxType/index.tsx b/src/components/transactions/TxType/index.tsx index 2fbf4c053d..a1c0e64ff6 100644 --- a/src/components/transactions/TxType/index.tsx +++ b/src/components/transactions/TxType/index.tsx @@ -1,8 +1,10 @@ import { useTransactionType } from '@/hooks/useTransactionType' import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionInfoType, TransferDirection } from '@safe-global/safe-gateway-typescript-sdk' import { Box } from '@mui/material' import css from './styles.module.css' import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' +import { HumanDescription, TransferDescription } from '@/components/transactions/HumanDescription' type TxTypeProps = { tx: TransactionSummary @@ -11,6 +13,8 @@ type TxTypeProps = { const TxType = ({ tx }: TxTypeProps) => { const type = useTransactionType(tx) + const humanDescription = tx.txInfo.richDecodedInfo?.fragments + return ( { height={16} fallback="/images/transactions/custom.svg" /> - {type.text} + {humanDescription ? ( + + ) : tx.txInfo.type === TransactionInfoType.TRANSFER ? ( + + ) : ( + type.text + )} ) } diff --git a/src/components/transactions/TxType/styles.module.css b/src/components/transactions/TxType/styles.module.css index 17d5f0d140..2a8aa827b3 100644 --- a/src/components/transactions/TxType/styles.module.css +++ b/src/components/transactions/TxType/styles.module.css @@ -1,6 +1,7 @@ .txType { display: flex; align-items: center; + flex-wrap: wrap; gap: var(--space-1); color: var(--color-text-primary); } diff --git a/src/components/tx-flow/SafeTxProvider.tsx b/src/components/tx-flow/SafeTxProvider.tsx index cdc263d815..06c3b95574 100644 --- a/src/components/tx-flow/SafeTxProvider.tsx +++ b/src/components/tx-flow/SafeTxProvider.tsx @@ -4,6 +4,7 @@ import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { createTx } from '@/services/tx/tx-sender' import { useRecommendedNonce, useSafeTxGas } from '../tx/SignOrExecuteForm/hooks' import { Errors, logError } from '@/services/exceptions' +import useSafeInfo from '@/hooks/useSafeInfo' export const SafeTxContext = createContext<{ safeTx?: SafeTransaction @@ -35,12 +36,13 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => const [nonce, setNonce] = useState() const [nonceNeeded, setNonceNeeded] = useState(true) const [safeTxGas, setSafeTxGas] = useState() + const { safe } = useSafeInfo() // Signed txs cannot be updated const isSigned = safeTx && safeTx.signatures.size > 0 // Recommended nonce and safeTxGas - const recommendedNonce = useRecommendedNonce() + const recommendedNonce = Math.max(safe.nonce, useRecommendedNonce() ?? 0) const recommendedSafeTxGas = useSafeTxGas(safeTx) // Priority to external nonce, then to the recommended one diff --git a/src/components/tx-flow/common/TxButton.tsx b/src/components/tx-flow/common/TxButton.tsx index 032437807c..c0f03ac000 100644 --- a/src/components/tx-flow/common/TxButton.tsx +++ b/src/components/tx-flow/common/TxButton.tsx @@ -6,6 +6,8 @@ import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp' import { AppRoutes } from '@/config/routes' import Track from '@/components/common/Track' import { MODALS_EVENTS } from '@/services/analytics' +import { useContext } from 'react' +import { TxModalContext } from '..' const buttonSx = { height: '58px', @@ -24,11 +26,15 @@ export const SendTokensButton = ({ onClick, sx }: { onClick: () => void; sx?: Bu export const SendNFTsButton = () => { const router = useRouter() + const { setTxFlow } = useContext(TxModalContext) + + const isNftPage = router.pathname === AppRoutes.balances.nfts + const onClick = isNftPage ? () => setTxFlow(undefined) : undefined return ( - @@ -38,12 +44,18 @@ export const SendNFTsButton = () => { export const TxBuilderButton = () => { const txBuilder = useTxBuilderApp() + const router = useRouter() + const { setTxFlow } = useContext(TxModalContext) + if (!txBuilder?.app) return null + const isTxBuilder = typeof txBuilder.link.query === 'object' && router.query.appUrl === txBuilder.link.query?.appUrl + const onClick = isTxBuilder ? () => setTxFlow(undefined) : undefined + return ( - diff --git a/src/components/tx-flow/common/TxNonce/index.tsx b/src/components/tx-flow/common/TxNonce/index.tsx index c472641793..18f031f93c 100644 --- a/src/components/tx-flow/common/TxNonce/index.tsx +++ b/src/components/tx-flow/common/TxNonce/index.tsx @@ -59,7 +59,7 @@ const NonceFormOption = memo(function NonceFormOption({ const addressBook = useAddressBook() const transactions = useQueuedTxByNonce(Number(nonce)) - const label = useMemo(() => { + const txLabel = useMemo(() => { const latestTransactions = getLatestTransactions(transactions) if (latestTransactions.length === 0) { @@ -67,13 +67,15 @@ const NonceFormOption = memo(function NonceFormOption({ } const [{ transaction }] = latestTransactions - return getTransactionType(transaction, addressBook).text + return transaction.txInfo.humanDescription || `${getTransactionType(transaction, addressBook).text} transaction` }, [addressBook, transactions]) + const label = txLabel || 'New transaction' + return ( - {nonce} - {`${label || 'New'} transaction`} + {nonce} - {label} ) @@ -102,7 +104,10 @@ const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNo defaultValues: { [TxNonceFormFieldNames.NONCE]: nonce, }, - mode: 'all', + mode: 'onTouched', + values: { + [TxNonceFormFieldNames.NONCE]: nonce, + }, }) const resetNonce = () => { @@ -168,7 +173,7 @@ const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNo return ( <> {isRecommendedNonce && Recommended nonce} - {isInitialPreviousNonce && Already in queue} + {isInitialPreviousNonce && Replace existing} ) diff --git a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx index aa2272cbdf..69f1a35528 100644 --- a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx +++ b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx @@ -1,13 +1,26 @@ import { Methods } from '@safe-global/safe-apps-sdk' import * as web3 from '@/hooks/wallets/web3' +import * as useSafeInfo from '@/hooks/useSafeInfo' import { Web3Provider } from '@ethersproject/providers' import { render, screen } from '@/tests/test-utils' import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk' import ReviewSignMessageOnChain from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain' +import { hexZeroPad } from 'ethers/lib/utils' describe('ReviewSignMessageOnChain', () => { test('can handle messages with EIP712Domain type in the JSON-RPC payload', () => { jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => new Web3Provider(jest.fn())) + jest.spyOn(useSafeInfo, 'default').mockImplementation( + () => + ({ + safe: { + address: { + value: hexZeroPad('0x1', 20), + }, + version: '1.3.0', + } as ReturnType['safe'], + } as ReturnType), + ) render( getReadOnlySignMessageLibContract(chainId), [chainId]) + const readOnlySignMessageLibContract = useMemo( + () => getReadOnlySignMessageLibContract(chainId, safe.version), + [chainId, safe.version], + ) const signMessageAddress = readOnlySignMessageLibContract.getAddress() const [decodedMessage, readableMessage] = useMemo(() => { diff --git a/src/components/tx-flow/index.tsx b/src/components/tx-flow/index.tsx index 28e4bf8267..eebcbb0338 100644 --- a/src/components/tx-flow/index.tsx +++ b/src/components/tx-flow/index.tsx @@ -1,6 +1,7 @@ import { createContext, type ReactElement, type ReactNode, useState, useEffect, useCallback } from 'react' import TxModalDialog from '@/components/common/TxModalDialog' -import { useRouter } from 'next/router' +import { usePathname } from 'next/navigation' +import useSafeInfo from '@/hooks/useSafeInfo' const noop = () => {} @@ -21,7 +22,9 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle const [shouldWarn, setShouldWarn] = useState(true) const [, setOnClose] = useState[1]>(noop) const [fullWidth, setFullWidth] = useState(false) - const router = useRouter() + const pathname = usePathname() + const [, setLastPath] = useState(pathname) + const { safeAddress, safe } = useSafeInfo() const handleModalClose = useCallback(() => { setOnClose((prevOnClose) => { @@ -38,13 +41,10 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle } const ok = confirm('Closing this window will discard your current progress.') - if (!ok) { - router.events.emit('routeChangeError') - throw 'routeChange aborted. This error can be safely ignored - https://github.com/zeit/next.js/issues/2476.' + if (ok) { + handleModalClose() } - - handleModalClose() - }, [shouldWarn, handleModalClose, router]) + }, [shouldWarn, handleModalClose]) const setTxFlow = useCallback( (txFlow: TxModalContextType['txFlow'], onClose?: () => void, shouldWarn?: boolean) => { @@ -57,13 +57,20 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle // Show the confirmation dialog if user navigates useEffect(() => { - if (!txFlow) return + setLastPath((prev) => { + if (prev !== pathname && txFlow) { + handleShowWarning() + } + return pathname + }) + }, [txFlow, handleShowWarning, pathname]) - router.events.on('routeChangeStart', handleShowWarning) - return () => { - router.events.off('routeChangeStart', handleShowWarning) - } - }, [txFlow, handleShowWarning, router]) + // Close the modal when the Safe changes + useEffect(() => { + handleModalClose() + // Could have same address but different chain + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [safe.chainId, safeAddress]) return ( diff --git a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx index 4038f58e56..3afcb8ca1e 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx +++ b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx @@ -1,9 +1,9 @@ import { type ReactElement, type SyntheticEvent, useContext, useState } from 'react' -import { Button, CardActions, Divider } from '@mui/material' +import { Box, Button, CardActions, Divider } from '@mui/material' import classNames from 'classnames' import ErrorMessage from '@/components/tx/ErrorMessage' -import { logError, Errors } from '@/services/exceptions' +import { trackError, Errors } from '@/services/exceptions' import { useCurrentChain } from '@/hooks/useChains' import { getTxOptions } from '@/utils/transactions' import useIsValidExecution from '@/hooks/useIsValidExecution' @@ -26,6 +26,8 @@ import commonCss from '@/components/tx-flow/common/styles.module.css' import { TxSecurityContext } from '../security/shared/TxSecurityContext' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError' +import { useAppSelector } from '@/store' +import { selectQueuedTransactionById } from '@/store/txQueueSlice' const ExecuteForm = ({ safeTx, @@ -50,6 +52,8 @@ const ExecuteForm = ({ const { setTxFlow } = useContext(TxModalContext) const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = useContext(TxSecurityContext) + const tx = useAppSelector((state) => selectQueuedTransactionById(state, txId)) + // Check that the transaction is executable const isExecutionLoop = useIsExecutionLoop() @@ -85,11 +89,11 @@ const ExecuteForm = ({ const txOptions = getTxOptions(advancedParams, currentChain) try { - const executedTxId = await executeTx(txOptions, safeTx, txId, origin, willRelay) + const executedTxId = await executeTx(txOptions, safeTx, txId, origin, willRelay, tx) setTxFlow(, undefined, false) } catch (_err) { const err = asError(_err) - logError(Errors._804, err) + trackError(Errors._804, err) setIsSubmittable(true) setSubmitError(err) return @@ -133,17 +137,21 @@ const ExecuteForm = ({ Cannot execute a transaction from the Safe Account itself, please connect a different account. - ) : executionValidationError || gasLimitError ? ( - - This transaction will most likely fail. - {` To save gas costs, ${isCreation ? 'avoid creating' : 'reject'} this transaction.`} - ) : ( - submitError && ( - Error submitting the transaction. Please try again. + (executionValidationError || gasLimitError) && ( + + This transaction will most likely fail. + {` To save gas costs, ${isCreation ? 'avoid creating' : 'reject'} this transaction.`} + ) )} + {submitError && ( + + Error submitting the transaction. Please try again. + + )} + diff --git a/src/components/tx/SignOrExecuteForm/SignForm.tsx b/src/components/tx/SignOrExecuteForm/SignForm.tsx index 6dbb3a7f93..7f9b48675f 100644 --- a/src/components/tx/SignOrExecuteForm/SignForm.tsx +++ b/src/components/tx/SignOrExecuteForm/SignForm.tsx @@ -2,7 +2,7 @@ import { type ReactElement, type SyntheticEvent, useContext, useState } from 're import { Box, Button, CardActions, Divider } from '@mui/material' import ErrorMessage from '@/components/tx/ErrorMessage' -import { logError, Errors } from '@/services/exceptions' +import { trackError, Errors } from '@/services/exceptions' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import CheckWallet from '@/components/common/CheckWallet' import { useAlreadySigned, useTxActions } from './hooks' @@ -14,6 +14,8 @@ import commonCss from '@/components/tx-flow/common/styles.module.css' import { TxSecurityContext } from '../security/shared/TxSecurityContext' import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError' import BatchButton from './BatchButton' +import { useAppSelector } from '@/store' +import { selectQueuedTransactionById } from '@/store/txQueueSlice' const SignForm = ({ safeTx, @@ -38,6 +40,8 @@ const SignForm = ({ const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = useContext(TxSecurityContext) const hasSigned = useAlreadySigned(safeTx) + const tx = useAppSelector((state) => selectQueuedTransactionById(state, txId)) + // On modal submit const handleSubmit = async (e: SyntheticEvent, isAddingToBatch = false) => { e.preventDefault() @@ -53,10 +57,10 @@ const SignForm = ({ setSubmitError(undefined) try { - await (isAddingToBatch ? addToBatch(safeTx, origin) : signTx(safeTx, txId, origin)) + await (isAddingToBatch ? addToBatch(safeTx, origin) : signTx(safeTx, txId, origin, tx)) } catch (_err) { const err = asError(_err) - logError(Errors._804, err) + trackError(Errors._805, err) setIsSubmittable(true) setSubmitError(err) return diff --git a/src/components/tx/SignOrExecuteForm/hooks.ts b/src/components/tx/SignOrExecuteForm/hooks.ts index bd3895e15b..a7ca048452 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.ts @@ -18,16 +18,18 @@ import type { OnboardAPI } from '@web3-onboard/core' import { getSafeTxGas, getRecommendedNonce } from '@/services/tx/tx-sender/recommendedNonce' import useAsync from '@/hooks/useAsync' import { useUpdateBatch } from '@/hooks/useDraftBatch' +import { type Transaction, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' type TxActions = { addToBatch: (safeTx?: SafeTransaction, origin?: string) => Promise - signTx: (safeTx?: SafeTransaction, txId?: string, origin?: string) => Promise + signTx: (safeTx?: SafeTransaction, txId?: string, origin?: string, transaction?: Transaction) => Promise executeTx: ( txOptions: TransactionOptions, safeTx?: SafeTransaction, txId?: string, origin?: string, isRelayed?: boolean, + transaction?: Transaction, ) => Promise } @@ -83,50 +85,55 @@ export const useTxActions = (): TxActions => { return await dispatchTxSigning(safeTx, version, onboard, chainId, txId) } - const signTx: TxActions['signTx'] = async (safeTx, txId, origin) => { + const signTx: TxActions['signTx'] = async (safeTx, txId, origin, transaction) => { assertTx(safeTx) assertWallet(wallet) assertOnboard(onboard) + const humanDescription = transaction?.transaction?.txInfo?.humanDescription + // Smart contract wallets must sign via an on-chain tx if (await isSmartContractWallet(wallet)) { // 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 proposeTx(wallet.address, safeTx, txId, origin)).txId - await dispatchOnChainSigning(safeTx, id, onboard, chainId) + await dispatchOnChainSigning(safeTx, id, onboard, chainId, humanDescription) return id } // Otherwise, sign off-chain - const signedTx = await dispatchTxSigning(safeTx, version, onboard, chainId, txId) + const signedTx = await dispatchTxSigning(safeTx, version, onboard, chainId, txId, humanDescription) const tx = await proposeTx(wallet.address, signedTx, txId, origin) return tx.txId } - const executeTx: TxActions['executeTx'] = async (txOptions, safeTx, txId, origin, isRelayed) => { + const executeTx: TxActions['executeTx'] = async (txOptions, safeTx, txId, origin, isRelayed, transaction) => { assertTx(safeTx) assertWallet(wallet) assertOnboard(onboard) + let tx: TransactionDetails | undefined // Relayed transactions must be fully signed, so request a final signature if needed if (isRelayed && safeTx.signatures.size < safe.threshold) { safeTx = await signRelayedTx(safeTx) - const tx = await proposeTx(wallet.address, safeTx, txId, origin) + tx = await proposeTx(wallet.address, safeTx, txId, origin) txId = tx.txId } // Propose the tx if there's no id yet ("immediate execution") if (!txId) { - const tx = await proposeTx(wallet.address, safeTx, txId, origin) + tx = await proposeTx(wallet.address, safeTx, txId, origin) txId = tx.txId } + const humanDescription = tx?.txInfo?.humanDescription || transaction?.transaction?.txInfo?.humanDescription + // Relay or execute the tx via connected wallet if (isRelayed) { - await dispatchTxRelay(safeTx, safe, txId, txOptions.gasLimit) + await dispatchTxRelay(safeTx, safe, txId, txOptions.gasLimit, humanDescription) } else { - await dispatchTxExecution(safeTx, txOptions, txId, onboard, chainId, safeAddress) + await dispatchTxExecution(safeTx, txOptions, txId, onboard, chainId, safeAddress, humanDescription) } return txId diff --git a/src/components/welcome/NewSafe.tsx b/src/components/welcome/NewSafe.tsx index 07cd05dee3..9387296884 100644 --- a/src/components/welcome/NewSafe.tsx +++ b/src/components/welcome/NewSafe.tsx @@ -59,11 +59,25 @@ const NewSafe = () => { - + + + + + +
diff --git a/src/config/constants.ts b/src/config/constants.ts index 16c50fe958..7da89ed2ec 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -62,9 +62,9 @@ export enum SafeAppsTag { // Safe Gelato relay service export const SAFE_RELAY_SERVICE_URL_PRODUCTION = - process.env.NEXT_PUBLIC_SAFE_RELAY_SERVICE_URL_PRODUCTION || 'https://safe-client-nest.safe.global/v1/relay' + process.env.NEXT_PUBLIC_SAFE_RELAY_SERVICE_URL_PRODUCTION || 'https://safe-client.safe.global/v1/relay' export const SAFE_RELAY_SERVICE_URL_STAGING = - process.env.NEXT_PUBLIC_SAFE_RELAY_SERVICE_URL_STAGING || 'https://safe-client-nest.staging.5afe.dev/v1/relay' + process.env.NEXT_PUBLIC_SAFE_RELAY_SERVICE_URL_STAGING || 'https://safe-client.staging.5afe.dev/v1/relay' // Help Center export const HELP_CENTER_URL = 'https://help.safe.global' diff --git a/src/hooks/useBeamer.ts b/src/hooks/Beamer/useBeamer.ts similarity index 91% rename from src/hooks/useBeamer.ts rename to src/hooks/Beamer/useBeamer.ts index 96c774fd68..b6af7e3cf6 100644 --- a/src/hooks/useBeamer.ts +++ b/src/hooks/Beamer/useBeamer.ts @@ -4,12 +4,15 @@ import { useAppSelector } from '@/store' import { CookieType, selectCookies } from '@/store/cookiesSlice' import { loadBeamer, unloadBeamer, updateBeamer } from '@/services/beamer' import { useCurrentChain } from '@/hooks/useChains' +import { useBeamerNps } from '@/hooks/Beamer/useBeamerNps' const useBeamer = () => { const cookies = useAppSelector(selectCookies) const isBeamerEnabled = cookies[CookieType.UPDATES] const chain = useCurrentChain() + useBeamerNps() + useEffect(() => { if (!chain?.shortName) { return diff --git a/src/hooks/Beamer/useBeamerNps.ts b/src/hooks/Beamer/useBeamerNps.ts new file mode 100644 index 0000000000..e51138aa65 --- /dev/null +++ b/src/hooks/Beamer/useBeamerNps.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react' + +import { TxEvent, txSubscribe } from '@/services/tx/txEvents' +import { useAppSelector } from '@/store' +import { selectCookies, CookieType } from '@/store/cookiesSlice' +import { shouldShowBeamerNps } from '@/services/beamer' + +export const useBeamerNps = (): void => { + const cookies = useAppSelector(selectCookies) + const isBeamerEnabled = cookies[CookieType.UPDATES] + + useEffect(() => { + if (typeof window === 'undefined' || !isBeamerEnabled) { + return + } + + const unsubscribe = txSubscribe(TxEvent.PROPOSED, () => { + // Cannot check at the top of effect as Beamer may not have loaded yet + if (shouldShowBeamerNps()) { + // We "force" the NPS banner as we have it globally disabled in Beamer to prevent it + // randomly showing on pages that we don't want it to + // Note: this is not documented but confirmed by Beamer support + window.Beamer?.forceShowNPS() + } + + unsubscribe() + }) + + return unsubscribe + }, [isBeamerEnabled]) +} diff --git a/src/hooks/__tests__/useChainId.test.ts b/src/hooks/__tests__/useChainId.test.ts index 48abbf2bbc..9ebf467db8 100644 --- a/src/hooks/__tests__/useChainId.test.ts +++ b/src/hooks/__tests__/useChainId.test.ts @@ -1,4 +1,4 @@ -import { useRouter } from 'next/router' +import { useParams } from 'next/navigation' import useChainId from '@/hooks/useChainId' import { useAppDispatch } from '@/store' import { setLastChainId } from '@/store/sessionSlice' @@ -9,18 +9,14 @@ import type { ConnectedWallet } from '@/services/onboard' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' // mock useRouter -jest.mock('next/router', () => ({ - useRouter: jest.fn(() => ({ - query: {}, - })), +jest.mock('next/navigation', () => ({ + useParams: jest.fn(() => ({})), })) describe('useChainId hook', () => { // Reset mocks before each test beforeEach(() => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) Object.defineProperty(window, 'location', { writable: true, @@ -29,9 +25,7 @@ describe('useChainId hook', () => { }) it('should read location.pathname if useRouter query.safe is empty', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) Object.defineProperty(window, 'location', { writable: true, @@ -47,9 +41,7 @@ describe('useChainId hook', () => { }) it('should read location.search if useRouter query.safe is empty', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) Object.defineProperty(window, 'location', { writable: true, @@ -65,9 +57,7 @@ describe('useChainId hook', () => { }) it('should read location.search if useRouter query.chain is empty', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) Object.defineProperty(window, 'location', { writable: true, @@ -88,10 +78,8 @@ describe('useChainId hook', () => { }) it('should return the chainId based on the chain query', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: { - chain: 'gno', - }, + ;(useParams as any).mockImplementation(() => ({ + chain: 'gno', })) const { result } = renderHook(() => useChainId()) @@ -99,10 +87,8 @@ describe('useChainId hook', () => { }) it('should return the chainId from the safe address', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: { - safe: 'matic:0x0000000000000000000000000000000000000000', - }, + ;(useParams as any).mockImplementation(() => ({ + safe: 'matic:0x0000000000000000000000000000000000000000', })) const { result } = renderHook(() => useChainId()) @@ -110,9 +96,7 @@ describe('useChainId hook', () => { }) it('should return the wallet chain id if no chain in the URL and it is present in the chain configs', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) jest.spyOn(useWalletHook, 'default').mockImplementation( () => @@ -130,9 +114,7 @@ describe('useChainId hook', () => { }) it('should return the last used chain id if no chain in the URL and the connect wallet chain id is not present in the chain configs', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) jest.spyOn(useWalletHook, 'default').mockImplementation( () => @@ -150,9 +132,7 @@ describe('useChainId hook', () => { }) it('should return the last used chain id if no wallet is connected and there is no chain in the URL', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) renderHook(() => useAppDispatch()(setLastChainId('100'))) diff --git a/src/hooks/__tests__/useSafeTokenAllocation.test.ts b/src/hooks/__tests__/useSafeTokenAllocation.test.ts index c6d3c22dba..1eef4008f6 100644 --- a/src/hooks/__tests__/useSafeTokenAllocation.test.ts +++ b/src/hooks/__tests__/useSafeTokenAllocation.test.ts @@ -1,6 +1,11 @@ import { renderHook, waitFor } from '@/tests/test-utils' import { defaultAbiCoder, hexZeroPad, keccak256, parseEther, toUtf8Bytes } from 'ethers/lib/utils' -import useSafeTokenAllocation, { type VestingData, _getRedeemDeadline } from '../useSafeTokenAllocation' +import useSafeTokenAllocation, { + type VestingData, + _getRedeemDeadline, + useSafeVotingPower, + type Vesting, +} from '../useSafeTokenAllocation' import * as web3 from '../wallets/web3' import * as useSafeInfoHook from '@/hooks/useSafeInfo' import { ZERO_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' @@ -55,8 +60,7 @@ describe('_getRedeemDeadline', () => { }) }) -// TODO: use mockWeb3Provider() -describe('useSafeTokenAllocation', () => { +describe('Allocations', () => { afterEach(() => { //@ts-ignore global.fetch?.mockClear?.() @@ -84,361 +88,302 @@ describe('useSafeTokenAllocation', () => { ) }) - test('return undefined without safe address', async () => { - jest.spyOn(useSafeInfoHook, 'default').mockImplementation( - () => - ({ - safeAddress: undefined, - safe: { - address: undefined, - chainId: '1', - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(result.current[1]).toBeFalsy() - expect(result.current[0]).toBeUndefined() + describe('useSafeTokenAllocation', () => { + it('should return undefined without safe address', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safeAddress: undefined, + safe: { + address: undefined, + chainId: '1', + }, + } as any), + ) + + const { result } = renderHook(() => useSafeTokenAllocation()) + + await waitFor(() => { + expect(result.current[1]).toBeFalsy() + expect(result.current[0]).toBeUndefined() + }) }) - }) - test('return 0 without web3Provider', async () => { - global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) - const { result } = renderHook(() => useSafeTokenAllocation()) + it('should return an empty array without web3Provider', async () => { + global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) + const { result } = renderHook(() => useSafeTokenAllocation()) - await waitFor(() => { - expect(result.current[1]).toBeFalsy() - expect(result.current[0]?.toNumber()).toEqual(0) + await waitFor(() => { + expect(result.current[1]).toBeFalsy() + expect(result.current[0]).toStrictEqual([]) + }) }) - }) - test('return 0 if no allocations / balances exist', async () => { - global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) - const mockFetch = jest.spyOn(global, 'fetch') - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const sigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - if (transaction.data?.startsWith(sigHash)) { - return Promise.resolve('0x0') - } - return Promise.resolve('0x') - }, - } as any), - ) + it('should return an empty array if no allocations exist', async () => { + global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) + const mockFetch = jest.spyOn(global, 'fetch') - const { result } = renderHook(() => useSafeTokenAllocation()) + const { result } = renderHook(() => useSafeTokenAllocation()) - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(0) - expect(result.current[1]).toBeFalsy() + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + expect(result.current[0]).toStrictEqual([]) + expect(result.current[1]).toBeFalsy() + }) }) - }) - test('return balance if no allocation exists', async () => { - global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const sigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - if (transaction.data?.startsWith(sigHash)) { - return Promise.resolve(parseEther('100').toHexString()) - } - return Promise.resolve('0x') + it('should calculate expiration', async () => { + const mockAllocations = [ + { + tag: 'user', + account: hexZeroPad('0x2', 20), + chainId: 1, + contract: hexZeroPad('0xabc', 20), + vestingId: hexZeroPad('0x4110', 32), + durationWeeks: 208, + startDate: 1657231200, + amount: '2000', + curve: 0, + proof: [], + }, + ] + + global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocations, 200)) + const mockFetch = jest.spyOn(global, 'fetch') + + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) + const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) + + if (transaction.data?.startsWith(vestingsSigHash)) { + return Promise.resolve( + defaultAbiCoder.encode( + ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], + [ZERO_ADDRESS, '0x1', false, 208, 1657231200, 2000, 0, 0, false], + ), + ) + } + if (transaction.data?.startsWith(redeemDeadlineSigHash)) { + // 30th Nov 2022 + return Promise.resolve(defaultAbiCoder.encode(['uint64'], [1669766400])) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const { result } = renderHook(() => useSafeTokenAllocation()) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + expect(result.current[0]).toEqual([ + { + ...mockAllocations[0], + amountClaimed: '0', + isExpired: true, + isRedeemed: false, }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.eq(parseEther('100'))).toBeTruthy() - expect(result.current[1]).toBeFalsy() + ]) + expect(result.current[1]).toBeFalsy() + }) }) - }) - test('always return allocation if it is rededeemed', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(parseEther('0').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - [hexZeroPad('0x2', 20), '0x1', false, 208, 1657231200, 2000, 0, 0, false], - ), - ) - } - return Promise.resolve('0x') + it('should calculate redemption', async () => { + const mockAllocation = [ + { + tag: 'user', + account: hexZeroPad('0x2', 20), + chainId: 1, + contract: hexZeroPad('0xabc', 20), + vestingId: hexZeroPad('0x4110', 32), + durationWeeks: 208, + startDate: 1657231200, + amount: '2000', + curve: 0, + proof: [], + }, + ] + + global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) + const mockFetch = jest.spyOn(global, 'fetch') + + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) + const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) + + if (transaction.data?.startsWith(vestingsSigHash)) { + return Promise.resolve( + defaultAbiCoder.encode( + ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], + [hexZeroPad('0x2', 20), '0x1', false, 208, 1657231200, 2000, 0, 0, false], + ), + ) + } + if (transaction.data?.startsWith(redeemDeadlineSigHash)) { + // 08.Dec 2200 + return Promise.resolve(defaultAbiCoder.encode(['uint64'], [7287610110])) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const { result } = renderHook(() => useSafeTokenAllocation()) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + expect(result.current[0]).toEqual([ + { + ...mockAllocation[0], + amountClaimed: BigNumber.from(0), + isExpired: false, + isRedeemed: true, }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(2000) - expect(result.current[1]).toBeFalsy() + ]) + expect(result.current[1]).toBeFalsy() + }) }) }) - test('ignore not redeemed allocations if deadline has passed', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(parseEther('0').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - [ZERO_ADDRESS, 0, false, 0, 0, 0, 0, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 30th Nov 2022 - return Promise.resolve(defaultAbiCoder.encode(['uint64'], [1669766400])) - } - return Promise.resolve('0x') - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) + describe('useSafeTokenBalance', () => { + it('should return undefined without allocation data', async () => { + const { result } = renderHook(() => useSafeVotingPower()) - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(0) - expect(result.current[1]).toBeFalsy() + await waitFor(() => { + expect(result.current[1]).toBeFalsy() + expect(result.current[0]).toBeUndefined() + }) }) - }) - test('add not redeemed allocations if deadline has not passed', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(parseEther('0').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - [ZERO_ADDRESS, 0, false, 0, 0, 0, 0, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 08.Dec 2200 - return Promise.resolve(defaultAbiCoder.encode(['uint64'], [7287610110])) - } - return Promise.resolve('0x') - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(2000) - expect(result.current[1]).toBeFalsy() + it('should return undefined without safe address', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safeAddress: undefined, + safe: { + address: undefined, + chainId: '1', + }, + } as any), + ) + + const { result } = renderHook(() => useSafeVotingPower([{} as Vesting])) + + await waitFor(() => { + expect(result.current[1]).toBeFalsy() + expect(result.current[0]).toBeUndefined() + }) }) - }) - test('test formula: allocation - claimed + balance', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(BigNumber.from('400').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - // 1000 of 2000 tokens are claimed - [hexZeroPad('0x2', 20), '0x1', false, 208, 1657231200, 2000, 1000, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 08.Dec 2200 - return Promise.resolve(defaultAbiCoder.encode(['uint64'], [7287610110])) - } - return Promise.resolve('0x') - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(2000 - 1000 + 400) - expect(result.current[1]).toBeFalsy() + it('should return balance if no allocation exists', async () => { + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const sigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + + if (transaction.data?.startsWith(sigHash)) { + return Promise.resolve(parseEther('100').toHexString()) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const { result } = renderHook(() => useSafeVotingPower()) + + await waitFor(() => { + expect(result.current[0]?.eq(parseEther('100'))).toBeTruthy() + expect(result.current[1]).toBeFalsy() + }) }) - }) - test('test formula: allocation - claimed + balance, everything claimed and no balance', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(BigNumber.from('0').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - // 1000 of 2000 tokens are claimed - [hexZeroPad('0x2', 20), '0x1', false, 208, 1657231200, 2000, 2000, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 08.Dec 2200 - return Promise.resolve(defaultAbiCoder.encode(['uint64'], [7287610110])) - } - return Promise.resolve('0x') - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) + test('formula: allocation - claimed + balance', async () => { + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + + if (transaction.data?.startsWith(balanceOfSigHash)) { + return Promise.resolve(BigNumber.from('400').toHexString()) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const mockAllocation: Vesting[] = [ + { + tag: 'user', + account: hexZeroPad('0x2', 20), + chainId: 1, + contract: hexZeroPad('0xabc', 20), + vestingId: hexZeroPad('0x4110', 32), + durationWeeks: 208, + startDate: 1657231200, + amount: '2000', + curve: 0, + proof: [], + isExpired: false, + isRedeemed: false, + amountClaimed: '1000', + }, + ] + + const { result } = renderHook(() => useSafeVotingPower(mockAllocation)) + + await waitFor(() => { + expect(result.current[0]?.toNumber()).toEqual(2000 - 1000 + 400) + expect(result.current[1]).toBeFalsy() + }) + }) - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(0) - expect(result.current[1]).toBeFalsy() + test('formula: allocation - claimed + balance, everything claimed and no balance', async () => { + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + + if (transaction.data?.startsWith(balanceOfSigHash)) { + return Promise.resolve(BigNumber.from('0').toHexString()) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const mockAllocation: Vesting[] = [ + { + tag: 'user', + account: hexZeroPad('0x2', 20), + chainId: 1, + contract: hexZeroPad('0xabc', 20), + vestingId: hexZeroPad('0x4110', 32), + durationWeeks: 208, + startDate: 1657231200, + amount: '2000', + curve: 0, + proof: [], + isExpired: false, + isRedeemed: false, + amountClaimed: '2000', + }, + ] + + const { result } = renderHook(() => useSafeVotingPower(mockAllocation)) + + await waitFor(() => { + expect(result.current[0]?.toNumber()).toEqual(0) + expect(result.current[1]).toBeFalsy() + }) }) }) }) diff --git a/src/hooks/useChainId.ts b/src/hooks/useChainId.ts index cb95e7ebd3..8be8d525ff 100644 --- a/src/hooks/useChainId.ts +++ b/src/hooks/useChainId.ts @@ -1,4 +1,4 @@ -import { useRouter } from 'next/router' +import { useParams } from 'next/navigation' import { parse, type ParsedUrlQuery } from 'querystring' import { IS_PRODUCTION } from '@/config/constants' import chains from '@/config/chains' @@ -31,11 +31,11 @@ const getLocationQuery = (): ParsedUrlQuery => { } export const useUrlChainId = (): string | undefined => { - const router = useRouter() + const queryParams = useParams() const { configs } = useChains() // Dynamic query params - const query = router && (router.query.safe || router.query.chain) ? router.query : getLocationQuery() + const query = queryParams && (queryParams.safe || queryParams.chain) ? queryParams : getLocationQuery() const chain = query.chain?.toString() || '' const safe = query.safe?.toString() || '' diff --git a/src/hooks/useSafeTokenAllocation.ts b/src/hooks/useSafeTokenAllocation.ts index 4712cd134c..b78b81ee24 100644 --- a/src/hooks/useSafeTokenAllocation.ts +++ b/src/hooks/useSafeTokenAllocation.ts @@ -6,7 +6,7 @@ import { isPast } from 'date-fns' import { BigNumber } from 'ethers' import { defaultAbiCoder, Interface } from 'ethers/lib/utils' import { useMemo } from 'react' -import useAsync from './useAsync' +import useAsync, { type AsyncResult } from './useAsync' import useSafeInfo from './useSafeInfo' import { getWeb3ReadOnly } from './wallets/web3' import { memoize } from 'lodash' @@ -30,7 +30,7 @@ export type VestingData = { proof: string[] } -type Vesting = VestingData & { +export type Vesting = VestingData & { isExpired: boolean isRedeemed: boolean amountClaimed: string @@ -107,6 +107,22 @@ const fetchAllocation = async (chainId: string, safeAddress: string): Promise => { + const { safe, safeAddress } = useSafeInfo() + const chainId = safe.chainId + + return useAsync(async () => { + if (!safeAddress) return + return Promise.all( + await fetchAllocation(chainId, safeAddress).then((allocations) => + allocations.map((allocation) => completeAllocation(allocation)), + ), + ) + // If the history tag changes we could have claimed / redeemed tokens + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chainId, safeAddress, safe.txHistoryTag]) +} + const fetchTokenBalance = async (chainId: string, safeAddress: string): Promise => { try { const web3ReadOnly = getWeb3ReadOnly() @@ -126,22 +142,11 @@ const fetchTokenBalance = async (chainId: string, safeAddress: string): Promise< * The Safe token allocation is equal to the voting power. * It is computed by adding all vested tokens - claimed tokens + token balance */ -const useSafeTokenAllocation = (): [BigNumber | undefined, boolean] => { +export const useSafeVotingPower = (allocationData?: Vesting[]): AsyncResult => { const { safe, safeAddress } = useSafeInfo() const chainId = safe.chainId - const [allocationData, _, allocationLoading] = useAsync(async () => { - if (!safeAddress) return - return Promise.all( - await fetchAllocation(chainId, safeAddress).then((allocations) => - allocations.map((allocation) => completeAllocation(allocation)), - ), - ) - // If the history tag changes we could have claimed / redeemed tokens - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chainId, safeAddress, safe.txHistoryTag]) - - const [balance, _error, balanceLoading] = useAsync(() => { + const [balance, balanceError, balanceLoading] = useAsync(() => { if (!safeAddress) return return fetchTokenBalance(chainId, safeAddress) // If the history tag changes we could have claimed / redeemed tokens @@ -149,7 +154,14 @@ const useSafeTokenAllocation = (): [BigNumber | undefined, boolean] => { }, [chainId, safeAddress, safe.txHistoryTag]) const allocation = useMemo(() => { - if (!allocationData || !balance) return + if (!balance) { + return + } + + // Return current balance if no allocation exists + if (!allocationData) { + return BigNumber.from(balance) + } const tokensInVesting = allocationData.reduce( (acc, data) => (data.isExpired ? acc : acc.add(data.amount).sub(data.amountClaimed)), @@ -157,11 +169,11 @@ const useSafeTokenAllocation = (): [BigNumber | undefined, boolean] => { ) // add balance - const totalAllocation = tokensInVesting.add(balance || '0') + const totalAllocation = tokensInVesting.add(BigNumber.from(balance)) return totalAllocation }, [allocationData, balance]) - return [allocation, allocationLoading || balanceLoading] + return [allocation, balanceError, balanceLoading] } export default useSafeTokenAllocation diff --git a/src/hooks/useTxNotifications.ts b/src/hooks/useTxNotifications.ts index cbe777b785..a2f1e12e3e 100644 --- a/src/hooks/useTxNotifications.ts +++ b/src/hooks/useTxNotifications.ts @@ -16,23 +16,20 @@ import useSafeAddress from './useSafeAddress' import { getExplorerLink } from '@/utils/gateway' const TxNotifications = { - [TxEvent.SIGN_FAILED]: 'Signature failed. Please try again.', - [TxEvent.PROPOSED]: 'Your transaction was successfully proposed.', - [TxEvent.PROPOSE_FAILED]: 'Failed proposing the transaction. Please try again.', - [TxEvent.SIGNATURE_PROPOSED]: 'You successfully signed the transaction.', - [TxEvent.SIGNATURE_PROPOSE_FAILED]: 'Failed to send the signature. Please try again.', - [TxEvent.EXECUTING]: 'Please confirm the execution in your wallet.', - [TxEvent.PROCESSING]: 'Your transaction is being processed.', - [TxEvent.PROCESSING_MODULE]: - 'Your transaction has been submitted and will appear in the interface only after it has been successfully processed and indexed.', - [TxEvent.ONCHAIN_SIGNATURE_REQUESTED]: - 'An on-chain signature is required. Please confirm the transaction in your wallet.', - [TxEvent.ONCHAIN_SIGNATURE_SUCCESS]: - "The on-chain signature request was confirmed. Once it's on chain, the transaction will be signed.", - [TxEvent.PROCESSED]: 'Your transaction was successfully processed and is now being indexed.', - [TxEvent.REVERTED]: 'Transaction reverted. Please check your gas settings.', - [TxEvent.SUCCESS]: 'Your transaction was successfully executed.', - [TxEvent.FAILED]: 'Your transaction was unsuccessful.', + [TxEvent.SIGN_FAILED]: 'Failed to sign. Please try again.', + [TxEvent.PROPOSED]: 'Successfully added to queue.', + [TxEvent.PROPOSE_FAILED]: 'Failed to add to queue. Please try again.', + [TxEvent.SIGNATURE_PROPOSED]: 'Successfully signed.', + [TxEvent.SIGNATURE_PROPOSE_FAILED]: 'Failed to send signature. Please try again.', + [TxEvent.EXECUTING]: 'Confirm the execution in your wallet.', + [TxEvent.PROCESSING]: 'Validating...', + [TxEvent.PROCESSING_MODULE]: 'Validating module interaction...', + [TxEvent.ONCHAIN_SIGNATURE_REQUESTED]: 'Confirm on-chain signature in your wallet.', + [TxEvent.ONCHAIN_SIGNATURE_SUCCESS]: 'On-chain signature request confirmed.', + [TxEvent.PROCESSED]: 'Successfully validated. Indexing...', + [TxEvent.REVERTED]: 'Reverted. Please check your gas settings.', + [TxEvent.SUCCESS]: 'Successfully executed.', + [TxEvent.FAILED]: 'Failed.', } enum Variant { @@ -79,9 +76,12 @@ const useTxNotifications = (): void => { const txId = 'txId' in detail ? detail.txId : undefined const txHash = 'txHash' in detail ? detail.txHash : undefined const groupKey = 'groupKey' in detail && detail.groupKey ? detail.groupKey : txId || '' + const humanDescription = + 'humanDescription' in detail && detail.humanDescription ? detail.humanDescription : 'Transaction' dispatch( showNotification({ + title: humanDescription, message, detailedMessage: isError ? detail.error.message : undefined, groupKey, diff --git a/src/hooks/wallets/useInitWeb3.ts b/src/hooks/wallets/useInitWeb3.ts index 04dc3da0a6..1015214a39 100644 --- a/src/hooks/wallets/useInitWeb3.ts +++ b/src/hooks/wallets/useInitWeb3.ts @@ -6,33 +6,29 @@ import { createWeb3, createWeb3ReadOnly, setWeb3, setWeb3ReadOnly } from '@/hook import { useAppSelector } from '@/store' import { selectRpc } from '@/store/settingsSlice' -const READONLY_WAIT = 1000 - export const useInitWeb3 = () => { const chain = useCurrentChain() + const chainId = chain?.chainId + const rpcUri = chain?.rpcUri const wallet = useWallet() const customRpc = useAppSelector(selectRpc) const customRpcUrl = chain ? customRpc?.[chain.chainId] : undefined useEffect(() => { - if (!chain) return - - if (wallet) { + if (wallet && wallet.chainId === chainId) { const web3 = createWeb3(wallet.provider) setWeb3(web3) - - if (wallet.chainId === chain.chainId) { - setWeb3ReadOnly(web3) - return - } + } else { + setWeb3(undefined) } + }, [wallet, chainId]) - // Wait for wallet to be connected - const timeout = setTimeout(() => { - const web3ReadOnly = createWeb3ReadOnly(chain.rpcUri, customRpcUrl) - setWeb3ReadOnly(web3ReadOnly) - }, READONLY_WAIT) - - return () => clearTimeout(timeout) - }, [wallet, chain, customRpcUrl]) + useEffect(() => { + if (!rpcUri) { + setWeb3ReadOnly(undefined) + return + } + const web3ReadOnly = createWeb3ReadOnly(rpcUri, customRpcUrl) + setWeb3ReadOnly(web3ReadOnly) + }, [rpcUri, customRpcUrl]) } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 602e6f0da9..9351fa4eff 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -28,7 +28,7 @@ import { cgwDebugStorage } from '@/components/sidebar/DebugToggle' import { useTxTracking } from '@/hooks/useTxTracking' import { useSafeMsgTracking } from '@/hooks/messages/useSafeMsgTracking' import useGtm from '@/services/analytics/useGtm' -import useBeamer from '@/hooks/useBeamer' +import useBeamer from '@/hooks/Beamer/useBeamer' import ErrorBoundary from '@/components/common/ErrorBoundary' import createEmotionCache from '@/utils/createEmotionCache' import MetaTags from '@/components/common/MetaTags' diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index 5cdb7912e6..3fae0cbbf9 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -88,4 +88,8 @@ export const OVERVIEW_EVENTS = { action: 'Open relaying help article', category: OVERVIEW_CATEGORY, }, + SEP5_ALLOCATION_BUTTON: { + action: 'Click on SEP5 allocation button', + category: OVERVIEW_CATEGORY, + }, } diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index 232a247766..cbd4029111 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -43,6 +43,7 @@ const GTM_ENV_AUTH: Record = { const commonEventParams = { chainId: '', deviceType: DeviceType.DESKTOP, + safeAddress: '', } export const gtmSetChainId = (chainId: string): void => { @@ -53,6 +54,10 @@ export const gtmSetDeviceType = (type: DeviceType): void => { commonEventParams.deviceType = type } +export const gtmSetSafeAddress = (safeAddress: string): void => { + commonEventParams.safeAddress = safeAddress.slice(2) +} + export const gtmInit = (): void => { const GTM_ENVIRONMENT = IS_PRODUCTION ? GTM_ENV_AUTH.LIVE : GTM_ENV_AUTH.DEVELOPMENT diff --git a/src/services/analytics/useGtm.ts b/src/services/analytics/useGtm.ts index d17d097e68..5f5164c1e5 100644 --- a/src/services/analytics/useGtm.ts +++ b/src/services/analytics/useGtm.ts @@ -12,6 +12,7 @@ import { gtmEnableCookies, gtmDisableCookies, gtmSetDeviceType, + gtmSetSafeAddress, } from '@/services/analytics/gtm' import { useAppSelector } from '@/store' import { CookieType, selectCookies } from '@/store/cookiesSlice' @@ -21,6 +22,7 @@ import { AppRoutes } from '@/config/routes' import useMetaEvents from './useMetaEvents' import { useMediaQuery } from '@mui/material' import { DeviceType } from './types' +import useSafeAddress from '@/hooks/useSafeAddress' const useGtm = () => { const chainId = useChainId() @@ -32,6 +34,7 @@ const useGtm = () => { const isMobile = useMediaQuery(theme.breakpoints.down('sm')) const isTablet = useMediaQuery(theme.breakpoints.down('md')) const deviceType = isMobile ? DeviceType.MOBILE : isTablet ? DeviceType.TABLET : DeviceType.DESKTOP + const safeAddress = useSafeAddress() // Initialize GTM useEffect(() => { @@ -63,6 +66,11 @@ const useGtm = () => { gtmSetDeviceType(deviceType) }, [deviceType]) + // Set safe address for all GTM events + useEffect(() => { + gtmSetSafeAddress(safeAddress) + }, [safeAddress]) + // Track page views – anononimized by default. // Sensitive info, like the safe address or tx id, is always in the query string, which we DO NOT track. useEffect(() => { diff --git a/src/services/beamer/index.ts b/src/services/beamer/index.ts index 6174c5b89e..edcb4afe69 100644 --- a/src/services/beamer/index.ts +++ b/src/services/beamer/index.ts @@ -65,6 +65,7 @@ export const unloadBeamer = (): void => { '_BEAMER_FILTER_BY_URL_', '_BEAMER_LAST_UPDATE_', '_BEAMER_BOOSTED_ANNOUNCEMENT_DATE_', + '_BEAMER_NPS_LAST_SHOWN_', ] if (!window?.Beamer || !scriptRef) { @@ -82,3 +83,16 @@ export const unloadBeamer = (): void => { BEAMER_COOKIES.forEach((name) => Cookies.remove(name, { domain, path: '/' })) }, 100) } + +export const shouldShowBeamerNps = (): boolean => { + if (!isBeamerLoaded() || !window?.Beamer) { + return false + } + + const COOKIE_NAME = `_BEAMER_NPS_LAST_SHOWN_${BEAMER_ID}` + + // Beamer advise using their '/nps/check' endpoint to see if the NPS should be shown + // As we need to check this more than the request limit, we instead check the cookie + // @see https://www.getbeamer.com/api + return !window.Beamer.getCookie(COOKIE_NAME) +} diff --git a/src/services/contracts/__tests__/deployments.test.ts b/src/services/contracts/__tests__/deployments.test.ts new file mode 100644 index 0000000000..dc194418f0 --- /dev/null +++ b/src/services/contracts/__tests__/deployments.test.ts @@ -0,0 +1,390 @@ +import { LATEST_SAFE_VERSION } from '@/config/constants' +import * as safeDeployments from '@safe-global/safe-deployments' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import * as deployments from '../deployments' + +describe('deployments', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('tryDeploymentVersions', () => { + const getSafeSpy = jest.spyOn(safeDeployments, 'getSafeSingletonDeployment') + + it('should call the deployment getter with a supported version/network', () => { + deployments._tryDeploymentVersions( + getSafeSpy as unknown as typeof safeDeployments.getSafeSingletonDeployment, + '1', + '1.1.1', + ) + + expect(getSafeSpy).toHaveBeenCalledTimes(1) + + expect(getSafeSpy).toHaveBeenNthCalledWith(1, { + version: '1.1.1', + network: '1', + }) + }) + + it('should call the deployment getter with a supported version/unsupported network', () => { + deployments._tryDeploymentVersions( + getSafeSpy as unknown as typeof safeDeployments.getSafeSingletonDeployment, + '69420', + '1.1.1', + ) + + expect(getSafeSpy).toHaveBeenCalledTimes(1) + + expect(getSafeSpy).toHaveBeenNthCalledWith(1, { + version: '1.1.1', + network: '69420', + }) + }) + + it('should call the deployment getter with an unsupported version/unsupported', () => { + deployments._tryDeploymentVersions( + getSafeSpy as unknown as typeof safeDeployments.getSafeSingletonDeployment, + '69420', + '1.2.3', + ) + + expect(getSafeSpy).toHaveBeenCalledTimes(1) + + expect(getSafeSpy).toHaveBeenNthCalledWith(1, { + version: '1.2.3', + network: '69420', + }) + }) + + it('should call the deployment getter with the latest version/supported network if no version is provider', () => { + deployments._tryDeploymentVersions( + getSafeSpy as unknown as typeof safeDeployments.getSafeSingletonDeployment, + '1', + null, + ) + + expect(getSafeSpy).toHaveBeenCalledTimes(1) + + expect(getSafeSpy).toHaveBeenNthCalledWith(1, { + version: '1.3.0', + network: '1', + }) + }) + + it('should call the deployment getter with the latest version/unsupported network if no version is provider', () => { + deployments._tryDeploymentVersions( + getSafeSpy as unknown as typeof safeDeployments.getSafeSingletonDeployment, + '69420', + null, + ) + + expect(getSafeSpy).toHaveBeenCalledTimes(1) + + expect(getSafeSpy).toHaveBeenNthCalledWith(1, { + network: '69420', + version: '1.3.0', + }) + }) + }) + + describe('isLegacy', () => { + it('should return true for legacy versions', () => { + expect(deployments._isLegacy('0.0.1')).toBe(true) + expect(deployments._isLegacy('1.0.0')).toBe(true) + }) + + it('should return false for non-legacy versions', () => { + expect(deployments._isLegacy('1.1.1')).toBe(false) + expect(deployments._isLegacy('1.2.0')).toBe(false) + expect(deployments._isLegacy('1.3.0')).toBe(false) + expect(deployments._isLegacy(LATEST_SAFE_VERSION)).toBe(false) + }) + + it('should return false for unsupported versions', () => { + expect(deployments._isLegacy(null)).toBe(false) + }) + }) + + describe('isL2', () => { + it('should return true for L2 versions', () => { + expect(deployments._isL2({ l2: true } as ChainInfo, '1.3.0')).toBe(true) + expect(deployments._isL2({ l2: true } as ChainInfo, '1.3.0+L2')).toBe(true) + expect(deployments._isL2({ l2: true } as ChainInfo, LATEST_SAFE_VERSION)).toBe(true) + expect(deployments._isL2({ l2: true } as ChainInfo, `${LATEST_SAFE_VERSION}+L2`)).toBe(true) + }) + + it('should return true for unsupported L2 versions', () => { + expect(deployments._isL2({ l2: true } as ChainInfo, null)).toBe(true) + }) + + it('should return false for non-L2 versions', () => { + expect(deployments._isL2({ l2: false } as ChainInfo, '1.0.0')).toBe(false) + expect(deployments._isL2({ l2: false } as ChainInfo, '1.1.1')).toBe(false) + expect(deployments._isL2({ l2: false } as ChainInfo, '1.2.0')).toBe(false) + expect(deployments._isL2({ l2: false } as ChainInfo, '1.3.0')).toBe(false) + expect(deployments._isL2({ l2: false } as ChainInfo, LATEST_SAFE_VERSION)).toBe(false) + }) + }) + + describe('getSafeContractDeployment', () => { + describe('L1', () => { + it('should return the versioned deployment for supported version/chain', () => { + const expected = safeDeployments.getSafeSingletonDeployment({ + version: '1.1.1', + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getSafeContractDeployment({ chainId: '1' } as ChainInfo, '1.1.1') + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for supported version/unsupported chain', () => { + const deployment = deployments.getSafeContractDeployment({ chainId: '69420' } as ChainInfo, '1.1.1') + expect(deployment).toBe(undefined) + }) + + it('should return the oldest deployment for legacy version/supported chain', () => { + const expected = safeDeployments.getSafeSingletonDeployment({ + version: '1.0.0', // First available version + }) + + expect(expected).toBeDefined() + const deployment = deployments.getSafeContractDeployment({ chainId: '1' } as ChainInfo, '0.0.1') + expect(deployment).toStrictEqual(expected) + }) + + it('should return the oldest deployment for legacy version/unsupported chain', () => { + const expected = safeDeployments.getSafeSingletonDeployment({ + version: '1.0.0', // First available version + }) + + expect(expected).toBeDefined() + const deployment = deployments.getSafeContractDeployment({ chainId: '69420' } as ChainInfo, '0.0.1') + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for unsupported version/chain', () => { + const deployment = deployments.getSafeContractDeployment({ chainId: '69420' } as ChainInfo, '1.2.3') + expect(deployment).toStrictEqual(undefined) + }) + + it('should return the latest deployment for no version/supported chain', () => { + const expected = safeDeployments.getSafeSingletonDeployment({ + version: LATEST_SAFE_VERSION, + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getSafeContractDeployment({ chainId: '1' } as ChainInfo, null) + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for no version/unsupported chain', () => { + const deployment = deployments.getSafeContractDeployment({ chainId: '69420' } as ChainInfo, null) + expect(deployment).toBe(undefined) + }) + }) + + describe('L2', () => { + it('should return the versioned deployment for supported version/chain', () => { + const expected = safeDeployments.getSafeL2SingletonDeployment({ + version: '1.3.0', // First available version + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getSafeContractDeployment({ chainId: '1', l2: true } as ChainInfo, '1.3.0') + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for supported version/unsupported chain', () => { + const deployment = deployments.getSafeContractDeployment({ chainId: '69420', l2: true } as ChainInfo, '1.3.0') + expect(deployment).toBe(undefined) + }) + + it('should return undefined for unsupported version/chain', () => { + const expected = safeDeployments.getSafeSingletonDeployment({ + version: LATEST_SAFE_VERSION, + }) + + expect(expected).toBeDefined() + const deployment = deployments.getSafeContractDeployment({ chainId: '69420' } as ChainInfo, '1.2.3') + expect(deployment).toBe(undefined) + }) + + it('should return the latest deployment for no version/supported chain', () => { + const expected = safeDeployments.getSafeL2SingletonDeployment({ + version: LATEST_SAFE_VERSION, + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getSafeContractDeployment({ chainId: '1', l2: true } as ChainInfo, null) + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined no version/unsupported chain', () => { + const deployment = deployments.getSafeContractDeployment({ chainId: '69420', l2: true } as ChainInfo, null) + expect(deployment).toStrictEqual(undefined) + }) + }) + }) + + describe('getMultiSendCallOnlyContractDeployment', () => { + it('should return the versioned deployment for supported version/chain', () => { + const expected = safeDeployments.getMultiSendCallOnlyDeployment({ + version: '1.3.0', // First available version + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getMultiSendCallOnlyContractDeployment('1', '1.3.0') + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for supported version/unsupported chain', () => { + const deployment = deployments.getMultiSendCallOnlyContractDeployment('69420', '1.3.0') + expect(deployment).toBe(undefined) + }) + + it('should return undefined for unsupported version/chain', () => { + const deployment = deployments.getMultiSendCallOnlyContractDeployment('69420', '1.2.3') + expect(deployment).toBe(undefined) + }) + + it('should return the latest deployment for no version/supported chain', () => { + const expected = safeDeployments.getMultiSendCallOnlyDeployment({ + version: LATEST_SAFE_VERSION, + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getMultiSendCallOnlyContractDeployment('1', null) + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for no version/unsupported chain', () => { + const deployment = deployments.getMultiSendCallOnlyContractDeployment('69420', null) + expect(deployment).toBe(undefined) + }) + }) + + describe('getFallbackHandlerContractDeployment', () => { + it('should return the versioned deployment for supported version/chain', () => { + const expected = safeDeployments.getFallbackHandlerDeployment({ + version: '1.3.0', // First available version + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getFallbackHandlerContractDeployment('1', '1.3.0') + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for supported version/unsupported chain', () => { + const deployment = deployments.getFallbackHandlerContractDeployment('69420', '1.3.0') + expect(deployment).toBe(undefined) + }) + + it('should return undefined for unsupported version/chain', () => { + const deployment = deployments.getFallbackHandlerContractDeployment('69420', '1.2.3') + expect(deployment).toBe(undefined) + }) + + it('should return the latest deployment for no version/supported chain', () => { + const expected = safeDeployments.getFallbackHandlerDeployment({ + version: LATEST_SAFE_VERSION, + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getFallbackHandlerContractDeployment('1', null) + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for no version/unsupported chain', () => { + const deployment = deployments.getFallbackHandlerContractDeployment('69420', null) + expect(deployment).toBe(undefined) + }) + }) + + describe('getProxyFactoryContractDeployment', () => { + it('should return the versioned deployment for supported version/chain', () => { + const expected = safeDeployments.getProxyFactoryDeployment({ + version: '1.1.1', // First available version + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getProxyFactoryContractDeployment('1', '1.1.1') + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for supported version/unsupported chain', () => { + const deployment = deployments.getProxyFactoryContractDeployment('69420', '1.1.1') + expect(deployment).toBe(undefined) + }) + + it('should return undefined for unsupported version/chain', () => { + const deployment = deployments.getProxyFactoryContractDeployment('69420', '1.2.3') + expect(deployment).toBe(undefined) + }) + + it('should return the latest deployment for no version/supported chain', () => { + const expected = safeDeployments.getProxyFactoryDeployment({ + version: LATEST_SAFE_VERSION, + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getProxyFactoryContractDeployment('1', null) + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for no version/unsupported chain', () => { + const deployment = deployments.getProxyFactoryContractDeployment('69420', null) + expect(deployment).toBe(undefined) + }) + }) + + describe('getSignMessageLibContractDeployment', () => { + it('should return the versioned deployment for supported version/chain', () => { + const expected = safeDeployments.getSignMessageLibDeployment({ + version: '1.3.0', // First available version + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getSignMessageLibContractDeployment('1', '1.3.0') + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for supported version/unsupported chain', () => { + const deployment = deployments.getSignMessageLibContractDeployment('69420', '1.3.0') + expect(deployment).toBe(undefined) + }) + + it('should return undefined for unsupported version/chain', () => { + const deployment = deployments.getSignMessageLibContractDeployment('69420', '1.2.3') + expect(deployment).toBe(undefined) + }) + + it('should return the latest deployment for no version/supported chain', () => { + const expected = safeDeployments.getSignMessageLibDeployment({ + version: LATEST_SAFE_VERSION, + network: '1', + }) + + expect(expected).toBeDefined() + const deployment = deployments.getSignMessageLibContractDeployment('1', null) + expect(deployment).toStrictEqual(expected) + }) + + it('should return undefined for no version/unsupported chain', () => { + const deployment = deployments.getSignMessageLibContractDeployment('69420', null) + expect(deployment).toBe(undefined) + }) + }) +}) diff --git a/src/services/__tests__/safeContracts.test.ts b/src/services/contracts/__tests__/safeContracts.test.ts similarity index 98% rename from src/services/__tests__/safeContracts.test.ts rename to src/services/contracts/__tests__/safeContracts.test.ts index dbf1683595..729baad7bf 100644 --- a/src/services/__tests__/safeContracts.test.ts +++ b/src/services/contracts/__tests__/safeContracts.test.ts @@ -1,5 +1,5 @@ import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' -import { _getValidatedGetContractProps, isValidMasterCopy } from '../contracts/safeContracts' +import { _getValidatedGetContractProps, isValidMasterCopy } from '../safeContracts' describe('safeContracts', () => { describe('isValidMasterCopy', () => { diff --git a/src/services/contracts/deployments.ts b/src/services/contracts/deployments.ts new file mode 100644 index 0000000000..d1bf681416 --- /dev/null +++ b/src/services/contracts/deployments.ts @@ -0,0 +1,81 @@ +import semverSatisfies from 'semver/functions/satisfies' +import { + getSafeSingletonDeployment, + getSafeL2SingletonDeployment, + getMultiSendCallOnlyDeployment, + getFallbackHandlerDeployment, + getProxyFactoryDeployment, + getSignMessageLibDeployment, +} from '@safe-global/safe-deployments' +import type { SingletonDeployment, DeploymentFilter } from '@safe-global/safe-deployments' +import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { LATEST_SAFE_VERSION } from '@/config/constants' + +export const _tryDeploymentVersions = ( + getDeployment: (filter?: DeploymentFilter) => SingletonDeployment | undefined, + network: string, + version: SafeInfo['version'], +): SingletonDeployment | undefined => { + // Unsupported Safe version + if (version === null) { + // Assume latest version as fallback + return getDeployment({ + version: LATEST_SAFE_VERSION, + network, + }) + } + + // Supported Safe version + return getDeployment({ + version, + network, + }) +} + +export const _isLegacy = (safeVersion: SafeInfo['version']): boolean => { + const LEGACY_VERSIONS = '<=1.0.0' + return !!safeVersion && semverSatisfies(safeVersion, LEGACY_VERSIONS) +} + +export const _isL2 = (chain: ChainInfo, safeVersion: SafeInfo['version']): boolean => { + const L2_VERSIONS = '>=1.3.0' + + // Unsupported safe version + if (safeVersion === null) { + return chain.l2 + } + + // We had L1 contracts on xDai, EWC and Volta so we also need to check version is after 1.3.0 + return chain.l2 && semverSatisfies(safeVersion, L2_VERSIONS) +} + +export const getSafeContractDeployment = ( + chain: ChainInfo, + safeVersion: SafeInfo['version'], +): SingletonDeployment | undefined => { + // Check if prior to 1.0.0 to keep minimum compatibility + if (_isLegacy(safeVersion)) { + return getSafeSingletonDeployment({ version: '1.0.0' }) + } + + const getDeployment = _isL2(chain, safeVersion) ? getSafeL2SingletonDeployment : getSafeSingletonDeployment + + return _tryDeploymentVersions(getDeployment, chain.chainId, safeVersion) +} + +export const getMultiSendCallOnlyContractDeployment = (chainId: string, safeVersion: SafeInfo['version']) => { + return _tryDeploymentVersions(getMultiSendCallOnlyDeployment, chainId, safeVersion) +} + +export const getFallbackHandlerContractDeployment = (chainId: string, safeVersion: SafeInfo['version']) => { + return _tryDeploymentVersions(getFallbackHandlerDeployment, chainId, safeVersion) +} + +export const getProxyFactoryContractDeployment = (chainId: string, safeVersion: SafeInfo['version']) => { + return _tryDeploymentVersions(getProxyFactoryDeployment, chainId, safeVersion) +} + +export const getSignMessageLibContractDeployment = (chainId: string, safeVersion: SafeInfo['version']) => { + return _tryDeploymentVersions(getSignMessageLibDeployment, chainId, safeVersion) +} diff --git a/src/services/contracts/safeContracts.ts b/src/services/contracts/safeContracts.ts index 922584745d..5da8cdd528 100644 --- a/src/services/contracts/safeContracts.ts +++ b/src/services/contracts/safeContracts.ts @@ -1,14 +1,11 @@ import { - getFallbackHandlerDeployment, - getMultiSendCallOnlyDeployment, - getProxyFactoryDeployment, - getSafeL2SingletonDeployment, - getSafeSingletonDeployment, - getSignMessageLibDeployment, - type SingletonDeployment, -} from '@safe-global/safe-deployments' + getFallbackHandlerContractDeployment, + getMultiSendCallOnlyContractDeployment, + getProxyFactoryContractDeployment, + getSafeContractDeployment, + getSignMessageLibContractDeployment, +} from './deployments' import { LATEST_SAFE_VERSION } from '@/config/constants' -import semverSatisfies from 'semver/functions/satisfies' import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import type { GetContractProps, SafeVersion } from '@safe-global/safe-core-sdk-types' @@ -61,35 +58,6 @@ export const getCurrentGnosisSafeContract = (safe: SafeInfo, provider: Web3Provi return getGnosisSafeContractEthers(safe, ethAdapter) } -const isOldestVersion = (safeVersion: string): boolean => { - return semverSatisfies(safeVersion, '<=1.0.0') -} - -const getSafeContractDeployment = (chain: ChainInfo, safeVersion: string): SingletonDeployment | undefined => { - // We check if version is prior to v1.0.0 as they are not supported but still we want to keep a minimum compatibility - const useOldestContractVersion = isOldestVersion(safeVersion) - - // We had L1 contracts in three L2 networks, xDai, EWC and Volta so even if network is L2 we have to check that safe version is after v1.3.0 - const useL2ContractVersion = chain.l2 && semverSatisfies(safeVersion, '>=1.3.0') - const getDeployment = useL2ContractVersion ? getSafeL2SingletonDeployment : getSafeSingletonDeployment - - return ( - getDeployment({ - version: safeVersion, - network: chain.chainId, - }) || - getDeployment({ - version: safeVersion, - }) || - // In case we couldn't find a valid deployment and it's a version before 1.0.0 we return v1.0.0 to allow a minimum compatibility - (useOldestContractVersion - ? getDeployment({ - version: '1.0.0', - }) - : undefined) - ) -} - export const getReadOnlyGnosisSafeContract = (chain: ChainInfo, safeVersion: string = LATEST_SAFE_VERSION) => { const ethAdapter = createReadOnlyEthersAdapter() @@ -103,91 +71,61 @@ export const getReadOnlyGnosisSafeContract = (chain: ChainInfo, safeVersion: str export const getMultiSendCallOnlyContract = ( chainId: string, - safeVersion: SafeInfo['version'] = LATEST_SAFE_VERSION, + safeVersion: SafeInfo['version'], provider: Web3Provider, ) => { const ethAdapter = createEthersAdapter(provider) return ethAdapter.getMultiSendCallOnlyContract({ - singletonDeployment: getMultiSendCallOnlyDeployment({ network: chainId, version: safeVersion || undefined }), + singletonDeployment: getMultiSendCallOnlyContractDeployment(chainId, safeVersion), ..._getValidatedGetContractProps(chainId, safeVersion), }) } -export const getReadOnlyMultiSendCallOnlyContract = ( - chainId: string, - safeVersion: SafeInfo['version'] = LATEST_SAFE_VERSION, -) => { +export const getReadOnlyMultiSendCallOnlyContract = (chainId: string, safeVersion: SafeInfo['version']) => { const ethAdapter = createReadOnlyEthersAdapter() return ethAdapter.getMultiSendCallOnlyContract({ - singletonDeployment: getMultiSendCallOnlyDeployment({ network: chainId, version: safeVersion || undefined }), + singletonDeployment: getMultiSendCallOnlyContractDeployment(chainId, safeVersion), ..._getValidatedGetContractProps(chainId, safeVersion), }) } // GnosisSafeProxyFactory -const getProxyFactoryContractDeployment = (chainId: string) => { - return ( - getProxyFactoryDeployment({ - version: LATEST_SAFE_VERSION, - network: chainId, - }) || - getProxyFactoryDeployment({ - version: LATEST_SAFE_VERSION, - }) - ) -} - -export const getReadOnlyProxyFactoryContract = (chainId: string, safeVersion: string = LATEST_SAFE_VERSION) => { +export const getReadOnlyProxyFactoryContract = (chainId: string, safeVersion: SafeInfo['version']) => { const ethAdapter = createReadOnlyEthersAdapter() return ethAdapter.getSafeProxyFactoryContract({ - singletonDeployment: getProxyFactoryContractDeployment(chainId), + singletonDeployment: getProxyFactoryContractDeployment(chainId, safeVersion), ..._getValidatedGetContractProps(chainId, safeVersion), }) } // Fallback handler -const getFallbackHandlerContractDeployment = (chainId: string) => { - return ( - getFallbackHandlerDeployment({ - version: LATEST_SAFE_VERSION, - network: chainId, - }) || - getFallbackHandlerDeployment({ - version: LATEST_SAFE_VERSION, - }) - ) -} - export const getReadOnlyFallbackHandlerContract = ( chainId: string, - safeVersion: string = LATEST_SAFE_VERSION, + safeVersion: SafeInfo['version'], ): CompatibilityFallbackHandlerEthersContract => { const ethAdapter = createReadOnlyEthersAdapter() return ethAdapter.getCompatibilityFallbackHandlerContract({ - singletonDeployment: getFallbackHandlerContractDeployment(chainId), + singletonDeployment: getFallbackHandlerContractDeployment(chainId, safeVersion), ..._getValidatedGetContractProps(chainId, safeVersion), }) } // Sign messages deployment -const getSignMessageLibContractDeployment = (chainId: string) => { - return getSignMessageLibDeployment({ network: chainId }) || getSignMessageLibDeployment() -} export const getReadOnlySignMessageLibContract = ( chainId: string, - safeVersion: string = LATEST_SAFE_VERSION, + safeVersion: SafeInfo['version'], ): SignMessageLibEthersContract => { const ethAdapter = createReadOnlyEthersAdapter() return ethAdapter.getSignMessageLibContract({ - singletonDeployment: getSignMessageLibContractDeployment(chainId), + singletonDeployment: getSignMessageLibContractDeployment(chainId, safeVersion), ..._getValidatedGetContractProps(chainId, safeVersion), }) } diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts index 1850f53c92..3e28789c8e 100644 --- a/src/services/exceptions/ErrorCodes.ts +++ b/src/services/exceptions/ErrorCodes.ts @@ -50,7 +50,8 @@ enum ErrorCodes { _800 = '800: Safe creation tx failed', _801 = '801: Failed to send a tx with a spending limit', - _804 = '804: Error processing a transaction', + _804 = '804: Error executing a transaction', + _805 = '805: Error proposing or confirming a transaction', _806 = '806: Failed to remove module', _807 = '807: Failed to remove guard', _808 = '808: Failed to get transaction origin', diff --git a/src/services/pairing/QRModal.tsx b/src/services/pairing/QRModal.tsx index 6765394596..df2e3285eb 100644 --- a/src/services/pairing/QRModal.tsx +++ b/src/services/pairing/QRModal.tsx @@ -45,6 +45,7 @@ const close = () => { const wrapper = document.getElementById(WRAPPER_ID) if (wrapper) { document.body.removeChild(wrapper) + document.body.style.overflow = '' } } diff --git a/src/services/security/modules/DelegateCallModule/index.ts b/src/services/security/modules/DelegateCallModule/index.ts index e9704a3a17..85555bafc8 100644 --- a/src/services/security/modules/DelegateCallModule/index.ts +++ b/src/services/security/modules/DelegateCallModule/index.ts @@ -1,5 +1,5 @@ import { OperationType } from '@safe-global/safe-core-sdk-types' -import { getMultiSendCallOnlyDeployment } from '@safe-global/safe-deployments' +import { getMultiSendCallOnlyContractDeployment } from '@/services/contracts/deployments' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' @@ -28,7 +28,7 @@ export class DelegateCallModule implements SecurityModule ({ signatures: new Map(), addSignature: jest.fn(), + data: { + nonce: '1', + }, })), createRejectionTransaction: jest.fn(() => ({ addSignature: jest.fn(), @@ -399,7 +402,10 @@ describe('txSender', () => { expect((error as Error).message).toBe('rejected') - expect(txEvents.txDispatch).toHaveBeenCalledWith('SIGN_FAILED', { txId: '0x345', error }) + expect(txEvents.txDispatch).toHaveBeenCalledWith('SIGN_FAILED', { + txId: '0x345', + error, + }) expect(txEvents.txDispatch).not.toHaveBeenCalledWith('SIGNED', { txId: '0x345' }) } }) @@ -430,7 +436,10 @@ describe('txSender', () => { expect((error as Error).message).toBe('failure-specific error') - expect(txEvents.txDispatch).toHaveBeenCalledWith('SIGN_FAILED', { txId: '0x345', error }) + expect(txEvents.txDispatch).toHaveBeenCalledWith('SIGN_FAILED', { + txId: '0x345', + error, + }) expect(txEvents.txDispatch).not.toHaveBeenCalledWith('SIGNED', { txId: '0x345' }) } }) diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index 42e709d6cc..a5a885d0e5 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -63,6 +63,7 @@ export const dispatchTxProposal = async ({ txDispatch(txId ? TxEvent.SIGNATURE_PROPOSED : TxEvent.PROPOSED, { txId: proposedTx.txId, signerAddress: txId ? sender : undefined, + humanDescription: proposedTx?.txInfo?.humanDescription, }) } @@ -78,6 +79,7 @@ export const dispatchTxSigning = async ( onboard: OnboardAPI, chainId: SafeInfo['chainId'], txId?: string, + humanDescription?: string, ): Promise => { const sdk = await getSafeSDKWithSigner(onboard, chainId) @@ -85,7 +87,11 @@ export const dispatchTxSigning = async ( try { signedTx = await tryOffChainTxSigning(safeTx, safeVersion, sdk) } catch (error) { - txDispatch(TxEvent.SIGN_FAILED, { txId, error: asError(error) }) + txDispatch(TxEvent.SIGN_FAILED, { + txId, + error: asError(error), + humanDescription, + }) throw error } @@ -102,10 +108,11 @@ export const dispatchOnChainSigning = async ( txId: string, onboard: OnboardAPI, chainId: SafeInfo['chainId'], + humanDescription?: string, ) => { const sdkUnchecked = await getUncheckedSafeSDK(onboard, chainId) const safeTxHash = await sdkUnchecked.getTransactionHash(safeTx) - const eventParams = { txId } + const eventParams = { txId, humanDescription } try { // With the unchecked signer, the contract call resolves once the tx @@ -133,9 +140,10 @@ export const dispatchTxExecution = async ( onboard: OnboardAPI, chainId: SafeInfo['chainId'], safeAddress: string, + humanDescription?: string, ): Promise => { const sdkUnchecked = await getUncheckedSafeSDK(onboard, chainId) - const eventParams = { txId } + const eventParams = { txId, humanDescription } // Execute the tx let result: TransactionResult | undefined @@ -288,7 +296,10 @@ export const dispatchSpendingLimitTxExecution = async ( ?.wait() .then((receipt) => { if (didRevert(receipt)) { - txDispatch(TxEvent.REVERTED, { groupKey: id, error: new Error('Transaction reverted by EVM') }) + txDispatch(TxEvent.REVERTED, { + groupKey: id, + error: new Error('Transaction reverted by EVM'), + }) } else { txDispatch(TxEvent.PROCESSED, { groupKey: id, safeAddress }) } @@ -316,6 +327,7 @@ export const dispatchTxRelay = async ( safe: SafeInfo, txId: string, gasLimit?: string | number, + humanDescription?: string, ) => { const readOnlySafeContract = getReadOnlyCurrentGnosisSafeContract(safe) @@ -344,9 +356,9 @@ export const dispatchTxRelay = async ( txDispatch(TxEvent.RELAYING, { taskId, txId }) // Monitor relay tx - waitForRelayedTx(taskId, [txId], safe.address.value) + waitForRelayedTx(taskId, [txId], safe.address.value, humanDescription) } catch (error) { - txDispatch(TxEvent.FAILED, { txId, error: asError(error) }) + txDispatch(TxEvent.FAILED, { txId, error: asError(error), humanDescription }) throw error } } diff --git a/src/services/tx/txEvents.ts b/src/services/tx/txEvents.ts index 0adc2102de..6e63e527b1 100644 --- a/src/services/tx/txEvents.ts +++ b/src/services/tx/txEvents.ts @@ -24,25 +24,26 @@ export enum TxEvent { } type Id = { txId: string; groupKey?: string } | { txId?: string; groupKey: string } +type HumanDescription = { humanDescription?: string } interface TxEvents { [TxEvent.SIGNED]: { txId?: string } - [TxEvent.SIGN_FAILED]: { txId?: string; error: Error } - [TxEvent.PROPOSE_FAILED]: { error: Error } - [TxEvent.PROPOSED]: { txId: string } - [TxEvent.SIGNATURE_PROPOSE_FAILED]: { txId: string; error: Error } - [TxEvent.SIGNATURE_PROPOSED]: { txId: string; signerAddress: string } + [TxEvent.SIGN_FAILED]: HumanDescription & { txId?: string; error: Error } + [TxEvent.PROPOSE_FAILED]: HumanDescription & { error: Error } + [TxEvent.PROPOSED]: HumanDescription & { txId: string } + [TxEvent.SIGNATURE_PROPOSE_FAILED]: HumanDescription & { txId: string; error: Error } + [TxEvent.SIGNATURE_PROPOSED]: HumanDescription & { txId: string; signerAddress: string } [TxEvent.SIGNATURE_INDEXED]: { txId: string } - [TxEvent.ONCHAIN_SIGNATURE_REQUESTED]: Id - [TxEvent.ONCHAIN_SIGNATURE_SUCCESS]: Id - [TxEvent.EXECUTING]: Id - [TxEvent.PROCESSING]: Id & { txHash: string } - [TxEvent.PROCESSING_MODULE]: Id & { txHash: string } - [TxEvent.PROCESSED]: Id & { safeAddress: string } - [TxEvent.REVERTED]: Id & { error: Error } + [TxEvent.ONCHAIN_SIGNATURE_REQUESTED]: Id & HumanDescription + [TxEvent.ONCHAIN_SIGNATURE_SUCCESS]: Id & HumanDescription + [TxEvent.EXECUTING]: Id & HumanDescription + [TxEvent.PROCESSING]: Id & HumanDescription & { txHash: string } + [TxEvent.PROCESSING_MODULE]: Id & HumanDescription & { txHash: string } + [TxEvent.PROCESSED]: Id & HumanDescription & { safeAddress: string } + [TxEvent.REVERTED]: Id & HumanDescription & { error: Error } [TxEvent.RELAYING]: Id & { taskId: string } - [TxEvent.FAILED]: Id & { error: Error } - [TxEvent.SUCCESS]: Id + [TxEvent.FAILED]: Id & HumanDescription & { error: Error } + [TxEvent.SUCCESS]: Id & HumanDescription [TxEvent.SAFE_APPS_REQUEST]: { safeAppRequestId: RequestId; safeTxHash: string } [TxEvent.BATCH_ADD]: Id } diff --git a/src/services/tx/txMonitor.ts b/src/services/tx/txMonitor.ts index b75ac03f8d..878b47d61a 100644 --- a/src/services/tx/txMonitor.ts +++ b/src/services/tx/txMonitor.ts @@ -91,7 +91,13 @@ const getRelayTxStatus = async (taskId: string): Promise<{ task: TransactionStat const WAIT_FOR_RELAY_TIMEOUT = 3 * 60_000 // 3 minutes -export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: string, groupKey?: string): void => { +export const waitForRelayedTx = ( + taskId: string, + txIds: string[], + safeAddress: string, + groupKey?: string, + humanDescription?: string, +): void => { let intervalId: NodeJS.Timeout let failAfterTimeoutId: NodeJS.Timeout @@ -110,6 +116,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, groupKey, safeAddress, + humanDescription, }), ) break @@ -119,6 +126,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, error: new Error(`Relayed transaction reverted by EVM.`), groupKey, + humanDescription, }), ) break @@ -128,6 +136,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, error: new Error(`Relayed transaction was blacklisted by relay provider.`), groupKey, + humanDescription, }), ) break @@ -137,6 +146,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, error: new Error(`Relayed transaction was cancelled by relay provider.`), groupKey, + humanDescription, }), ) break @@ -146,6 +156,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, error: new Error(`Relayed transaction was not found.`), groupKey, + humanDescription, }), ) break @@ -168,6 +179,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s } minutes. Be aware that it might still be relayed.`, ), groupKey, + humanDescription, }), ) diff --git a/src/store/notificationsSlice.ts b/src/store/notificationsSlice.ts index 0cfe38b788..36f4703877 100644 --- a/src/store/notificationsSlice.ts +++ b/src/store/notificationsSlice.ts @@ -7,6 +7,7 @@ export type Notification = { id: string message: string detailedMessage?: string + title?: string groupKey: string variant: AlertColor timestamp: number diff --git a/src/store/txHistorySlice.ts b/src/store/txHistorySlice.ts index 2561ad2f85..4a771de1d3 100644 --- a/src/store/txHistorySlice.ts +++ b/src/store/txHistorySlice.ts @@ -28,7 +28,13 @@ export const txHistoryListener = (listenerMiddleware: typeof listenerMiddlewareI const txId = result.transaction.id if (pendingTxs[txId]) { - txDispatch(TxEvent.SUCCESS, { txId, groupKey: pendingTxs[txId].groupKey }) + const humanDescription = result.transaction.txInfo?.humanDescription + + txDispatch(TxEvent.SUCCESS, { + txId, + groupKey: pendingTxs[txId].groupKey, + humanDescription, + }) } } }, diff --git a/src/store/txQueueSlice.ts b/src/store/txQueueSlice.ts index 5d84b62437..6ec1a50c27 100644 --- a/src/store/txQueueSlice.ts +++ b/src/store/txQueueSlice.ts @@ -27,6 +27,14 @@ export const selectQueuedTransactionsByNonce = createSelector( }, ) +export const selectQueuedTransactionById = createSelector( + selectQueuedTransactions, + (_: RootState, txId?: string) => txId, + (queuedTransactions, txId?: string) => { + return (queuedTransactions || []).find((item) => item.transaction.id === txId) + }, +) + export const txQueueListener = (listenerMiddleware: typeof listenerMiddlewareInstance) => { listenerMiddleware.startListening({ actionCreator: txQueueSlice.actions.set, diff --git a/src/tests/pages/apps.test.tsx b/src/tests/pages/apps.test.tsx index 3674c745e1..1fa6d6bf65 100644 --- a/src/tests/pages/apps.test.tsx +++ b/src/tests/pages/apps.test.tsx @@ -25,6 +25,11 @@ jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({ getSafeApps: (chainId: string) => Promise.resolve(mockedSafeApps), })) +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useParams: jest.fn(() => ({ safe: 'matic:0x0000000000000000000000000000000000000000' })), +})) + describe('AppsPage', () => { beforeEach(() => { jest.restoreAllMocks() diff --git a/yarn.lock b/yarn.lock index 9c1065535f..32934fccf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1930,10 +1930,10 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@cypress/request@^2.88.10": - version "2.88.10" - resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.10.tgz#b66d76b07f860d3a4b8d7a0604d020c662752cce" - integrity sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg== +"@cypress/request@2.88.12": + version "2.88.12" + resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.12.tgz#ba4911431738494a85e93fb04498cb38bc55d590" + integrity sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -1948,9 +1948,9 @@ json-stringify-safe "~5.0.1" mime-types "~2.1.19" performance-now "^2.1.0" - qs "~6.5.2" + qs "~6.10.3" safe-buffer "^5.1.2" - tough-cookie "~2.5.0" + tough-cookie "^4.1.3" tunnel-agent "^0.6.0" uuid "^8.3.2" @@ -5495,10 +5495,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== -"@types/node@^14.14.31": - version "14.18.35" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.35.tgz#879c4659cb7b3fe515844f029c75079c941bb65c" - integrity sha512-2ATO8pfhG1kDvw4Lc4C0GXIMSQFFJBCo/R1fSgTwmUlq5oy95LXyjDQinsRVgQY6gp6ghh3H91wk9ES5/5C+Tw== +"@types/node@^16.18.39": + version "16.18.54" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.54.tgz#4a63bdcea5b714f546aa27406a1c60621236a132" + integrity sha512-oTmGy68gxZZ21FhTJVVvZBYpQHEBZxHKTsGshobMqm9qWpbqdZsA5jvsuPZcHu0KwpmLrOHWPdEfg7XDpNT9UA== "@types/papaparse@^5.3.1": version "5.3.5" @@ -7884,11 +7884,6 @@ commander@^2.20.0, commander@^2.20.3: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== - commander@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" @@ -8215,14 +8210,14 @@ cypress-file-upload@^5.0.8: resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz#d8824cbeaab798e44be8009769f9a6c9daa1b4a1" integrity sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g== -cypress@^11.1.0: - version "11.2.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-11.2.0.tgz#63edef8c387b687066c5493f6f0ad7b9ced4b2b7" - integrity sha512-u61UGwtu7lpsNWLUma/FKNOsrjcI6wleNmda/TyKHe0dOBcVjbCPlp1N6uwFZ0doXev7f/91YDpU9bqDCFeBLA== +cypress@^12.15.0: + version "12.17.4" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-12.17.4.tgz#b4dadf41673058493fa0d2362faa3da1f6ae2e6c" + integrity sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ== dependencies: - "@cypress/request" "^2.88.10" + "@cypress/request" "2.88.12" "@cypress/xvfb" "^1.2.4" - "@types/node" "^14.14.31" + "@types/node" "^16.18.39" "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" arch "^2.2.0" @@ -8234,10 +8229,10 @@ cypress@^11.1.0: check-more-types "^2.24.0" cli-cursor "^3.1.0" cli-table3 "~0.6.1" - commander "^5.1.0" + commander "^6.2.1" common-tags "^1.8.0" dayjs "^1.10.4" - debug "^4.3.2" + debug "^4.3.4" enquirer "^2.3.6" eventemitter2 "6.4.7" execa "4.1.0" @@ -8252,12 +8247,13 @@ cypress@^11.1.0: listr2 "^3.8.3" lodash "^4.17.21" log-symbols "^4.0.0" - minimist "^1.2.6" + minimist "^1.2.8" ospath "^1.2.2" pretty-bytes "^5.6.0" + process "^0.11.10" proxy-from-env "1.0.0" request-progress "^3.0.0" - semver "^7.3.2" + semver "^7.5.3" supports-color "^8.1.1" tmp "~0.2.1" untildify "^4.0.0" @@ -12589,6 +12585,11 @@ minimist@^1.2.0, minimist@^1.2.6, minimist@~1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + minipass@^2.6.0, minipass@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" @@ -13685,6 +13686,13 @@ qs@6.11.0, qs@^6.10.3: dependencies: side-channel "^1.0.4" +qs@~6.10.3: + version "6.10.5" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.5.tgz#974715920a80ff6a262264acd2c7e6c2a53282b4" + integrity sha512-O5RlPh0VFtR78y79rgcgKK4wbAI0C5zGVLztOIdpWX6ep368q5Hv6XRxDvXuZ9q3C6v+e3n8UfZZJw7IIG27eQ== + dependencies: + side-channel "^1.0.4" + qs@~6.5.2: version "6.5.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" @@ -14563,7 +14571,7 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.2: +semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.2: version "7.5.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.2.tgz#5b851e66d1be07c1cdaf37dfc856f543325a2beb" integrity sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ== @@ -15423,7 +15431,7 @@ totalist@^1.0.0: resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== -tough-cookie@^4.1.2: +tough-cookie@^4.1.2, tough-cookie@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==