From 132b4a2589526a41c9213988f39b2ee99ce940a3 Mon Sep 17 00:00:00 2001 From: Michael <30682308+mike10ca@users.noreply.github.com> Date: Thu, 31 Oct 2024 16:40:53 +0100 Subject: [PATCH 01/10] Tests: Add twap tests (#4455) * Add twap tests --- cypress/e2e/pages/create_tx.pages.js | 13 + cypress/e2e/pages/swaps.pages.js | 313 +++++++++++++++++- cypress/e2e/regression/swaps.cy.js | 4 + cypress/e2e/regression/twaps.cy.js | 72 ++++ cypress/e2e/regression/twaps_2.cy.js | 86 +++++ cypress/e2e/regression/twaps_history.cy.js | 98 ++++++ cypress/fixtures/safes/static.json | 3 +- cypress/fixtures/swaps_data.json | 5 + cypress/support/e2e.js | 6 + .../transactions/BulkTxListGroup/index.tsx | 2 +- 10 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 cypress/e2e/regression/twaps.cy.js create mode 100644 cypress/e2e/regression/twaps_2.cy.js create mode 100644 cypress/e2e/regression/twaps_history.cy.js diff --git a/cypress/e2e/pages/create_tx.pages.js b/cypress/e2e/pages/create_tx.pages.js index 9f33243345..07eb587c89 100644 --- a/cypress/e2e/pages/create_tx.pages.js +++ b/cypress/e2e/pages/create_tx.pages.js @@ -104,6 +104,19 @@ export function deleteTx() { cy.get(deleteTxModalBtn).click() } +export function deleteAllTx() { + cy.get('body').then(($body) => { + if ($body.find(transactionItem).length > 0) { + cy.get(transactionItem).then(($items) => { + for (let i = $items.length - 1; i >= 0; i--) { + cy.wrap($items[i]).click({ force: true }) + deleteTx() + } + }) + } + }) +} + export function setTxType(type) { cy.get(radioSelector).find('label').contains(type).click() } diff --git a/cypress/e2e/pages/swaps.pages.js b/cypress/e2e/pages/swaps.pages.js index ec5db5fc4c..8707e5d35d 100644 --- a/cypress/e2e/pages/swaps.pages.js +++ b/cypress/e2e/pages/swaps.pages.js @@ -1,6 +1,7 @@ import * as constants from '../../support/constants.js' import * as main from '../pages/main.page.js' import * as create_tx from '../pages/create_tx.pages.js' +import * as table from '../pages/tables.page.js' export const inputCurrencyInput = '[id="input-currency-input"]' export const outputCurrencyInput = '[id="output-currency-input"]' @@ -8,6 +9,7 @@ const tokenList = '[id="tokens-list"]' export const swapBtn = '[id="swap-button"]' const exceedFeesChkbox = 'input[id="fees-exceed-checkbox"]' const settingsBtn = 'button[id="open-settings-dialog-button"]' +const settingsBtnTwap = 'button[id^="menu-button--menu"]' export const assetsSwapBtn = '[data-testid="swap-btn"]' export const dashboardSwapBtn = '[data-testid="overview-swap-btn"]' export const customRecipient = 'div[id="recipient"]' @@ -21,7 +23,30 @@ const orderIDFld = '[data-testid="order-id"]' const widgetFeeFld = '[data-testid="widget-fee"]' const interactWithFld = '[data-testid="interact-wth"]' const recipientAlert = '[data-testid="recipient-alert"]' +const groupedItems = '[data-testid="grouped-items"]' +const inputCurrencyPreview = '[id="input-currency-preview"]' +const outputCurrencyPreview = '[id="output-currency-preview"]' +const reviewTwapBtn = '[id="do-trade-button"]' + +const swapStrBtn = 'Swap' +const twapStrBtn = 'TWAP' const confirmSwapStr = 'Confirm Swap' +const maxStrBtn = 'Max' +const numberOfPartsStr = /No\.? of parts/ +const sellAmountStr = 'Sell amount' +const buyAmountStr = 'Buy amount' +const filledStr = 'Filled' +const partDuration = 'Part duration' +const totalDurationStr = 'Total duration' +const oneHr = '1 Hour' +const halfHr = '30m' +const sellperPartStr = /Sell( amount)? per part/ +const buyperPartStr = /Buy( amount)? per part/ +const priceProtectionStr = 'Price protection' +const orderSplit = 'TWAP order split in' + +const getInsufficientBalanceStr = (token) => `Insufficient ${token} balance` +const sellAmountIsSmallStr = 'Sell amount too small' const swapBtnStr = /Confirm Swap|Swap|Confirm (Approve COW and Swap)|Confirm/ const orderSubmittedStr = 'Order Submitted' @@ -40,6 +65,17 @@ export const swapTokens = { eth: 'ETH', } +export const swapTokenNames = { + eth: 'Ether', + cow: 'CoW Protocol Token', + daiTest: 'DAI (test)', + gnoTest: 'GNO (test)', + uni: 'Uniswap', + usdcTest: 'USDC (test)', + usdt: 'Tether USD', + weth: 'Wrapped Ether', +} + export const orderTypes = { swap: 'Swap', limit: 'Limit', @@ -82,6 +118,10 @@ export function clickOnSettingsBtn() { cy.get(settingsBtn).click() } +export function clickOnSettingsBtnTwaps() { + cy.get(settingsBtnTwap).eq(0).click() +} + export function setExpiry(value) { cy.get('div').contains('Swap deadline').parent().next().find('input').clear().type(value) } @@ -135,6 +175,10 @@ export function clickOnSwapBtn() { cy.get('@swapBtn').should('exist').click({ force: true }) } +export function clickOnReviewTwapBtn() { + cy.get(reviewTwapBtn).click() +} + export function checkSwapBtnIsVisible() { cy.get('button').contains(swapBtnStr).should('be.visible') } @@ -209,7 +253,7 @@ export function selectOutputCurrency(option) { export function setInputValue(value) { cy.get(inputCurrencyInput).within(() => { - cy.get('input').type(value) + cy.get('input').clear().type(value) }) } @@ -248,6 +292,10 @@ export function createRegex(pattern, placeholder) { return new RegExp(pattern_, 'i') } +export function getTokenPrice(token) { + return new RegExp(`\\d+\\.\\d+\\s*${token}`, 'i') +} + export function getOrderID() { return new RegExp(`[a-fA-F0-9]{8}`, 'i') } @@ -256,6 +304,10 @@ export function getWidgetFee() { return new RegExp(`\\s*\\d*\\.?\\d+\\s*%\\s*`, 'i') } +export function getTokenValue() { + return new RegExp(`\\$\\d+\\.\\d{2}`, 'i') +} + export function checkTokenOrder(regexPattern, option) { cy.get(create_tx.txRowTitle) .filter(`:contains("${option}")`) @@ -292,3 +344,262 @@ export function verifyOrderDetails(limitPrice, expiry, slippage, interactWith, o export function verifyRecipientAlertIsDisplayed() { main.verifyElementsIsVisible([recipientAlert]) } + +export function switchToTwap() { + cy.get('a').contains(swapStrBtn).click() + cy.wait(1000) + cy.get('a').contains(twapStrBtn).click() +} + +export function checkTokenBalanceAndValue(tokenDirection, balance, value) { + let direction = inputCurrencyInput + if (tokenDirection === 'output') direction = outputCurrencyInput + cy.get(direction).within(() => { + cy.contains(balance).should('be.visible') + cy.contains(value).should('be.visible') + }) +} + +export function checkSellAmount(amount) { + cy.contains(sellAmountStr) + .parent() + .parent() + .within(() => { + cy.contains(amount).should('exist') + }) +} + +export function checkBuyAmount(amount) { + cy.contains(buyAmountStr) + .parent() + .parent() + .within(() => { + cy.contains(amount).should('exist') + }) +} + +export function checkPartDuration(time) { + cy.contains(partDuration) + .parent() + .parent() + .within(() => { + cy.contains(time).should('exist') + }) +} + +export function checkPercentageFilled(percentage, str) { + cy.contains(filledStr) + .parent() + .parent() + .within(() => { + cy.contains(percentage) + cy.contains(str).should('exist') + cy.contains('sold').should('exist') + }) +} + +export function clickOnTokenSelctor(direction) { + let selector = inputCurrencyInput + if (direction === 'output') selector = outputCurrencyInput + cy.get(selector).find('button').click() +} + +export function checkTokenList(tokens) { + cy.get(tokenList).within(() => { + tokens.forEach(({ name, balance }) => { + cy.get('span').contains(name).should('exist') + cy.get('span').contains(balance).should('exist') + }) + }) +} + +export function clickOnMaxBtn() { + cy.get('button').contains(maxStrBtn).click() +} + +export function checkInputValue(direction, value) { + let selector = inputCurrencyInput + if (direction === 'output') selector = outputCurrencyInput + cy.get(selector).find('input').invoke('val').should('eq', value) +} + +export function checkInsufficientBalanceMessageDisplayed(token) { + const text = getInsufficientBalanceStr(token) + cy.get('button').contains(text).should('be.disabled') +} + +export function checkSmallSellAmountMessageDisplayed() { + cy.get('button').contains(sellAmountIsSmallStr).should('be.disabled') +} + +export function checkNumberOfParts(parts) { + cy.contains(numberOfPartsStr) + .parent() + .parent() + .within(() => { + cy.get(table.dataRow) + .invoke('text') + .then((text) => { + const partsInt = parseInt(text, 10) + expect(partsInt).to.eq(parts) + }) + }) +} + +export function checkTwapSettlement(index, sentValue, receivedValue) { + cy.get(groupedItems) + .eq(index) + .within(() => { + cy.get(create_tx.transactionItem).eq(0).contains(sentValue).should('exist') + cy.get(create_tx.transactionItem).eq(1).contains(receivedValue).should('exist') + }) +} + +export function getTwapInitialData() { + let formData = {} + + return cy + .wrap(null) + .then(() => { + cy.get(inputCurrencyInput).within(() => { + cy.get('input') + .invoke('val') + .then((value) => { + formData.inputToken = value + }) + }) + + cy.get(outputCurrencyInput).within(() => { + cy.get('input') + .invoke('val') + .then((value) => { + formData.outputToken = value + }) + }) + + cy.get(inputCurrencyInput).within(() => { + cy.get('button') + .find('span') + .filter((index, button) => Cypress.$(button).text().trim().length > 0) + .invoke('text') + .then((text) => { + formData.inputTokenName = text + }) + }) + + cy.get(outputCurrencyInput).within(() => { + cy.get('button') + .filter((index, button) => Cypress.$(button).text().trim().length > 0) + .invoke('text') + .then((text) => { + formData.outputTokenName = text + }) + }) + + cy.get('span') + .contains(totalDurationStr) + .next() + .invoke('text') + .then((value) => { + formData.totalDuration = value + .toLowerCase() + .replace(/\bhours?\b/, 'hour') + .trim() + }) + + cy.get('span') + .contains(partDuration) + .next() + .invoke('text') + .then((value) => { + formData.partDuration = value + .toLowerCase() + .replace(/(\d+)m\b/, '$1 minutes') + .trim() + }) + + cy.get('span') + .contains(sellperPartStr) + .next() + .invoke('text') + .then((value) => { + formData.sellPart = value + }) + + cy.get('span') + .contains(buyperPartStr) + .next() + .invoke('text') + .then((value) => { + formData.buyPart = value + }) + + cy.get('span') + .contains(numberOfPartsStr) + .next() + .find('input') + .invoke('val') + .then((value) => { + formData.numberOfParts = value + }) + }) + .then(() => { + console.log('****************** Collected FormData:', formData) + return cy.wrap(formData) + }) +} + +export function checkTwapValuesInReviewScreen(formData) { + cy.get(inputCurrencyPreview).should('contain', formData.inputToken) + cy.get(inputCurrencyPreview).should('contain', formData.inputTokenName) + cy.get(outputCurrencyPreview).should('contain', formData.outputToken) + cy.get(outputCurrencyPreview).should('contain', formData.outputTokenName) + + cy.get('span') + .contains(totalDurationStr) + .parent() + .next() + .invoke('text') + .then((displayedValue) => { + const normalizedDisplayedValue = displayedValue + .toLowerCase() + .replace(/\bhours?\b/, 'hour') + .trim() + expect(normalizedDisplayedValue).to.eq(formData.totalDuration) + }) + + cy.get('span') + .contains(partDuration) + .parent() + .next() + .invoke('text') + .then((displayedValue) => { + const normalizedDisplayedValue = displayedValue + .toLowerCase() + .replace(/\b(m|minutes?)\b/, 'minutes') + .trim() + expect(normalizedDisplayedValue).to.eq(formData.partDuration) + }) + + cy.get('span').contains(sellperPartStr).parent().next().should('contain', formData.sellPart) + + const buyPartValue = formData.buyPart + let [number, tokenName] = buyPartValue.split(' ') + + cy.get('span') + .contains(buyperPartStr) + .parent() + .next() + .invoke('text') + .then((text) => { + const numericValue = text.match(/\d+(\.\d+)?/)[0] + expect(text).to.include(`${numericValue} ${tokenName}`) + }) + + cy.contains(orderSplit) + .next() + .invoke('text') + .then((text) => { + expect(text).to.include(formData.numberOfParts) + }) +} diff --git a/cypress/e2e/regression/swaps.cy.js b/cypress/e2e/regression/swaps.cy.js index d5818d2aba..cc5413bdaf 100644 --- a/cypress/e2e/regression/swaps.cy.js +++ b/cypress/e2e/regression/swaps.cy.js @@ -179,6 +179,10 @@ describe('Swaps tests', () => { safeAddress: staticSafes.SEP_STATIC_SAFE_1.slice(6), }, ] + // Clean txs in the queue + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_1) + create_tx.deleteAllTx() + swaps.acceptLegalDisclaimer() cy.wait(4000) main.getIframeBody(iframeSelector).within(() => { diff --git a/cypress/e2e/regression/twaps.cy.js b/cypress/e2e/regression/twaps.cy.js new file mode 100644 index 0000000000..ee918f2776 --- /dev/null +++ b/cypress/e2e/regression/twaps.cy.js @@ -0,0 +1,72 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as swaps from '../pages/swaps.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +let staticSafes = [] +let iframeSelector + +// Blocked by a bug on UI +describe.skip('Twaps tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_27) + main.waitForHistoryCallToComplete() + wallet.connectSigner(signer) + iframeSelector = `iframe[src*="${constants.swapWidget}"]` + }) + + it('Verify list of tokens with balances is displayed in the token selector', () => { + const tokens = [ + { name: swaps.swapTokenNames.eth, balance: '0' }, + { name: swaps.swapTokenNames.cow, balance: '750' }, + { name: swaps.swapTokenNames.daiTest, balance: '0' }, + { name: swaps.swapTokenNames.gnoTest, balance: '0' }, + { name: swaps.swapTokenNames.uni, balance: '0' }, + { name: swaps.swapTokenNames.usdcTest, balance: '0' }, + { name: swaps.swapTokenNames.usdt, balance: '0' }, + { name: swaps.swapTokenNames.weth, balance: '0' }, + ] + + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + swaps.clickOnTokenSelctor('input') + swaps.checkTokenList(tokens) + }) + }) + + it('Verify "Balances" tag and value is present for selected token', () => { + const tokenValue = swaps.getTokenValue() + + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(500) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.checkTokenBalanceAndValue('input', '750 COW', tokenValue) + }) + }) + + it('Verify that the "Max" button sets the value as the max balance', () => { + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.clickOnMaxBtn() + swaps.checkInputValue('input', '750') + }) + }) +}) diff --git a/cypress/e2e/regression/twaps_2.cy.js b/cypress/e2e/regression/twaps_2.cy.js new file mode 100644 index 0000000000..84f1399de1 --- /dev/null +++ b/cypress/e2e/regression/twaps_2.cy.js @@ -0,0 +1,86 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as swaps from '../pages/swaps.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +let staticSafes = [] +let iframeSelector + +// Blocked by a bug on UI +describe.skip('Twaps 2 tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_27) + main.waitForHistoryCallToComplete() + wallet.connectSigner(signer) + iframeSelector = `iframe[src*="${constants.swapWidget}"]` + }) + + it( + 'Verify "Insufficient balance" message appears when the entered token amount exceeds "Max" balance', + { defaultCommandTimeout: 30000 }, + () => { + wallet.connectSigner(signer) + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(2000) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.checkInsufficientBalanceMessageDisplayed(swaps.swapTokens.cow) + }) + }, + ) + + it( + 'Verify "Sell amount too low" if the amount of tokens is worth less than 200 USD', + { defaultCommandTimeout: 30000 }, + () => { + wallet.connectSigner(signer) + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(100) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.checkSmallSellAmountMessageDisplayed() + }) + }, + ) + + it( + 'Verify entering a blocked address in the custom recipient input blocks the form', + { defaultCommandTimeout: 30000 }, + () => { + let isCustomRecipientFound + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main + .getIframeBody(iframeSelector) + .then(($frame) => { + isCustomRecipientFound = (customRecipient) => { + const element = $frame.find(customRecipient) + return element.length > 0 + } + }) + .within(() => { + swaps.switchToTwap() + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.clickOnSettingsBtnTwaps() + swaps.enableCustomRecipient(isCustomRecipientFound(swaps.customRecipient)) + swaps.clickOnSettingsBtnTwaps() + swaps.enterRecipient(swaps.blockedAddress) + }) + cy.contains(swaps.blockedAddressStr) + }, + ) +}) diff --git a/cypress/e2e/regression/twaps_history.cy.js b/cypress/e2e/regression/twaps_history.cy.js new file mode 100644 index 0000000000..23cb87a543 --- /dev/null +++ b/cypress/e2e/regression/twaps_history.cy.js @@ -0,0 +1,98 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as swaps from '../pages/swaps.pages.js' +import * as create_tx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as swaps_data from '../../fixtures/swaps_data.json' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +let staticSafes = [] + +let iframeSelector + +const swapsHistory = swaps_data.type.history + +// Blocked by a bug on UI +describe.skip('Twaps history tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_27) + main.waitForHistoryCallToComplete() + wallet.connectSigner(signer) + iframeSelector = `iframe[src*="${constants.swapWidget}"]` + }) + + it('Verify order deails', () => { + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(500) + cy.wait(5000) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + cy.wait(5000) + swaps.getTwapInitialData().then((formData) => { + swaps.clickOnReviewTwapBtn() + swaps.checkTwapValuesInReviewScreen(formData) + }) + }) + }) + + it('Verify partially filled sell order', () => { + const tx = + 'sep:0x8f4A19C85b39032A37f7a6dCc65234f966F72551&id=multisig_0x8f4A19C85b39032A37f7a6dCc65234f966F72551_0x2fdf5e5d94306de5f7285fd74ca014067b090338b3ff15e3f66d6c02ef81e4a4' + cy.visit(constants.transactionUrl + tx) + const weth = swaps.createRegex(swapsHistory.forAtLeastFullWETH, 'WETH') + const eq = swaps.createRegex(swapsHistory.WETHeqDAI, 'DAI') + const sellAmount = swaps.getTokenPrice('DAI') + const buyAmount = swaps.getTokenPrice('WETH') + const tokenSoldPrice = swaps.getTokenPrice('DAI') + + create_tx.verifyExpandedDetails([swapsHistory.sell, weth, eq, swapsHistory.dai, swapsHistory.partiallyFilled]) + swaps.checkNumberOfParts(2) + swaps.checkSellAmount(sellAmount) + swaps.checkBuyAmount(buyAmount) + swaps.checkPercentageFilled(50, tokenSoldPrice) + swaps.checkPartDuration('30 minutes') + + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.createWithContext, swapsHistory.composableCoW]) + }) + + it('Verify that an order has the received and sent txs', () => { + const sentValue = '-250 COW' + const receivedValue = '303.16951 DAI' + + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_27) + create_tx.toggleUntrustedTxs() + swaps.checkTwapSettlement(0, sentValue, receivedValue) + }) + + it('Verify fully filled sell order', () => { + const tx = + 'sep:0x8f4A19C85b39032A37f7a6dCc65234f966F72551&id=multisig_0x8f4A19C85b39032A37f7a6dCc65234f966F72551_0xc8a9399afbba45e82a0645770db38386cbe10bec77dd8b6395f7d24e19a45c9a' + cy.visit(constants.transactionUrl + tx) + const weth = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqWETH, 'WETH') + const sellAmount = swaps.getTokenPrice('WETH') + const buyAmount = swaps.getTokenPrice('DAI') + const tokenSoldPrice = swaps.getTokenPrice('WETH') + + create_tx.verifyExpandedDetails([swapsHistory.sell, weth, eq, swapsHistory.dai, swapsHistory.filled]) + swaps.checkNumberOfParts(2) + swaps.checkSellAmount(sellAmount) + swaps.checkBuyAmount(buyAmount) + swaps.checkPercentageFilled(100, tokenSoldPrice) + swaps.checkPartDuration('30 minutes') + + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.createWithContext, swapsHistory.composableCoW]) + }) +}) diff --git a/cypress/fixtures/safes/static.json b/cypress/fixtures/safes/static.json index eb1acacfa7..4793a62b83 100644 --- a/cypress/fixtures/safes/static.json +++ b/cypress/fixtures/safes/static.json @@ -26,5 +26,6 @@ "SEP_STATIC_SAFE_23": "sep:0x589d862CE2d519d5A862066bB923da0564c3D2EA", "SEP_STATIC_SAFE_24": "sep:0x49DC5764961DA4864DC5469f16BC68a0F765f2F2", "SEP_STATIC_SAFE_25": "sep:0x4ECFAa2E8cb4697bCD27bdC9Ce3E16f03F73124F", - "SEP_STATIC_SAFE_26": "sep:0x755428b02A458eD17fa93c86F6C3a2046F2c4C3C" + "SEP_STATIC_SAFE_26": "sep:0x755428b02A458eD17fa93c86F6C3a2046F2c4C3C", + "SEP_STATIC_SAFE_27": "sep:0xC97FCf0B8890a5a7b1a1490d44Dc9EbE3cE04884" } diff --git a/cypress/fixtures/swaps_data.json b/cypress/fixtures/swaps_data.json index 9160f5a338..20efadf8a3 100644 --- a/cypress/fixtures/swaps_data.json +++ b/cypress/fixtures/swaps_data.json @@ -28,6 +28,7 @@ "sellOrder": "Sell order", "actionApproveG": "approve", "actionPreSignatureG": "setPreSignature", + "composableCoW": "ComposableCoW", "actionDepositG": "deposit", "amount": "Amount", "executionPrice": "Execution price", @@ -43,6 +44,9 @@ "forAtLeastFullDai": "for at least DAI", "forAtLeastFullUni": "for at least UNI", "forAtLeastFullUSDT": "for at least USDT", + "forAtLeastFullWETH": "for at least WETH", + "daiSold": "DAI sold", + "WETHeqDAI": "1 WETH = 1.32K DAI", "DAIeqCOW": "1 DAI = COW", "UNIeqCOW": "1 UNI = K COW", "DAIeqWETH": "1 DAI = WETH", @@ -51,6 +55,7 @@ "filled": "Filled", "partiallyFilled": "Partially filled", "gGpV2": "GPv2Settlement", + "createWithContext": "createWithContext", "safeAppTitile": "CowSwap", "title": "Swap order", "multiSend": "multiSend", diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index eef18956b2..5be72d9f02 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -47,6 +47,12 @@ before(() => { app.document.head.appendChild(style) } } + const originalConsoleLog = console.log + console.log = (...args) => { + if (typeof args[0] === 'string' && !args[0].includes('Intercepted request with headers')) { + originalConsoleLog(...args) + } + } }) }) diff --git a/src/components/transactions/BulkTxListGroup/index.tsx b/src/components/transactions/BulkTxListGroup/index.tsx index b793fef6cc..935c2254c2 100644 --- a/src/components/transactions/BulkTxListGroup/index.tsx +++ b/src/components/transactions/BulkTxListGroup/index.tsx @@ -38,7 +38,7 @@ const GroupedTxListItems = ({ title = getSettlementOrderTitle(groupedListItems[0].transaction.txInfo as Order) } return ( - + From aad2bb54b255c141b795836850cfba4b6c827dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=B3vis=20Neto?= Date: Fri, 1 Nov 2024 11:04:11 +0100 Subject: [PATCH 02/10] Refactor: unify confirmations in the transaction flow (#4177) * chore: create a wrapper on top of signOrExecuteForm to fetch async data before showing the ui * chore: show the confirmation view component based on the transaction type * chore: move presentational layer of reviewOwner to SettingsChange component * chore: do not use showMethodCall to signOrExecuteForm component * feat: Add a loader in the signOrExecuteForm component while the necessary data is not fetched * fix: unit tests * fix: lint errors * mend * fix: pass the txInfo in the useTxDetails mock to avoid null pointer exception * fix: generated snapshots * fix: generated snapshots * fix: change the fundReceiver address on snapshots * fix: remove unnecessary casting * chore: rename useDetailsHook to be useProposeTx * fix: grab the transaction id from the txDetails in case the txId is not provided * fix: rename file from utils to mockData * chore: move the settings component to be exported directly in the index file * fix: add the batch button back to the confirmation views * fix: use the txData component in the confirmation view in case any confirmation view component is found * feat: show an error screen if something happens while fetching the txDetails * fix: show contract name when it is a multisend transaction * fix: eslint errors * fix: move error condition to top of the confirmation view component * fix: do not propose transaction for contrafactual safes * fix: cypress drain e2e test * fix: remove duplicated data in the DecodedTx component * fix: Add back showMethodCall and keep prop drilling it * fix: do not show method call for approve transactions * fix: add showMethodCall into the confirmationView component * fix: pass isApproval down to the confirmation view component * fix: Add isCreation check inside DecodedTx to render partial summary * fix: Update snapshot and mock hex data generation to be even length * chore: generated snapshots * chore: refactor changeThresholdReview screen (#4212) * Approval editor * Fix error display in confirmation screen * chore: unify confirmBatch screen (#4217) * fix(account-flow-import): change import src in the recover account flow screen * fix(eslint): eslint hook dependencies * fix(unit-tests): change txDetails mocked data in unit tests * fix(eslint): change operators order * fix(unit-tests): mock useSafeAddress hook * fix(settings-change): add address name in the change owner screen * fix(settings-change): duplicated owner name in the add owner flow --------- Co-authored-by: Usame Algan Co-authored-by: katspaugh --- cypress/e2e/pages/safeapps.pages.js | 2 +- src/components/common/ErrorBoundary/index.tsx | 7 +- .../Summary/SafeTxHashDataRow/index.tsx | 1 + .../TxData/DecodedData/Multisend/index.tsx | 1 - src/components/tx-flow/SafeTxProvider.tsx | 11 +- .../tx-flow/flows/AddOwner/ReviewOwner.tsx | 38 +- .../tx-flow/flows/AddOwner/context.ts | 7 + .../ChangeThreshold/ReviewChangeThreshold.tsx | 23 +- .../tx-flow/flows/ChangeThreshold/context.tsx | 5 + .../tx-flow/flows/ConfirmBatch/index.tsx | 7 +- .../flows/ConfirmTx/ConfirmProposedTx.tsx | 2 +- .../RecoverAccountFlowReview.tsx | 6 +- .../ReviewSignMessageOnChain.test.tsx | 141 ++-- .../TokenTransfer/ReviewSpendingLimitTx.tsx | 2 +- .../TokenTransfer/ReviewTokenTransfer.tsx | 3 +- src/components/tx/DecodedTx/index.test.tsx | 115 ++- src/components/tx/DecodedTx/index.tsx | 61 +- .../tx/SignOrExecuteForm/DelegateForm.tsx | 2 +- .../tx/SignOrExecuteForm/ExecuteForm.tsx | 3 +- .../__test__/ExecuteThroughRoleForm.test.tsx | 5 +- .../ExecuteThroughRoleForm/index.tsx | 2 +- .../tx/SignOrExecuteForm/SignForm.tsx | 3 +- .../SignOrExecuteForm/SignOrExecuteForm.tsx | 242 +++++++ .../SignOrExecuteSkeleton.tsx | 13 + .../__tests__/ExecuteForm.test.tsx | 33 +- .../__tests__/SignForm.test.tsx | 33 +- .../__tests__/SignOrExecute.test.tsx | 255 ++----- .../__tests__/SignOrExecuteForm.test.tsx | 365 ++++++++++ .../__snapshots__/SignOrExecute.test.tsx.snap | 524 ++++++++++++++ src/components/tx/SignOrExecuteForm/hooks.ts | 49 +- src/components/tx/SignOrExecuteForm/index.tsx | 261 +------ .../BatchTransactions.stories.tsx | 42 ++ .../BatchTransactions.test.tsx | 15 + .../BatchTransactions.test.tsx.snap | 273 +++++++ .../BatchTransactions/index.tsx | 10 + .../BatchTransactions/mockData.ts | 121 ++++ .../ChangeThreshold.stories.tsx | 33 + .../ChangeThreshold/ChangeThreshold.test.tsx | 31 + .../ChangeThreshold.test.tsx.snap | 33 + .../ChangeThreshold/index.tsx | 33 + .../ConfirmationView.test.tsx | 135 ++++ .../SettingsChange/SettingsChange.stories.tsx | 59 ++ .../SettingsChange/SettingsChange.test.tsx | 55 ++ .../SettingsChange.test.tsx.snap | 360 ++++++++++ .../SettingsChange/index.tsx | 67 ++ .../SettingsChange/mockData.ts | 32 + .../ConfirmationView.test.tsx.snap | 679 ++++++++++++++++++ .../tx/confirmation-views/index.tsx | 85 +++ .../tx/confirmation-views/types.d.ts | 6 + src/components/tx/confirmation-views/utils.ts | 10 + .../counterfactual/CounterfactualForm.tsx | 3 +- src/tests/transactions.ts | 19 + 52 files changed, 3682 insertions(+), 641 deletions(-) create mode 100644 src/components/tx-flow/flows/AddOwner/context.ts create mode 100644 src/components/tx-flow/flows/ChangeThreshold/context.tsx create mode 100644 src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx create mode 100644 src/components/tx/SignOrExecuteForm/SignOrExecuteSkeleton.tsx create mode 100644 src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx create mode 100644 src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap create mode 100644 src/components/tx/confirmation-views/BatchTransactions/BatchTransactions.stories.tsx create mode 100644 src/components/tx/confirmation-views/BatchTransactions/BatchTransactions.test.tsx create mode 100644 src/components/tx/confirmation-views/BatchTransactions/__snapshots__/BatchTransactions.test.tsx.snap create mode 100644 src/components/tx/confirmation-views/BatchTransactions/index.tsx create mode 100644 src/components/tx/confirmation-views/BatchTransactions/mockData.ts create mode 100644 src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.stories.tsx create mode 100644 src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.test.tsx create mode 100644 src/components/tx/confirmation-views/ChangeThreshold/__snapshots__/ChangeThreshold.test.tsx.snap create mode 100644 src/components/tx/confirmation-views/ChangeThreshold/index.tsx create mode 100644 src/components/tx/confirmation-views/ConfirmationView.test.tsx create mode 100644 src/components/tx/confirmation-views/SettingsChange/SettingsChange.stories.tsx create mode 100644 src/components/tx/confirmation-views/SettingsChange/SettingsChange.test.tsx create mode 100644 src/components/tx/confirmation-views/SettingsChange/__snapshots__/SettingsChange.test.tsx.snap create mode 100644 src/components/tx/confirmation-views/SettingsChange/index.tsx create mode 100644 src/components/tx/confirmation-views/SettingsChange/mockData.ts create mode 100644 src/components/tx/confirmation-views/__snapshots__/ConfirmationView.test.tsx.snap create mode 100644 src/components/tx/confirmation-views/index.tsx create mode 100644 src/components/tx/confirmation-views/types.d.ts create mode 100644 src/components/tx/confirmation-views/utils.ts diff --git a/cypress/e2e/pages/safeapps.pages.js b/cypress/e2e/pages/safeapps.pages.js index ec67d8cc5f..19f9c525b4 100644 --- a/cypress/e2e/pages/safeapps.pages.js +++ b/cypress/e2e/pages/safeapps.pages.js @@ -64,7 +64,7 @@ export const testBooleanValue3 = '3 testBooleanValue' export const transfer2AssetsStr = 'Transfer 2 assets' export const testTransfer1 = '1 transfer' -export const testTransfer2 = '2 transfer' +export const testTransfer2 = '2 MetaMultiSigWallet: transfer' export const nativeTransfer2 = '2 native transfer' export const nativeTransfer1 = '1 native transfer' diff --git a/src/components/common/ErrorBoundary/index.tsx b/src/components/common/ErrorBoundary/index.tsx index f25c1bbcfa..bb410b7c98 100644 --- a/src/components/common/ErrorBoundary/index.tsx +++ b/src/components/common/ErrorBoundary/index.tsx @@ -1,5 +1,4 @@ import { Typography, Link } from '@mui/material' -import type { FallbackRender } from '@sentry/react' import { HELP_CENTER_URL, IS_PRODUCTION } from '@/config/constants' import { AppRoutes } from '@/config/routes' @@ -8,8 +7,12 @@ import WarningIcon from '@/public/images/notifications/warning.svg' import css from '@/components/common/ErrorBoundary/styles.module.css' import CircularIcon from '../icons/CircularIcon' import ExternalLink from '../ExternalLink' +interface ErrorBoundaryProps { + error: Error + componentStack: string +} -const ErrorBoundary: FallbackRender = ({ error, componentStack }) => { +const ErrorBoundary = ({ error, componentStack }: ErrorBoundaryProps) => { return (
diff --git a/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx b/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx index c6b1d2b26a..20a26981c8 100644 --- a/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx +++ b/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx @@ -16,6 +16,7 @@ export const SafeTxHashDataRow = ({ }) => { const chainId = useChainId() const safeAddress = useSafeAddress() + const domainHash = TypedDataEncoder.hashDomain({ chainId, verifyingContract: safeAddress, diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx index 0672be131c..5110562ff6 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx @@ -71,7 +71,6 @@ export const Multisend = ({ txData, compact = false }: MultisendProps): ReactEle if (!multiSendTransactions) { return null } - return ( <> diff --git a/src/components/tx-flow/SafeTxProvider.tsx b/src/components/tx-flow/SafeTxProvider.tsx index b964f7546c..f83c86663a 100644 --- a/src/components/tx-flow/SafeTxProvider.tsx +++ b/src/components/tx-flow/SafeTxProvider.tsx @@ -9,7 +9,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { useCurrentChain } from '@/hooks/useChains' import { prependSafeToL2Migration } from '@/utils/transactions' -export const SafeTxContext = createContext<{ +export type SafeTxContextParams = { safeTx?: SafeTransaction setSafeTx: Dispatch> @@ -28,7 +28,9 @@ export const SafeTxContext = createContext<{ setSafeTxGas: Dispatch> recommendedNonce?: number -}>({ +} + +export const SafeTxContext = createContext({ setSafeTx: () => {}, setSafeMessage: () => {}, setSafeTxError: () => {}, @@ -81,7 +83,10 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => if (safeTx.data.nonce === finalNonce && safeTx.data.safeTxGas === finalSafeTxGas) return createTx({ ...safeTx.data, safeTxGas: String(finalSafeTxGas) }, finalNonce) - .then(setSafeTx) + .then((tx) => { + console.log('SafeTxProvider: Updated tx with nonce and safeTxGas', tx) + setSafeTx(tx) + }) .catch(setSafeTxError) }, [isSigned, finalNonce, finalSafeTxGas, safeTx?.data]) diff --git a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx index 7806b28b97..7c148cc2d2 100644 --- a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx +++ b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx @@ -1,6 +1,5 @@ import { useCurrentChain } from '@/hooks/useChains' import { useContext, useEffect } from 'react' -import { Typography, Divider, Box, SvgIcon, Paper } from '@mui/material' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import useSafeInfo from '@/hooks/useSafeInfo' @@ -11,11 +10,7 @@ import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { SafeTxContext } from '../../SafeTxProvider' import type { AddOwnerFlowProps } from '.' import type { ReplaceOwnerFlowProps } from '../ReplaceOwner' -import { OwnerList } from '../../common/OwnerList' -import MinusIcon from '@/public/images/common/minus.svg' -import EthHashInfo from '@/components/common/EthHashInfo' -import commonCss from '@/components/tx-flow/common/styles.module.css' -import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' +import { SettingsChangeContext } from './context' export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwnerFlowProps }) => { const dispatch = useAppDispatch() @@ -57,33 +52,8 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn } return ( - - {params.removedOwner && ( - palette.warning.background, p: 2 }}> - - - Previous signer - - - - )} - - - - - - Any transaction requires the confirmation of: - - {threshold} out of {safe.owners.length + (removedOwner ? 0 : 1)} signers - - - - + + + ) } diff --git a/src/components/tx-flow/flows/AddOwner/context.ts b/src/components/tx-flow/flows/AddOwner/context.ts new file mode 100644 index 0000000000..ce829bcb8b --- /dev/null +++ b/src/components/tx-flow/flows/AddOwner/context.ts @@ -0,0 +1,7 @@ +import { type Context, createContext } from 'react' +import { type AddOwnerFlowProps } from '.' +import { type ReplaceOwnerFlowProps } from '../ReplaceOwner' + +type SettingsChange = Context + +export const SettingsChangeContext: SettingsChange = createContext({} as AddOwnerFlowProps | ReplaceOwnerFlowProps) diff --git a/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx index 8fe4425152..48e7a2a238 100644 --- a/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx +++ b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx @@ -1,6 +1,5 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { useContext, useEffect } from 'react' -import { Box, Divider, Typography } from '@mui/material' import { createUpdateThresholdTx } from '@/services/tx/tx-sender' import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' @@ -9,8 +8,7 @@ import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { ChangeThresholdFlowFieldNames } from '@/components/tx-flow/flows/ChangeThreshold' import type { ChangeThresholdFlowProps } from '@/components/tx-flow/flows/ChangeThreshold' -import commonCss from '@/components/tx-flow/common/styles.module.css' -import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' +import { ChangeThresholdReviewContext } from './context' const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) => { const { safe } = useSafeInfo() @@ -28,22 +26,9 @@ const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) } return ( - - - -
- - Any transaction will require the confirmation of: - - - - {newThreshold} out of {safe.owners.length} signer(s) - -
- - - -
+ + + ) } diff --git a/src/components/tx-flow/flows/ChangeThreshold/context.tsx b/src/components/tx-flow/flows/ChangeThreshold/context.tsx new file mode 100644 index 0000000000..037a1fe6b7 --- /dev/null +++ b/src/components/tx-flow/flows/ChangeThreshold/context.tsx @@ -0,0 +1,5 @@ +import { createContext } from 'react' + +export const ChangeThresholdReviewContext = createContext({ + newThreshold: 0, +}) diff --git a/src/components/tx-flow/flows/ConfirmBatch/index.tsx b/src/components/tx-flow/flows/ConfirmBatch/index.tsx index 27e53d211f..a0159b21e8 100644 --- a/src/components/tx-flow/flows/ConfirmBatch/index.tsx +++ b/src/components/tx-flow/flows/ConfirmBatch/index.tsx @@ -8,7 +8,6 @@ import { OperationType } from '@safe-global/safe-core-sdk-types' import TxLayout from '../../common/TxLayout' import BatchIcon from '@/public/images/common/batch.svg' import { useDraftBatch } from '@/hooks/useDraftBatch' -import BatchTxList from '@/components/batch/BatchSidebar/BatchTxList' type ConfirmBatchProps = { onSubmit: () => void @@ -32,11 +31,7 @@ const ConfirmBatch = ({ onSubmit }: ConfirmBatchProps): ReactElement => { createMultiSendCallOnlyTx(calls).then(setSafeTx).catch(setSafeTxError) }, [batchTxs, setSafeTx, setSafeTxError]) - return ( - - - - ) + return } const ConfirmBatchFlow = (props: ConfirmBatchProps) => { diff --git a/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx b/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx index c9e4587cb9..c62c776a3b 100644 --- a/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx +++ b/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx @@ -4,10 +4,10 @@ import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sd import useSafeInfo from '@/hooks/useSafeInfo' import { useChainId } from '@/hooks/useChainId' import useWallet from '@/hooks/wallets/useWallet' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { isExecutable, isMultisigExecutionInfo, isSignableBy } from '@/utils/transaction-guards' import { createExistingTx } from '@/services/tx/tx-sender' import { SafeTxContext } from '../../SafeTxProvider' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' type ConfirmProposedTxProps = { txSummary: TransactionSummary diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx index e057ea107f..71db742fce 100644 --- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx @@ -34,6 +34,8 @@ import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletReject import commonCss from '@/components/tx-flow/common/styles.module.css' import { BlockaidBalanceChanges } from '@/components/tx/security/blockaid/BlockaidBalanceChange' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import { useGetTransactionDetailsQuery } from '@/store/api/gateway' +import { skipToken } from '@reduxjs/toolkit/query' export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlowProps }): ReactElement | null { // Form state @@ -52,6 +54,8 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo const recovery = data && selectDelayModifierByRecoverer(data, wallet?.address ?? '') const [, executionValidationError] = useIsValidRecoveryExecTransactionFromModule(recovery?.address, safeTx) + const { data: txDetails } = useGetTransactionDetailsQuery(skipToken) + // Proposal const newThreshold = Number(params[RecoverAccountFlowFields.threshold]) const newOwners = params[RecoverAccountFlowFields.owners] @@ -127,7 +131,7 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo - + diff --git a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx index 4acae50f16..a874915640 100644 --- a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx +++ b/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx @@ -3,15 +3,26 @@ import * as web3 from '@/hooks/wallets/web3' import * as useSafeInfo from '@/hooks/useSafeInfo' import { render, screen } from '@/tests/test-utils' import * as execThroughRoleHooks from '@/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk' import ReviewSignMessageOnChain from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain' import { JsonRpcProvider, zeroPadValue } from 'ethers' import { act } from '@testing-library/react' +import type { SafeTxContextParams } from '../../SafeTxProvider' +import { SafeTxContext } from '../../SafeTxProvider' +import { createSafeTx } from '@/tests/builders/safeTx' +import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([]) - describe('ReviewSignMessageOnChain', () => { test('can handle messages with EIP712Domain type in the JSON-RPC payload', async () => { + jest.spyOn(hooks, 'useProposeTx').mockReturnValue([ + { + txInfo: {}, + } as TransactionDetails, + undefined, + false, + ]) jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => new JsonRpcProvider()) jest.spyOn(useSafeInfo, 'default').mockImplementation( () => @@ -27,66 +38,74 @@ describe('ReviewSignMessageOnChain', () => { await act(async () => { render( - , + + + , ) }) diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx index 8c28146da8..80f51861f4 100644 --- a/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx +++ b/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx @@ -23,7 +23,7 @@ import useOnboard from '@/hooks/wallets/useOnboard' import { asError } from '@/services/exceptions/utils' import TxCard from '@/components/tx-flow/common/TxCard' import { TxModalContext } from '@/components/tx-flow' -import { type SubmitCallback } from '@/components/tx/SignOrExecuteForm' +import { type SubmitCallback } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' import { TX_EVENTS, TX_TYPES } from '@/services/analytics/events/transactions' import { isWalletRejection } from '@/utils/wallets' import { safeParseUnits } from '@/utils/formatters' diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx index d7ec1e3ea1..0c161bff10 100644 --- a/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx +++ b/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx @@ -1,6 +1,6 @@ import { useContext, useEffect, useMemo } from 'react' import useBalances from '@/hooks/useBalances' -import SignOrExecuteForm, { type SubmitCallback } from '@/components/tx/SignOrExecuteForm' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' import SendToBlock from '@/components/tx/SendToBlock' import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' @@ -8,6 +8,7 @@ import { createTx } from '@/services/tx/tx-sender' import type { TokenTransferParams } from '.' import { SafeTxContext } from '../../SafeTxProvider' import { safeParseUnits } from '@/utils/formatters' +import type { SubmitCallback } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' const ReviewTokenTransfer = ({ params, diff --git a/src/components/tx/DecodedTx/index.test.tsx b/src/components/tx/DecodedTx/index.test.tsx index 078c5c8895..0cf81f15f1 100644 --- a/src/components/tx/DecodedTx/index.test.tsx +++ b/src/components/tx/DecodedTx/index.test.tsx @@ -2,12 +2,110 @@ import { fireEvent, render } from '@/tests/test-utils' import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' import DecodedTx from '.' import { waitFor } from '@testing-library/react' +import { createMockTransactionDetails } from '@/tests/transactions' +import { + DetailedExecutionInfoType, + SettingsInfoType, + TransactionInfoType, +} from '@safe-global/safe-gateway-typescript-sdk' import type { DecodedDataResponse } from '@safe-global/safe-gateway-typescript-sdk' +const txDetails = createMockTransactionDetails({ + txInfo: { + type: TransactionInfoType.SETTINGS_CHANGE, + humanDescription: 'Add new owner 0xd8dA...6045 with threshold 1', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + }, + ], + }, + settingsInfo: { + type: SettingsInfoType.ADD_OWNER, + owner: { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: 'Nevinha', + logoUri: 'http://something.com', + }, + threshold: 1, + }, + }, + txData: { + hexData: + '0x0d582f13000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000000000000000001', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + }, + ], + }, + to: { + value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + name: '', + }, + value: '0', + operation: 0, + trustedDelegateCallTarget: false, + addressInfoIndex: { + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045': { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: 'MetaMultiSigWallet', + }, + }, + }, + detailedExecutionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + submittedAt: 1726064794013, + nonce: 4, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: 'MetaMultiSigWallet', + }, + safeTxHash: '0x96a96c11b8d013ff5d7a6ce960b22e961046cfa42eff422ac71c1daf6adef2e0', + signers: [ + { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, + ], + confirmationsRequired: 1, + confirmations: [], + rejectors: [], + trusted: false, + proposer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, + }, +}) describe('DecodedTx', () => { it('should render a native transfer', async () => { const result = render( { fireEvent.click(result.getByText('Advanced details')) await waitFor(() => { - expect(result.queryByText('safeTxGas:')).toBeInTheDocument() - expect(result.queryByText('Raw data:')).toBeInTheDocument() + expect(result.queryAllByText('safeTxGas:').length).toBeGreaterThan(0) + expect(result.queryAllByText('Raw data:').length).toBeGreaterThan(0) }) }) @@ -95,6 +193,9 @@ describe('DecodedTx', () => { it('should render an ERC20 transfer', async () => { const result = render( { }, ], }} - showMethodCall />, ) @@ -134,12 +234,12 @@ describe('DecodedTx', () => { await waitFor(() => { expect(result.queryByText('transfer')).toBeInTheDocument() - expect(result.queryByText('Parameters')).toBeInTheDocument() + expect(result.queryAllByText('Parameters').length).toBeGreaterThan(0) expect(result.queryByText('to')).toBeInTheDocument() - expect(result.queryByText('address')).toBeInTheDocument() + expect(result.queryAllByText('address').length).toBeGreaterThan(0) expect(result.queryByText('0x474e...78C8')).toBeInTheDocument() expect(result.queryByText('value')).toBeInTheDocument() - expect(result.queryByText('uint256')).toBeInTheDocument() + expect(result.queryAllByText('uint256').length).toBeGreaterThan(0) expect(result.queryByText('16745726664999765048')).toBeInTheDocument() }) }) @@ -228,6 +328,7 @@ describe('DecodedTx', () => { ], }} showMethodCall + showMultisend />, ) @@ -237,6 +338,7 @@ describe('DecodedTx', () => { it('should render a function call without parameters', async () => { const result = render( { }, } as SafeTransaction } + showMultisend={false} decodedData={{ method: 'deposit', parameters: [], diff --git a/src/components/tx/DecodedTx/index.tsx b/src/components/tx/DecodedTx/index.tsx index 09912275da..e213290c02 100644 --- a/src/components/tx/DecodedTx/index.tsx +++ b/src/components/tx/DecodedTx/index.tsx @@ -1,11 +1,14 @@ import { type SyntheticEvent, type ReactElement, memo } from 'react' -import { isCustomTxInfo } from '@/utils/transaction-guards' -import { Accordion, AccordionDetails, AccordionSummary, Box, Skeleton, Stack } from '@mui/material' +import { + isCustomTxInfo, + isMultisigDetailedExecutionInfo, + isNativeTokenTransfer, + isTransferTxInfo, +} from '@/utils/transaction-guards' +import { Accordion, AccordionDetails, AccordionSummary, Box, Stack } from '@mui/material' import { OperationType, type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import type { DecodedDataResponse } from '@safe-global/safe-gateway-typescript-sdk' +import type { DecodedDataResponse, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { Operation } from '@safe-global/safe-gateway-typescript-sdk' -import useChainId from '@/hooks/useChainId' -import ErrorMessage from '../ErrorMessage' import Summary, { PartialSummary } from '@/components/transactions/TxDetails/Summary' import { trackEvent, MODALS_EVENTS } from '@/services/analytics' import Multisend from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend' @@ -13,13 +16,11 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' import accordionCss from '@/styles/accordion.module.css' import HelpToolTip from './HelpTooltip' -import { useGetTransactionDetailsQuery } from '@/store/api/gateway' -import { skipToken } from '@reduxjs/toolkit/query/react' -import { asError } from '@/services/exceptions/utils' type DecodedTxProps = { tx?: SafeTransaction txId?: string + txDetails?: TransactionDetails showMultisend?: boolean decodedData?: DecodedDataResponse showMethodCall?: boolean @@ -36,33 +37,24 @@ export const Divider = () => ( const DecodedTx = ({ tx, - txId, + txDetails, decodedData, showMultisend = true, showMethodCall = false, }: DecodedTxProps): ReactElement => { - const chainId = useChainId() - const isMultisend = !!decodedData?.parameters?.[0]?.valueDecoded + const isMultisend = decodedData?.parameters && !!decodedData?.parameters[0]?.valueDecoded const isMethodCallInAdvanced = !showMethodCall || (isMultisend && showMultisend) - const { - data: txDetails, - error: txDetailsError, - isLoading: txDetailsLoading, - } = useGetTransactionDetailsQuery( - chainId && txId - ? { - chainId, - txId, - } - : skipToken, - ) - const onChangeExpand = (_: SyntheticEvent, expanded: boolean) => { trackEvent({ ...MODALS_EVENTS.TX_DETAILS, label: expanded ? 'Open' : 'Close' }) } const addressInfoIndex = txDetails?.txData?.addressInfoIndex + const isCreation = + txDetails && + isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) && + txDetails.detailedExecutionInfo.confirmations.length === 0 + const txData = { dataDecoded: decodedData, to: { value: tx?.data.to || '' }, @@ -76,11 +68,12 @@ const DecodedTx = ({ let toInfo = tx && { value: tx.data.to, } - if (txDetails && isCustomTxInfo(txDetails.txInfo)) { - toInfo = txDetails.txInfo.to + if (txDetails && isCustomTxInfo(txDetails?.txInfo)) { + toInfo = txDetails?.txInfo.to } const decodedDataBlock = + const showDecodedData = isMethodCallInAdvanced && decodedData?.method return ( @@ -103,18 +96,20 @@ const DecodedTx = ({ {isMethodCallInAdvanced && decodedData?.method} - {!showMethodCall && !decodedData?.method && Number(tx?.data.value) > 0 && 'native transfer'} + {txDetails && + isTransferTxInfo(txDetails.txInfo) && + isNativeTokenTransfer(txDetails.txInfo.transferInfo) && + 'native transfer'} - - {isMethodCallInAdvanced && decodedData?.method && ( + {showDecodedData && ( <> {decodedDataBlock} )} - {txDetails ? ( + {txDetails && !showDecodedData && !isCreation ? ( )} - - {txDetailsLoading && } - - {txDetailsError && ( - Failed loading all transaction details - )} diff --git a/src/components/tx/SignOrExecuteForm/DelegateForm.tsx b/src/components/tx/SignOrExecuteForm/DelegateForm.tsx index 37b222ef98..eb02a623bf 100644 --- a/src/components/tx/SignOrExecuteForm/DelegateForm.tsx +++ b/src/components/tx/SignOrExecuteForm/DelegateForm.tsx @@ -9,7 +9,7 @@ import commonCss from '@/components/tx-flow/common/styles.module.css' import ErrorMessage from '@/components/tx/ErrorMessage' import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' import { useTxActions } from '@/components/tx/SignOrExecuteForm/hooks' -import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/index' +import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' import useWallet from '@/hooks/wallets/useWallet' import { Errors, trackError } from '@/services/exceptions' import { asError } from '@/services/exceptions/utils' diff --git a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx index ea19347871..398614fdf9 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx +++ b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx @@ -15,7 +15,7 @@ import { useRelaysBySafe } from '@/hooks/useRemainingRelays' import useWalletCanRelay from '@/hooks/useWalletCanRelay' import { ExecutionMethod, ExecutionMethodSelector } from '../ExecutionMethodSelector' import { hasRemainingRelays } from '@/utils/relaying' -import type { SignOrExecuteProps } from '.' +import type { SignOrExecuteProps } from './SignOrExecuteForm' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { TxModalContext } from '@/components/tx-flow' import { SuccessScreenFlow } from '@/components/tx-flow/flows' @@ -48,6 +48,7 @@ export const ExecuteForm = ({ isExecutionLoop: ReturnType txActions: ReturnType txSecurity: ReturnType + isCreation?: boolean safeTx?: SafeTransaction }): ReactElement => { // Form state diff --git a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx index ee0382dcd5..3563336048 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx +++ b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx @@ -143,6 +143,7 @@ describe('ExecuteThroughRoleForm', () => { const { findByText, getByText } = render( , @@ -170,7 +171,9 @@ describe('ExecuteThroughRoleForm', () => { const onSubmit = jest.fn() - const { findByText } = render() + const { findByText } = render( + , + ) fireEvent.click(await findByText('Execute')) diff --git a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx index 39acb5a1d1..b8fcc7a600 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx @@ -9,7 +9,7 @@ import { useCurrentChain } from '@/hooks/useChains' import { getTxOptions } from '@/utils/transactions' import CheckWallet from '@/components/common/CheckWallet' -import type { SignOrExecuteProps } from '..' +import type { SignOrExecuteProps } from '../SignOrExecuteForm' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { TxModalContext } from '@/components/tx-flow' import { SuccessScreenFlow } from '@/components/tx-flow/flows' diff --git a/src/components/tx/SignOrExecuteForm/SignForm.tsx b/src/components/tx/SignOrExecuteForm/SignForm.tsx index 850432f10a..3e16bc37fe 100644 --- a/src/components/tx/SignOrExecuteForm/SignForm.tsx +++ b/src/components/tx/SignOrExecuteForm/SignForm.tsx @@ -7,7 +7,7 @@ import { trackError, Errors } from '@/services/exceptions' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import CheckWallet from '@/components/common/CheckWallet' import { useAlreadySigned, useTxActions } from './hooks' -import type { SignOrExecuteProps } from '.' +import type { SignOrExecuteProps } from './SignOrExecuteForm' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { TxModalContext } from '@/components/tx-flow' import commonCss from '@/components/tx-flow/common/styles.module.css' @@ -34,6 +34,7 @@ export const SignForm = ({ isOwner: ReturnType txActions: ReturnType txSecurity: ReturnType + isCreation?: boolean safeTx?: SafeTransaction }): ReactElement => { // Form state diff --git a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx new file mode 100644 index 0000000000..98bf41fada --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx @@ -0,0 +1,242 @@ +import DelegateForm from '@/components/tx/SignOrExecuteForm/DelegateForm' +import CounterfactualForm from '@/features/counterfactual/CounterfactualForm' +import { useIsWalletDelegate } from '@/hooks/useDelegates' +import useSafeInfo from '@/hooks/useSafeInfo' +import { type ReactElement, type ReactNode, useState, useContext, useCallback } from 'react' +import madProps from '@/utils/mad-props' +import ExecuteCheckbox from '../ExecuteCheckbox' +import { useImmediatelyExecutable, useValidateNonce } from './hooks' +import ExecuteForm from './ExecuteForm' +import SignForm from './SignForm' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import ErrorMessage from '../ErrorMessage' +import TxChecks from './TxChecks' +import TxCard from '@/components/tx-flow/common/TxCard' +import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle' +import { useAppSelector } from '@/store' +import { selectSettings } from '@/store/settingsSlice' +import UnknownContractError from './UnknownContractError' +import { ErrorBoundary } from '@sentry/react' +import ApprovalEditor from '../ApprovalEditor' +import { isDelegateCall } from '@/services/tx/tx-sender/sdk' +import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' +import { TX_EVENTS } from '@/services/analytics/events/transactions' +import { trackEvent } from '@/services/analytics' +import useChainId from '@/hooks/useChainId' +import ExecuteThroughRoleForm from './ExecuteThroughRoleForm' +import { findAllowingRole, findMostLikelyRole, useRoles } from './ExecuteThroughRoleForm/hooks' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { BlockaidBalanceChanges } from '../security/blockaid/BlockaidBalanceChange' +import { Blockaid } from '../security/blockaid' + +import { MigrateToL2Information } from './MigrateToL2Information' +import { extractMigrationL2MasterCopyAddress } from '@/utils/transactions' + +import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' +import { useApprovalInfos } from '../ApprovalEditor/hooks/useApprovalInfos' + +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import ConfirmationView from '../confirmation-views' + +export type SubmitCallback = (txId: string, isExecuted?: boolean) => void + +export type SignOrExecuteProps = { + txId?: string + onSubmit?: SubmitCallback + children?: ReactNode + isExecutable?: boolean + isRejection?: boolean + isBatch?: boolean + isBatchable?: boolean + onlyExecute?: boolean + disableSubmit?: boolean + origin?: string + showMethodCall?: boolean +} + +const trackTxEvents = ( + details: TransactionDetails | undefined, + isCreation: boolean, + isExecuted: boolean, + isRoleExecution: boolean, + isDelegateCreation: boolean, +) => { + const creationEvent = isRoleExecution + ? TX_EVENTS.CREATE_VIA_ROLE + : isDelegateCreation + ? TX_EVENTS.CREATE_VIA_DELEGATE + : TX_EVENTS.CREATE + const executionEvent = isRoleExecution ? TX_EVENTS.EXECUTE_VIA_ROLE : TX_EVENTS.EXECUTE + const event = isCreation ? creationEvent : isExecuted ? executionEvent : TX_EVENTS.CONFIRM + const txType = getTransactionTrackingType(details) + trackEvent({ ...event, label: txType }) + + // Immediate execution on creation + if (isCreation && isExecuted) { + trackEvent({ ...executionEvent, label: txType }) + } +} + +export const SignOrExecuteForm = ({ + chainId, + safeTx, + safeTxError, + onSubmit, + isCreation, + ...props +}: SignOrExecuteProps & { + chainId: ReturnType + safeTx: ReturnType + safeTxError: ReturnType + isCreation?: boolean + txDetails?: TransactionDetails +}): ReactElement => { + const { transactionExecution } = useAppSelector(selectSettings) + const [shouldExecute, setShouldExecute] = useState(transactionExecution) + const isNewExecutableTx = useImmediatelyExecutable() && isCreation + const isCorrectNonce = useValidateNonce(safeTx) + + console.log(props.txDetails) + + // TODO: move it to the confirmation view + // const showTxDetails = + // !isAnyStakingTxInfo(txDetails.txInfo) && + // !isOrderTxInfo(txDetails.txInfo) + + const isBatchable = props.isBatchable !== false && safeTx && !isDelegateCall(safeTx) + + const isDelegate = useIsWalletDelegate() + + const [trigger] = useLazyGetTransactionDetailsQuery() + const [readableApprovals] = useApprovalInfos({ safeTransaction: safeTx }) + const isApproval = readableApprovals && readableApprovals.length > 0 + const { safe } = useSafeInfo() + const isSafeOwner = useIsSafeOwner() + const isCounterfactualSafe = !safe.deployed + const multiChainMigrationTarget = extractMigrationL2MasterCopyAddress(safeTx) + const isMultiChainMigration = !!multiChainMigrationTarget + + // Check if a Zodiac Roles mod is enabled and if the user is a member of any role that allows the transaction + const roles = useRoles( + !isCounterfactualSafe && isCreation && !(isNewExecutableTx && isSafeOwner) ? safeTx : undefined, + ) + const allowingRole = findAllowingRole(roles) + const mostLikelyRole = findMostLikelyRole(roles) + const canExecuteThroughRole = !!allowingRole || (!!mostLikelyRole && !isSafeOwner) + const preferThroughRole = canExecuteThroughRole && !isSafeOwner // execute through role if a non-owner role member wallet is connected + + // If checkbox is checked and the transaction is executable, execute it, otherwise sign it + const canExecute = isCorrectNonce && (props.isExecutable || isNewExecutableTx) + const willExecute = (props.onlyExecute || shouldExecute) && canExecute && !preferThroughRole + const willExecuteThroughRole = + (props.onlyExecute || shouldExecute) && canExecuteThroughRole && (!canExecute || preferThroughRole) + + const onFormSubmit = useCallback( + async (txId: string, isExecuted = false, isRoleExecution = false, isDelegateCreation = false) => { + onSubmit?.(txId, isExecuted) + + const { data: details } = await trigger({ chainId, txId }) + // Track tx event + trackTxEvents(details, !!isCreation, isExecuted, isRoleExecution, isDelegateCreation) + }, + [chainId, isCreation, onSubmit, trigger], + ) + + const onRoleExecutionSubmit = useCallback( + (txId, isExecuted) => onFormSubmit(txId, isExecuted, true), + [onFormSubmit], + ) + + const onDelegateFormSubmit = useCallback( + (txId, isExecuted) => onFormSubmit(txId, isExecuted, false, true), + [onFormSubmit], + ) + + return ( + <> + + {props.children} + {isMultiChainMigration && } + + + {!props.isRejection && ( + Error parsing data
}> + {isApproval && } + + )} + + + {!isCounterfactualSafe && !props.isRejection && } + + + {!isCounterfactualSafe && !props.isRejection && } + + + + + {safeTxError && ( + + This transaction will most likely fail. To save gas costs, avoid confirming the transaction. + + )} + + {(canExecute || canExecuteThroughRole) && !props.onlyExecute && !isCounterfactualSafe && !isDelegate && ( + + )} + + + + {!isMultiChainMigration && } + + + + {isCounterfactualSafe && !isDelegate && ( + + )} + {!isCounterfactualSafe && willExecute && !isDelegate && ( + + )} + {!isCounterfactualSafe && willExecuteThroughRole && ( + + )} + {!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && !isDelegate && ( + + )} + + {isDelegate && } + + + ) +} + +const useSafeTx = () => useContext(SafeTxContext).safeTx +const useSafeTxError = () => useContext(SafeTxContext).safeTxError + +export default madProps(SignOrExecuteForm, { + chainId: useChainId, + safeTx: useSafeTx, + safeTxError: useSafeTxError, +}) diff --git a/src/components/tx/SignOrExecuteForm/SignOrExecuteSkeleton.tsx b/src/components/tx/SignOrExecuteForm/SignOrExecuteSkeleton.tsx new file mode 100644 index 0000000000..d6c6c20f71 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/SignOrExecuteSkeleton.tsx @@ -0,0 +1,13 @@ +import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' +import TxCard from '@/components/tx-flow/common/TxCard' +import { Box } from '@mui/material' + +const SignOrExecuteSkeleton = () => ( + + + + + +) + +export default SignOrExecuteSkeleton diff --git a/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx index 60141794fd..3b85d1baee 100644 --- a/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx +++ b/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx @@ -31,9 +31,16 @@ describe('ExecuteForm', () => { const defaultProps = { onSubmit: jest.fn(), isOwner: true, + txId: '0x123123', isExecutionLoop: false, relays: [undefined, undefined, false] as AsyncResult, - txActions: { signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn(), signDelegateTx: jest.fn() }, + txActions: { + proposeTx: jest.fn(), + signTx: jest.fn(), + addToBatch: jest.fn(), + executeTx: jest.fn(), + signDelegateTx: jest.fn(), + }, txSecurity: defaultSecurityContextValues, } @@ -107,7 +114,13 @@ describe('ExecuteForm', () => { const { getByText } = render( , ) @@ -138,7 +151,13 @@ describe('ExecuteForm', () => { {...defaultProps} safeTx={safeTransaction} onSubmit={jest.fn()} - txActions={{ signTx: jest.fn(), addToBatch: jest.fn(), executeTx: mockExecuteTx, signDelegateTx: jest.fn() }} + txActions={{ + proposeTx: jest.fn(), + signTx: jest.fn(), + addToBatch: jest.fn(), + executeTx: mockExecuteTx, + signDelegateTx: jest.fn(), + }} />, ) @@ -158,7 +177,13 @@ describe('ExecuteForm', () => { , ) diff --git a/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx index ea18ea7c9e..0b4f58851a 100644 --- a/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx +++ b/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx @@ -24,8 +24,15 @@ describe('SignForm', () => { const defaultProps = { onSubmit: jest.fn(), + txId: '0x01231', isOwner: true, - txActions: { signTx: jest.fn(), addToBatch: jest.fn(), executeTx: jest.fn(), signDelegateTx: jest.fn() }, + txActions: { + proposeTx: jest.fn(), + signTx: jest.fn(), + addToBatch: jest.fn(), + executeTx: jest.fn(), + signDelegateTx: jest.fn(), + }, txSecurity: defaultSecurityContextValues, } @@ -70,7 +77,13 @@ describe('SignForm', () => { , ) @@ -90,7 +103,13 @@ describe('SignForm', () => { , ) @@ -133,7 +152,13 @@ describe('SignForm', () => { safeTx={safeTransaction} isBatchable isCreation - txActions={{ signTx: jest.fn(), addToBatch: mockAddToBatch, executeTx: jest.fn(), signDelegateTx: jest.fn() }} + txActions={{ + proposeTx: jest.fn(), + signTx: jest.fn(), + addToBatch: mockAddToBatch, + executeTx: jest.fn(), + signDelegateTx: jest.fn(), + }} />, ) diff --git a/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx index 7008fa0c19..44d7769f9d 100644 --- a/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx +++ b/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx @@ -1,11 +1,10 @@ -import { SignOrExecuteForm } from '@/components/tx/SignOrExecuteForm' -import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' -import * as execThroughRoleHooks from '@/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks' -import { safeTxBuilder } from '@/tests/builders/safeTx' +import SignOrExecute from '../index' import { render } from '@/tests/test-utils' -import { fireEvent } from '@testing-library/react' -import { encodeBytes32String } from 'ethers' -import { Status } from 'zodiac-roles-deployments' +import * as hooks from '../hooks' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeTxContextParams } from '@/components/tx-flow/SafeTxProvider' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { createSafeTx } from '@/tests/builders/safeTx' let isSafeOwner = true // mock useIsSafeOwner @@ -14,215 +13,65 @@ jest.mock('@/hooks/useIsSafeOwner', () => ({ default: jest.fn(() => isSafeOwner), })) +// Mock proposeTx +jest.mock('@/services/tx/proposeTransaction', () => ({ + __esModule: true, + default: jest.fn(() => Promise.resolve({ txId: '123' })), +})) + describe('SignOrExecute', () => { beforeEach(() => { isSafeOwner = true + jest.clearAllMocks() }) - it('should display a safeTxError', () => { - const { getByText } = render( - , - ) - - expect( - getByText('This transaction will most likely fail. To save gas costs, avoid confirming the transaction.'), - ).toBeInTheDocument() - }) - - describe('Existing transaction', () => { - it('should display radio options to sign or execute if both are possible', () => { - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - - const { getByText } = render( - , - ) - - expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() - }) - }) - - describe('New transaction', () => { - it('should display radio options to sign or execute if both are possible', () => { - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(true) - - const { getByText } = render( - , - ) - - expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() - }) - - it('should offer to execute through a role if the user is a role member and the transaction is executable through the role', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) - - const { queryByTestId } = render( - , - ) - - expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() - }) - - it('should not offer to execute through a role if the user is a safe owner and role member but the role lacks permissions', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) - isSafeOwner = true - - const { queryByTestId } = render( - , - ) - - expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() - }) - - it('should offer to execute through a role if the user is a role member but not a safe owner, even if the role lacks permissions', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) - isSafeOwner = false - - const { queryByTestId } = render( - , - ) - - expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() - }) - - it('should not offer to execute through a role if the transaction can also be directly executed without going through the role', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - - const { queryByTestId } = render( - , - ) - - expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() - }) - }) - - it('should not display radio options if execution is the only option', () => { - jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([]) + it('should display a loading component', () => { + const { container } = render() - const { queryByText } = render( - , - ) - expect(queryByText('Would you like to execute the transaction immediately?')).not.toBeInTheDocument() + expect(container).toMatchSnapshot() }) - it('should display a sign/execute title if that option is selected', () => { - jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) - - const { getByTestId, getByText } = render( - , + it('should display a confirmation screen', async () => { + jest.spyOn(hooks, 'useProposeTx').mockReturnValue([ + { + txInfo: {}, + } as TransactionDetails, + undefined, + false, + ]) + + const { container, getByTestId } = render( + + + , ) - expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() - - const executeCheckbox = getByTestId('execute-checkbox') - const signCheckbox = getByTestId('sign-checkbox') - - expect(getByText("You're about to execute this transaction.")).toBeInTheDocument() - - fireEvent.click(signCheckbox) - - expect(getByText("You're about to confirm this transaction.")).toBeInTheDocument() - - fireEvent.click(executeCheckbox) - - expect(getByText("You're about to execute this transaction.")).toBeInTheDocument() + expect(getByTestId('sign-btn')).toBeInTheDocument() + expect(container).toMatchSnapshot() }) - it('should not display safeTxError message for valid transactions', () => { - const { queryByText } = render( - , + it('should display an error screen', async () => { + jest.spyOn(hooks, 'useProposeTx').mockReturnValue([undefined, new Error('This is a mock error message'), false]) + + const { container } = render( + + + , ) - expect( - queryByText('This transaction will most likely fail. To save gas costs, avoid confirming the transaction.'), - ).not.toBeInTheDocument() + expect(container.querySelector('sign-btn')).not.toBeInTheDocument() + expect(container).toMatchSnapshot() }) }) - -const ROLES_MOD_ADDRESS = '0x1234567890000000000000000000000000000000' -const ROLE_KEY = encodeBytes32String('eth_wrapping') - -const TEST_ROLE_OK: execThroughRoleHooks.Role = { - modAddress: ROLES_MOD_ADDRESS, - roleKey: ROLE_KEY as `0x${string}`, - multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', - status: Status.Ok, -} - -const TEST_ROLE_TARGET_NOT_ALLOWED: execThroughRoleHooks.Role = { - modAddress: ROLES_MOD_ADDRESS, - roleKey: ROLE_KEY as `0x${string}`, - multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', - status: Status.TargetAddressNotAllowed, -} diff --git a/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx new file mode 100644 index 0000000000..2abab2eef7 --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx @@ -0,0 +1,365 @@ +import { SignOrExecuteForm } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' +import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' +import * as execThroughRoleHooks from '@/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks' +import { safeTxBuilder } from '@/tests/builders/safeTx' +import { render } from '@/tests/test-utils' +import { fireEvent } from '@testing-library/react' +import { encodeBytes32String } from 'ethers' +import { Status } from 'zodiac-roles-deployments' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +let isSafeOwner = true +// mock useIsSafeOwner +jest.mock('@/hooks/useIsSafeOwner', () => ({ + __esModule: true, + default: jest.fn(() => isSafeOwner), +})) + +jest.mock('@/hooks/useSafeAddress', () => ({ + __esModule: true, + default: jest.fn(() => '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67'), +})) + +const txDetails = { + safeAddress: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + txId: 'multisig_0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67_0x938635afdeab5ab17b377896f10dbe161fcc44d488296bc0000b733623d57c80', + executedAt: null, + txStatus: 'AWAITING_EXECUTION', + txInfo: { + type: 'SettingsChange', + humanDescription: 'Add new owner 0xd8dA...6045 with threshold 1', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + valueDecoded: null, + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + valueDecoded: null, + }, + ], + }, + settingsInfo: { + type: 'ADD_OWNER', + owner: { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: null, + logoUri: null, + }, + threshold: 1, + }, + }, + txData: { + hexData: + '0x0d582f13000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000000000000000001', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + valueDecoded: null, + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + valueDecoded: null, + }, + ], + }, + to: { + value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + name: 'SafeProxy', + logoUri: null, + }, + value: '0', + operation: 0, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + txHash: null, + detailedExecutionInfo: { + type: 'MULTISIG', + submittedAt: 1726497729356, + nonce: 8, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: 'MetaMultiSigWallet', + logoUri: null, + }, + safeTxHash: '0x938635afdeab5ab17b377896f10dbe161fcc44d488296bc0000b733623d57c80', + executor: null, + signers: [ + { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + ], + confirmationsRequired: 1, + confirmations: [ + { + signer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + signature: + '0xd91721922d38384a4d40b20d923c49cefb56f60bfe0b357de11a4a044483d670075842d7bba26cf4aa84788ab0bd85137ad09c7f9cd84154db00d456b15e42dc1b', + submittedAt: 1726497740521, + }, + ], + rejectors: [], + gasTokenInfo: null, + trusted: true, + proposer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + }, + safeAppInfo: null, +} as unknown as TransactionDetails + +describe('SignOrExecute', () => { + beforeEach(() => { + isSafeOwner = true + }) + + it('should display a safeTxError', () => { + const { getByText } = render( + , + ) + + expect( + getByText('This transaction will most likely fail. To save gas costs, avoid confirming the transaction.'), + ).toBeInTheDocument() + }) + + describe('Existing transaction', () => { + it('should display radio options to sign or execute if both are possible', () => { + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + + const { getByText } = render( + , + ) + + expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() + }) + }) + + describe('New transaction', () => { + it('should display radio options to sign or execute if both are possible', () => { + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(true) + + const { getByText } = render( + , + ) + + expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() + }) + + it('should offer to execute through a role if the user is a role member and the transaction is executable through the role', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) + + const { queryByTestId } = render( + , + ) + + expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() + }) + + it('should not offer to execute through a role if the user is a safe owner and role member but the role lacks permissions', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) + isSafeOwner = true + + const { queryByTestId } = render( + , + ) + + expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() + }) + + it('should offer to execute through a role if the user is a role member but not a safe owner, even if the role lacks permissions', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) + isSafeOwner = false + + const { queryByTestId } = render( + , + ) + + expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() + }) + + it('should not offer to execute through a role if the transaction can also be directly executed without going through the role', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + + const { queryByTestId } = render( + , + ) + + expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() + }) + }) + + it('should not display radio options if execution is the only option', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([]) + + const { queryByText } = render( + , + ) + expect(queryByText('Would you like to execute the transaction immediately?')).not.toBeInTheDocument() + }) + + it('should display a sign/execute title if that option is selected', () => { + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + + const { getByTestId, getByText } = render( + , + ) + + expect(getByText('Would you like to execute the transaction immediately?')).toBeInTheDocument() + + const executeCheckbox = getByTestId('execute-checkbox') + const signCheckbox = getByTestId('sign-checkbox') + + expect(getByText("You're about to execute this transaction.")).toBeInTheDocument() + + fireEvent.click(signCheckbox) + + expect(getByText("You're about to confirm this transaction.")).toBeInTheDocument() + + fireEvent.click(executeCheckbox) + + expect(getByText("You're about to execute this transaction.")).toBeInTheDocument() + }) + + it('should not display safeTxError message for valid transactions', () => { + const { queryByText } = render( + , + ) + + expect( + queryByText('This transaction will most likely fail. To save gas costs, avoid confirming the transaction.'), + ).not.toBeInTheDocument() + }) +}) + +const ROLES_MOD_ADDRESS = '0x1234567890000000000000000000000000000000' +const ROLE_KEY = encodeBytes32String('eth_wrapping') + +const TEST_ROLE_OK: execThroughRoleHooks.Role = { + modAddress: ROLES_MOD_ADDRESS, + roleKey: ROLE_KEY as `0x${string}`, + multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', + status: Status.Ok, +} + +const TEST_ROLE_TARGET_NOT_ALLOWED: execThroughRoleHooks.Role = { + modAddress: ROLES_MOD_ADDRESS, + roleKey: ROLE_KEY as `0x${string}`, + multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', + status: Status.TargetAddressNotAllowed, +} diff --git a/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap b/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap new file mode 100644 index 0000000000..87e0722b7b --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap @@ -0,0 +1,524 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SignOrExecute should display a confirmation screen 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ confirm +
+

+ You're about to + create and + confirm + this transaction. +

+
+
+
+
+
+
+ + + + + +
+ + +
+ + + +
+
+
+
+
+
+`; + +exports[`SignOrExecute should display a loading component 1`] = ` +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + +
+
+
+
+
+`; + +exports[`SignOrExecute should display an error screen 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ confirm +
+

+ You're about to + create and + confirm + this transaction. +

+
+
+
+
+
+
+ + + + + +
+ + +
+ + + +
+
+
+
+
+
+`; diff --git a/src/components/tx/SignOrExecuteForm/hooks.ts b/src/components/tx/SignOrExecuteForm/hooks.ts index 528b64c22c..0230cf14c2 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.ts @@ -16,10 +16,13 @@ import { } from '@/services/tx/tx-sender' import { useHasPendingTxs } from '@/hooks/usePendingTxs' import { getSafeTxGas, getNonces } from '@/services/tx/tx-sender/recommendedNonce' +import type { AsyncResult } from '@/hooks/useAsync' import useAsync from '@/hooks/useAsync' import { useUpdateBatch } from '@/hooks/useDraftBatch' -import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { getTransactionDetails, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { useCurrentChain } from '@/hooks/useChains' +import directProposeTx from '@/services/tx/proposeTransaction' +import { getAndValidateSafeSDK } from '@/services/tx/tx-sender/sdk' type TxActions = { addToBatch: (safeTx?: SafeTransaction, origin?: string) => Promise @@ -32,6 +35,27 @@ type TxActions = { isRelayed?: boolean, ) => Promise signDelegateTx: (safeTx?: SafeTransaction) => Promise + proposeTx: (safeTx: SafeTransaction, txId?: string, origin?: string) => Promise +} + +type txDetails = AsyncResult + +export const useProposeTx = (safeTx?: SafeTransaction, txId?: string, origin?: string): txDetails => { + const { safe } = useSafeInfo() + const wallet = useWallet() + const sender = wallet?.address || safe.owners?.[0]?.value + + return useAsync( + async () => { + if (txId) return getTransactionDetails(safe.chainId, txId) + if (!safeTx || !sender) return + const safeSDK = getAndValidateSafeSDK() + const safeTxHash = await safeSDK.getTransactionHash(safeTx) + return directProposeTx(safe.chainId, safe.address.value, sender, safeTx, safeTxHash, origin) + }, + [safeTx, txId, origin, safe.chainId, safe.address.value, sender], + false, + ) } export const useTxActions = (): TxActions => { @@ -45,7 +69,7 @@ export const useTxActions = (): TxActions => { const safeAddress = safe.address.value const { chainId, version } = safe - const proposeTx = async (sender: string, safeTx: SafeTransaction, txId?: string, origin?: string) => { + const _propose = async (sender: string, safeTx: SafeTransaction, txId?: string, origin?: string) => { return dispatchTxProposal({ chainId, safeAddress, @@ -56,11 +80,16 @@ export const useTxActions = (): TxActions => { }) } + const proposeTx: TxActions['proposeTx'] = async (safeTx, txId, origin) => { + assertTx(safeTx) + return _propose(wallet?.address || safe.owners[0].value, safeTx, txId, origin) + } + const addToBatch: TxActions['addToBatch'] = async (safeTx, origin) => { assertTx(safeTx) assertWallet(wallet) - const tx = await proposeTx(wallet.address, safeTx, undefined, origin) + const tx = await _propose(wallet.address, safeTx, undefined, origin) await addTxToBatch(tx) return tx.txId } @@ -86,14 +115,14 @@ export const useTxActions = (): TxActions => { // 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 + const id = txId || (await _propose(wallet.address, safeTx, txId, origin)).txId await dispatchOnChainSigning(safeTx, id, wallet.provider, chainId, wallet.address, safeAddress) return id } // Otherwise, sign off-chain const signedTx = await dispatchTxSigning(safeTx, version, wallet.provider, txId) - const tx = await proposeTx(wallet.address, signedTx, txId, origin) + const tx = await _propose(wallet.address, signedTx, txId, origin) return tx.txId } @@ -104,7 +133,7 @@ export const useTxActions = (): TxActions => { const signedTx = await dispatchDelegateTxSigning(safeTx, wallet) - const tx = await proposeTx(wallet.address, signedTx) + const tx = await _propose(wallet.address, signedTx) return tx.txId } @@ -119,9 +148,9 @@ export const useTxActions = (): TxActions => { if (isRelayed && safeTx.signatures.size < safe.threshold) { if (txId) { safeTx = await signRelayedTx(safeTx) - tx = await proposeTx(wallet.address, safeTx, txId, origin) + tx = await _propose(wallet.address, safeTx, txId, origin) } else { - tx = await proposeTx(wallet.address, safeTx, txId, origin) + tx = await _propose(wallet.address, safeTx, txId, origin) safeTx = await signRelayedTx(safeTx) } txId = tx.txId @@ -129,7 +158,7 @@ export const useTxActions = (): TxActions => { // Propose the tx if there's no id yet ("immediate execution") if (!txId) { - tx = await proposeTx(wallet.address, safeTx, txId, origin) + tx = await _propose(wallet.address, safeTx, txId, origin) txId = tx.txId } @@ -145,7 +174,7 @@ export const useTxActions = (): TxActions => { return txId } - return { addToBatch, signTx, executeTx, signDelegateTx } + return { addToBatch, signTx, executeTx, signDelegateTx, proposeTx } }, [safe, wallet, addTxToBatch, onboard, chain]) } diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index 2fcbde8c56..d7696aeb0b 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -1,54 +1,15 @@ -import DelegateForm from '@/components/tx/SignOrExecuteForm/DelegateForm' -import CounterfactualForm from '@/features/counterfactual/CounterfactualForm' -import { useIsWalletDelegate } from '@/hooks/useDelegates' -import useSafeInfo from '@/hooks/useSafeInfo' -import { type ReactElement, type ReactNode, useState, useContext, useCallback } from 'react' -import madProps from '@/utils/mad-props' -import DecodedTx from '../DecodedTx' -import ExecuteCheckbox from '../ExecuteCheckbox' -import { useImmediatelyExecutable, useValidateNonce } from './hooks' -import ExecuteForm from './ExecuteForm' -import SignForm from './SignForm' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' -import ErrorMessage from '../ErrorMessage' -import TxChecks from './TxChecks' -import TxCard from '@/components/tx-flow/common/TxCard' -import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle' -import { useAppSelector } from '@/store' -import { selectSettings } from '@/store/settingsSlice' -import UnknownContractError from './UnknownContractError' -import useDecodeTx from '@/hooks/useDecodeTx' -import { ErrorBoundary } from '@sentry/react' -import ApprovalEditor from '../ApprovalEditor' -import { isDelegateCall } from '@/services/tx/tx-sender/sdk' -import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' -import { TX_EVENTS } from '@/services/analytics/events/transactions' -import { trackEvent } from '@/services/analytics' -import useChainId from '@/hooks/useChainId' -import ExecuteThroughRoleForm from './ExecuteThroughRoleForm' -import { findAllowingRole, findMostLikelyRole, useRoles } from './ExecuteThroughRoleForm/hooks' -import { isAnyStakingTxInfo, isCustomTxInfo, isGenericConfirmation, isOrderTxInfo } from '@/utils/transaction-guards' -import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import { BlockaidBalanceChanges } from '../security/blockaid/BlockaidBalanceChange' -import { Blockaid } from '../security/blockaid' - -import TxData from '@/components/transactions/TxDetails/TxData' -import ConfirmationOrder from '@/components/tx/ConfirmationOrder' -import { useApprovalInfos } from '../ApprovalEditor/hooks/useApprovalInfos' -import { MigrateToL2Information } from './MigrateToL2Information' -import { extractMigrationL2MasterCopyAddress } from '@/utils/transactions' - -import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' -import { skipToken } from '@reduxjs/toolkit/query/react' -import NetworkWarning from '@/components/new-safe/create/NetworkWarning' - -export type SubmitCallback = (txId: string, isExecuted?: boolean) => void +import SignOrExecuteForm from './SignOrExecuteForm' +import type { SignOrExecuteProps, SubmitCallback } from './SignOrExecuteForm' +import SignOrExecuteSkeleton from './SignOrExecuteSkeleton' +import { useProposeTx } from './hooks' +import { useContext } from 'react' +import useSafeInfo from '@/hooks/useSafeInfo' -export type SignOrExecuteProps = { - txId?: string +type SignOrExecuteExtendedProps = Omit & { onSubmit?: SubmitCallback - children?: ReactNode + txId?: string + children?: React.ReactNode isExecutable?: boolean isRejection?: boolean isBatch?: boolean @@ -60,203 +21,21 @@ export type SignOrExecuteProps = { showMethodCall?: boolean } -const trackTxEvents = ( - details: TransactionDetails | undefined, - isCreation: boolean, - isExecuted: boolean, - isRoleExecution: boolean, - isDelegateCreation: boolean, -) => { - const creationEvent = isRoleExecution - ? TX_EVENTS.CREATE_VIA_ROLE - : isDelegateCreation - ? TX_EVENTS.CREATE_VIA_DELEGATE - : TX_EVENTS.CREATE - const executionEvent = isRoleExecution ? TX_EVENTS.EXECUTE_VIA_ROLE : TX_EVENTS.EXECUTE - const event = isCreation ? creationEvent : isExecuted ? executionEvent : TX_EVENTS.CONFIRM - const txType = getTransactionTrackingType(details) - trackEvent({ ...event, label: txType }) - - // Immediate execution on creation - if (isCreation && isExecuted) { - trackEvent({ ...executionEvent, label: txType }) - } -} - -export const SignOrExecuteForm = ({ - chainId, - safeTx, - safeTxError, - onSubmit, - ...props -}: SignOrExecuteProps & { - chainId: ReturnType - safeTx: ReturnType - safeTxError: ReturnType -}): ReactElement => { - const { transactionExecution } = useAppSelector(selectSettings) - const [shouldExecute, setShouldExecute] = useState(transactionExecution) - const isCreation = !props.txId - const isNewExecutableTx = useImmediatelyExecutable() && isCreation - const isCorrectNonce = useValidateNonce(safeTx) - const [decodedData] = useDecodeTx(safeTx) - - const isBatchable = props.isBatchable !== false && safeTx && !isDelegateCall(safeTx) - - const { data: txDetails } = useGetTransactionDetailsQuery( - chainId && props.txId - ? { - chainId, - txId: props.txId, - } - : skipToken, - ) - const showTxDetails = - props.txId && - txDetails && - !isCustomTxInfo(txDetails.txInfo) && - !isAnyStakingTxInfo(txDetails.txInfo) && - !isOrderTxInfo(txDetails.txInfo) - const isDelegate = useIsWalletDelegate() - const [trigger] = useLazyGetTransactionDetailsQuery() - const [readableApprovals] = useApprovalInfos({ safeTransaction: safeTx }) - const isApproval = readableApprovals && readableApprovals.length > 0 - +const SignOrExecute = (props: SignOrExecuteExtendedProps) => { + const { safeTx } = useContext(SafeTxContext) const { safe } = useSafeInfo() - const isSafeOwner = useIsSafeOwner() - const isCounterfactualSafe = !safe.deployed - const multiChainMigrationTarget = extractMigrationL2MasterCopyAddress(safeTx) - const isMultiChainMigration = !!multiChainMigrationTarget - - // Check if a Zodiac Roles mod is enabled and if the user is a member of any role that allows the transaction - const roles = useRoles( - !isCounterfactualSafe && isCreation && !(isNewExecutableTx && isSafeOwner) ? safeTx : undefined, - ) - const allowingRole = findAllowingRole(roles) - const mostLikelyRole = findMostLikelyRole(roles) - const canExecuteThroughRole = !!allowingRole || (!!mostLikelyRole && !isSafeOwner) - const preferThroughRole = canExecuteThroughRole && !isSafeOwner // execute through role if a non-owner role member wallet is connected - - // If checkbox is checked and the transaction is executable, execute it, otherwise sign it - const canExecute = isCorrectNonce && (props.isExecutable || isNewExecutableTx) - const willExecute = (props.onlyExecute || shouldExecute) && canExecute && !preferThroughRole - const willExecuteThroughRole = - (props.onlyExecute || shouldExecute) && canExecuteThroughRole && (!canExecute || preferThroughRole) - - const onFormSubmit = useCallback( - async (txId: string, isExecuted = false, isRoleExecution = false, isDelegateCreation = false) => { - onSubmit?.(txId, isExecuted) - - const { data: details } = await trigger({ chainId, txId }) - // Track tx event - trackTxEvents(details, isCreation, isExecuted, isRoleExecution, isDelegateCreation) - }, - [chainId, isCreation, onSubmit, trigger], - ) - - const onRoleExecutionSubmit = useCallback( - (txId, isExecuted) => onFormSubmit(txId, isExecuted, true), - [onFormSubmit], - ) + const [txDetails, error] = useProposeTx(safe.deployed ? safeTx : undefined, props.txId, props.origin) - const onDelegateFormSubmit = useCallback( - (txId, isExecuted) => onFormSubmit(txId, isExecuted, false, true), - [onFormSubmit], - ) + // Show the loader only the first time the tx is being loaded + if ((!txDetails && !error && safe.deployed) || !safeTx) { + return + } return ( - <> - - {props.children} - - {isMultiChainMigration && } - - {decodedData && ( - }> - - - )} - - {!props.isRejection && decodedData && ( - Error parsing data
}> - {isApproval && } - - {showTxDetails && } - - - - )} - {!isCounterfactualSafe && !props.isRejection && } - - - {!isCounterfactualSafe && !props.isRejection && } - - - - - {safeTxError && ( - - This transaction will most likely fail. To save gas costs, avoid confirming the transaction. - - )} - - {(canExecute || canExecuteThroughRole) && !props.onlyExecute && !isCounterfactualSafe && !isDelegate && ( - - )} - - - - {!isMultiChainMigration && } - - - - {isCounterfactualSafe && !isDelegate && ( - - )} - {!isCounterfactualSafe && willExecute && !isDelegate && ( - - )} - {!isCounterfactualSafe && willExecuteThroughRole && ( - - )} - {!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && !isDelegate && ( - - )} - - {isDelegate && } - - + + {props.children} + ) } -const useSafeTx = () => useContext(SafeTxContext).safeTx -const useSafeTxError = () => useContext(SafeTxContext).safeTxError - -export default madProps(SignOrExecuteForm, { - chainId: useChainId, - safeTx: useSafeTx, - safeTxError: useSafeTxError, -}) +export default SignOrExecute diff --git a/src/components/tx/confirmation-views/BatchTransactions/BatchTransactions.stories.tsx b/src/components/tx/confirmation-views/BatchTransactions/BatchTransactions.stories.tsx new file mode 100644 index 0000000000..5625ff11d2 --- /dev/null +++ b/src/components/tx/confirmation-views/BatchTransactions/BatchTransactions.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Paper, ThemeProvider } from '@mui/material' +import { StoreDecorator } from '@/stories/storeDecorator' +import BatchTransactions from './index' +import { mockedDarftBatch } from './mockData' +import createSafeTheme from '@/components/theme/safeTheme' + +const meta = { + component: BatchTransactions, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => { + return ( + + + + + + + + ) + }, + ], + + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/src/components/tx/confirmation-views/BatchTransactions/BatchTransactions.test.tsx b/src/components/tx/confirmation-views/BatchTransactions/BatchTransactions.test.tsx new file mode 100644 index 0000000000..154f417764 --- /dev/null +++ b/src/components/tx/confirmation-views/BatchTransactions/BatchTransactions.test.tsx @@ -0,0 +1,15 @@ +import { render } from '@/tests/test-utils' +import BatchTransactions from '.' +import * as useDraftBatch from '@/hooks/useDraftBatch' +import { mockedDarftBatch } from './mockData' + +jest.spyOn(useDraftBatch, 'useDraftBatch').mockImplementation(() => mockedDarftBatch) + +describe('BatchTransactions', () => { + it('should render a list of batch transactions', () => { + const { container, getByText } = render() + + expect(container).toMatchSnapshot() + expect(getByText('GnosisSafeProxy')).toBeDefined() + }) +}) diff --git a/src/components/tx/confirmation-views/BatchTransactions/__snapshots__/BatchTransactions.test.tsx.snap b/src/components/tx/confirmation-views/BatchTransactions/__snapshots__/BatchTransactions.test.tsx.snap new file mode 100644 index 0000000000..516101446f --- /dev/null +++ b/src/components/tx/confirmation-views/BatchTransactions/__snapshots__/BatchTransactions.test.tsx.snap @@ -0,0 +1,273 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BatchTransactions should render a list of batch transactions 1`] = ` +
+
    +
  • +
    + 1 +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +

    + Send + + + + + 1000000000000 + + + + + to: +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    + GnosisSafeProxy +
    +
    +
    +
    + + + 0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6 + + +
    + + + +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +

    + Created: +

    +
    +
    + 9/20/2024, 10:20:15 AM +
    +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
+
+`; diff --git a/src/components/tx/confirmation-views/BatchTransactions/index.tsx b/src/components/tx/confirmation-views/BatchTransactions/index.tsx new file mode 100644 index 0000000000..f219a74200 --- /dev/null +++ b/src/components/tx/confirmation-views/BatchTransactions/index.tsx @@ -0,0 +1,10 @@ +import BatchTxList from '@/components/batch/BatchSidebar/BatchTxList' +import { useDraftBatch } from '@/hooks/useDraftBatch' + +function BatchTransactions() { + const batchTxs = useDraftBatch() + + return +} + +export default BatchTransactions diff --git a/src/components/tx/confirmation-views/BatchTransactions/mockData.ts b/src/components/tx/confirmation-views/BatchTransactions/mockData.ts new file mode 100644 index 0000000000..4300923cda --- /dev/null +++ b/src/components/tx/confirmation-views/BatchTransactions/mockData.ts @@ -0,0 +1,121 @@ +import type { DraftBatchItem } from '@/store/batchSlice' + +export const mockedDarftBatch = [ + { + id: '6283sw7pzyk', + timestamp: 1726820415651, + txDetails: { + safeAddress: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + txId: 'multisig_0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6_0x876e728deafcc9ba46461cc63078a521f520b620b0a3c2e4b40a5f1f69358f6c', + executedAt: null, + txStatus: 'AWAITING_CONFIRMATIONS', + txInfo: { + type: 'Transfer', + humanDescription: null, + sender: { + value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + name: null, + logoUri: null, + }, + recipient: { + value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + name: 'GnosisSafeProxy', + logoUri: null, + }, + direction: 'OUTGOING', + transferInfo: { + type: 'NATIVE_COIN', + value: '1000000000000', + }, + }, + txData: { + hexData: null, + dataDecoded: null, + to: { + value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + name: 'GnosisSafeProxy', + logoUri: null, + }, + value: '1000000000000', + operation: 0, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + txHash: null, + detailedExecutionInfo: { + type: 'MULTISIG', + submittedAt: 1726820406700, + nonce: 45, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: null, + logoUri: null, + }, + safeTxHash: '0x876e728deafcc9ba46461cc63078a521f520b620b0a3c2e4b40a5f1f69358f6c', + executor: null, + signers: [ + { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + { + value: '0xEe91F585eA6ABc2822FaD082a095B46939059a31', + name: null, + logoUri: null, + }, + { + value: '0x3819b800c67Be64029C1393c8b2e0d0d627dADE2', + name: null, + logoUri: null, + }, + { + value: '0xd8BBcB76BC9AeA78972ED4773A5EB67B413f26A5', + name: null, + logoUri: null, + }, + { + value: '0x21D62C6894741DE97944D7844ED44D7782C66ABC', + name: null, + logoUri: null, + }, + { + value: '0xD33dD066fC8a0BC70269AC06B0ED98B00BFA3A0a', + name: null, + logoUri: null, + }, + { + value: '0xC2e333cb4aFfD6067D1d46ff80A6e631EC7B5A17', + name: null, + logoUri: null, + }, + { + value: '0xbc2BB26a6d821e69A38016f3858561a1D80d4182', + name: null, + logoUri: null, + }, + { + value: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', + name: null, + logoUri: null, + }, + ], + confirmationsRequired: 2, + confirmations: [], + rejectors: [], + gasTokenInfo: null, + trusted: false, + proposer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + }, + safeAppInfo: null, + }, + }, +] as unknown as DraftBatchItem[] diff --git a/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.stories.tsx b/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.stories.tsx new file mode 100644 index 0000000000..694b4a720c --- /dev/null +++ b/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Paper } from '@mui/material' +import { StoreDecorator } from '@/stories/storeDecorator' +import ChangeThreshold from './index' +import { ChangeThresholdReviewContext } from '@/components/tx-flow/flows/ChangeThreshold/context' + +const meta = { + component: ChangeThreshold, + parameters: { + layout: 'centered', + newThreshold: 1, + }, + decorators: [ + (Story, { parameters }) => { + return ( + + + + + + + + ) + }, + ], + + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.test.tsx b/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.test.tsx new file mode 100644 index 0000000000..2ec8500f3a --- /dev/null +++ b/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.test.tsx @@ -0,0 +1,31 @@ +import { render } from '@/tests/test-utils' +import ChangeThreshold from '.' +import { ChangeThresholdReviewContext } from '@/components/tx-flow/flows/ChangeThreshold/context' +import * as useSafeInfo from '@/hooks/useSafeInfo' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' + +const extendedSafeInfo = extendedSafeInfoBuilder().build() + +jest.spyOn(useSafeInfo, 'default').mockImplementation(() => ({ + safeAddress: 'eth:0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + safe: { + ...extendedSafeInfo, + owners: [extendedSafeInfo.owners[0]], + }, + safeError: undefined, + safeLoading: false, + safeLoaded: true, +})) + +describe('ChangeThreshold', () => { + it('should display the ChangeThreshold component with the new threshold range', () => { + const { container, getByLabelText } = render( + + + , + ) + + expect(container).toMatchSnapshot() + expect(getByLabelText('threshold')).toHaveTextContent('3 out of 1 signer(s)') + }) +}) diff --git a/src/components/tx/confirmation-views/ChangeThreshold/__snapshots__/ChangeThreshold.test.tsx.snap b/src/components/tx/confirmation-views/ChangeThreshold/__snapshots__/ChangeThreshold.test.tsx.snap new file mode 100644 index 0000000000..34ba41b75c --- /dev/null +++ b/src/components/tx/confirmation-views/ChangeThreshold/__snapshots__/ChangeThreshold.test.tsx.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChangeThreshold should display the ChangeThreshold component with the new threshold range 1`] = ` +
+
+

+ Any transaction will require the confirmation of: +

+

+ + 3 + + out of + + 1 + signer(s) + +

+
+
+
+
+
+`; diff --git a/src/components/tx/confirmation-views/ChangeThreshold/index.tsx b/src/components/tx/confirmation-views/ChangeThreshold/index.tsx new file mode 100644 index 0000000000..857c8fb185 --- /dev/null +++ b/src/components/tx/confirmation-views/ChangeThreshold/index.tsx @@ -0,0 +1,33 @@ +import { Box, Divider, Typography } from '@mui/material' +import React, { useContext } from 'react' + +import commonCss from '@/components/tx-flow/common/styles.module.css' +import useSafeInfo from '@/hooks/useSafeInfo' +import { ChangeThresholdReviewContext } from '@/components/tx-flow/flows/ChangeThreshold/context' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' + +function ChangeThreshold() { + const { safe } = useSafeInfo() + const { newThreshold } = useContext(ChangeThresholdReviewContext) + + return ( + <> + + +
+ + Any transaction will require the confirmation of: + + + + {newThreshold} out of {safe.owners.length} signer(s) + +
+ + + + + ) +} + +export default ChangeThreshold diff --git a/src/components/tx/confirmation-views/ConfirmationView.test.tsx b/src/components/tx/confirmation-views/ConfirmationView.test.tsx new file mode 100644 index 0000000000..dfc3fb5098 --- /dev/null +++ b/src/components/tx/confirmation-views/ConfirmationView.test.tsx @@ -0,0 +1,135 @@ +import { safeTxBuilder } from '@/tests/builders/safeTx' +import ConfirmationView from './index' +import { render } from '@/tests/test-utils' +import { createMockTransactionDetails } from '@/tests/transactions' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { + DetailedExecutionInfoType, + SettingsInfoType, + TransactionInfoType, +} from '@safe-global/safe-gateway-typescript-sdk' + +const txDetails = createMockTransactionDetails({ + txInfo: { + type: TransactionInfoType.SETTINGS_CHANGE, + humanDescription: 'Add new owner 0xd8dA...6045 with threshold 1', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + }, + ], + }, + settingsInfo: { + type: SettingsInfoType.ADD_OWNER, + owner: { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: 'Nevinha', + logoUri: 'http://something.com', + }, + threshold: 1, + }, + }, + txData: { + hexData: + '0x0d582f13000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000000000000000001', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + }, + ], + }, + to: { + value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + name: '', + }, + value: '0', + operation: 0, + trustedDelegateCallTarget: false, + addressInfoIndex: { + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045': { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: 'MetaMultiSigWallet', + }, + }, + }, + detailedExecutionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + submittedAt: 1726064794013, + nonce: 4, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: 'MetaMultiSigWallet', + }, + safeTxHash: '0x96a96c11b8d013ff5d7a6ce960b22e961046cfa42eff422ac71c1daf6adef2e0', + signers: [ + { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, + ], + confirmationsRequired: 1, + confirmations: [], + rejectors: [], + trusted: false, + proposer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, + }, +}) +const safeTx = safeTxBuilder().build() +const safeTxWithNativeData = { + ...safeTx, + data: { + ...safeTx.data, + refundReceiver: '0x79964FA459D36EbFfc2a2cA66321B689F6E4aC52', + to: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + data: '0x', + }, +} +describe('ConfirmationView', () => { + it('should display a confirmation screen for a SETTINGS_CHANGE transaction', () => { + const { container } = render( + , + ) + + expect(container).toMatchSnapshot() + }) + + it("should display a confirmation with method call when the transaction type is not found in the ConfirmationView's mapper", () => { + const CustomTxDetails = { ...txDetails, txInfo: { ...txDetails.txInfo, type: TransactionInfoType.CUSTOM } } + + const { container } = render( + , + ) + + expect(container).toMatchSnapshot() + }) +}) diff --git a/src/components/tx/confirmation-views/SettingsChange/SettingsChange.stories.tsx b/src/components/tx/confirmation-views/SettingsChange/SettingsChange.stories.tsx new file mode 100644 index 0000000000..cc35bf17cd --- /dev/null +++ b/src/components/tx/confirmation-views/SettingsChange/SettingsChange.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Paper } from '@mui/material' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { SettingsInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import { StoreDecorator } from '@/stories/storeDecorator' +import { ownerAddress, txInfo } from './mockData' +import SettingsChange from '.' + +const meta = { + component: SettingsChange, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => { + return ( + + + + + + ) + }, + ], + + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const AddOwner: Story = { + args: { + txInfo, + txDetails: {} as TransactionDetails, + }, +} + +export const SwapOwner: Story = { + args: { + txInfo: { + ...txInfo, + settingsInfo: { + type: SettingsInfoType.SWAP_OWNER, + oldOwner: { + value: '0x00000000', + name: 'Bob', + logoUri: 'http://bob.com', + }, + newOwner: { + value: ownerAddress, + name: 'Alice', + logoUri: 'http://something.com', + }, + }, + }, + txDetails: {} as TransactionDetails, + }, +} diff --git a/src/components/tx/confirmation-views/SettingsChange/SettingsChange.test.tsx b/src/components/tx/confirmation-views/SettingsChange/SettingsChange.test.tsx new file mode 100644 index 0000000000..1cc63a7808 --- /dev/null +++ b/src/components/tx/confirmation-views/SettingsChange/SettingsChange.test.tsx @@ -0,0 +1,55 @@ +import { render } from '@/tests/test-utils' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { SettingsInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import SettingsChange from '.' +import { ownerAddress, txInfo } from './mockData' +import { SettingsChangeContext } from '@/components/tx-flow/flows/AddOwner/context' +import { type AddOwnerFlowProps } from '@/components/tx-flow/flows/AddOwner' +import { type ReplaceOwnerFlowProps } from '@/components/tx-flow/flows/ReplaceOwner' +import { type SwapOwner } from '@safe-global/safe-apps-sdk' + +describe('SettingsChange', () => { + it('should display the SettingsChange component with owner details', () => { + const { container, getByText } = render( + + + , + ) + + expect(container).toMatchSnapshot() + expect(getByText('New signer')).toBeInTheDocument() + expect(getByText(ownerAddress)).toBeInTheDocument() + }) + + it('should display the SettingsChange component with newOwner details', () => { + const newOwnerAddress = '0x0000000000000000' + const contextValue = { + newOwner: { + address: newOwnerAddress, + name: 'Alice', + }, + } + const { container, getByText } = render( + + + , + ) + + expect(container).toMatchSnapshot() + expect(getByText('Previous signer')).toBeInTheDocument() + expect(getByText(newOwnerAddress)).toBeInTheDocument() + }) +}) diff --git a/src/components/tx/confirmation-views/SettingsChange/__snapshots__/SettingsChange.test.tsx.snap b/src/components/tx/confirmation-views/SettingsChange/__snapshots__/SettingsChange.test.tsx.snap new file mode 100644 index 0000000000..ed0525fc33 --- /dev/null +++ b/src/components/tx/confirmation-views/SettingsChange/__snapshots__/SettingsChange.test.tsx.snap @@ -0,0 +1,360 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SettingsChange should display the SettingsChange component with newOwner details 1`] = ` +
+
+

+

+
+
+
+
+
+
+
+ Bob +
+
+
+
+ + + 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + + +
+ + + +
+ +
+
+
+
+
+
+

+

+
+
+ +
+
+
+
+ Alice +
+
+
+
+ + + 0x0000000000000000 + + +
+ + + +
+ +
+
+
+
+
+
+
+`; + +exports[`SettingsChange should display the SettingsChange component with owner details 1`] = ` +
+
+

+

+
+
+
+
+
+
+
+ Nevinha +
+
+
+
+ + + 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + + +
+ + + +
+ +
+
+
+
+
+
+
+

+ Any transaction requires the confirmation of: +

+

+ + 1 + + out of + + + 1 + signers + +

+
+
+
+`; diff --git a/src/components/tx/confirmation-views/SettingsChange/index.tsx b/src/components/tx/confirmation-views/SettingsChange/index.tsx new file mode 100644 index 0000000000..425d27a8d8 --- /dev/null +++ b/src/components/tx/confirmation-views/SettingsChange/index.tsx @@ -0,0 +1,67 @@ +import { Paper, Typography, Box, Divider, SvgIcon } from '@mui/material' +import EthHashInfo from '@/components/common/EthHashInfo' +import type { NarrowConfirmationViewProps } from '../types' +import { OwnerList } from '@/components/tx-flow/common/OwnerList' +import MinusIcon from '@/public/images/common/minus.svg' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import useSafeInfo from '@/hooks/useSafeInfo' +import { SettingsInfoType, type SettingsChange } from '@safe-global/safe-gateway-typescript-sdk' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' +import { useContext } from 'react' +import { SettingsChangeContext } from '@/components/tx-flow/flows/AddOwner/context' + +export interface SettingsChangeProps extends NarrowConfirmationViewProps { + txInfo: SettingsChange +} + +const SettingsChange: React.FC = ({ txInfo: { settingsInfo } }) => { + const { safe } = useSafeInfo() + const params = useContext(SettingsChangeContext) + + if (!settingsInfo || settingsInfo.type === SettingsInfoType.REMOVE_OWNER) return null + + const shouldShowChangeSigner = 'owner' in settingsInfo || 'newOwner' in params + const hasNewOwner = 'newOwner' in params + + return ( + <> + {'oldOwner' in settingsInfo && ( + palette.warning.background, p: 2 }}> + + + Previous signer + + + + )} + + {'owner' in settingsInfo && !hasNewOwner && } + {hasNewOwner && } + + {shouldShowChangeSigner && } + + {'threshold' in settingsInfo && ( + <> + + + + Any transaction requires the confirmation of: + + {settingsInfo.threshold} out of{' '} + {safe.owners.length + ('removedOwner' in settingsInfo ? 0 : 1)} signers + + + + )} + + + ) +} + +export default SettingsChange diff --git a/src/components/tx/confirmation-views/SettingsChange/mockData.ts b/src/components/tx/confirmation-views/SettingsChange/mockData.ts new file mode 100644 index 0000000000..497a79c09a --- /dev/null +++ b/src/components/tx/confirmation-views/SettingsChange/mockData.ts @@ -0,0 +1,32 @@ +import type { SettingsChange } from '@safe-global/safe-gateway-typescript-sdk' +import { SettingsInfoType, TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' + +export const ownerAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' +export const txInfo: SettingsChange = { + type: TransactionInfoType.SETTINGS_CHANGE, + humanDescription: 'Add new owner 0xd8dA...6045 with threshold 1', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: ownerAddress, + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + }, + ], + }, + settingsInfo: { + type: SettingsInfoType.ADD_OWNER, + owner: { + value: ownerAddress, + name: 'Nevinha', + logoUri: 'http://something.com', + }, + threshold: 1, + }, +} diff --git a/src/components/tx/confirmation-views/__snapshots__/ConfirmationView.test.tsx.snap b/src/components/tx/confirmation-views/__snapshots__/ConfirmationView.test.tsx.snap new file mode 100644 index 0000000000..fe0820a949 --- /dev/null +++ b/src/components/tx/confirmation-views/__snapshots__/ConfirmationView.test.tsx.snap @@ -0,0 +1,679 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConfirmationView should display a confirmation screen for a SETTINGS_CHANGE transaction 1`] = ` +
+
+

+

+
+
+
+
+
+
+
+ Nevinha +
+
+
+
+ + + 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + + +
+ + + +
+ +
+
+
+
+
+
+
+

+ Any transaction requires the confirmation of: +

+

+ + 1 + + out of + + + 1 + signers + +

+
+
+
+
+
+ +
+
+
+`; + +exports[`ConfirmationView should display a confirmation with method call when the transaction type is not found in the ConfirmationView's mapper 1`] = ` +
+
+
+
+ +
+
+
+`; diff --git a/src/components/tx/confirmation-views/index.tsx b/src/components/tx/confirmation-views/index.tsx new file mode 100644 index 0000000000..4d0df98b55 --- /dev/null +++ b/src/components/tx/confirmation-views/index.tsx @@ -0,0 +1,85 @@ +import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import DecodedTx from '../DecodedTx' +import ConfirmationOrder from '../ConfirmationOrder' +import useDecodeTx from '@/hooks/useDecodeTx' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { isCustomTxInfo, isGenericConfirmation } from '@/utils/transaction-guards' +import { type ReactNode, useContext, useMemo } from 'react' +import TxData from '@/components/transactions/TxDetails/TxData' +import type { NarrowConfirmationViewProps } from './types' +import SettingsChange from './SettingsChange' +import ChangeThreshold from './ChangeThreshold' +import BatchTransactions from './BatchTransactions' +import { TxModalContext } from '@/components/tx-flow' +import { isSettingsChangeView, isChangeThresholdView, isConfirmBatchView } from './utils' + +type ConfirmationViewProps = { + txDetails?: TransactionDetails + safeTx?: SafeTransaction + txId?: string + isBatch?: boolean + isApproval?: boolean + isCreation?: boolean + showMethodCall?: boolean // @TODO: remove this prop when we migrate all tx types + children?: ReactNode +} + +const getConfirmationViewComponent = ({ + txDetails, + txInfo, + txFlow, +}: NarrowConfirmationViewProps & { txFlow?: JSX.Element }) => { + if (isChangeThresholdView(txInfo)) return + + if (isConfirmBatchView(txFlow)) return + + if (isSettingsChangeView(txInfo)) return + + return null +} + +const ConfirmationView = (props: ConfirmationViewProps) => { + const { txId } = props.txDetails || {} + const [decodedData] = useDecodeTx(props.safeTx) + const { txFlow } = useContext(TxModalContext) + + const ConfirmationViewComponent = useMemo( + () => + props.txDetails + ? getConfirmationViewComponent({ + txDetails: props.txDetails, + txInfo: props.txDetails.txInfo, + txFlow, + }) + : undefined, + [props.txDetails, txFlow], + ) + const showTxDetails = txId && !props.isCreation && props.txDetails && !isCustomTxInfo(props.txDetails.txInfo) + + return ( + <> + {ConfirmationViewComponent || + (showTxDetails && props.txDetails && )} + + {decodedData && } + + {props.children} + + + + ) +} + +export default ConfirmationView diff --git a/src/components/tx/confirmation-views/types.d.ts b/src/components/tx/confirmation-views/types.d.ts new file mode 100644 index 0000000000..6e60f8fb9c --- /dev/null +++ b/src/components/tx/confirmation-views/types.d.ts @@ -0,0 +1,6 @@ +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +export type NarrowConfirmationViewProps = { + txDetails: TransactionDetails + txInfo: TransactionDetails['txInfo'] +} diff --git a/src/components/tx/confirmation-views/utils.ts b/src/components/tx/confirmation-views/utils.ts new file mode 100644 index 0000000000..5329b31aa2 --- /dev/null +++ b/src/components/tx/confirmation-views/utils.ts @@ -0,0 +1,10 @@ +import type { TransactionInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { SettingsInfoType, TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import { ConfirmBatchFlow } from '@/components/tx-flow/flows' + +export const isSettingsChangeView = (txInfo: TransactionInfo) => txInfo.type === TransactionInfoType.SETTINGS_CHANGE + +export const isConfirmBatchView = (txFlow?: JSX.Element) => txFlow?.type === ConfirmBatchFlow + +export const isChangeThresholdView = (txInfo: TransactionInfo) => + txInfo.type === TransactionInfoType.SETTINGS_CHANGE && txInfo.settingsInfo?.type === SettingsInfoType.CHANGE_THRESHOLD diff --git a/src/features/counterfactual/CounterfactualForm.tsx b/src/features/counterfactual/CounterfactualForm.tsx index 98d59a5712..e91a6199cf 100644 --- a/src/features/counterfactual/CounterfactualForm.tsx +++ b/src/features/counterfactual/CounterfactualForm.tsx @@ -18,7 +18,7 @@ import { useCurrentChain } from '@/hooks/useChains' import { getTxOptions } from '@/utils/transactions' import CheckWallet from '@/components/common/CheckWallet' import { useIsExecutionLoop } from '@/components/tx/SignOrExecuteForm/hooks' -import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm' +import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import AdvancedParams, { useAdvancedParams } from '@/components/tx/AdvancedParams' import { asError } from '@/services/exceptions/utils' @@ -43,6 +43,7 @@ export const CounterfactualForm = ({ isExecutionLoop: ReturnType txSecurity: ReturnType safeTx?: SafeTransaction + isCreation?: boolean }): ReactElement => { const wallet = useWallet() const chain = useCurrentChain() diff --git a/src/tests/transactions.ts b/src/tests/transactions.ts index 244e64e366..3cfdfea9db 100644 --- a/src/tests/transactions.ts +++ b/src/tests/transactions.ts @@ -5,6 +5,8 @@ import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { ERC20__factory, ERC721__factory, Multi_send__factory } from '@/types/contracts' import EthSafeTransaction from '@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionStatus } from '@safe-global/safe-apps-sdk' export const getMockErc20TransferCalldata = (to: string) => { const erc20Interface = ERC20__factory.createInterface() @@ -65,6 +67,23 @@ export const getMockMultiSendCalldata = (recipients: Array): string => { return multiSendInterface.encodeFunctionData('multiSend', [concat(internalTransactions)]) } +export const createMockTransactionDetails = ({ + txInfo, + txData, + detailedExecutionInfo, +}: { + txInfo: TransactionDetails['txInfo'] + txData: TransactionDetails['txData'] + detailedExecutionInfo: TransactionDetails['detailedExecutionInfo'] +}): TransactionDetails => ({ + safeAddress: 'sep:0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + txId: 'multisig_0xBd69b0a9DC90eB6F9bAc3E4a5875f437348b6415_0xcb83bc36cf4a2998e7fe222e36c458c59c3778f65b4e5bb361c29a73c2de62cc', + txStatus: TransactionStatus.AWAITING_CONFIRMATIONS, + txInfo, + txData, + detailedExecutionInfo, +}) + // TODO: Replace with safeTxBuilder export const createMockSafeTransaction = ({ to, From 7f42a860ccb4626b0900ee01a2b005de83060b1b Mon Sep 17 00:00:00 2001 From: Michael <30682308+mike10ca@users.noreply.github.com> Date: Mon, 4 Nov 2024 20:37:25 +0100 Subject: [PATCH 03/10] Tests: Fix regression tests (#4470) * Fix tests --- cypress/e2e/regression/swaps.cy.js | 2 ++ cypress/support/utils/wallet.js | 17 +++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/cypress/e2e/regression/swaps.cy.js b/cypress/e2e/regression/swaps.cy.js index cc5413bdaf..132737cec3 100644 --- a/cypress/e2e/regression/swaps.cy.js +++ b/cypress/e2e/regression/swaps.cy.js @@ -181,8 +181,10 @@ describe('Swaps tests', () => { ] // Clean txs in the queue cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_1) + cy.wait(5000) create_tx.deleteAllTx() + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_1) swaps.acceptLegalDisclaimer() cy.wait(4000) main.getIframeBody(iframeSelector).within(() => { diff --git a/cypress/support/utils/wallet.js b/cypress/support/utils/wallet.js index 145702eed8..37d22753fe 100644 --- a/cypress/support/utils/wallet.js +++ b/cypress/support/utils/wallet.js @@ -8,12 +8,17 @@ const privateKeyStr = 'Private key' export function connectSigner(signer) { const actions = { privateKey: () => { - cy.get(onboardv2) - .shadow() - .find('button') - .contains(privateKeyStr) - .click() - .then(() => handlePkConnect()) + cy.wait(2000) + cy.get('body').then(($body) => { + if ($body.find(onboardv2).length > 0) { + cy.get(onboardv2) + .shadow() + .find('button') + .contains(privateKeyStr) + .click() + .then(() => handlePkConnect()) + } + }) }, retry: () => { cy.wait(1000).then(enterPrivateKey) From 69fe761694cbe6441f03595b7defaf01a9eb1048 Mon Sep 17 00:00:00 2001 From: Michael <30682308+mike10ca@users.noreply.github.com> Date: Tue, 5 Nov 2024 13:52:51 +0100 Subject: [PATCH 04/10] Tests: Multichain sidebar tests (#4476) Add tests for multichain sidebar --- cypress/e2e/pages/address_book.page.js | 1 + cypress/e2e/pages/main.page.js | 4 + cypress/e2e/pages/sidebar.pages.js | 87 +++++++++++++- .../e2e/regression/multichain_sidebar.cy.js | 109 ++++++++++++++++++ cypress/fixtures/safes/static.json | 3 +- cypress/support/localstorage_data.js | 30 +++++ .../address-book/EntryDialog/index.tsx | 1 + src/components/common/SafeIcon/index.tsx | 2 +- .../welcome/MyAccounts/AddNetworkButton.tsx | 2 +- .../welcome/MyAccounts/MultiAccountItem.tsx | 17 ++- .../components/CreateSafeOnNewChain/index.tsx | 2 +- 11 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 cypress/e2e/regression/multichain_sidebar.cy.js diff --git a/cypress/e2e/pages/address_book.page.js b/cypress/e2e/pages/address_book.page.js index 8f998797e7..4efcd976f4 100644 --- a/cypress/e2e/pages/address_book.page.js +++ b/cypress/e2e/pages/address_book.page.js @@ -23,6 +23,7 @@ const exportSummary = '[data-testid="export-summary"]' const sendBtn = '[data-testid="send-btn"]' const nextPageBtn = 'button[aria-label="Go to next page"]' const previousPageBtn = 'button[aria-label="Go to previous page"]' +export const entryDialog = '[data-testid="entry-dialog"]' //TODO Move to specific component const moreActionIcon = '[data-testid="MoreHorizIcon"]' diff --git a/cypress/e2e/pages/main.page.js b/cypress/e2e/pages/main.page.js index 401fb84df8..53a1a66de4 100644 --- a/cypress/e2e/pages/main.page.js +++ b/cypress/e2e/pages/main.page.js @@ -364,3 +364,7 @@ export function getAddedSafeAddressFromLocalStorage(chainId, index) { return safeAddress }) } + +export function changeSafeChainName(originalChain, newChain) { + return originalChain.replace(/^[^:]+:/, newChain + ':') +} diff --git a/cypress/e2e/pages/sidebar.pages.js b/cypress/e2e/pages/sidebar.pages.js index 4729e6f974..9ef2f2c40e 100644 --- a/cypress/e2e/pages/sidebar.pages.js +++ b/cypress/e2e/pages/sidebar.pages.js @@ -5,6 +5,7 @@ import * as navigation from './navigation.page.js' import { safeHeaderInfo } from './import_export.pages.js' import * as file from './import_export.pages.js' import safes from '../../fixtures/safes/static.json' +import * as address_book from './address_book.page.js' export const chainLogo = '[data-testid="chain-logo"]' const safeIcon = '[data-testid="safe-icon"]' @@ -20,8 +21,9 @@ const sideSafeListItem = '[data-testid="safe-list-item"]' const sidebarSafeHeader = '[data-testid="safe-header-info"]' const sidebarSafeContainer = '[data-testid="sidebar-safe-container"]' const safeItemOptionsBtn = '[data-testid="safe-options-btn"]' -const safeItemOptionsRenameBtn = '[data-testid="rename-btn"]' +export const safeItemOptionsRenameBtn = '[data-testid="rename-btn"]' const safeItemOptionsRemoveBtn = '[data-testid="remove-btn"]' +export const safeItemOptionsAddChainBtn = '[data-testid="add-chain-btn"]' const nameInput = '[data-testid="name-input"]' const saveBtn = '[data-testid="save-btn"]' const cancelBtn = '[data-testid="cancel-btn"]' @@ -34,6 +36,14 @@ const showMoreBtn = '[data-testid="show-more-btn" ]' const importBtn = '[data-testid="import-btn"]' export const pendingActivationIcon = '[data-testid="pending-activation-icon"]' const safeItemMenuIcon = '[data-testid="MoreVertIcon"]' +const multichainItemSummary = '[data-testid="multichain-item-summary"]' +const addChainDialog = "[data-testid='add-chain-dialog']" +export const addNetworkBtn = "[data-testid='add-network-btn']" +const subAccountContainer = '[data-testid="subacounts-container"]' +const groupBalance = '[data-testid="group-balance"]' +const groupAddress = '[data-testid="group-address"]' +const groupSafeIcon = '[data-testid="group-safe-icon"]' +const multichainTooltip = '[data-testid="multichain-tooltip"]' export const importBtnStr = 'Import' export const exportBtnStr = 'Export' @@ -60,6 +70,11 @@ const confirmTxStr = (number) => `${number} to confirm` const pedningTxStr = (n) => `${n} pending transaction` export const confirmGenStr = 'to confirm' +export const multichainSafes = { + polygon: 'Multichain polygon', + sepolia: 'Multichain Sepolia', +} + export function verifyNumberOfPendingTxTag(tx) { cy.contains(pedningTxStr(tx)) } @@ -206,10 +221,70 @@ export function verifyQueuedTx(safe) { return getSafeItemByName(safe).find(queuedTxInfo).should('exist') } -function clickOnSafeItemOptionsBtn(name) { +export function clickOnSafeItemOptionsBtn(name) { getSafeItemByName(name).find(safeItemOptionsBtn).click() } +export function clickOnSafeItemOptionsBtnByIndex(index) { + cy.get(safeItemOptionsBtn).eq(index).click() +} + +export function clickOnMultichainItemOptionsBtn(index) { + cy.get(multichainItemSummary).eq(index).find(safeItemOptionsBtn).click() +} + +export function checkMultichainTooltipExists(index) { + cy.get(multichainItemSummary).eq(index).find(chainLogo).eq(0).trigger('mouseover', { force: true }) + + cy.get(multichainTooltip).should('exist') +} + +export function checkSafeGroupBalance(index, balance) { + cy.get(multichainItemSummary) + .eq(index) + .find(groupBalance) + .invoke('text') + .then((text) => { + expect(text).to.include(balance) + }) +} + +export function checkSafeGroupAddress(index, address) { + cy.get(multichainItemSummary) + .eq(index) + .find(groupAddress) + .invoke('text') + .then((text) => { + expect(text).to.include(address) + }) +} +export function checkSafeGroupIconsExist(index, icons) { + cy.get(multichainItemSummary).eq(index).find(groupSafeIcon).should('have.length', 1) + cy.get(multichainItemSummary).eq(index).find(safeIcon).should('have.length', icons) +} + +export function getSubAccountContainer(index) { + return cy.get(subAccountContainer).eq(index) +} + +export function checkThereIsNoOptionsMenu(index) { + getSubAccountContainer(index).find(safeItemOptionsBtn).should('not.exist') +} + +export function checkAddNetworkBtnPosition(index) { + cy.get(multichainItemSummary) + .eq(index) + .should('exist') + .within(() => { + cy.get(addNetworkBtn) + .should('exist') + .should('be.visible') + .then(($btn) => { + expect($btn.parent().children().last()[0]).to.equal($btn[0]) + }) + }) +} + export function renameSafeItem(oldName, newName) { clickOnSafeItemOptionsBtn(oldName) clickOnRenameBtn() @@ -227,8 +302,9 @@ function typeSafeName(name) { cy.get(nameInput).find('input').clear().type(name) } -function clickOnRenameBtn() { +export function clickOnRenameBtn() { cy.get(safeItemOptionsRenameBtn).click() + cy.get(address_book.entryDialog).should('exist') } function clickOnRemoveBtn() { @@ -298,3 +374,8 @@ export function checkBalanceExists() { const balance = new RegExp(`\\s*\\d*\\.?\\d*\\s*`, 'i') const element = cy.get(chainLogo).prev().contains(balance) } + +export function checkAddChainDialogDisplayed() { + cy.get(safeItemOptionsAddChainBtn).click() + cy.get(addChainDialog).should('be.visible') +} diff --git a/cypress/e2e/regression/multichain_sidebar.cy.js b/cypress/e2e/regression/multichain_sidebar.cy.js new file mode 100644 index 0000000000..2e14001b18 --- /dev/null +++ b/cypress/e2e/regression/multichain_sidebar.cy.js @@ -0,0 +1,109 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as sideBar from '../pages/sidebar.pages.js' +import * as ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +let staticSafes = [] + +const newSafeName = 'Added safe 3' +const addedSafe900 = 'Added safe 900' +const staticSafe200 = 'Added safe 200' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Multichain sidebar tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.MATIC_STATIC_SAFE_28) + cy.wait(2000) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set5) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.multichain) + wallet.connectSigner(signer) + }) + + it('Verify Rename and Add network options are available for Group of safes', () => { + sideBar.openSidebar() + sideBar.clickOnMultichainItemOptionsBtn(0) + main.verifyElementsIsVisible([sideBar.safeItemOptionsAddChainBtn, sideBar.safeItemOptionsRenameBtn]) + }) + + it('Verify Give name and Add network options are available for a deployed safe', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.BALANCE_URL + safe) + + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.verifySafeGiveNameOptionExists(1) + }) + + it('Verify "Add network" in more options menu for the single safe', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.BALANCE_URL + safe) + + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.clickOnSafeItemOptionsBtnByIndex(1) + sideBar.checkAddChainDialogDisplayed() + }) + + it('Verify "Add Networks" option for the group of safes with multi-chain safe', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.clickOnSafeItemOptionsBtnByIndex(0) + sideBar.checkAddChainDialogDisplayed() + }) + + it('Verify "Add another network" button in safe group', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + main.verifyElementsExist([sideBar.addNetworkBtn]) + }) + + it('Verify there is no Rename option for a safe in the group', () => { + sideBar.openSidebar() + sideBar.checkThereIsNoOptionsMenu(0) + }) + + it('Verify Rename option in the group of safes opens a new edit entry modal', () => { + sideBar.openSidebar() + sideBar.clickOnMultichainItemOptionsBtn(0) + sideBar.clickOnRenameBtn() + }) + it('Verify "Add another network" at the end of the group list', () => { + sideBar.openSidebar() + sideBar.checkAddNetworkBtnPosition(0) + }) + + it('Verify balance of the safe group', () => { + sideBar.openSidebar() + sideBar.checkSafeGroupBalance(0, '0.24') + }) + + it('Verify address of the safe group', () => { + const address = '0xC96e...ee3B' + sideBar.openSidebar() + sideBar.checkSafeGroupAddress(0, address) + }) + + it('Verify network logo for safes in the group', () => { + sideBar.openSidebar() + sideBar.checkSafeGroupIconsExist(0, 3) + }) + + it('Verify tooltip with networks for multichain safe', () => { + sideBar.openSidebar() + sideBar.checkMultichainTooltipExists(0) + }) +}) diff --git a/cypress/fixtures/safes/static.json b/cypress/fixtures/safes/static.json index 4793a62b83..e02fa35f23 100644 --- a/cypress/fixtures/safes/static.json +++ b/cypress/fixtures/safes/static.json @@ -27,5 +27,6 @@ "SEP_STATIC_SAFE_24": "sep:0x49DC5764961DA4864DC5469f16BC68a0F765f2F2", "SEP_STATIC_SAFE_25": "sep:0x4ECFAa2E8cb4697bCD27bdC9Ce3E16f03F73124F", "SEP_STATIC_SAFE_26": "sep:0x755428b02A458eD17fa93c86F6C3a2046F2c4C3C", - "SEP_STATIC_SAFE_27": "sep:0xC97FCf0B8890a5a7b1a1490d44Dc9EbE3cE04884" + "SEP_STATIC_SAFE_27": "sep:0xC97FCf0B8890a5a7b1a1490d44Dc9EbE3cE04884", + "MATIC_STATIC_SAFE_28": "matic:0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B" } diff --git a/cypress/support/localstorage_data.js b/cypress/support/localstorage_data.js index 986afb30cf..4292228788 100644 --- a/cypress/support/localstorage_data.js +++ b/cypress/support/localstorage_data.js @@ -340,6 +340,14 @@ export const addressBookData = { '0x9E6DAfe829431e1892EcF8461FDAd02665170c31': 'Added non-owner', }, }, + multichain: { + 137: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': 'Multichain polygon', + }, + 11155111: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': 'Multichain Sepolia', + }, + }, sortingData: { 11155111: { '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED': 'AA Safe', @@ -657,6 +665,28 @@ export const addedSafes = { }, }, }, + set5: { + 137: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': { + owners: [ + { + value: '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED', + }, + ], + threshold: 1, + }, + }, + 11155111: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': { + owners: [ + { + value: '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED', + }, + ], + threshold: 1, + }, + }, + }, } export const pinnedApps = { diff --git a/src/components/address-book/EntryDialog/index.tsx b/src/components/address-book/EntryDialog/index.tsx index e2d3202776..0d3ceb9852 100644 --- a/src/components/address-book/EntryDialog/index.tsx +++ b/src/components/address-book/EntryDialog/index.tsx @@ -52,6 +52,7 @@ function EntryDialog({ return ( { const SafeIcon = ({ address, threshold, owners, size, chainId, isSubItem = false }: SafeIconProps): ReactElement => { return ( -
+
{threshold && owners ? : null} {isSubItem && chainId ? : }
diff --git a/src/components/welcome/MyAccounts/AddNetworkButton.tsx b/src/components/welcome/MyAccounts/AddNetworkButton.tsx index d2860555a5..7cea238c7a 100644 --- a/src/components/welcome/MyAccounts/AddNetworkButton.tsx +++ b/src/components/welcome/MyAccounts/AddNetworkButton.tsx @@ -19,7 +19,7 @@ export const AddNetworkButton = ({ return ( <> - diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/components/welcome/MyAccounts/MultiAccountItem.tsx index edadf108bd..9b2279b2f9 100644 --- a/src/components/welcome/MyAccounts/MultiAccountItem.tsx +++ b/src/components/welcome/MyAccounts/MultiAccountItem.tsx @@ -48,7 +48,7 @@ const MultichainIndicator = ({ safes }: { safes: SafeItem[] }) => { return ( + Multichain account on: {safes.map((safeItem) => ( @@ -143,7 +143,7 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte className={classnames(css.multiListItem, css.listItem, { [css.currentListItem]: isCurrentSafe })} sx={{ p: 0 }} > - + - + @@ -162,12 +162,17 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte {name} )} - + {shortenAddress(address)} - + {totalFiatValue !== undefined ? ( ) : ( @@ -183,7 +188,7 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte /> - + {safes.map((safeItem) => (
- + Add this Safe to another network with the same address. From f965400163fd7e719c1cba02e95f360816e22b7a Mon Sep 17 00:00:00 2001 From: Michael <30682308+mike10ca@users.noreply.github.com> Date: Tue, 5 Nov 2024 15:22:14 +0100 Subject: [PATCH 05/10] Tests: Fix add owner test (#4480) --- cypress/e2e/regression/add_owner.cy.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cypress/e2e/regression/add_owner.cy.js b/cypress/e2e/regression/add_owner.cy.js index f7487c6a5b..68d01be987 100644 --- a/cypress/e2e/regression/add_owner.cy.js +++ b/cypress/e2e/regression/add_owner.cy.js @@ -76,6 +76,12 @@ describe('Add Owners tests', () => { }, ] function step1() { + // Clean txs in the queue + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_24) + wallet.connectSigner(signer2) + cy.wait(5000) + createTx.deleteAllTx() + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_24) wallet.connectSigner(signer2) owner.waitForConnectionStatus() From d90cbfa6cc8504b8c6474193546a31844e0ef2f1 Mon Sep 17 00:00:00 2001 From: katspaugh <381895+katspaugh@users.noreply.github.com> Date: Wed, 6 Nov 2024 02:51:51 +0900 Subject: [PATCH 06/10] Feat: indexing status (SWS-134) (#4258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: indexing status (SWS-134) * Add a tooltip * fix: don’t use nested ternaries --------- Co-authored-by: Daniel Dimitrov --- .../sidebar/IndexingStatus/index.tsx | 72 +++++++++++++++++++ src/components/sidebar/Sidebar/index.tsx | 5 ++ yarn.lock | 31 +++++++- 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 src/components/sidebar/IndexingStatus/index.tsx diff --git a/src/components/sidebar/IndexingStatus/index.tsx b/src/components/sidebar/IndexingStatus/index.tsx new file mode 100644 index 0000000000..c7e81db635 --- /dev/null +++ b/src/components/sidebar/IndexingStatus/index.tsx @@ -0,0 +1,72 @@ +import { Stack, Box, Typography, Tooltip } from '@mui/material' +import { formatDistanceToNow } from 'date-fns' +import { getIndexingStatus } from '@safe-global/safe-gateway-typescript-sdk' +import useAsync from '@/hooks/useAsync' +import useChainId from '@/hooks/useChainId' +import ExternalLink from '@/components/common/ExternalLink' + +const STATUS_PAGE = 'https://status.safe.global' +const MAX_SYNC_DELAY = 1000 * 60 * 5 // 5 minutes + +const useIndexingStatus = () => { + const chainId = useChainId() + + return useAsync(() => { + return getIndexingStatus(chainId) + }, [chainId]) +} + +const STATUSES = { + synced: { + color: 'success', + text: 'Synced', + }, + slow: { + color: 'warning', + text: 'Slow network', + }, + outOfSync: { + color: 'error', + text: 'Out of sync', + }, +} + +const getStatus = (synced: boolean, lastSync: number) => { + let status = STATUSES.outOfSync + + if (synced) { + status = STATUSES.synced + } else if (Date.now() - lastSync > MAX_SYNC_DELAY) { + status = STATUSES.slow + } + + return status +} + +const IndexingStatus = () => { + const [data] = useIndexingStatus() + + if (!data) { + return null + } + + const status = getStatus(data.synced, data.lastSync) + + const time = formatDistanceToNow(data.lastSync, { addSuffix: true }) + + return ( + + + + + + {status.text} + + + + + + ) +} + +export default IndexingStatus diff --git a/src/components/sidebar/Sidebar/index.tsx b/src/components/sidebar/Sidebar/index.tsx index 51655d35a0..134acf0e33 100644 --- a/src/components/sidebar/Sidebar/index.tsx +++ b/src/components/sidebar/Sidebar/index.tsx @@ -6,6 +6,7 @@ import ChainIndicator from '@/components/common/ChainIndicator' import SidebarHeader from '@/components/sidebar/SidebarHeader' import SidebarNavigation from '@/components/sidebar/SidebarNavigation' import SidebarFooter from '@/components/sidebar/SidebarFooter' +import IndexingStatus from '@/components/sidebar/IndexingStatus' import css from './styles.module.css' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' @@ -48,6 +49,10 @@ const Sidebar = (): ReactElement => { {/* What's new + Need help? */} + + + +
diff --git a/yarn.lock b/yarn.lock index 3ffec1e41e..ed4c495811 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17238,7 +17238,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -17334,7 +17343,14 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -19090,7 +19106,7 @@ workbox-window@7.0.0: "@types/trusted-types" "^2.0.2" workbox-core "7.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -19108,6 +19124,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 50e44fe2494f18af6ad95a33b3377eb0b204e94a Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:33:38 +0100 Subject: [PATCH 07/10] feat: Safe Proposers (#4426) * init * feat: Add remove delegate option and adjust delegate list layout (#4390) * feat: Add remove delegate button, adjust delegate list layout * feat: Update gateway-sdk package, add delegate form * feat: Optimistically update delegates cache when adding or removing delegate * fix: Add missing ga events, add enum * chore: Update gateway-sdk package * fix: Update setup settings layout * fix: Add notifications when adding and removing proposer * fix: Add validation for add proposer * fix: Rename delegate to proposer * fix: Rename variable for add proposer dialog * fix: Align remove icons in tables * fix: Handle update proposer in rtk query * feat: Show Proposal chip for unsigned transactions in the queue (#4422) * feat: Allow deletion of delegate transactions from the queue [SW-297] (#4400) * init * feat: Allow deletion of delegate transactions from the queue * feat: Add text to signer view if tx is from proposer * fix: Disable add proposer and delete proposer [SW-400] [SW-396] (#4429) * fix: Disable add proposer and delete proposer * fix: Account for owners that are proposers in CheckWallet * fix: Adjust message when proposing transaction (#4435) * feat: Edit proposer dialog [SW-391] [SW-396] (#4436) * feat: Show proposer address in queue (#4443) * fix: Hide tooltip on confirm button for proposers (#4444) * fix: Use safe owner address for tenderly simulation with proposer (#4445) * fix: Only show proposal chip if transaction is not pending (#4450) * fix: Allow owners to be added as proposers [SW-407] [SW-428] [SW-381] (#4446) * fix: Remove scrollbar when adding proposer * fix: Allow owners being added as proposers * fix: Add check that isProposing only when its also a creation * fix: Update testid to fix add owner smoke test * fix: Hide batch button for proposers (#4457) * feat: Support hardware wallets for adding and removing proposers (#4466) * feat: Display creator in the proposer list [SW-408] [SW-470] (#4471) * feat: Display creator in proposer list * fix: Correctly update proposers when editing and deleting * fix: Remove dangling console.log * fix: Add network switch to delete proposer dialog and reset values when closing * fix: AdjustVInSignature when managing proposers with a hardware wallet (#4477) * fix: Add proposers feature flag (#4488) --- package.json | 2 +- .../common/AddressInput/styles.module.css | 1 - .../common/CheckWallet/index.test.tsx | 32 ++- src/components/common/CheckWallet/index.tsx | 16 +- src/components/common/Header/index.test.tsx | 129 ++++++++++ src/components/common/Header/index.tsx | 8 +- .../settings/DelegatesList/index.tsx | 67 ------ .../settings/ProposersList/index.tsx | 134 +++++++++++ .../settings/RequiredConfirmations/index.tsx | 7 +- .../settings/SpendingLimits/index.tsx | 1 + .../settings/owner/OwnerList/index.tsx | 25 +- src/components/theme/safeTheme.ts | 11 +- .../transactions/SignTxButton/index.tsx | 4 +- .../transactions/TxDetails/index.tsx | 21 +- .../transactions/TxSigners/index.tsx | 60 +++-- .../transactions/TxSummary/index.tsx | 13 +- .../SignOrExecuteForm/ConfirmationTitle.tsx | 1 + .../{DelegateForm.tsx => ProposerForm.tsx} | 19 +- .../SignOrExecuteForm/SignOrExecuteForm.tsx | 38 +-- .../__tests__/ExecuteForm.test.tsx | 8 +- .../__tests__/SignForm.test.tsx | 8 +- src/components/tx/SignOrExecuteForm/hooks.ts | 10 +- src/components/tx/security/tenderly/index.tsx | 5 +- .../components/DeleteProposerDialog.tsx | 207 ++++++++++++++++ .../components/EditProposerDialog.tsx | 46 ++++ .../proposers/components/TxProposalChip.tsx | 32 +++ .../proposers/components/UpsertProposer.tsx | 223 ++++++++++++++++++ src/features/proposers/utils/utils.ts | 51 ++++ src/hooks/useDelegates.ts | 22 -- .../useIsOnlySpendingLimitBeneficiary.tsx | 4 +- src/hooks/useProposers.ts | 22 ++ .../wallets/__tests__/useOnboard.test.ts | 2 +- src/hooks/wallets/useOnboard.ts | 4 +- src/pages/settings/setup.tsx | 6 +- src/services/analytics/events/settings.ts | 38 +++ src/services/analytics/events/transactions.ts | 4 +- src/services/tx/tx-sender/dispatch.ts | 4 +- src/store/api/gateway/index.ts | 15 +- src/store/api/gateway/proposers.ts | 100 ++++++++ src/utils/chains.ts | 1 + src/utils/validation.ts | 11 +- yarn.lock | 8 +- 42 files changed, 1211 insertions(+), 209 deletions(-) create mode 100644 src/components/common/Header/index.test.tsx delete mode 100644 src/components/settings/DelegatesList/index.tsx create mode 100644 src/components/settings/ProposersList/index.tsx rename src/components/tx/SignOrExecuteForm/{DelegateForm.tsx => ProposerForm.tsx} (87%) create mode 100644 src/features/proposers/components/DeleteProposerDialog.tsx create mode 100644 src/features/proposers/components/EditProposerDialog.tsx create mode 100644 src/features/proposers/components/TxProposalChip.tsx create mode 100644 src/features/proposers/components/UpsertProposer.tsx create mode 100644 src/features/proposers/utils/utils.ts delete mode 100644 src/hooks/useDelegates.ts create mode 100644 src/hooks/useProposers.ts create mode 100644 src/store/api/gateway/proposers.ts diff --git a/package.json b/package.json index d1a993ca7d..987eb7b4c3 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@safe-global/protocol-kit": "^4.1.1", "@safe-global/safe-apps-sdk": "^9.1.0", "@safe-global/safe-deployments": "1.37.12", - "@safe-global/safe-client-gateway-sdk": "v1.60.1", + "@safe-global/safe-client-gateway-sdk": "1.60.1-next-069fa2b", "@safe-global/safe-gateway-typescript-sdk": "3.22.3-beta.15", "@safe-global/safe-modules-deployments": "^2.2.1", "@sentry/react": "^7.91.0", diff --git a/src/components/common/AddressInput/styles.module.css b/src/components/common/AddressInput/styles.module.css index 7e95c6959d..bd88bff8cd 100644 --- a/src/components/common/AddressInput/styles.module.css +++ b/src/components/common/AddressInput/styles.module.css @@ -16,5 +16,4 @@ .readOnly :global .MuiInputBase-input { visibility: hidden; - position: absolute; } diff --git a/src/components/common/CheckWallet/index.test.tsx b/src/components/common/CheckWallet/index.test.tsx index 8455fa4c93..7058f79761 100644 --- a/src/components/common/CheckWallet/index.test.tsx +++ b/src/components/common/CheckWallet/index.test.tsx @@ -5,7 +5,7 @@ import useIsSafeOwner from '@/hooks/useIsSafeOwner' import useIsWrongChain from '@/hooks/useIsWrongChain' import useWallet from '@/hooks/wallets/useWallet' import { chainBuilder } from '@/tests/builders/chains' -import { useIsWalletDelegate } from '@/hooks/useDelegates' +import { useIsWalletProposer } from '@/hooks/useProposers' import { faker } from '@faker-js/faker' import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import useSafeInfo from '@/hooks/useSafeInfo' @@ -42,9 +42,9 @@ jest.mock('@/hooks/useIsWrongChain', () => ({ default: jest.fn(() => false), })) -jest.mock('@/hooks/useDelegates', () => ({ +jest.mock('@/hooks/useProposers', () => ({ __esModule: true, - useIsWalletDelegate: jest.fn(() => false), + useIsWalletProposer: jest.fn(() => false), })) jest.mock('@/hooks/useSafeInfo', () => ({ @@ -125,15 +125,37 @@ describe('CheckWallet', () => { expect(allowContainer.querySelector('button')).not.toBeDisabled() }) - it('should not disable the button for delegates', () => { + it('should not disable the button for proposers', () => { ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) - ;(useIsWalletDelegate as jest.MockedFunction).mockReturnValueOnce(true) + ;(useIsWalletProposer as jest.MockedFunction).mockReturnValueOnce(true) const { container } = renderButton() expect(container.querySelector('button')).not.toBeDisabled() }) + it('should disable the button for proposers if specified via flag', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;(useIsWalletProposer as jest.MockedFunction).mockReturnValueOnce(true) + + const { getByText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).toBeDisabled() + }) + + it('should not disable the button for proposers that are also owners', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + ;(useIsWalletProposer as jest.MockedFunction).mockReturnValueOnce(true) + + const { getByText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).not.toBeDisabled() + }) + it('should disable the button for counterfactual Safes', () => { ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) diff --git a/src/components/common/CheckWallet/index.tsx b/src/components/common/CheckWallet/index.tsx index 65d6e2ab21..93296deb67 100644 --- a/src/components/common/CheckWallet/index.tsx +++ b/src/components/common/CheckWallet/index.tsx @@ -1,4 +1,4 @@ -import { useIsWalletDelegate } from '@/hooks/useDelegates' +import { useIsWalletProposer } from '@/hooks/useProposers' import { useMemo, type ReactElement } from 'react' import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' import useIsSafeOwner from '@/hooks/useIsSafeOwner' @@ -15,6 +15,7 @@ type CheckWalletProps = { noTooltip?: boolean checkNetwork?: boolean allowUndeployedSafe?: boolean + allowProposer?: boolean } enum Message { @@ -30,13 +31,14 @@ const CheckWallet = ({ noTooltip, checkNetwork = false, allowUndeployedSafe = false, + allowProposer = true, }: CheckWalletProps): ReactElement => { const wallet = useWallet() const isSafeOwner = useIsSafeOwner() const isOnlySpendingLimit = useIsOnlySpendingLimitBeneficiary() const connectWallet = useConnectWallet() const isWrongChain = useIsWrongChain() - const isDelegate = useIsWalletDelegate() + const isProposer = useIsWalletProposer() const { safe } = useSafeInfo() @@ -46,18 +48,24 @@ const CheckWallet = ({ if (!wallet) { return Message.WalletNotConnected } + if (isUndeployedSafe && !allowUndeployedSafe) { return Message.SafeNotActivated } - if (!allowNonOwner && !isSafeOwner && !isDelegate && (!isOnlySpendingLimit || !allowSpendingLimit)) { + if (!allowNonOwner && !isSafeOwner && !isProposer && (!isOnlySpendingLimit || !allowSpendingLimit)) { + return Message.NotSafeOwner + } + + if (!allowProposer && isProposer && !isSafeOwner) { return Message.NotSafeOwner } }, [ allowNonOwner, + allowProposer, allowSpendingLimit, allowUndeployedSafe, - isDelegate, + isProposer, isOnlySpendingLimit, isSafeOwner, isUndeployedSafe, diff --git a/src/components/common/Header/index.test.tsx b/src/components/common/Header/index.test.tsx new file mode 100644 index 0000000000..d5ee8fca72 --- /dev/null +++ b/src/components/common/Header/index.test.tsx @@ -0,0 +1,129 @@ +import Header from '@/components/common/Header/index' +import * as useChains from '@/hooks/useChains' +import * as useIsSafeOwner from '@/hooks/useIsSafeOwner' +import * as useProposers from '@/hooks/useProposers' +import * as useSafeAddress from '@/hooks/useSafeAddress' +import * as useSafeTokenEnabled from '@/hooks/useSafeTokenEnabled' +import { render } from '@/tests/test-utils' +import { faker } from '@faker-js/faker' +import { screen, fireEvent } from '@testing-library/react' + +jest.mock( + '@/components/common/SafeTokenWidget', + () => + function SafeTokenWidget() { + return
SafeTokenWidget
+ }, +) + +jest.mock( + '@/features/walletconnect/components', + () => + function WalletConnect() { + return
WalletConnect
+ }, +) + +jest.mock( + '@/components/common/NetworkSelector', + () => + function NetworkSelector() { + return
NetworkSelector
+ }, +) + +describe('Header', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('renders the menu button when onMenuToggle is provided', () => { + render(
) + expect(screen.getByLabelText('menu')).toBeInTheDocument() + }) + + it('does not render the menu button when onMenuToggle is not provided', () => { + render(
) + expect(screen.queryByLabelText('menu')).not.toBeInTheDocument() + }) + + it('calls onMenuToggle when menu button is clicked', () => { + const onMenuToggle = jest.fn() + render(
) + + const menuButton = screen.getByLabelText('menu') + fireEvent.click(menuButton) + + expect(onMenuToggle).toHaveBeenCalled() + }) + + it('renders the SafeTokenWidget when showSafeToken is true', () => { + jest.spyOn(useSafeTokenEnabled, 'useSafeTokenEnabled').mockReturnValue(true) + + render(
) + expect(screen.getByText('SafeTokenWidget')).toBeInTheDocument() + }) + + it('does not render the SafeTokenWidget when showSafeToken is false', () => { + jest.spyOn(useSafeTokenEnabled, 'useSafeTokenEnabled').mockReturnValue(false) + + render(
) + expect(screen.queryByText('SafeTokenWidget')).not.toBeInTheDocument() + }) + + it('displays the safe logo', () => { + render(
) + expect(screen.getAllByAltText('Safe logo')[0]).toBeInTheDocument() + }) + + it('renders the BatchIndicator when showBatchButton is true', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue(faker.finance.ethereumAddress()) + jest.spyOn(useProposers, 'useIsWalletProposer').mockReturnValue(false) + jest.spyOn(useIsSafeOwner, 'default').mockReturnValue(false) + + render(
) + expect(screen.getByTitle('Batch')).toBeInTheDocument() + }) + + it('does not render the BatchIndicator when there is no safe address', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue('') + + render(
) + expect(screen.queryByTitle('Batch')).not.toBeInTheDocument() + }) + + it('does not render the BatchIndicator when connected wallet is a proposer', () => { + jest.spyOn(useProposers, 'useIsWalletProposer').mockReturnValue(true) + + render(
) + expect(screen.queryByTitle('Batch')).not.toBeInTheDocument() + }) + + it('renders the WalletConnect component when enableWc is true', () => { + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + + render(
) + expect(screen.getByText('WalletConnect')).toBeInTheDocument() + }) + + it('does not render the WalletConnect component when enableWc is false', () => { + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(false) + + render(
) + expect(screen.queryByText('WalletConnect')).not.toBeInTheDocument() + }) + + it('renders the NetworkSelector when safeAddress exists', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue(faker.finance.ethereumAddress()) + + render(
) + expect(screen.getByText('NetworkSelector')).toBeInTheDocument() + }) + + it('does not render the NetworkSelector when safeAddress is falsy', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue('') + + render(
) + expect(screen.queryByText('NetworkSelector')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index 5d7cdbf2ff..215ef45aa6 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -1,3 +1,5 @@ +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useIsWalletProposer } from '@/hooks/useProposers' import type { Dispatch, SetStateAction } from 'react' import { type ReactElement } from 'react' import { useRouter } from 'next/router' @@ -39,6 +41,8 @@ function getLogoLink(router: ReturnType): Url { const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => { const safeAddress = useSafeAddress() const showSafeToken = useSafeTokenEnabled() + const isProposer = useIsWalletProposer() + const isSafeOwner = useIsSafeOwner() const router = useRouter() const enableWc = useHasFeature(FEATURES.NATIVE_WALLETCONNECT) @@ -59,6 +63,8 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => { } } + const showBatchButton = safeAddress && (!isProposer || isSafeOwner) + return (
@@ -91,7 +97,7 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => {
- {safeAddress && ( + {showBatchButton && (
diff --git a/src/components/settings/DelegatesList/index.tsx b/src/components/settings/DelegatesList/index.tsx deleted file mode 100644 index 9aa65bb02c..0000000000 --- a/src/components/settings/DelegatesList/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import useDelegates from '@/hooks/useDelegates' -import { Box, Grid, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' -import EthHashInfo from '@/components/common/EthHashInfo' -import InfoIcon from '@/public/images/notifications/info.svg' -import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle } from '@/config/constants' - -const DelegatesList = () => { - const delegates = useDelegates() - - if (!delegates.data?.results) return null - - return ( - - - - - - - What are delegated accounts?{' '} - Learn more - - } - > - - Delegated accounts - - - - - - - -
    - {delegates.data.results.map((item) => ( -
  • - -
  • - ))} -
-
-
-
-
- ) -} - -export default DelegatesList diff --git a/src/components/settings/ProposersList/index.tsx b/src/components/settings/ProposersList/index.tsx new file mode 100644 index 0000000000..ad43226dda --- /dev/null +++ b/src/components/settings/ProposersList/index.tsx @@ -0,0 +1,134 @@ +import CheckWallet from '@/components/common/CheckWallet' +import { Chip } from '@/components/common/Chip' +import EnhancedTable from '@/components/common/EnhancedTable' +import tableCss from '@/components/common/EnhancedTable/styles.module.css' +import Track from '@/components/common/Track' +import UpsertProposer from '@/features/proposers/components/UpsertProposer' +import DeleteProposerDialog from '@/features/proposers/components/DeleteProposerDialog' +import EditProposerDialog from '@/features/proposers/components/EditProposerDialog' +import { useHasFeature } from '@/hooks/useChains' +import useProposers from '@/hooks/useProposers' +import AddIcon from '@/public/images/common/add.svg' +import { SETTINGS_EVENTS } from '@/services/analytics' +import { FEATURES } from '@/utils/chains' +import { Box, Button, Grid, Paper, SvgIcon, Typography } from '@mui/material' +import EthHashInfo from '@/components/common/EthHashInfo' +import ExternalLink from '@/components/common/ExternalLink' +import { HelpCenterArticle } from '@/config/constants' +import React, { useMemo, useState } from 'react' + +const headCells = [ + { + id: 'proposer', + label: 'Proposer', + }, + { + id: 'creator', + label: 'Creator', + }, + { + id: 'Actions', + label: '', + }, +] + +const ProposersList = () => { + const [isAddDialogOpen, setIsAddDialogOpen] = useState() + const proposers = useProposers() + const isEnabled = useHasFeature(FEATURES.PROPOSERS) + + const rows = useMemo(() => { + if (!proposers.data) return [] + + return proposers.data.results.map((proposer) => { + return { + cells: { + proposer: { + rawValue: proposer.delegate, + content: ( + + ), + }, + + creator: { + rawValue: proposer.delegator, + content: , + }, + actions: { + rawValue: '', + sticky: true, + content: isEnabled && ( +
+ + +
+ ), + }, + }, + } + }) + }, [isEnabled, proposers.data]) + + if (!proposers.data?.results) return null + + const onAdd = () => { + setIsAddDialogOpen(true) + } + + return ( + + + + + + + + + + Proposers + + + Proposers can suggest transactions but cannot approve or execute them. Signers should review and approve + transactions first. Learn more + + + {isEnabled && ( + + + {(isOk) => ( + + + + )} + + + )} + + {rows.length > 0 && } + + + {isAddDialogOpen && ( + setIsAddDialogOpen(false)} onSuccess={() => setIsAddDialogOpen(false)} /> + )} + + + + ) +} + +export default ProposersList diff --git a/src/components/settings/RequiredConfirmations/index.tsx b/src/components/settings/RequiredConfirmations/index.tsx index 2ccc9fc094..9b6df6aa5a 100644 --- a/src/components/settings/RequiredConfirmations/index.tsx +++ b/src/components/settings/RequiredConfirmations/index.tsx @@ -29,7 +29,12 @@ export const RequiredConfirmation = ({ threshold, owners }: { threshold: number; {(isOk) => ( - diff --git a/src/components/settings/SpendingLimits/index.tsx b/src/components/settings/SpendingLimits/index.tsx index 7a5f38c05e..77c779a2d7 100644 --- a/src/components/settings/SpendingLimits/index.tsx +++ b/src/components/settings/SpendingLimits/index.tsx @@ -44,6 +44,7 @@ const SpendingLimits = () => { sx={{ mt: 2 }} variant="contained" disabled={!isOk} + size="small" > New spending limit diff --git a/src/components/settings/owner/OwnerList/index.tsx b/src/components/settings/owner/OwnerList/index.tsx index 0b8df0aae5..d7c4871506 100644 --- a/src/components/settings/owner/OwnerList/index.tsx +++ b/src/components/settings/owner/OwnerList/index.tsx @@ -19,11 +19,6 @@ import type { AddressBook } from '@/store/addressBookSlice' import tableCss from '@/components/common/EnhancedTable/styles.module.css' -const headCells = [ - { id: 'owner', label: 'Name' }, - { id: 'actions', label: '', sticky: true }, -] - export const OwnerList = () => { const addressBook = useAddressBook() const { safe } = useSafeInfo() @@ -99,19 +94,20 @@ export const OwnerList = () => { - Manage Safe Account signers + Members + + Signers + - Add, remove and replace or rename existing signers. Signer names are only stored locally and will never be - shared with us or any third parties. + Signers have full control over the account, they can propose, sign and execute transactions, as well as + reject them. - - - + {(isOk) => ( @@ -121,17 +117,20 @@ export const OwnerList = () => { variant="text" startIcon={} disabled={!isOk} + size="compact" > - Add new signer + Add signer )} - + + diff --git a/src/components/theme/safeTheme.ts b/src/components/theme/safeTheme.ts index e9108c3258..9c43de5545 100644 --- a/src/components/theme/safeTheme.ts +++ b/src/components/theme/safeTheme.ts @@ -49,6 +49,7 @@ declare module '@mui/material/SvgIcon' { declare module '@mui/material/Button' { export interface ButtonPropsSizeOverrides { stretched: true + compact: true } export interface ButtonPropsColorOverrides { @@ -100,6 +101,12 @@ const createSafeTheme = (mode: PaletteMode): Theme => { }, MuiButton: { variants: [ + { + props: { size: 'compact' }, + style: { + padding: '8px 12px', + }, + }, { props: { size: 'stretched' }, style: { @@ -299,7 +306,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.error.background, }, - border: `1px solid ${theme.palette.error.main}`, }), standardInfo: ({ theme }) => ({ '& .MuiAlert-icon': { @@ -308,7 +314,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.info.background, }, - border: `1px solid ${theme.palette.info.main}`, }), standardSuccess: ({ theme }) => ({ '& .MuiAlert-icon': { @@ -317,7 +322,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.success.background, }, - border: `1px solid ${theme.palette.success.main}`, }), standardWarning: ({ theme }) => ({ '& .MuiAlert-icon': { @@ -326,7 +330,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.warning.background, }, - border: `1px solid ${theme.palette.warning.main}`, }), root: ({ theme }) => ({ color: theme.palette.text.primary, diff --git a/src/components/transactions/SignTxButton/index.tsx b/src/components/transactions/SignTxButton/index.tsx index 76298771c5..f1fbdeb8a7 100644 --- a/src/components/transactions/SignTxButton/index.tsx +++ b/src/components/transactions/SignTxButton/index.tsx @@ -1,4 +1,5 @@ import useIsExpiredSwap from '@/features/swap/hooks/useIsExpiredSwap' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' import type { SyntheticEvent } from 'react' import { useContext, type ReactElement } from 'react' import { type TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' @@ -22,6 +23,7 @@ const SignTxButton = ({ }): ReactElement => { const { setTxFlow } = useContext(TxModalContext) const wallet = useWallet() + const isSafeOwner = useIsSafeOwner() const isSignable = isSignableBy(txSummary, wallet?.address || '') const safeSDK = useSafeSDK() const expiredSwap = useIsExpiredSwap(txSummary.txInfo) @@ -36,7 +38,7 @@ const SignTxButton = ({ return ( {(isOk) => ( - + + + + {(isOk) => ( + + )} + + + + + ) +} + +const DeleteProposerDialog = madProps(_DeleteProposer, { + wallet: useWallet, + chainId: useChainId, + safeAddress: useSafeAddress, +}) + +export default DeleteProposerDialog diff --git a/src/features/proposers/components/EditProposerDialog.tsx b/src/features/proposers/components/EditProposerDialog.tsx new file mode 100644 index 0000000000..9450125237 --- /dev/null +++ b/src/features/proposers/components/EditProposerDialog.tsx @@ -0,0 +1,46 @@ +import CheckWallet from '@/components/common/CheckWallet' +import Track from '@/components/common/Track' +import UpsertProposer from '@/features/proposers/components/UpsertProposer' +import useWallet from '@/hooks/wallets/useWallet' +import EditIcon from '@/public/images/common/edit.svg' +import { SETTINGS_EVENTS } from '@/services/analytics' +import { IconButton, SvgIcon, Tooltip } from '@mui/material' +import type { Delegate } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' +import React, { useState } from 'react' + +const EditProposerDialog = ({ proposer }: { proposer: Delegate }) => { + const [open, setOpen] = useState(false) + const wallet = useWallet() + + const canEdit = wallet?.address === proposer.delegator + + return ( + <> + + {(isOk) => ( + + + + setOpen(true)} size="small" disabled={!isOk || !canEdit}> + + + + + + )} + + + {open && setOpen(false)} onSuccess={() => setOpen(false)} proposer={proposer} />} + + ) +} + +export default EditProposerDialog diff --git a/src/features/proposers/components/TxProposalChip.tsx b/src/features/proposers/components/TxProposalChip.tsx new file mode 100644 index 0000000000..ee77076164 --- /dev/null +++ b/src/features/proposers/components/TxProposalChip.tsx @@ -0,0 +1,32 @@ +import { Chip, SvgIcon, Tooltip, Typography } from '@mui/material' +import InfoIcon from '@/public/images/notifications/info.svg' + +const TxProposalChip = () => { + return ( + + + + + + Proposal + + + } + /> + + + ) +} + +export default TxProposalChip diff --git a/src/features/proposers/components/UpsertProposer.tsx b/src/features/proposers/components/UpsertProposer.tsx new file mode 100644 index 0000000000..975135bd5d --- /dev/null +++ b/src/features/proposers/components/UpsertProposer.tsx @@ -0,0 +1,223 @@ +import AddressBookInput from '@/components/common/AddressBookInput' +import CheckWallet from '@/components/common/CheckWallet' +import EthHashInfo from '@/components/common/EthHashInfo' +import NameInput from '@/components/common/NameInput' +import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { signProposerData, signProposerTypedData } from '@/features/proposers/utils/utils' +import useChainId from '@/hooks/useChainId' +import useSafeAddress from '@/hooks/useSafeAddress' +import useWallet from '@/hooks/wallets/useWallet' +import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' +import { getAssertedChainSigner } from '@/services/tx/tx-sender/sdk' +import { useAppDispatch } from '@/store' +import { useAddProposerMutation } from '@/store/api/gateway' +import { showNotification } from '@/store/notificationsSlice' +import { shortenAddress } from '@/utils/formatters' +import { addressIsNotCurrentSafe } from '@/utils/validation' +import { isHardwareWallet } from '@/utils/wallets' +import { Close } from '@mui/icons-material' +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + Typography, +} from '@mui/material' +import type { Delegate } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' +import { type BaseSyntheticEvent, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' + +type UpsertProposerProps = { + onClose: () => void + onSuccess: () => void + proposer?: Delegate +} + +enum ProposerEntryFields { + address = 'address', + name = 'name', +} + +type ProposerEntry = { + [ProposerEntryFields.name]: string + [ProposerEntryFields.address]: string +} + +const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) => { + const [error, setError] = useState() + const [isLoading, setIsLoading] = useState(false) + const [addProposer] = useAddProposerMutation() + const dispatch = useAppDispatch() + + const chainId = useChainId() + const wallet = useWallet() + const safeAddress = useSafeAddress() + + const methods = useForm({ + defaultValues: { + [ProposerEntryFields.address]: proposer?.delegate, + [ProposerEntryFields.name]: proposer?.label, + }, + mode: 'onChange', + }) + + const notCurrentSafe = addressIsNotCurrentSafe(safeAddress, 'Cannot add Safe Account itself as proposer') + + const { handleSubmit, formState } = methods + + const onConfirm = handleSubmit(async (data: ProposerEntry) => { + if (!wallet) return + + setError(undefined) + setIsLoading(true) + + try { + const hardwareWallet = isHardwareWallet(wallet) + const signer = await getAssertedChainSigner(wallet.provider) + const signature = hardwareWallet + ? await signProposerData(data.address, signer) + : await signProposerTypedData(chainId, data.address, signer) + + await addProposer({ + chainId, + delegator: wallet.address, + signature, + label: data.name, + delegate: data.address, + safeAddress, + isHardwareWallet: hardwareWallet, + }) + + trackEvent( + isEditing ? SETTINGS_EVENTS.PROPOSERS.SUBMIT_EDIT_PROPOSER : SETTINGS_EVENTS.PROPOSERS.SUBMIT_ADD_PROPOSER, + ) + + dispatch( + showNotification({ + variant: 'success', + groupKey: 'add-proposer-success', + title: 'Proposer added successfully!', + message: `${shortenAddress(data.address)} can now suggest transactions for this account.`, + }), + ) + } catch (error) { + setIsLoading(false) + setError(error as Error) + return + } + + setIsLoading(false) + onSuccess() + }) + + const onSubmit = (e: BaseSyntheticEvent) => { + e.stopPropagation() + onConfirm(e) + } + + const onCancel = () => { + trackEvent( + isEditing ? SETTINGS_EVENTS.PROPOSERS.CANCEL_EDIT_PROPOSER : SETTINGS_EVENTS.PROPOSERS.CANCEL_ADD_PROPOSER, + ) + onClose() + } + + const isEditing = !!proposer + const canEdit = wallet?.address === proposer?.delegator + + return ( + + + + + + + {isEditing ? 'Edit' : 'Add'} proposer + + + + + + + + + + + + + + + + You're about to grant this address the ability to propose transactions. To complete the setup, + confirm with a signature from your connected wallet. + + + + Proposer’s name and address are publicly visible. + + + {isEditing ? ( + + + + ) : ( + + )} + + + + + + + {error && ( + + Error adding proposer + + )} + + + + + + + + + + + {(isOk) => ( + + )} + + + + + + ) +} + +export default UpsertProposer diff --git a/src/features/proposers/utils/utils.ts b/src/features/proposers/utils/utils.ts new file mode 100644 index 0000000000..13afbb762e --- /dev/null +++ b/src/features/proposers/utils/utils.ts @@ -0,0 +1,51 @@ +import { signTypedData } from '@/utils/web3' +import { SigningMethod } from '@safe-global/protocol-kit' +import { adjustVInSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures' +import type { JsonRpcSigner } from 'ethers' + +const getProposerDataV2 = (chainId: string, proposerAddress: string) => { + const totp = Math.floor(Date.now() / 1000 / 3600) + + const domain = { + name: 'Safe Transaction Service', + version: '1.0', + chainId, + } + + const types = { + Delegate: [ + { name: 'delegateAddress', type: 'address' }, + { name: 'totp', type: 'uint256' }, + ], + } + + const message = { + delegateAddress: proposerAddress, + totp, + } + + return { + domain, + types, + message, + } +} + +export const signProposerTypedData = async (chainId: string, proposerAddress: string, signer: JsonRpcSigner) => { + const typedData = getProposerDataV2(chainId, proposerAddress) + return signTypedData(signer, typedData) +} + +const getProposerDataV1 = (proposerAddress: string) => { + const totp = Math.floor(Date.now() / 1000 / 3600) + + return `${proposerAddress}${totp}` +} + +export const signProposerData = async (proposerAddress: string, signer: JsonRpcSigner) => { + const data = getProposerDataV1(proposerAddress) + + const signature = await signer.signMessage(data) + + return adjustVInSignature(SigningMethod.ETH_SIGN_TYPED_DATA, signature) +} diff --git a/src/hooks/useDelegates.ts b/src/hooks/useDelegates.ts deleted file mode 100644 index c669ed93c0..0000000000 --- a/src/hooks/useDelegates.ts +++ /dev/null @@ -1,22 +0,0 @@ -import useSafeInfo from '@/hooks/useSafeInfo' -import useWallet from '@/hooks/wallets/useWallet' -import { useGetDelegatesQuery } from '@/store/api/gateway' -import { skipToken } from '@reduxjs/toolkit/query/react' - -const useDelegates = () => { - const { - safe: { chainId }, - safeAddress, - } = useSafeInfo() - - return useGetDelegatesQuery(chainId && safeAddress ? { chainId, safeAddress } : skipToken) -} - -export const useIsWalletDelegate = () => { - const wallet = useWallet() - const delegates = useDelegates() - - return delegates.data?.results.some((delegate) => delegate.delegate === wallet?.address) -} - -export default useDelegates diff --git a/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx b/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx index 544d865c34..a2b9a6517a 100644 --- a/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx +++ b/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx @@ -1,3 +1,4 @@ +import { useIsWalletProposer } from '@/hooks/useProposers' import { FEATURES } from '@/utils/chains' import { useAppSelector } from '@/store' import { selectSpendingLimits } from '@/store/spendingLimitsSlice' @@ -10,8 +11,9 @@ const useIsOnlySpendingLimitBeneficiary = (): boolean => { const spendingLimits = useAppSelector(selectSpendingLimits) const wallet = useWallet() const isSafeOwner = useIsSafeOwner() + const isProposer = useIsWalletProposer() - if (isSafeOwner || !isEnabled || spendingLimits.length === 0) { + if (isSafeOwner || !isEnabled || spendingLimits.length === 0 || isProposer) { return false } diff --git a/src/hooks/useProposers.ts b/src/hooks/useProposers.ts new file mode 100644 index 0000000000..bd85674bb7 --- /dev/null +++ b/src/hooks/useProposers.ts @@ -0,0 +1,22 @@ +import useSafeInfo from '@/hooks/useSafeInfo' +import useWallet from '@/hooks/wallets/useWallet' +import { useGetProposersQuery } from '@/store/api/gateway' +import { skipToken } from '@reduxjs/toolkit/query/react' + +const useProposers = () => { + const { + safe: { chainId }, + safeAddress, + } = useSafeInfo() + + return useGetProposersQuery(chainId && safeAddress ? { chainId, safeAddress } : skipToken) +} + +export const useIsWalletProposer = () => { + const wallet = useWallet() + const proposers = useProposers() + + return proposers.data?.results.some((proposer) => proposer.delegate === wallet?.address) +} + +export default useProposers diff --git a/src/hooks/wallets/__tests__/useOnboard.test.ts b/src/hooks/wallets/__tests__/useOnboard.test.ts index a885e21afa..237ceeaf1b 100644 --- a/src/hooks/wallets/__tests__/useOnboard.test.ts +++ b/src/hooks/wallets/__tests__/useOnboard.test.ts @@ -51,7 +51,7 @@ describe('useOnboard', () => { chainId: '4', ens: 'test.eth', balance: '0.00235 ETH', - isDelegate: false, + isProposer: false, }) }) diff --git a/src/hooks/wallets/useOnboard.ts b/src/hooks/wallets/useOnboard.ts index 583976d5ee..b73596ffac 100644 --- a/src/hooks/wallets/useOnboard.ts +++ b/src/hooks/wallets/useOnboard.ts @@ -21,7 +21,7 @@ export type ConnectedWallet = { provider: Eip1193Provider icon?: string balance?: string - isDelegate?: boolean + isProposer?: boolean } const { getStore, setStore, useStore } = new ExternalStore() @@ -71,7 +71,7 @@ export const getConnectedWallet = (wallets: WalletState[]): ConnectedWallet | nu provider: primaryWallet.provider, icon: primaryWallet.icon, balance, - isDelegate: false, + isProposer: false, } } catch (e) { logError(Errors._106, e) diff --git a/src/pages/settings/setup.tsx b/src/pages/settings/setup.tsx index 92da73d9e2..1dad75fee9 100644 --- a/src/pages/settings/setup.tsx +++ b/src/pages/settings/setup.tsx @@ -7,7 +7,7 @@ import { OwnerList } from '@/components/settings/owner/OwnerList' import { RequiredConfirmation } from '@/components/settings/RequiredConfirmations' import useSafeInfo from '@/hooks/useSafeInfo' import SettingsHeader from '@/components/settings/SettingsHeader' -import DelegatesList from '@/components/settings/DelegatesList' +import ProposersList from 'src/components/settings/ProposersList' import SpendingLimits from '@/components/settings/SpendingLimits' const Setup: NextPage = () => { @@ -61,12 +61,12 @@ const Setup: NextPage = () => { + + - - ) diff --git a/src/services/analytics/events/settings.ts b/src/services/analytics/events/settings.ts index 0b1518ef16..5150ba40da 100644 --- a/src/services/analytics/events/settings.ts +++ b/src/services/analytics/events/settings.ts @@ -74,6 +74,44 @@ export const SETTINGS_EVENTS = { category: SETTINGS_CATEGORY, }, }, + PROPOSERS: { + ADD_PROPOSER: { + action: 'Add safe proposer', + category: SETTINGS_CATEGORY, + }, + REMOVE_PROPOSER: { + action: 'Remove safe proposer', + category: SETTINGS_CATEGORY, + }, + EDIT_PROPOSER: { + action: 'Edit safe proposer', + category: SETTINGS_CATEGORY, + }, + SUBMIT_ADD_PROPOSER: { + action: 'Submit add safe proposer', + category: SETTINGS_CATEGORY, + }, + SUBMIT_REMOVE_PROPOSER: { + action: 'Submit remove safe proposer', + category: SETTINGS_CATEGORY, + }, + SUBMIT_EDIT_PROPOSER: { + action: 'Submit edit safe proposer', + category: SETTINGS_CATEGORY, + }, + CANCEL_ADD_PROPOSER: { + action: 'Cancel add safe proposer', + category: SETTINGS_CATEGORY, + }, + CANCEL_REMOVE_PROPOSER: { + action: 'Cancel remove safe proposer', + category: SETTINGS_CATEGORY, + }, + CANCEL_EDIT_PROPOSER: { + action: 'Cancel edit safe proposer', + category: SETTINGS_CATEGORY, + }, + }, DATA: { IMPORT_ADDRESS_BOOK: { action: 'Imported address book via Import all', diff --git a/src/services/analytics/events/transactions.ts b/src/services/analytics/events/transactions.ts index 1a4a49ce34..839a8057a0 100644 --- a/src/services/analytics/events/transactions.ts +++ b/src/services/analytics/events/transactions.ts @@ -48,9 +48,9 @@ export const TX_EVENTS = { action: 'Create via spending limit', category: TX_CATEGORY, }, - CREATE_VIA_DELEGATE: { + CREATE_VIA_PROPOSER: { event: EventType.TX_CREATED, - action: 'Create via delegate', + action: 'Create via proposer', category: TX_CATEGORY, }, CONFIRM: { diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index 6da7ee20dc..a60ce9d11c 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -112,8 +112,8 @@ export const dispatchTxSigning = async ( return signedTx } -// We have to manually sign because sdk.signTransaction doesn't support delegates -export const dispatchDelegateTxSigning = async (safeTx: SafeTransaction, wallet: ConnectedWallet) => { +// We have to manually sign because sdk.signTransaction doesn't support proposers +export const dispatchProposerTxSigning = async (safeTx: SafeTransaction, wallet: ConnectedWallet) => { const sdk = await getSafeSDKWithSigner(wallet.provider) let signature: SafeSignature diff --git a/src/store/api/gateway/index.ts b/src/store/api/gateway/index.ts index 82f8241c70..8c8893b150 100644 --- a/src/store/api/gateway/index.ts +++ b/src/store/api/gateway/index.ts @@ -1,13 +1,12 @@ +import { proposerEndpoints } from '@/store/api/gateway/proposers' import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react' import { getTransactionDetails, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { asError } from '@/services/exceptions/utils' -import { getDelegates } from '@safe-global/safe-gateway-typescript-sdk' -import type { DelegateResponse } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' import { safeOverviewEndpoints } from './safeOverviews' import { createSubmission, getSubmission } from '@safe-global/safe-client-gateway-sdk' -async function buildQueryFn(fn: () => Promise) { +export async function buildQueryFn(fn: () => Promise) { try { return { data: await fn() } } catch (error) { @@ -30,11 +29,6 @@ export const gatewayApi = createApi({ return buildQueryFn(() => Promise.all(txIds.map((txId) => getTransactionDetails(chainId, txId)))) }, }), - getDelegates: builder.query({ - queryFn({ chainId, safeAddress }) { - return buildQueryFn(() => getDelegates(chainId, { safe: safeAddress })) - }, - }), getSubmission: builder.query< getSubmission, { outreachId: number; chainId: string; safeAddress: string; signerAddress: string } @@ -62,6 +56,7 @@ export const gatewayApi = createApi({ }, invalidatesTags: ['Submissions'], }), + ...proposerEndpoints(builder), ...safeOverviewEndpoints(builder), }), }) @@ -70,7 +65,9 @@ export const { useGetTransactionDetailsQuery, useGetMultipleTransactionDetailsQuery, useLazyGetTransactionDetailsQuery, - useGetDelegatesQuery, + useGetProposersQuery, + useDeleteProposerMutation, + useAddProposerMutation, useGetSubmissionQuery, useCreateSubmissionMutation, useGetSafeOverviewQuery, diff --git a/src/store/api/gateway/proposers.ts b/src/store/api/gateway/proposers.ts new file mode 100644 index 0000000000..fab8833fc7 --- /dev/null +++ b/src/store/api/gateway/proposers.ts @@ -0,0 +1,100 @@ +import { buildQueryFn, gatewayApi } from '@/store/api/gateway/index' +import { type fakeBaseQuery } from '@reduxjs/toolkit/dist/query/react' +import type { EndpointBuilder } from '@reduxjs/toolkit/dist/query/react' +import { deleteDelegate, deleteDelegateV2, postDelegate, postDelegateV2 } from '@safe-global/safe-client-gateway-sdk' +import { getDelegates } from '@safe-global/safe-gateway-typescript-sdk' +import type { Delegate, DelegateResponse } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' + +export const proposerEndpoints = ( + builder: EndpointBuilder>, 'Submissions', 'gatewayApi'>, +) => ({ + getProposers: builder.query({ + queryFn({ chainId, safeAddress }) { + return buildQueryFn(() => getDelegates(chainId, { safe: safeAddress })) + }, + }), + deleteProposer: builder.mutation< + void, + { + chainId: string + safeAddress: string + delegateAddress: string + delegator: string + signature: string + isHardwareWallet: boolean + } + >({ + queryFn({ chainId, safeAddress, delegateAddress, delegator, signature, isHardwareWallet }) { + const options = { + params: { path: { chainId, delegateAddress } }, + body: { safe: safeAddress, signature, delegator }, + } + return buildQueryFn(() => + isHardwareWallet + ? deleteDelegate({ params: options.params, body: { ...options.body, delegate: delegateAddress } }) + : deleteDelegateV2(options), + ) + }, + // Optimistically update the cache and roll back in case the mutation fails + async onQueryStarted({ chainId, safeAddress, delegateAddress, delegator }, { dispatch, queryFulfilled }) { + const patchResult = dispatch( + gatewayApi.util.updateQueryData('getProposers', { chainId, safeAddress }, (draft) => { + draft.results = draft.results.filter( + (delegate: Delegate) => delegate.delegate !== delegateAddress || delegate.delegator !== delegator, + ) + }), + ) + try { + await queryFulfilled + } catch { + patchResult.undo() + } + }, + }), + addProposer: builder.mutation< + Delegate, + { + chainId: string + safeAddress: string + delegate: string + delegator: string + label: string + signature: string + isHardwareWallet: boolean + } + >({ + queryFn({ chainId, safeAddress, delegate, delegator, label, signature, isHardwareWallet }) { + const options = { + params: { path: { chainId } }, + body: { delegate, delegator, label, signature, safe: safeAddress }, + } + + return buildQueryFn(() => (isHardwareWallet ? postDelegate(options) : postDelegateV2(options))) + }, + // Optimistically update the cache and roll back in case the mutation fails + async onQueryStarted({ chainId, safeAddress, delegate, delegator, label }, { dispatch, queryFulfilled }) { + const patchResult = dispatch( + gatewayApi.util.updateQueryData('getProposers', { chainId, safeAddress }, (draft) => { + const existingProposer = draft.results.findIndex( + (proposer: Delegate) => proposer.delegate === delegate && delegator === proposer.delegator, + ) + + if (existingProposer !== -1) { + // Update the existing delegate's label + draft.results[existingProposer] = { + ...draft.results[existingProposer], + label, + } + } else { + draft.results.push({ delegate, delegator, label, safe: safeAddress }) + } + }), + ) + try { + await queryFulfilled + } catch { + patchResult.undo() + } + }, + }), +}) diff --git a/src/utils/chains.ts b/src/utils/chains.ts index 3aab4b0a7a..ec3786dd41 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -37,6 +37,7 @@ export enum FEATURES { STAKING_BANNER = 'STAKING_BANNER', MULTI_CHAIN_SAFE_CREATION = 'MULTI_CHAIN_SAFE_CREATION', MULTI_CHAIN_SAFE_ADD_NETWORK = 'MULTI_CHAIN_SAFE_ADD_NETWORK', + PROPOSERS = 'PROPOSERS', } export const FeatureRoutes = { diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 174667ef75..f437838b2a 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -38,12 +38,19 @@ export const uniqueAddress = } export const addressIsNotCurrentSafe = - (safeAddress: string) => + (safeAddress: string, message?: string) => (address: string): string | undefined => { - const SIGNER_ADDRESS_IS_SAFE_ADDRESS_ERROR = 'Cannot use Safe Account itself as signer.' + const SIGNER_ADDRESS_IS_SAFE_ADDRESS_ERROR = message || 'Cannot use Safe Account itself as signer.' return sameAddress(safeAddress, address) ? SIGNER_ADDRESS_IS_SAFE_ADDRESS_ERROR : undefined } +export const addressIsNotOwner = + (owners: string[], message?: string) => + (address: string): string | undefined => { + const ADDRESS_IS_OWNER_ERROR = message || 'Cannot use Owners.' + return owners.some((owner) => owner === address) ? ADDRESS_IS_OWNER_ERROR : undefined + } + export const FLOAT_REGEX = /^[0-9]+([,.][0-9]+)?$/ export const validateAmount = (amount?: string, includingZero: boolean = false) => { diff --git a/yarn.lock b/yarn.lock index ed4c495811..b1a1c6c5f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4195,10 +4195,10 @@ "@safe-global/safe-gateway-typescript-sdk" "^3.5.3" viem "^2.1.1" -"@safe-global/safe-client-gateway-sdk@v1.60.1": - version "1.60.1" - resolved "https://registry.yarnpkg.com/@safe-global/safe-client-gateway-sdk/-/safe-client-gateway-sdk-1.60.1.tgz#4f24a4c7f0ba04a82a2208bd14163cde46189661" - integrity sha512-3vdDOSXLlvx9B+bo15MPTRGPrUn5jJEvtvWF0esY1oFWfI9riQOttWIIyQGYgDZhxMd+qW0aYKNMpn8DTP+7rw== +"@safe-global/safe-client-gateway-sdk@1.60.1-next-069fa2b": + version "1.60.1-next-069fa2b" + resolved "https://registry.yarnpkg.com/@safe-global/safe-client-gateway-sdk/-/safe-client-gateway-sdk-1.60.1-next-069fa2b.tgz#5a4ac69661713dd08b33bbcc15fa7d870339ed0a" + integrity sha512-gS+AcYuX1L6l79q87TA/A6vS34wovUgypRKS8lXzo4nDbHE333i/yp7JoOAcxKXFlJDHJXHIbPRkOKoMjdylEA== dependencies: openapi-fetch "0.10.5" From 430f45eb21f4200f7a1f4106236e5f27c02b4dc3 Mon Sep 17 00:00:00 2001 From: James Mealy Date: Wed, 6 Nov 2024 14:27:40 +0100 Subject: [PATCH 08/10] Docs: update release docs with missing step (#4489) --- docs/release-procedure.md | 49 ++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/docs/release-procedure.md b/docs/release-procedure.md index 2e8002bbd3..1842269acd 100644 --- a/docs/release-procedure.md +++ b/docs/release-procedure.md @@ -7,6 +7,7 @@ When it's time to make a release, we "freeze" the code by creating a release bra After the PR is tested and approved by QA, it's merged into the `main` branch. `Main` is automatically deployed to the staging environment. Schematically: + ``` –> dev -> release -> main ``` @@ -14,49 +15,59 @@ Schematically: We prepare at least one release every sprint. Sprints are two weeks long. ### Preparing a release branch -* Create a code-freeze branch named `release` - * If it's a regular release, this branch is typically based off of `dev` - * For hot fixes, it would be `main` + cherry-picked commits -* Bump the version in the `package.json` as a separate commit with the commit message equal to the exact version -* Create a PR with the list of changes - > 💡 To generate a quick changelog: +- Create a code-freeze branch named `release` + - If it's a regular release, this branch is typically based off of `dev` + - For hot fixes, it would be `main` + cherry-picked commits +- Bump the version in the `package.json` as a separate commit with the commit message equal to the exact version +- Create a PR with the list of changes + + > 💡 To generate a quick changelog: + > > ```bash > git log origin/main..origin/dev --pretty=format:'* %s' > ``` -* Add the PR to the Project `Web Squad` and set the status to `Ready for QA` +- Add the PR to the Project `Web Squad` and set the status to `Ready for QA` ### QA -* The QA team do regression testing on this branch -* If issues are found, bugfixes are merged into this branch -* Once the QA is done, proceed to the next step + +- The QA team do regression testing on this branch +- If issues are found, bugfixes are merged into this branch +- Once the QA is done, proceed to the next step ### Releasing to production + Wait for all the checks on GitHub to pass. -* Switch to the main branch and make sure it's up to date: + +- Switch to the main branch and make sure it's up to date: + ``` git checkout main git fetch --all git reset --hard origin/main ``` -* Pull from the release branch: + +- Pull from the release branch: + ``` git pull origin release ``` -* Push: + +- Push: + ``` git push ``` A deployment workflow will kick in and do the following things: -* Deploy the code to staging -* Create a new git tag from the version in package.json -* Create a [GitHub release](https://github.com/safe-global/safe-wallet-web/releases) linked to this tag, with a changelog taken from the release PR -* Build and upload the code to an S3 bucket +- Deploy the code to staging +- Create a new git tag from the version in package.json +- Create a draft [GitHub release](https://github.com/safe-global/safe-wallet-web/releases) linked to this tag, with a changelog taken from the release PR After that, the release manager should: -* Notify devops on Slack and send them the release link to deploy to production -* Back-merge `main` into the `dev` branch to keep them in sync unless the release branch was based on `dev` +- Create a final release from the draft release. This will trigger a build and upload the code to an S3 bucket +- Notify devops on Slack and send them the release link to deploy to production +- Back-merge `main` into the `dev` branch to keep them in sync unless the release branch was based on `dev` From 63695ad24bd8739bce91601872084227199c4f4c Mon Sep 17 00:00:00 2001 From: Usame Algan Date: Thu, 7 Nov 2024 12:42:03 +0100 Subject: [PATCH 09/10] fix: Delete proposer as proposer, adjust status widget --- .../tx-flow/common/TxStatusWidget/index.tsx | 12 ++++++++++-- .../proposers/components/DeleteProposerDialog.tsx | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/tx-flow/common/TxStatusWidget/index.tsx b/src/components/tx-flow/common/TxStatusWidget/index.tsx index 8a19a2eff8..11847b6ee9 100644 --- a/src/components/tx-flow/common/TxStatusWidget/index.tsx +++ b/src/components/tx-flow/common/TxStatusWidget/index.tsx @@ -11,6 +11,8 @@ import CloseIcon from '@mui/icons-material/Close' import useWallet from '@/hooks/wallets/useWallet' import SafeLogo from '@/public/images/logo-no-text.svg' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useIsWalletProposer } from '@/hooks/useProposers' const TxStatusWidget = ({ step, @@ -29,12 +31,18 @@ const TxStatusWidget = ({ const { safe } = useSafeInfo() const { nonceNeeded } = useContext(SafeTxContext) const { threshold } = safe + const isSafeOwner = useIsSafeOwner() + const isProposer = useIsWalletProposer() + const isProposing = isProposer && !isSafeOwner const { executionInfo = undefined } = txSummary || {} const { confirmationsSubmitted = 0 } = isMultisigExecutionInfo(executionInfo) ? executionInfo : {} - const canConfirm = txSummary ? isConfirmableBy(txSummary, wallet?.address || '') : safe.threshold === 1 - const canSign = txSummary ? isSignableBy(txSummary, wallet?.address || '') : true + const canConfirm = txSummary + ? isConfirmableBy(txSummary, wallet?.address || '') + : safe.threshold === 1 && !isProposing + + const canSign = txSummary ? isSignableBy(txSummary, wallet?.address || '') : !isProposing return ( diff --git a/src/features/proposers/components/DeleteProposerDialog.tsx b/src/features/proposers/components/DeleteProposerDialog.tsx index b809c7a45a..ac79ae1aec 100644 --- a/src/features/proposers/components/DeleteProposerDialog.tsx +++ b/src/features/proposers/components/DeleteProposerDialog.tsx @@ -67,7 +67,7 @@ const _DeleteProposer = ({ wallet, safeAddress, chainId, proposer }: DeletePropo await deleteProposer({ chainId, delegateAddress: proposer.delegate, - delegator: wallet.address, + delegator: proposer.delegator, safeAddress, signature, isHardwareWallet: hardwareWallet, From 2d14d252544f918989c5f6934f2461c7bc126e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=B3vis=20Neto?= Date: Thu, 7 Nov 2024 10:58:30 +0100 Subject: [PATCH 10/10] fix(staking-tx): Add staking condition back into the confirmation view (#4463) * fix(staking-tx): Add staking condition back into the confirmation view * fix(staking-confirmation): remove typo --- .../SignOrExecuteForm/SignOrExecuteForm.tsx | 8 ------ .../tx/confirmation-views/index.tsx | 27 ++++++++++++------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx index a51992c719..6cceedf7f7 100644 --- a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx +++ b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx @@ -96,14 +96,6 @@ export const SignOrExecuteForm = ({ const [shouldExecute, setShouldExecute] = useState(transactionExecution) const isNewExecutableTx = useImmediatelyExecutable() && isCreation const isCorrectNonce = useValidateNonce(safeTx) - - console.log(props.txDetails) - - // TODO: move it to the confirmation view - // const showTxDetails = - // !isAnyStakingTxInfo(txDetails.txInfo) && - // !isOrderTxInfo(txDetails.txInfo) - const isBatchable = props.isBatchable !== false && safeTx && !isDelegateCall(safeTx) const [trigger] = useLazyGetTransactionDetailsQuery() diff --git a/src/components/tx/confirmation-views/index.tsx b/src/components/tx/confirmation-views/index.tsx index 4d0df98b55..9352afb322 100644 --- a/src/components/tx/confirmation-views/index.tsx +++ b/src/components/tx/confirmation-views/index.tsx @@ -3,7 +3,7 @@ import DecodedTx from '../DecodedTx' import ConfirmationOrder from '../ConfirmationOrder' import useDecodeTx from '@/hooks/useDecodeTx' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { isCustomTxInfo, isGenericConfirmation } from '@/utils/transaction-guards' +import { isAnyStakingTxInfo, isCustomTxInfo, isGenericConfirmation, isOrderTxInfo } from '@/utils/transaction-guards' import { type ReactNode, useContext, useMemo } from 'react' import TxData from '@/components/transactions/TxDetails/TxData' import type { NarrowConfirmationViewProps } from './types' @@ -38,28 +38,35 @@ const getConfirmationViewComponent = ({ return null } -const ConfirmationView = (props: ConfirmationViewProps) => { - const { txId } = props.txDetails || {} +const ConfirmationView = ({ txDetails, ...props }: ConfirmationViewProps) => { + const { txId } = txDetails || {} const [decodedData] = useDecodeTx(props.safeTx) const { txFlow } = useContext(TxModalContext) const ConfirmationViewComponent = useMemo( () => - props.txDetails + txDetails ? getConfirmationViewComponent({ - txDetails: props.txDetails, - txInfo: props.txDetails.txInfo, + txDetails, + txInfo: txDetails.txInfo, txFlow, }) : undefined, - [props.txDetails, txFlow], + [txDetails, txFlow], ) - const showTxDetails = txId && !props.isCreation && props.txDetails && !isCustomTxInfo(props.txDetails.txInfo) + + const showTxDetails = + txId && + !props.isCreation && + txDetails && + !isCustomTxInfo(txDetails.txInfo) && + !isAnyStakingTxInfo(txDetails.txInfo) && + !isOrderTxInfo(txDetails.txInfo) return ( <> {ConfirmationViewComponent || - (showTxDetails && props.txDetails && )} + (showTxDetails && txDetails && )} {decodedData && } @@ -67,7 +74,7 @@ const ConfirmationView = (props: ConfirmationViewProps) => {